Files
silo-server/internal/watchsync/providers/mdblist/provider.go

1248 lines
37 KiB
Go

// Package mdblist implements the MDBList watch provider, authenticating via
// a user-supplied API key sent as the `apikey` query parameter.
package mdblist
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
const (
defaultBaseURL = "https://api.mdblist.com"
syncPageLimit = 1000
// MDBList enforces a daily request quota (1,000/day on the free tier) plus
// an undocumented short-window limit. Pacing to ~1 request/second keeps
// paginated fetches and chunked exports under the burst limit; the daily
// quota is handled by treating 429s as a signal to defer the whole sync.
requestInterval = time.Second
requestBurst = 2
// A 429 with a short Retry-After is retried in place so pagination
// survives burst-limit blips; anything longer defers the sync run.
maxInPlaceRetryWait = 15 * time.Second
maxRetryAttempts = 2
maxErrorBodyBytes = 4 << 10
// Without a Retry-After header we cannot tell a burst limit from an
// exhausted daily quota, so default to backing off until the next
// scheduled sync (the scheduler runs hourly).
defaultRetryAfter = time.Hour
)
type Provider struct {
client *http.Client
baseURL string
// limiters paces requests per API key: each key has its own MDBList
// quota, so one throttled account must not stall other users' syncs.
limiters sync.Map // apiKey string -> *rate.Limiter
}
func NewProvider(client *http.Client, baseURL string) *Provider {
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
if strings.TrimSpace(baseURL) == "" {
baseURL = defaultBaseURL
}
return &Provider{
client: client,
baseURL: strings.TrimRight(baseURL, "/"),
}
}
func (p *Provider) Key() string {
return "mdblist"
}
func (p *Provider) DisplayName() string {
return "MDBList"
}
func (p *Provider) Capabilities() watchsync.Capabilities {
// MDBList exposes a single list — its watchlist — so it binds to Silo's
// watchlist rather than favorites.
return watchsync.Capabilities{
ImportWatched: true,
ImportProgress: true,
ExportWatched: true,
ExportUnwatched: true,
ImportWatchlist: true,
ExportWatchlist: true,
RemoveWatchlist: true,
ProvidesWatchlistOrder: true,
ScrobblePlayback: true,
}
}
func (p *Provider) HistorySource() userstore.WatchHistorySource {
return userstore.WatchHistorySourceMDBList
}
func (p *Provider) ConnectWithAPIKey(ctx context.Context, apiKey string) (watchsync.TokenSet, watchsync.ProviderAccount, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return watchsync.TokenSet{}, watchsync.ProviderAccount{}, errors.New("mdblist api key is required")
}
user, err := p.fetchUser(ctx, apiKey)
if err != nil {
return watchsync.TokenSet{}, watchsync.ProviderAccount{}, err
}
account := user.account()
if account.ID == "" {
return watchsync.TokenSet{}, watchsync.ProviderAccount{}, errors.New("mdblist /user response missing identity")
}
return watchsync.TokenSet{AccessToken: apiKey}, account, nil
}
func (p *Provider) LookupAccount(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) (watchsync.ProviderAccount, error) {
user, err := p.fetchUser(ctx, conn.AccessToken)
if err != nil {
return watchsync.ProviderAccount{}, err
}
return user.account(), nil
}
// RefreshToken is a no-op: MDBList API keys don't expire on their own.
func (p *Provider) RefreshToken(_ context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) (watchsync.TokenSet, error) {
return watchsync.TokenSet{AccessToken: conn.AccessToken}, nil
}
func (p *Provider) FetchWatched(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteWatch, error) {
var rows []watchsync.RemoteWatch
page := mdblistPageState{}
for {
var payload mdblistWatchedResponse
path := page.path("/sync/watched")
if err := p.do(ctx, http.MethodGet, path, conn.AccessToken, nil, &payload); err != nil {
return nil, err
}
pageRows := watchedRowsFromPayload(p.Key(), payload)
rows = append(rows, pageRows...)
fetched := len(payload.Movies) + len(payload.Episodes)
done, err := page.advance(payload.Pagination, fetched)
if err != nil {
return nil, fmt.Errorf("mdblist watched pagination: %w", err)
}
if done {
break
}
}
return rows, nil
}
func watchedRowsFromPayload(providerKey string, payload mdblistWatchedResponse) []watchsync.RemoteWatch {
rows := make([]watchsync.RemoteWatch, 0, len(payload.Movies)+len(payload.Episodes))
for _, movie := range payload.Movies {
watchedAt := movie.WatchedAt
if watchedAt == nil {
continue
}
key := movieKey(movie.Movie.IDs)
if key == "" {
continue
}
rows = append(rows, watchsync.RemoteWatch{
Provider: providerKey,
ProviderItemKey: key,
Kind: historyimport.KindMovie,
Title: movie.Movie.Title,
Year: movie.Movie.Year,
IMDbID: movie.Movie.IDs.IMDb,
TMDBID: intString(movie.Movie.IDs.TMDB),
TVDBID: intString(movie.Movie.IDs.TVDB),
PlayCount: 1,
LastWatchedAt: watchedAt,
})
}
for _, episode := range payload.Episodes {
watchedAt := episode.WatchedAt
if watchedAt == nil {
continue
}
key := episodeKey(episode.Show.IDs, episode.Season, episode.Number, episode.IDs)
if key == "" {
continue
}
rows = append(rows, watchsync.RemoteWatch{
Provider: providerKey,
ProviderItemKey: key,
Kind: historyimport.KindEpisode,
Title: episode.Title,
IMDbID: episode.IDs.IMDb,
TMDBID: intString(episode.IDs.TMDB),
TVDBID: intString(episode.IDs.TVDB),
SeriesTitle: episode.Show.Title,
SeriesYear: episode.Show.Year,
SeriesIMDbID: episode.Show.IDs.IMDb,
SeriesTMDBID: intString(episode.Show.IDs.TMDB),
SeriesTVDBID: intString(episode.Show.IDs.TVDB),
SeasonNumber: episode.Season,
EpisodeNumber: episode.Number,
PlayCount: 1,
LastWatchedAt: watchedAt,
})
}
return rows
}
func (p *Provider) FetchProgress(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteProgress, error) {
var payload mdblistPlaybackResponse
if err := p.do(ctx, http.MethodGet, "/sync/playback", conn.AccessToken, nil, &payload); err != nil {
return nil, err
}
items := payload.items()
rows := make([]watchsync.RemoteProgress, 0, len(items))
for _, item := range items {
// Skip actively-scrobbling sessions: they're mid-playback on another
// device and would race with the local session if imported as resume points.
if strings.EqualFold(item.Action, "start") {
continue
}
row, ok := progressFromPlayback(p.Key(), item)
if !ok {
continue
}
rows = append(rows, row)
}
return rows, nil
}
func progressFromPlayback(providerKey string, item mdblistPlaybackItem) (watchsync.RemoteProgress, bool) {
pausedAt := item.PausedAt
if pausedAt.IsZero() {
pausedAt = time.Now().UTC()
}
if item.Episode != nil {
key := episodeKey(item.Show.IDs, item.Episode.Season, item.Episode.Number, item.Episode.IDs)
if key == "" {
return watchsync.RemoteProgress{}, false
}
return watchsync.RemoteProgress{
Provider: providerKey,
ProviderItemKey: key,
Kind: historyimport.KindEpisode,
Title: item.Episode.Title,
IMDbID: item.Episode.IDs.IMDb,
TMDBID: intString(item.Episode.IDs.TMDB),
TVDBID: intString(item.Episode.IDs.TVDB),
SeriesTitle: item.Show.Title,
SeriesYear: item.Show.Year,
SeriesIMDbID: item.Show.IDs.IMDb,
SeriesTMDBID: intString(item.Show.IDs.TMDB),
SeriesTVDBID: intString(item.Show.IDs.TVDB),
SeasonNumber: item.Episode.Season,
EpisodeNumber: item.Episode.Number,
ProgressPercent: float64(item.Progress),
PausedAt: pausedAt,
}, true
}
key := movieKey(item.Movie.IDs)
if key == "" {
return watchsync.RemoteProgress{}, false
}
return watchsync.RemoteProgress{
Provider: providerKey,
ProviderItemKey: key,
Kind: historyimport.KindMovie,
Title: item.Movie.Title,
Year: item.Movie.Year,
IMDbID: item.Movie.IDs.IMDb,
TMDBID: intString(item.Movie.IDs.TMDB),
TVDBID: intString(item.Movie.IDs.TVDB),
ProgressPercent: float64(item.Progress),
PausedAt: pausedAt,
}, true
}
// MDBList exposes a watched snapshot rather than a per-play history, so each
// watched record surfaces once with its last_watched_at as the play timestamp.
func (p *Provider) FetchHistory(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemotePlay, error) {
rows, err := p.FetchWatched(ctx, cfg, conn)
if err != nil {
return nil, err
}
plays := make([]watchsync.RemotePlay, 0, len(rows))
for _, row := range rows {
if row.LastWatchedAt == nil {
continue
}
plays = append(plays, watchsync.RemotePlay{
Provider: row.Provider,
ProviderItemKey: row.ProviderItemKey,
Kind: row.Kind,
Title: row.Title,
Year: row.Year,
IMDbID: row.IMDbID,
TMDBID: row.TMDBID,
TVDBID: row.TVDBID,
SeriesTitle: row.SeriesTitle,
SeriesYear: row.SeriesYear,
SeriesIMDbID: row.SeriesIMDbID,
SeriesTMDBID: row.SeriesTMDBID,
SeriesTVDBID: row.SeriesTVDBID,
SeasonNumber: row.SeasonNumber,
EpisodeNumber: row.EpisodeNumber,
WatchedAt: *row.LastWatchedAt,
})
}
return plays, nil
}
func (p *Provider) ExportHistory(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, plays []watchsync.LocalPlay) (watchsync.ExportResult, error) {
payload := buildWatchedPayload(plays, true)
return p.sendWatched(ctx, conn, plays, "/sync/watched", payload)
}
func (p *Provider) RemoveHistory(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, plays []watchsync.LocalPlay) (watchsync.ExportResult, error) {
payload := buildWatchedPayload(plays, false)
return p.sendWatched(ctx, conn, plays, "/sync/watched/remove", payload)
}
func (p *Provider) sendWatched(ctx context.Context, conn watchsync.Connection, plays []watchsync.LocalPlay, path string, payload mdblistWatchedPayload) (watchsync.ExportResult, error) {
if payload.empty() {
return watchedExportResult(plays, false), nil
}
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(payload); err != nil {
return watchsync.ExportResult{}, fmt.Errorf("encode mdblist watched payload: %w", err)
}
var response mdblistWatchedWriteResponse
if err := p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, &response); err != nil {
return watchsync.ExportResult{}, err
}
return watchedExportResult(plays, !emptyJSONValue(response.NotFound)), nil
}
func watchedExportResult(plays []watchsync.LocalPlay, responseHasNotFound bool) watchsync.ExportResult {
result := watchsync.ExportResult{
Sent: make([]string, 0, len(plays)),
Failed: make(map[string]string),
}
for _, play := range plays {
if play.HistoryID == "" {
continue
}
if !watchedPlaySupported(play) {
result.Failed[play.HistoryID] = "MDBList watched export requires a supported media kind and external ID"
continue
}
if responseHasNotFound {
// MDBList documents not_found only as an unstructured object. It does
// not guarantee enough identity information to safely attribute a
// partial rejection, so never claim that any item in this batch sent.
result.Failed[play.HistoryID] = "MDBList did not accept one or more watched items in the batch"
continue
}
result.Sent = append(result.Sent, play.HistoryID)
}
if len(result.Failed) == 0 {
result.Failed = nil
}
return result
}
func (p *Provider) FetchWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteFavorite, error) {
now := time.Now().UTC()
var rows []watchsync.RemoteFavorite
page := mdblistPageState{}
for {
var payload mdblistWatchlistResponse
path := page.path("/watchlist/items")
if err := p.do(ctx, http.MethodGet, path, conn.AccessToken, nil, &payload); err != nil {
return nil, err
}
rows = append(rows, watchlistRowsFromPayload(p.Key(), payload, now)...)
fetched := len(payload.Movies) + len(payload.Shows)
done, err := page.advance(payload.Pagination, fetched)
if err != nil {
return nil, fmt.Errorf("mdblist watchlist pagination: %w", err)
}
if done {
break
}
}
return rows, nil
}
func watchlistRowsFromPayload(providerKey string, payload mdblistWatchlistResponse, now time.Time) []watchsync.RemoteFavorite {
rows := make([]watchsync.RemoteFavorite, 0, len(payload.Movies)+len(payload.Shows))
for _, item := range payload.Movies {
ids := item.preferredIDs()
key := movieKey(ids)
if key == "" {
continue
}
rows = append(rows, watchsync.RemoteFavorite{
Provider: providerKey,
ProviderItemKey: key,
Kind: historyimport.KindMovie,
Title: item.Title,
Year: item.ReleaseYear,
IMDbID: ids.IMDb,
TMDBID: intString(ids.TMDB),
TVDBID: intString(ids.TVDB),
FavoritedAt: now,
})
}
for _, item := range payload.Shows {
ids := item.preferredIDs()
key := showKey(ids)
if key == "" {
continue
}
rows = append(rows, watchsync.RemoteFavorite{
Provider: providerKey,
ProviderItemKey: key,
Kind: historyimport.KindSeries,
Title: item.Title,
Year: item.ReleaseYear,
IMDbID: ids.IMDb,
TMDBID: intString(ids.TMDB),
TVDBID: intString(ids.TVDB),
FavoritedAt: now,
})
}
return rows
}
func (p *Provider) ExportWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
return p.sendWatchlist(ctx, conn, items, "/watchlist/items/add", false)
}
func (p *Provider) RemoveWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
return p.sendWatchlist(ctx, conn, items, "/watchlist/items/remove", true)
}
func (p *Provider) sendWatchlist(ctx context.Context, conn watchsync.Connection, favorites []watchsync.LocalFavorite, path string, removing bool) (watchsync.ExportResult, error) {
payload := buildWatchlistPayload(favorites)
if len(payload.Movies) == 0 && len(payload.Shows) == 0 {
return watchlistExportResult(favorites, false, removing), nil
}
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(payload); err != nil {
return watchsync.ExportResult{}, fmt.Errorf("encode mdblist watchlist payload: %w", err)
}
var response mdblistWatchlistWriteResponse
if err := p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, &response); err != nil {
return watchsync.ExportResult{}, err
}
return watchlistExportResult(favorites, !emptyJSONValue(response.NotFound), removing), nil
}
func watchlistExportResult(favorites []watchsync.LocalFavorite, responseHasNotFound, removing bool) watchsync.ExportResult {
result := watchsync.ExportResult{
Sent: make([]string, 0, len(favorites)),
NotFound: make([]string, 0, len(favorites)),
Failed: make(map[string]string),
}
for _, fav := range favorites {
if fav.MediaItemID == "" {
continue
}
if !watchlistFavoriteSupported(fav) {
result.Failed[fav.MediaItemID] = "MDBList watchlist export requires a movie or series with an external ID"
continue
}
if responseHasNotFound {
if removing {
// A successful removal leaves both removed and already-absent items
// absent remotely. MDBList returns only aggregate counts, so mark
// the batch reconciled without trying to attribute individual rows.
result.NotFound = append(result.NotFound, fav.MediaItemID)
continue
}
// Adds still need item-level identity that the aggregate response
// does not provide, so never claim that any item in the batch sent.
result.Failed[fav.MediaItemID] = "MDBList did not accept one or more watchlist items in the batch"
continue
}
result.Sent = append(result.Sent, fav.MediaItemID)
}
if len(result.Failed) == 0 {
result.Failed = nil
}
if len(result.NotFound) == 0 {
result.NotFound = nil
}
return result
}
func (p *Provider) Start(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, event watchsync.ScrobbleEvent) error {
return p.scrobble(ctx, "/scrobble/start", conn, event)
}
func (p *Provider) Pause(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, event watchsync.ScrobbleEvent) error {
return p.scrobble(ctx, "/scrobble/pause", conn, event)
}
func (p *Provider) Stop(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, event watchsync.ScrobbleEvent) error {
return p.scrobble(ctx, "/scrobble/stop", conn, event)
}
func (p *Provider) ScrobbleOrderingKey(conn watchsync.Connection, _ watchsync.ScrobbleEvent) string {
return "mdblist:" + conn.ID
}
func (p *Provider) scrobble(ctx context.Context, path string, conn watchsync.Connection, event watchsync.ScrobbleEvent) error {
payload := buildScrobblePayload(event)
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(payload); err != nil {
return fmt.Errorf("encode mdblist scrobble payload: %w", err)
}
return p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, nil)
}
func (p *Provider) fetchUser(ctx context.Context, apiKey string) (mdblistUser, error) {
var user mdblistUser
if err := p.do(ctx, http.MethodGet, "/user", apiKey, nil, &user); err != nil {
return mdblistUser{}, err
}
return user, nil
}
func (p *Provider) limiter(apiKey string) *rate.Limiter {
actual, _ := p.limiters.LoadOrStore(apiKey, rate.NewLimiter(rate.Every(requestInterval), requestBurst))
limiter, _ := actual.(*rate.Limiter)
return limiter
}
func (p *Provider) do(ctx context.Context, method string, path string, apiKey string, body io.Reader, out any) error {
if strings.TrimSpace(apiKey) == "" {
return errors.New("mdblist api key is missing")
}
target := p.baseURL + path
separator := "?"
if strings.Contains(path, "?") {
separator = "&"
}
target += separator + "apikey=" + url.QueryEscape(apiKey)
// Buffer the body so rate-limited attempts can be replayed.
var payload []byte
if body != nil {
buffered, err := io.ReadAll(body)
if err != nil {
return fmt.Errorf("read mdblist request body: %w", err)
}
payload = buffered
}
limiter := p.limiter(apiKey)
for attempt := 0; ; attempt++ {
if err := limiter.Wait(ctx); err != nil {
return fmt.Errorf("wait for mdblist rate limiter: %w", err)
}
retryAfter, err := p.doOnce(ctx, method, path, target, payload, out)
if err == nil {
return nil
}
if retryAfter < 0 {
return err
}
// Rate limited. Retry in place for short waits; otherwise surface a
// typed error so the sync run defers instead of burning more quota.
if retryAfter == 0 {
retryAfter = defaultRetryAfter
}
if attempt >= maxRetryAttempts || retryAfter > maxInPlaceRetryWait {
// Exhausted in-place retries mean the short Retry-After hints were
// not trustworthy; floor the deferral so the sync doesn't come
// straight back into the same limit.
if attempt >= maxRetryAttempts && retryAfter < defaultRetryAfter {
retryAfter = defaultRetryAfter
}
return watchsync.RateLimitedError{Provider: p.Key(), RetryAfter: retryAfter}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(retryAfter):
}
}
}
// doOnce performs a single HTTP attempt. On a 429 it returns the wait hinted
// by Retry-After (0 when absent) alongside the error; every other failure
// returns -1 to signal "not retryable".
func (p *Provider) doOnce(ctx context.Context, method, path, target string, payload []byte, out any) (time.Duration, error) {
var body io.Reader
if payload != nil {
body = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, target, body)
if err != nil {
return -1, fmt.Errorf("create mdblist request: %w", err)
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return -1, fmt.Errorf("send mdblist request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return parseRetryAfter(resp.Header.Get("Retry-After"), time.Now()),
fmt.Errorf("mdblist request %s %s rate limited: status 429", method, path)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return -1, fmt.Errorf("mdblist request %s %s rejected: status %d (check api key)", method, path, resp.StatusCode)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
detail := responseErrorDetail(resp.Body)
if detail != "" {
return -1, fmt.Errorf("mdblist request %s %s failed: status %d: %s", method, path, resp.StatusCode, detail)
}
return -1, fmt.Errorf("mdblist request %s %s failed: status %d", method, path, resp.StatusCode)
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return -1, nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return -1, fmt.Errorf("decode mdblist response: %w", err)
}
return -1, nil
}
func responseErrorDetail(body io.Reader) string {
raw, err := io.ReadAll(io.LimitReader(body, maxErrorBodyBytes))
if err != nil {
return ""
}
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return ""
}
var envelope struct {
Error json.RawMessage `json:"error"`
}
if json.Unmarshal(raw, &envelope) == nil && len(envelope.Error) > 0 {
raw = bytes.TrimSpace(envelope.Error)
}
var compact bytes.Buffer
if json.Compact(&compact, raw) == nil {
return compact.String()
}
return strings.Join(strings.Fields(string(raw)), " ")
}
// parseRetryAfter reads an RFC 7231 Retry-After value (delay-seconds or
// HTTP-date). It returns 0 when the header is absent or unparseable.
func parseRetryAfter(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
if seconds < 0 {
return 0
}
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(value); err == nil {
if wait := at.Sub(now); wait > 0 {
return wait
}
}
return 0
}
// --- ID & payload helpers ---
type mdblistIDs struct {
IMDb string `json:"imdb,omitempty"`
TMDB int `json:"tmdb,omitempty"`
TVDB int `json:"tvdb,omitempty"`
Trakt int `json:"trakt,omitempty"`
MDBList string `json:"mdblist,omitempty"`
}
func (ids *mdblistIDs) UnmarshalJSON(data []byte) error {
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
ids.IMDb = stringFromJSON(raw["imdb"])
ids.TMDB = intFromJSON(raw["tmdb"])
ids.TVDB = intFromJSON(raw["tvdb"])
ids.Trakt = intFromJSON(raw["trakt"])
ids.MDBList = stringFromJSON(raw["mdblist"])
return nil
}
type mdblistMovie struct {
Title string `json:"title"`
Year int `json:"year"`
IDs mdblistIDs `json:"ids"`
}
type mdblistShow struct {
Title string `json:"title"`
Year int `json:"year"`
IDs mdblistIDs `json:"ids"`
}
type mdblistEpisode struct {
Title string `json:"title"`
Season int `json:"season"`
Number int `json:"number"`
IDs mdblistIDs `json:"ids"`
}
type mdblistWatchedMovie struct {
WatchedAt *time.Time `json:"watched_at"`
Movie mdblistMovie `json:"movie"`
}
// mdblistWatchedEpisode tolerates both shapes MDBList uses for episode rows:
// season/number inlined on the row, or nested under an `episode` object.
type mdblistWatchedEpisode struct {
WatchedAt *time.Time `json:"watched_at"`
Season int `json:"season"`
Number int `json:"number"`
Title string `json:"title"`
IDs mdblistIDs `json:"ids"`
Show mdblistShow `json:"show"`
}
func (e *mdblistWatchedEpisode) UnmarshalJSON(data []byte) error {
var raw struct {
WatchedAt *time.Time `json:"watched_at"`
Season int `json:"season"`
Number int `json:"number"`
Title string `json:"title"`
IDs mdblistIDs `json:"ids"`
Show mdblistShow `json:"show"`
Episode *mdblistEpisode `json:"episode"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
e.WatchedAt = raw.WatchedAt
e.Season = raw.Season
e.Number = raw.Number
e.Title = raw.Title
e.IDs = raw.IDs
e.Show = raw.Show
if raw.Episode != nil {
if e.Season == 0 {
e.Season = raw.Episode.Season
}
if e.Number == 0 {
e.Number = raw.Episode.Number
}
if e.Title == "" {
e.Title = raw.Episode.Title
}
if e.IDs == (mdblistIDs{}) {
e.IDs = raw.Episode.IDs
}
}
return nil
}
type mdblistWatchedResponse struct {
Movies []mdblistWatchedMovie `json:"movies"`
Episodes []mdblistWatchedEpisode `json:"episodes"`
Pagination *mdblistPagination `json:"pagination"`
}
type mdblistPagination struct {
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
}
type mdblistPageState struct {
cursor string
offset int
legacyOffset bool
}
func (s mdblistPageState) path(base string) string {
query := url.Values{"limit": {strconv.Itoa(syncPageLimit)}}
if s.cursor != "" {
query.Set("cursor", s.cursor)
} else if s.legacyOffset {
query.Set("offset", strconv.Itoa(s.offset))
}
return base + "?" + query.Encode()
}
func (s *mdblistPageState) advance(pagination *mdblistPagination, fetched int) (bool, error) {
if pagination != nil {
next := strings.TrimSpace(pagination.NextCursor)
if next != "" {
if next == s.cursor {
return false, errors.New("provider returned the same cursor twice")
}
s.cursor = next
return false, nil
}
if !pagination.HasMore {
return true, nil
}
if fetched == 0 {
return false, errors.New("provider reported more pages without returning items")
}
s.cursor = ""
s.legacyOffset = true
s.offset += fetched
return false, nil
}
if fetched < syncPageLimit {
return true, nil
}
// Older MDBList deployments omitted pagination metadata. Preserve the
// deprecated offset fallback for those responses while preferring cursors.
s.cursor = ""
s.legacyOffset = true
s.offset += fetched
return false, nil
}
type mdblistWatchedWriteResponse struct {
NotFound json.RawMessage `json:"not_found"`
}
type mdblistWatchlistWriteResponse struct {
NotFound json.RawMessage `json:"not_found"`
}
func emptyJSONValue(raw json.RawMessage) bool {
if len(bytes.TrimSpace(raw)) == 0 {
return true
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return false
}
return emptyJSONTree(value)
}
func emptyJSONTree(value any) bool {
switch typed := value.(type) {
case nil:
return true
case bool:
return !typed
case float64:
return typed == 0
case string:
return strings.TrimSpace(typed) == ""
case []any:
for _, child := range typed {
if !emptyJSONTree(child) {
return false
}
}
return true
case map[string]any:
for _, child := range typed {
if !emptyJSONTree(child) {
return false
}
}
return true
default:
return false
}
}
type mdblistPlaybackItem struct {
Progress mdblistProgress `json:"progress"`
PausedAt time.Time `json:"paused_at"`
Type string `json:"type"`
Action string `json:"action"`
Movie mdblistMovie `json:"movie"`
Show mdblistShow `json:"show"`
Episode *mdblistEpisode `json:"episode"`
}
type mdblistProgress float64
func (p *mdblistProgress) UnmarshalJSON(data []byte) error {
trimmed := strings.TrimSpace(string(data))
if trimmed == "" || trimmed == "null" {
*p = 0
return nil
}
var value float64
if err := json.Unmarshal(data, &value); err == nil {
*p = mdblistProgress(value)
return nil
}
var text string
if err := json.Unmarshal(data, &text); err != nil {
return errors.New("mdblist progress must be a number or numeric string")
}
text = strings.TrimSpace(text)
text = strings.TrimSpace(strings.TrimSuffix(text, "%"))
if text == "" {
*p = 0
return nil
}
value, err := strconv.ParseFloat(text, 64)
if err != nil {
return fmt.Errorf("parse mdblist progress: %w", err)
}
*p = mdblistProgress(value)
return nil
}
// mdblistPlaybackResponse accepts both the live API shape (a flat array of
// playback items) and the OpenAPI-documented shape ({paused, scrobbling}
// arrays). Some MDBList deployments return the array form; some return the
// object form.
type mdblistPlaybackResponse struct {
flat []mdblistPlaybackItem
nested struct {
Paused []mdblistPlaybackItem `json:"paused"`
Scrobbling []mdblistPlaybackItem `json:"scrobbling"`
}
}
func (r *mdblistPlaybackResponse) UnmarshalJSON(data []byte) error {
trimmed := bytes.TrimLeft(data, " \t\r\n")
if len(trimmed) > 0 && trimmed[0] == '[' {
return json.Unmarshal(data, &r.flat)
}
return json.Unmarshal(data, &r.nested)
}
func (r mdblistPlaybackResponse) items() []mdblistPlaybackItem {
if len(r.flat) > 0 {
return r.flat
}
out := make([]mdblistPlaybackItem, 0, len(r.nested.Paused)+len(r.nested.Scrobbling))
out = append(out, r.nested.Paused...)
for _, item := range r.nested.Scrobbling {
if item.Action == "" {
item.Action = "start"
}
out = append(out, item)
}
return out
}
type mdblistWatchlistItem struct {
ID int `json:"id"`
Title string `json:"title"`
IMDbID string `json:"imdb_id"`
TVDBID int `json:"tvdb_id"`
Mediatype string `json:"mediatype"`
ReleaseYear int `json:"release_year"`
IDs mdblistIDs `json:"ids"`
}
func (i mdblistWatchlistItem) preferredIDs() mdblistIDs {
ids := i.IDs
if ids.IMDb == "" && i.IMDbID != "" {
ids.IMDb = i.IMDbID
}
if ids.TVDB == 0 && i.TVDBID > 0 {
ids.TVDB = i.TVDBID
}
if ids.TMDB == 0 && i.ID > 0 && (i.Mediatype == "movie" || i.Mediatype == "show") {
// `id` on watchlist items mirrors the TMDB id for movies and shows.
ids.TMDB = i.ID
}
return ids
}
type mdblistWatchlistResponse struct {
Movies []mdblistWatchlistItem `json:"movies"`
Shows []mdblistWatchlistItem `json:"shows"`
Pagination *mdblistPagination `json:"pagination"`
}
type mdblistUser struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Name string `json:"name"`
}
func (u mdblistUser) account() watchsync.ProviderAccount {
id := strings.TrimSpace(u.Username)
if u.UserID > 0 {
id = strconv.Itoa(u.UserID)
}
return watchsync.ProviderAccount{ID: id, Username: u.Username}
}
type mdblistWatchedPayload struct {
Movies []mdblistWatchedMoviePayload `json:"movies,omitempty"`
Episodes []mdblistWatchedEpisodePayload `json:"episodes,omitempty"`
}
func (p mdblistWatchedPayload) empty() bool {
return len(p.Movies) == 0 && len(p.Episodes) == 0
}
type mdblistWatchedMoviePayload struct {
IDs mdblistIDs `json:"ids"`
WatchedAt string `json:"watched_at,omitempty"`
}
type mdblistWatchedEpisodePayload struct {
IDs mdblistIDs `json:"ids,omitempty"`
Show *struct {
IDs mdblistIDs `json:"ids"`
} `json:"show,omitempty"`
Season int `json:"season"`
Episode int `json:"episode"`
WatchedAt string `json:"watched_at,omitempty"`
}
type mdblistWatchlistPayload struct {
Movies []mdblistWatchlistRef `json:"movies,omitempty"`
Shows []mdblistWatchlistRef `json:"shows,omitempty"`
}
type mdblistWatchlistRef struct {
IDs mdblistIDs `json:"ids"`
}
func buildWatchedPayload(plays []watchsync.LocalPlay, includeWatchedAt bool) mdblistWatchedPayload {
var payload mdblistWatchedPayload
for _, play := range plays {
watchedAt := ""
if includeWatchedAt && !play.WatchedAt.IsZero() {
watchedAt = play.WatchedAt.UTC().Format(time.RFC3339)
}
switch play.Kind {
case historyimport.KindMovie:
ids := idsFromLocal(play.IMDbID, play.TMDBID, play.TVDBID)
if ids == (mdblistIDs{}) {
continue
}
payload.Movies = append(payload.Movies, mdblistWatchedMoviePayload{
IDs: ids,
WatchedAt: watchedAt,
})
case historyimport.KindEpisode:
episodeIDs := idsFromLocal(play.IMDbID, play.TMDBID, play.TVDBID)
row := mdblistWatchedEpisodePayload{
IDs: episodeIDs,
Season: play.SeasonNumber,
Episode: play.EpisodeNumber,
WatchedAt: watchedAt,
}
showIDs := idsFromLocal(play.SeriesIMDbID, play.SeriesTMDBID, play.SeriesTVDBID)
if showIDs != (mdblistIDs{}) {
row.Show = &struct {
IDs mdblistIDs `json:"ids"`
}{IDs: showIDs}
}
if episodeIDs == (mdblistIDs{}) && row.Show == nil {
continue
}
payload.Episodes = append(payload.Episodes, row)
}
}
return payload
}
func watchedPlaySupported(play watchsync.LocalPlay) bool {
switch play.Kind {
case historyimport.KindMovie:
return idsFromLocal(play.IMDbID, play.TMDBID, play.TVDBID) != (mdblistIDs{})
case historyimport.KindEpisode:
return idsFromLocal(play.IMDbID, play.TMDBID, play.TVDBID) != (mdblistIDs{}) ||
idsFromLocal(play.SeriesIMDbID, play.SeriesTMDBID, play.SeriesTVDBID) != (mdblistIDs{})
default:
return false
}
}
func buildWatchlistPayload(favorites []watchsync.LocalFavorite) mdblistWatchlistPayload {
var payload mdblistWatchlistPayload
for _, fav := range favorites {
ids := idsFromLocal(fav.IMDbID, fav.TMDBID, fav.TVDBID)
if ids == (mdblistIDs{}) {
ids = idsFromProviderItemKey(fav.ProviderItemKey)
}
if ids == (mdblistIDs{}) {
continue
}
ref := mdblistWatchlistRef{IDs: ids}
switch fav.Kind {
case historyimport.KindMovie:
payload.Movies = append(payload.Movies, ref)
case historyimport.KindSeries:
payload.Shows = append(payload.Shows, ref)
}
}
return payload
}
func watchlistFavoriteSupported(fav watchsync.LocalFavorite) bool {
if fav.Kind != historyimport.KindMovie && fav.Kind != historyimport.KindSeries {
return false
}
ids := idsFromLocal(fav.IMDbID, fav.TMDBID, fav.TVDBID)
if ids == (mdblistIDs{}) {
ids = idsFromProviderItemKey(fav.ProviderItemKey)
}
return ids != (mdblistIDs{})
}
func buildScrobblePayload(event watchsync.ScrobbleEvent) map[string]any {
progress := 0.0
if event.DurationSeconds > 0 {
progress = event.PositionSeconds / event.DurationSeconds * 100
}
if progress > 100 {
progress = 100
}
if progress < 0 {
progress = 0
}
// MDBList validates progress as a decimal with at most five total digits.
// Two decimal places keeps every value in the 0-100 range within that
// contract and avoids raw floating-point expansion on the wire.
progress = math.Round(progress*100) / 100
payload := map[string]any{"progress": progress}
switch event.Kind {
case historyimport.KindEpisode:
showIDs := idsFromLocal(event.SeriesIMDbID, event.SeriesTMDBID, event.SeriesTVDBID)
if showIDs == (mdblistIDs{}) {
showIDs = idsFromLocal(event.IMDbID, event.TMDBID, event.TVDBID)
}
payload["show"] = map[string]any{
"ids": showIDs,
"season": map[string]any{
"number": event.SeasonNumber,
"episode": map[string]any{
"number": event.EpisodeNumber,
},
},
}
default:
payload["movie"] = map[string]any{"ids": idsFromLocal(event.IMDbID, event.TMDBID, event.TVDBID)}
}
return payload
}
func idsFromLocal(imdbID, tmdbID, tvdbID string) mdblistIDs {
return mdblistIDs{IMDb: imdbID, TMDB: parseInt(tmdbID), TVDB: parseInt(tvdbID)}
}
func idsFromProviderItemKey(key string) mdblistIDs {
prefix, value, ok := strings.Cut(key, ":")
if !ok || value == "" {
return mdblistIDs{}
}
switch prefix {
case "imdb":
return mdblistIDs{IMDb: value}
case "tmdb":
return mdblistIDs{TMDB: parseInt(value)}
case "tvdb":
return mdblistIDs{TVDB: parseInt(value)}
case "mdblist":
return mdblistIDs{MDBList: value}
default:
return mdblistIDs{}
}
}
func movieKey(ids mdblistIDs) string {
switch {
case ids.IMDb != "":
return "imdb:" + ids.IMDb
case ids.TMDB > 0:
return "tmdb:" + strconv.Itoa(ids.TMDB)
case ids.TVDB > 0:
return "tvdb:" + strconv.Itoa(ids.TVDB)
case ids.MDBList != "":
return "mdblist:" + ids.MDBList
default:
return ""
}
}
func showKey(ids mdblistIDs) string {
switch {
case ids.TVDB > 0:
return "tvdb:" + strconv.Itoa(ids.TVDB)
case ids.TMDB > 0:
return "tmdb:" + strconv.Itoa(ids.TMDB)
case ids.IMDb != "":
return "imdb:" + ids.IMDb
case ids.MDBList != "":
return "mdblist:" + ids.MDBList
default:
return ""
}
}
func episodeKey(showIDs mdblistIDs, season, episode int, episodeIDs mdblistIDs) string {
switch {
case episodeIDs.TVDB > 0:
return "tvdb:" + strconv.Itoa(episodeIDs.TVDB)
case episodeIDs.TMDB > 0:
return "tmdb:" + strconv.Itoa(episodeIDs.TMDB)
case episodeIDs.IMDb != "":
return "imdb:" + episodeIDs.IMDb
case showIDs.TVDB > 0:
return fmt.Sprintf("show:tvdb:%d:s%d:e%d", showIDs.TVDB, season, episode)
case showIDs.TMDB > 0:
return fmt.Sprintf("show:tmdb:%d:s%d:e%d", showIDs.TMDB, season, episode)
case showIDs.IMDb != "":
return fmt.Sprintf("show:imdb:%s:s%d:e%d", showIDs.IMDb, season, episode)
default:
return ""
}
}
func intString(value int) string {
if value == 0 {
return ""
}
return strconv.Itoa(value)
}
func parseInt(value string) int {
parsed, _ := strconv.Atoi(value)
return parsed
}
func intFromJSON(value any) int {
switch v := value.(type) {
case float64:
return int(v)
case string:
parsed, _ := strconv.Atoi(v)
return parsed
default:
return 0
}
}
func stringFromJSON(value any) string {
if s, ok := value.(string); ok {
return s
}
return ""
}