879 lines
26 KiB
Go
879 lines
26 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"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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
|
|
)
|
|
|
|
type Provider struct {
|
|
client *http.Client
|
|
baseURL string
|
|
}
|
|
|
|
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 {
|
|
return watchsync.Capabilities{
|
|
ImportWatched: true,
|
|
ImportProgress: true,
|
|
ExportWatched: true,
|
|
ExportUnwatched: true,
|
|
ImportFavorites: true,
|
|
ExportFavorites: true,
|
|
RemoveFavorites: 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
|
|
for offset := 0; ; {
|
|
var payload mdblistWatchedResponse
|
|
path := fmt.Sprintf("/sync/watched?limit=%d&offset=%d", syncPageLimit, offset)
|
|
if err := p.do(ctx, http.MethodGet, path, conn.AccessToken, nil, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
page := watchedRowsFromPayload(p.Key(), payload)
|
|
rows = append(rows, page...)
|
|
fetched := len(payload.Movies) + len(payload.Episodes)
|
|
if fetched < syncPageLimit {
|
|
break
|
|
}
|
|
offset += fetched
|
|
}
|
|
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: 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: 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)
|
|
if payload.empty() {
|
|
return watchsync.ExportResult{}, nil
|
|
}
|
|
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)
|
|
if payload.empty() {
|
|
return watchsync.ExportResult{}, nil
|
|
}
|
|
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) {
|
|
var body bytes.Buffer
|
|
if err := json.NewEncoder(&body).Encode(payload); err != nil {
|
|
return watchsync.ExportResult{}, fmt.Errorf("encode mdblist watched payload: %w", err)
|
|
}
|
|
if err := p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, nil); err != nil {
|
|
return watchsync.ExportResult{}, err
|
|
}
|
|
result := watchsync.ExportResult{Sent: make([]string, 0, len(plays))}
|
|
for _, play := range plays {
|
|
result.Sent = append(result.Sent, play.HistoryID)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (p *Provider) FetchFavorites(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteFavorite, error) {
|
|
now := time.Now().UTC()
|
|
var rows []watchsync.RemoteFavorite
|
|
for offset := 0; ; {
|
|
var payload mdblistWatchlistResponse
|
|
path := fmt.Sprintf("/watchlist/items?limit=%d&offset=%d", syncPageLimit, offset)
|
|
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)
|
|
if fetched < syncPageLimit {
|
|
break
|
|
}
|
|
offset += fetched
|
|
}
|
|
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) ExportFavorites(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, favorites []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
|
|
return p.sendWatchlist(ctx, conn, favorites, "/watchlist/items/add")
|
|
}
|
|
|
|
func (p *Provider) RemoveFavorites(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, favorites []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
|
|
return p.sendWatchlist(ctx, conn, favorites, "/watchlist/items/remove")
|
|
}
|
|
|
|
func (p *Provider) sendWatchlist(ctx context.Context, conn watchsync.Connection, favorites []watchsync.LocalFavorite, path string) (watchsync.ExportResult, error) {
|
|
payload := buildWatchlistPayload(favorites)
|
|
if len(payload.Movies) == 0 && len(payload.Shows) == 0 {
|
|
return watchsync.ExportResult{}, 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)
|
|
}
|
|
if err := p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, nil); err != nil {
|
|
return watchsync.ExportResult{}, err
|
|
}
|
|
result := watchsync.ExportResult{Sent: make([]string, 0, len(favorites)*2)}
|
|
for _, fav := range favorites {
|
|
result.Sent = append(result.Sent, fav.MediaItemID)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
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) 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)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, target, body)
|
|
if err != nil {
|
|
return fmt.Errorf("create mdblist request: %w", err)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("send mdblist request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
return 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 {
|
|
return fmt.Errorf("mdblist request %s %s failed: status %d", method, path, resp.StatusCode)
|
|
}
|
|
if out == nil || resp.StatusCode == http.StatusNoContent {
|
|
return nil
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return fmt.Errorf("decode mdblist response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- 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"`
|
|
}
|
|
|
|
type mdblistPlaybackItem struct {
|
|
Progress float64 `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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
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 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 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
|
|
}
|
|
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}
|
|
payload["season"] = event.SeasonNumber
|
|
payload["episode"] = 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 ""
|
|
}
|