Files
silo-server/internal/historyimport/plex_normalize.go
a5fb16a5c5 feat(historyimport): import the Plex account watchlist alongside watch history (#285)
* feat(historyimport): import the Plex account watchlist alongside watch history

The Plex import migrated only watch history; the user's saved watchlist
had to be rebuilt by hand (#245).

- PlexClient gains FetchWatchlist: pages the account-level watchlist on
  the Plex discover API (discover.provider.plex.tv). It authenticates
  with the plex.tv ACCOUNT token — the PIN/OAuth session token, which
  resolvePlexAuth now threads through plexAuth.AccountToken (manual-token
  imports pass the user token, which doubles as the account token).
- Watchlist entries become import Records flagged Watchlisted, carrying
  identity only (movie/show → KindMovie/KindSeries, guids parsed) and no
  watch state. They ride the existing matcher (series matching already
  exists), and matched entries are added to the importing profile's
  watchlist via the idempotent AddToWatchlistAt — re-imports do not
  duplicate. A watchlist fetch failure downgrades to a run warning so the
  history import still completes.
- Run summaries gain a watchlist_added counter (new column + repo
  plumbing + client/admin UI cards).

Fixes #245

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(historyimport): count only newly inserted watchlist rows in WatchlistAdded

AddToWatchlistAt now reports whether a row was actually inserted (the
insert is ON CONFLICT DO NOTHING / INSERT OR IGNORE), and the import
summary increments WatchlistAdded only for genuine inserts, so
re-importing the same Plex account no longer inflates the count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-05 00:19:31 -04:00

137 lines
3.3 KiB
Go

package historyimport
import (
"strings"
"time"
)
func NormalizePlexItem(item PlexItem, series *PlexItem) Record {
record := Record{
ExternalID: item.RatingKey,
Title: item.Title,
Year: item.Year,
Played: item.ViewCount > 0,
PlayCount: item.ViewCount,
PositionSeconds: float64(item.ViewOffset) / 1000,
DurationSeconds: float64(item.Duration) / 1000,
UpdatedAt: time.Now().UTC(),
}
if item.LastViewedAt > 0 {
t := time.Unix(item.LastViewedAt, 0).UTC()
record.LastPlayedAt = &t
record.UpdatedAt = t
}
ParsePlexGuids(item.Guid, &record.IMDbID, &record.TMDBID, &record.TVDBID)
switch item.Type {
case "movie":
record.Kind = KindMovie
case "episode":
record.Kind = KindEpisode
record.SeriesTitle = item.GrandparentTitle
record.SeasonNumber = item.ParentIndex
record.EpisodeNumber = item.Index
if series != nil {
ParsePlexGuids(series.Guid, &record.SeriesIMDbID, &record.SeriesTMDBID, &record.SeriesTVDBID)
record.SeriesYear = series.Year
if record.SeriesTitle == "" {
record.SeriesTitle = series.Title
}
}
default:
record.Kind = item.Type
}
return record
}
// NormalizePlexWatchlistItem maps an account-watchlist entry to an import
// record: movie or series identity only, flagged Watchlisted, and carrying
// no watch state (a watchlist entry says "want to watch", not "watched").
func NormalizePlexWatchlistItem(item PlexItem) Record {
record := Record{
ExternalID: item.RatingKey,
Title: item.Title,
Year: item.Year,
Watchlisted: true,
UpdatedAt: time.Now().UTC(),
}
ParsePlexGuids(item.Guid, &record.IMDbID, &record.TMDBID, &record.TVDBID)
switch item.Type {
case "movie":
record.Kind = KindMovie
case "show":
record.Kind = KindSeries
default:
record.Kind = item.Type
}
return record
}
func NormalizePlexHistoryItem(item PlexHistoryItem, series *PlexItem) Record {
record := Record{
ExternalID: item.RatingKey,
Title: item.Title,
Year: item.Year,
Played: true,
PlayCount: 1,
DurationSeconds: float64(item.Duration) / 1000,
UpdatedAt: time.Now().UTC(),
}
if item.ViewedAt > 0 {
t := time.Unix(item.ViewedAt, 0).UTC()
record.LastPlayedAt = &t
record.UpdatedAt = t
}
ParsePlexGuids(item.Guid, &record.IMDbID, &record.TMDBID, &record.TVDBID)
switch item.Type {
case "movie":
record.Kind = KindMovie
case "episode":
record.Kind = KindEpisode
record.SeriesTitle = item.GrandparentTitle
record.SeasonNumber = item.ParentIndex
record.EpisodeNumber = item.Index
if series != nil {
ParsePlexGuids(series.Guid, &record.SeriesIMDbID, &record.SeriesTMDBID, &record.SeriesTVDBID)
record.SeriesYear = series.Year
if record.SeriesTitle == "" {
record.SeriesTitle = series.Title
}
}
default:
record.Kind = item.Type
}
return record
}
func ParsePlexGuids(guids PlexGuids, imdbID, tmdbID, tvdbID *string) {
for _, g := range guids {
provider, value, ok := strings.Cut(g.ID, "://")
if !ok {
continue
}
value = strings.TrimSpace(value)
switch strings.ToLower(provider) {
case "imdb":
if *imdbID == "" {
*imdbID = value
}
case "tmdb":
if *tmdbID == "" {
*tmdbID = value
}
case "tvdb":
if *tvdbID == "" {
*tvdbID = value
}
}
}
}