* 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>
137 lines
3.3 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
}
|