fix(historyimport): make Plex watchlist import actually work

The browser Plex OAuth flow only sent the PMS server access token, which
the discover API rejects (401), so the watchlist step always failed with
a buried warning. The web client now forwards the plex.tv account token
via a new additive plex_account_token field.

The discover watchlist listing also ignores includeGuids, so items
arrived without external ids and could only exact-title/year match.
FetchWatchlist now resolves ids per item from the discover metadata
endpoint, decoding both Metadata- and Video-keyed containers, and
degrades to a title/year fallback warning instead of dropping items.

Part of #245

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-05 13:11:12 -04:00
co-authored by Claude Fable 5
parent f7954f226e
commit 9ead29bfcc
9 changed files with 195 additions and 17 deletions
+68 -7
View File
@@ -151,6 +151,10 @@ type plexMediaContainer struct {
TotalSize int `json:"totalSize"`
Offset int `json:"offset"`
Metadata []PlexItem `json:"Metadata"`
// Video mirrors Metadata: the discover API inconsistently keys some
// responses on "Video" instead of "Metadata" (movie items in
// particular), so both must be decoded.
Video []PlexItem `json:"Video"`
Directory []struct {
Key string `json:"key"`
Type string `json:"type"`
@@ -159,6 +163,15 @@ type plexMediaContainer struct {
} `json:"MediaContainer"`
}
// items returns the container's media entries regardless of whether the
// upstream keyed them on "Metadata" or "Video".
func (c *plexMediaContainer) items() []PlexItem {
if len(c.MediaContainer.Metadata) > 0 {
return c.MediaContainer.Metadata
}
return c.MediaContainer.Video
}
type PlexItem struct {
RatingKey string `json:"ratingKey"`
Key string `json:"key"`
@@ -268,7 +281,12 @@ func (c *PlexClient) fetchSectionItems(ctx context.Context, baseURL, token, sect
// Plex discover API. It authenticates with the plex.tv ACCOUNT token (from
// the PIN/OAuth session), not a server access token: the watchlist belongs
// to the account, not to any PMS.
func (c *PlexClient) FetchWatchlist(ctx context.Context, accountToken string) ([]PlexItem, error) {
//
// The discover listing does not honor includeGuids, so items usually arrive
// without external ids. Each id-less item gets a follow-up per-item metadata
// fetch to resolve its Guid array; failures there degrade to warnings so the
// rest of the watchlist still imports (matching falls back to title/year).
func (c *PlexClient) FetchWatchlist(ctx context.Context, accountToken string) ([]PlexItem, []string, error) {
base := c.discoverBaseURL
if base == "" {
base = plexDiscoverBaseURL
@@ -283,20 +301,63 @@ func (c *PlexClient) FetchWatchlist(ctx context.Context, accountToken string) ([
reqURL := fmt.Sprintf("%s/library/sections/watchlist/all?%s", base, query.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
return nil, nil, err
}
c.setPlexHeaders(req, accountToken)
var container plexMediaContainer
if err := c.doJSON(req, &container); err != nil {
return nil, fmt.Errorf("fetching Plex watchlist (offset %d): %w", offset, err)
return nil, nil, fmt.Errorf("fetching Plex watchlist (offset %d): %w", offset, err)
}
allItems = append(allItems, container.MediaContainer.Metadata...)
offset += len(container.MediaContainer.Metadata)
if offset >= container.MediaContainer.TotalSize || len(container.MediaContainer.Metadata) == 0 {
items := container.items()
allItems = append(allItems, items...)
offset += len(items)
if offset >= container.MediaContainer.TotalSize || len(items) == 0 {
break
}
}
return allItems, nil
var warnings []string
unresolved := 0
for i := range allItems {
if len(allItems[i].Guid) > 0 {
continue
}
detail, err := c.fetchWatchlistItemMetadata(ctx, base, accountToken, allItems[i].RatingKey)
if err != nil || detail == nil {
unresolved++
continue
}
allItems[i].Guid = detail.Guid
if allItems[i].Year == 0 {
allItems[i].Year = detail.Year
}
}
if unresolved > 0 {
warnings = append(warnings, fmt.Sprintf(
"watchlist: could not resolve external ids for %d of %d items; those fall back to exact title/year matching",
unresolved, len(allItems)))
}
return allItems, warnings, nil
}
// fetchWatchlistItemMetadata resolves one watchlist entry's full metadata
// (including its external-id Guid array) from the discover API.
func (c *PlexClient) fetchWatchlistItemMetadata(ctx context.Context, base, accountToken, ratingKey string) (*PlexItem, error) {
reqURL := fmt.Sprintf("%s/library/metadata/%s", base, url.PathEscape(ratingKey))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
c.setPlexHeaders(req, accountToken)
var container plexMediaContainer
if err := c.doJSON(req, &container); err != nil {
return nil, fmt.Errorf("fetching Plex watchlist metadata for %s: %w", ratingKey, err)
}
items := container.items()
if len(items) == 0 {
return nil, nil
}
return &items[0], nil
}
func (c *PlexClient) FetchOnDeck(ctx context.Context, baseURL, token string) ([]PlexItem, error) {
+2 -1
View File
@@ -80,10 +80,11 @@ func (p *PlexServerProvider) Fetch(ctx context.Context) ([]Record, []string, err
// effort: a watchlist fetch failure downgrades to a warning so the
// watch-history import still completes (issue #245).
if p.accountToken != "" {
items, err := p.client.FetchWatchlist(ctx, p.accountToken)
items, watchlistWarnings, err := p.client.FetchWatchlist(ctx, p.accountToken)
if err != nil {
warnings = append(warnings, fmt.Sprintf("watchlist fetch failed: %v", err))
} else {
warnings = append(warnings, watchlistWarnings...)
for _, item := range items {
records = append(records, NormalizePlexWatchlistItem(item))
}
+95 -3
View File
@@ -88,10 +88,13 @@ func TestFetchWatchlistPaginatesDiscoverAPI(t *testing.T) {
client := NewPlexClient()
client.discoverBaseURL = server.URL
items, err := client.FetchWatchlist(context.Background(), "account-token-1")
items, warnings, err := client.FetchWatchlist(context.Background(), "account-token-1")
if err != nil {
t.Fatalf("FetchWatchlist: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("warnings = %v, want none (all items carried guids)", warnings)
}
if gotToken != "account-token-1" {
t.Fatalf("token header = %q, want account token", gotToken)
}
@@ -103,19 +106,108 @@ func TestFetchWatchlistPaginatesDiscoverAPI(t *testing.T) {
}
}
// The discover listing does not honor includeGuids in practice: items arrive
// without external ids, and some detail responses key their payload on
// "Video" instead of "Metadata". Both must be handled or matching silently
// degrades to exact title/year.
func TestFetchWatchlistResolvesGuidsViaItemMetadata(t *testing.T) {
detailCalls := map[string]int{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/library/sections/watchlist/all":
_, _ = fmt.Fprint(w, `{"MediaContainer":{"totalSize":2,"Metadata":[
{"ratingKey":"wl-movie","type":"movie","title":"Dune: Part Two","year":2024},
{"ratingKey":"wl-show","type":"show","title":"Severance","year":2022}
]}}`)
case "/library/metadata/wl-movie":
detailCalls["wl-movie"]++
// Movie detail keyed on "Video" (discover inconsistency).
_, _ = fmt.Fprint(w, `{"MediaContainer":{"Video":[
{"ratingKey":"wl-movie","type":"movie","title":"Dune: Part Two","year":2024,
"Guid":[{"id":"imdb://tt15239678"},{"id":"tmdb://693134"}]}
]}}`)
case "/library/metadata/wl-show":
detailCalls["wl-show"]++
_, _ = fmt.Fprint(w, `{"MediaContainer":{"Metadata":[
{"ratingKey":"wl-show","type":"show","title":"Severance","year":2022,
"Guid":[{"id":"tvdb://371980"}]}
]}}`)
default:
t.Errorf("unexpected path %q", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
client := NewPlexClient()
client.discoverBaseURL = server.URL
items, warnings, err := client.FetchWatchlist(context.Background(), "tok")
if err != nil {
t.Fatalf("FetchWatchlist: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("warnings = %v, want none (all ids resolved)", warnings)
}
if len(items) != 2 {
t.Fatalf("items = %d, want 2", len(items))
}
if detailCalls["wl-movie"] != 1 || detailCalls["wl-show"] != 1 {
t.Fatalf("detail fetches = %v, want one per id-less item", detailCalls)
}
movie := NormalizePlexWatchlistItem(items[0])
if movie.IMDbID != "tt15239678" || movie.TMDBID != "693134" {
t.Fatalf("movie ids = imdb %q tmdb %q, want resolved from detail fetch", movie.IMDbID, movie.TMDBID)
}
show := NormalizePlexWatchlistItem(items[1])
if show.TVDBID != "371980" {
t.Fatalf("show tvdb id = %q, want resolved from detail fetch", show.TVDBID)
}
}
// A failed per-item metadata fetch must not sink the watchlist: the item
// falls back to title/year matching and the fetch reports one warning.
func TestFetchWatchlistWarnsWhenGuidResolutionFails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/library/sections/watchlist/all":
_, _ = fmt.Fprint(w, `{"MediaContainer":{"totalSize":1,"Metadata":[
{"ratingKey":"wl-1","type":"movie","title":"Dune: Part Two","year":2024}
]}}`)
default:
w.WriteHeader(http.StatusInternalServerError)
}
}))
defer server.Close()
client := NewPlexClient()
client.discoverBaseURL = server.URL
items, warnings, err := client.FetchWatchlist(context.Background(), "tok")
if err != nil {
t.Fatalf("FetchWatchlist: %v", err)
}
if len(items) != 1 || items[0].Title != "Dune: Part Two" {
t.Fatalf("items = %+v, want the listing entry kept", items)
}
if len(warnings) != 1 {
t.Fatalf("warnings = %v, want exactly one unresolved-ids warning", warnings)
}
}
// Guard against page-size regressions: a server reporting a huge total but
// returning empty pages must not loop forever.
func TestFetchWatchlistStopsOnEmptyPage(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
fmt.Fprint(w, `{"MediaContainer":{"totalSize":999,"Metadata":[]}}`)
_, _ = fmt.Fprint(w, `{"MediaContainer":{"totalSize":999,"Metadata":[]}}`)
}))
defer server.Close()
client := NewPlexClient()
client.discoverBaseURL = server.URL
items, err := client.FetchWatchlist(context.Background(), "tok")
items, _, err := client.FetchWatchlist(context.Background(), "tok")
if err != nil {
t.Fatalf("FetchWatchlist: %v", err)
}
+6 -2
View File
@@ -374,7 +374,11 @@ func (s *Service) resolvePlexAuth(ctx context.Context, userID int, input CreateR
if input.PlexToken == "" {
return nil, "", fmt.Errorf("plex_token is required for browser Plex imports")
}
return &plexAuth{BaseURL: input.PlexBaseURL, Token: input.PlexToken, AccountToken: input.PlexToken}, ConnectionModePlexOAuth, nil
// Prefer the explicit account token: in the browser OAuth flow
// PlexToken is a PMS access token, which account-level APIs (the
// watchlist) reject. Falling back to PlexToken keeps manually pasted
// plex.tv account tokens working.
return &plexAuth{BaseURL: input.PlexBaseURL, Token: input.PlexToken, AccountToken: firstNonEmpty(input.PlexAccountToken, input.PlexToken)}, ConnectionModePlexOAuth, nil
}
if input.PlexToken != "" {
return nil, "", fmt.Errorf("plex_base_url is required for browser Plex imports")
@@ -394,7 +398,7 @@ func (s *Service) resolvePlexAuth(ctx context.Context, userID int, input CreateR
if input.PlexToken == "" {
return nil, "", fmt.Errorf("plex_token is required for predefined Plex sources")
}
return &plexAuth{BaseURL: source.BaseURL, Token: input.PlexToken, AccountToken: input.PlexToken}, ConnectionModePredefined, nil
return &plexAuth{BaseURL: source.BaseURL, Token: input.PlexToken, AccountToken: firstNonEmpty(input.PlexAccountToken, input.PlexToken)}, ConnectionModePredefined, nil
}
return nil, "", fmt.Errorf("plex_session_id, plex_base_url, or source_id is required for Plex imports")
+5
View File
@@ -212,6 +212,11 @@ type CreateRunInput struct {
PlexServerID string `json:"plex_server_id,omitempty"`
PlexBaseURL string `json:"plex_base_url,omitempty"`
PlexToken string `json:"plex_token,omitempty"`
// PlexAccountToken is the plex.tv account token from a browser-side
// PIN/OAuth flow. PlexToken is a PMS access token in that flow and is
// rejected by account-level APIs (the watchlist), so clients that hold
// both must send both.
PlexAccountToken string `json:"plex_account_token,omitempty"`
}
type LoginConnectInput struct {
+1
View File
@@ -480,6 +480,7 @@ export interface CreateHistoryImportRunRequest {
plex_server_id?: string;
plex_base_url?: string;
plex_token?: string;
plex_account_token?: string;
}
export interface CreateHistoryImportSourceRequest {
+9 -2
View File
@@ -169,15 +169,22 @@ export async function listPlexResources(token: string): Promise<BrowserPlexServe
});
}
export interface PlexAuthenticationResult {
/** The plex.tv account token; needed for account-level APIs like the watchlist. */
accountToken: string;
servers: BrowserPlexServer[];
}
export async function completePlexAuthentication(
pinID: number,
pinCode: string,
): Promise<BrowserPlexServer[]> {
): Promise<PlexAuthenticationResult> {
const authToken = await checkPlexPin(pinID, pinCode);
if (!authToken) {
throw new Error("Plex sign-in was not completed. Please try again.");
}
return listPlexResources(authToken);
const servers = await listPlexResources(authToken);
return { accountToken: authToken, servers };
}
export function getPreferredPlexServerURL(server: BrowserPlexServer): string {
@@ -102,6 +102,7 @@ export default function HistoryImportSettings() {
// Plex state
const [plexServers, setPlexServers] = useState<BrowserPlexServer[]>([]);
const [plexAccountToken, setPlexAccountToken] = useState("");
const [plexServerId, setPlexServerId] = useState("");
const [plexSavedSourceId, setPlexSavedSourceId] = useState("");
const [plexToken, setPlexToken] = useState("");
@@ -169,16 +170,21 @@ export default function HistoryImportSettings() {
void (async () => {
try {
const servers = await completePlexAuthentication(pinID, returnedPlexPinCode);
const { accountToken, servers } = await completePlexAuthentication(
pinID,
returnedPlexPinCode,
);
if (cancelled) {
return;
}
setPlexAccountToken(accountToken);
setPlexServers(servers);
setPlexServerId(servers[0]?.clientIdentifier ?? "");
} catch (error) {
if (cancelled) {
return;
}
setPlexAccountToken("");
setPlexServers([]);
setPlexServerId("");
setPlexAuthError(error instanceof Error ? error.message : "Failed to finish Plex sign-in");
@@ -255,6 +261,7 @@ export default function HistoryImportSettings() {
source: "plex",
plex_base_url: selectedPlexOAuthServerURL,
plex_token: selectedPlexOAuthServer.accessToken,
plex_account_token: plexAccountToken || undefined,
});
setActiveRunId(run.id);
} else if (plexMode === "saved" && selectedPlexSavedSource) {
@@ -421,7 +421,7 @@ export default function WebhookSyncSettings() {
void (async () => {
try {
const servers = await completePlexAuthentication(pinID, returnedPlexPinCode);
const { servers } = await completePlexAuthentication(pinID, returnedPlexPinCode);
if (cancelled) {
return;
}