One "video" kind meant every video library behaved the same, so a film shelf tried to parse episode numbers and a TV library could not be told apart from it. The kinds are now a declaration rather than a special case. Five video kinds, all owning the same containers and differing only in how their files become items: movies flat tv series/season/episode adult flat hentai series/season/episode video series/season/episode (kept: existing libraries already use it) The scanner dispatches on library.GroupModeFor(lib.Kind) instead of comparing the kind to "video", so a new episodic kind is a registry entry and nothing in the scanner changes. The decision worth recording, because it could have gone the other way: media_items.kind stays STRUCTURAL. Every video library stores 'video' on its rows, not its own kind. media_items.kind answers "is this a playable video", which is what the playback, catalog and Jellyfin paths actually ask, and migration 0013 already committed to that rule when it decided an episode would stay kind='video'. Storing the library kind instead would have meant teaching every query that says kind='video' about five new values - catalog's episode and next-episode reads, Jellyfin's episodes-only filter, the item-type mapping, the web player's routing - and every kind added later would have had to do it again. So the two jobs are split: the ROW kind says what a thing is, the LIBRARY kind says how it is classified, and the library kind is what routes metadata. The enricher now reads the library to choose providers, because a movies library and an adult library hold identical rows and must not query the same catalogue. The adult kinds have no metadata provider. There is no mainstream catalogue for that content - the ecosystem uses StashDB or local NFO files - so they scan and browse and stay unenriched until someone registers one. That is stated in the registry rather than left as a surprise. Verified end-to-end: the same tree under a movies library stays flat, under a tv library builds series/season/episodes, and every row in both is stored as kind='video'. The UI needs no change - it reads /api/v1/media-types, so the new kinds appeared in the picker by themselves. Red-green: dispatching on the literal kind fails the tv and hentai cases; storing the library kind on rows fails the structural-kind assertion.
185 lines
5.2 KiB
Go
185 lines
5.2 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// TMDB matches video — films and television — against The Movie Database.
|
|
//
|
|
// A Query does not say which of the two it is, and TMDB keeps them in separate
|
|
// namespaces behind separate endpoints, so Search asks both at once rather than
|
|
// guessing from the title. The results are merged by TMDB's own popularity,
|
|
// which is the only ranking signal available without a second request per item.
|
|
type TMDB struct {
|
|
APIKey string
|
|
BaseURL string
|
|
Client *http.Client
|
|
}
|
|
|
|
// NewTMDB builds the provider. An empty key yields a provider that finds
|
|
// nothing, so a misconfiguration degrades rather than erroring.
|
|
func NewTMDB(apiKey string) *TMDB {
|
|
return &TMDB{
|
|
APIKey: apiKey,
|
|
BaseURL: "https://api.themoviedb.org/3",
|
|
// One client for both requests: connection reuse across the pair is
|
|
// most of what makes the concurrent form cheap.
|
|
Client: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (p *TMDB) Name() string { return "tmdb" }
|
|
|
|
func (p *TMDB) Kinds() []string { return []string{"video", "movies", "tv"} }
|
|
|
|
// tmdbResponse is the subset of a TMDB search payload we read. Film and
|
|
// television share the envelope but not the field names, so the few that differ
|
|
// are read together and picked between below.
|
|
type tmdbResponse struct {
|
|
Results []struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"`
|
|
Name string `json:"name"`
|
|
Overview string `json:"overview"`
|
|
ReleaseDate string `json:"release_date"`
|
|
FirstAirDate string `json:"first_air_date"`
|
|
PosterPath string `json:"poster_path"`
|
|
Popularity float64 `json:"popularity"`
|
|
} `json:"results"`
|
|
}
|
|
|
|
// tmdbHit carries a candidate with the popularity it was ranked by, which
|
|
// Candidate has no field for.
|
|
type tmdbHit struct {
|
|
cand Candidate
|
|
popularity float64
|
|
}
|
|
|
|
func (p *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|
title := strings.TrimSpace(q.Title)
|
|
if p.APIKey == "" || title == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
// The two endpoints run at once: the call costs one response time, not two.
|
|
// Whichever fails first cancels the other, so one dead endpoint cannot hold
|
|
// the result hostage while the healthy one is waited out.
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
hits [2][]tmdbHit
|
|
errs [2]error
|
|
fetch = func(i int, endpoint, kind string) {
|
|
defer wg.Done()
|
|
hits[i], errs[i] = p.search(ctx, endpoint, kind, title, q.Year)
|
|
if errs[i] != nil {
|
|
cancel()
|
|
}
|
|
}
|
|
)
|
|
wg.Add(2)
|
|
go fetch(0, "/search/movie", "movie")
|
|
go fetch(1, "/search/tv", "tv")
|
|
wg.Wait()
|
|
|
|
merged := append(hits[0], hits[1]...)
|
|
if len(merged) == 0 && (errs[0] != nil || errs[1] != nil) {
|
|
// Only a total failure is an error. One endpoint answering is a result.
|
|
return nil, errors.Join(errs[0], errs[1])
|
|
}
|
|
sort.SliceStable(merged, func(i, j int) bool {
|
|
return merged[i].popularity > merged[j].popularity
|
|
})
|
|
|
|
out := make([]Candidate, 0, len(merged))
|
|
for _, h := range merged {
|
|
out = append(out, h.cand)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// search queries one TMDB endpoint and maps its results.
|
|
func (p *TMDB) search(ctx context.Context, endpoint, kind, title string, year int) ([]tmdbHit, error) {
|
|
params := url.Values{}
|
|
params.Set("api_key", p.APIKey)
|
|
params.Set("query", title)
|
|
params.Set("limit", "5")
|
|
if year > 0 {
|
|
// The two namespaces spell the year filter differently.
|
|
if kind == "movie" {
|
|
params.Set("year", strconv.Itoa(year))
|
|
} else {
|
|
params.Set("first_air_date_year", strconv.Itoa(year))
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
|
strings.TrimRight(p.BaseURL, "/")+endpoint+"?"+params.Encode(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := p.Client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tmdb: %s: %w", kind, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("tmdb: %s: status %d", kind, resp.StatusCode)
|
|
}
|
|
|
|
var parsed tmdbResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
|
return nil, fmt.Errorf("tmdb: %s: decode: %w", kind, err)
|
|
}
|
|
|
|
out := make([]tmdbHit, 0, len(parsed.Results))
|
|
for _, r := range parsed.Results {
|
|
name := r.Title
|
|
if name == "" {
|
|
name = r.Name // television has no title, only a name
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
date := r.ReleaseDate
|
|
if date == "" {
|
|
date = r.FirstAirDate
|
|
}
|
|
out = append(out, tmdbHit{
|
|
cand: Candidate{
|
|
Provider: p.Name(),
|
|
// Namespaced: TMDB keeps films and television in separate id
|
|
// spaces, so film 603 and show 603 are different works. A bare
|
|
// id would store them identically and collide in provider_ids.
|
|
ExternalID: kind + "/" + strconv.Itoa(r.ID),
|
|
Title: name,
|
|
Year: parseYear(date),
|
|
Description: r.Overview,
|
|
CoverURL: tmdbPoster(r.PosterPath),
|
|
},
|
|
popularity: r.Popularity,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// tmdbPoster expands TMDB's relative poster path into a full image URL. The
|
|
// bytes are never fetched here: the enricher caches covers it actually uses.
|
|
func tmdbPoster(path string) string {
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
return "https://image.tmdb.org/t/p/w500" + path
|
|
}
|