Добавил еще провайдер TVMaze
This commit is contained in:
@@ -160,6 +160,17 @@ func runServe(args []string) error {
|
|||||||
// сериалов Jellyfin привычнее tvdbid, поэтому TVDB идёт первым.
|
// сериалов Jellyfin привычнее tvdbid, поэтому TVDB идёт первым.
|
||||||
func metadataProviders(cfg *config.Config) ([]metadata.Provider, error) {
|
func metadataProviders(cfg *config.Config) ([]metadata.Provider, error) {
|
||||||
var out []metadata.Provider
|
var out []metadata.Provider
|
||||||
|
// TVMaze без ключа и покрывает сериалы — ставим первым.
|
||||||
|
if cfg.Metadata.TVMaze.Enabled {
|
||||||
|
p, err := metadata.NewTVMaze(metadata.TVMazeConfig{
|
||||||
|
Proxy: cfg.Metadata.TVMaze.Proxy,
|
||||||
|
Timeout: cfg.Metadata.TVMaze.Timeout.Std(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tvmaze provider: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
if cfg.Metadata.TVDB.Enabled {
|
if cfg.Metadata.TVDB.Enabled {
|
||||||
p, err := metadata.NewTVDB(metadata.TVDBConfig{
|
p, err := metadata.NewTVDB(metadata.TVDBConfig{
|
||||||
APIKey: cfg.Metadata.TVDB.APIKey,
|
APIKey: cfg.Metadata.TVDB.APIKey,
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ api_key = ""
|
|||||||
proxy = ""
|
proxy = ""
|
||||||
timeout = "10s"
|
timeout = "10s"
|
||||||
|
|
||||||
|
[metadata.tvmaze]
|
||||||
|
enabled = false # без ключа; только сериалы, тег [tvdbid-…] из externals
|
||||||
|
proxy = ""
|
||||||
|
timeout = "10s"
|
||||||
|
|
||||||
[worker]
|
[worker]
|
||||||
poll_interval = "5s"
|
poll_interval = "5s"
|
||||||
stuck_after = "1h"
|
stuck_after = "1h"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ qBittorrent, определяет содержимое (фильм или сер
|
|||||||
| `worker` | владелец машины состояний; поллинг, сериализация команд |
|
| `worker` | владелец машины состояний; поллинг, сериализация команд |
|
||||||
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
|
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
|
||||||
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
|
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
|
||||||
| `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) |
|
| `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) |
|
||||||
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
|
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
|
||||||
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
|
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
|
||||||
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
|
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
|
||||||
|
|||||||
@@ -61,9 +61,11 @@ type LLM struct {
|
|||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
TMDB MetadataProvider `toml:"tmdb"`
|
TMDB MetadataProvider `toml:"tmdb"`
|
||||||
TVDB MetadataProvider `toml:"tvdb"`
|
TVDB MetadataProvider `toml:"tvdb"`
|
||||||
|
TVMaze MetadataProvider `toml:"tvmaze"` // без ключа, только сериалы
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetadataProvider — настройки одного провайдера метаданных.
|
// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
|
||||||
|
// (TVMaze) поле api_key не используется.
|
||||||
type MetadataProvider struct {
|
type MetadataProvider struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
APIKey string `toml:"api_key"`
|
APIKey string `toml:"api_key"`
|
||||||
|
|||||||
@@ -9,6 +9,45 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestIntegration_TVMaze бьётся в реальный TVMaze (без ключа). Сетевой, по
|
||||||
|
// умолчанию пропускается; включается флагом:
|
||||||
|
//
|
||||||
|
// JELLYBIT_LIVE=1 go test ./internal/metadata/ -run Integration -v
|
||||||
|
func TestIntegration_TVMaze(t *testing.T) {
|
||||||
|
if os.Getenv("JELLYBIT_LIVE") == "" {
|
||||||
|
t.Skip("set JELLYBIT_LIVE=1 to run network tests")
|
||||||
|
}
|
||||||
|
c, err := metadata.NewTVMaze(metadata.TVMazeConfig{Timeout: 20 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTVMaze: %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cands, err := c.Search(ctx, metadata.Query{Type: metadata.Series, Title: "Fargo", Year: 2014})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if len(cands) == 0 {
|
||||||
|
t.Fatal("ожидался хотя бы один кандидат")
|
||||||
|
}
|
||||||
|
top := cands[0]
|
||||||
|
t.Logf("top: id=%s title=%q year=%d tag=%s/%s",
|
||||||
|
top.ID, top.Title, top.Year, top.TagProvider, top.TagID)
|
||||||
|
if top.TagProvider != "tvdb" || top.TagID == "" {
|
||||||
|
t.Errorf("ожидался TVDB-тег из externals, got %s/%s", top.TagProvider, top.TagID)
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, err := c.SeasonEpisodeCounts(ctx, top.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeasonEpisodeCounts: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("season counts: %v", counts)
|
||||||
|
if counts[1] == 0 {
|
||||||
|
t.Error("ожидались серии в первом сезоне")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestIntegration_TVDB бьётся в реальный TheTVDB v4. По умолчанию
|
// TestIntegration_TVDB бьётся в реальный TheTVDB v4. По умолчанию
|
||||||
// пропускается; включается ключом:
|
// пропускается; включается ключом:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -25,12 +25,19 @@ type Query struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Candidate — результат поиска: официальный id и каноническое имя.
|
// Candidate — результат поиска: официальный id и каноническое имя.
|
||||||
|
//
|
||||||
|
// ID — нативный id провайдера (по нему запрашиваются SeasonEpisodeCounts).
|
||||||
|
// TagProvider/TagID — опц. внешний id для имени папки Jellyfin: напр. TVMaze
|
||||||
|
// ищет без ключа, но отдаёт TVDB/IMDb-id во внешних ссылках, и тег ставим
|
||||||
|
// привычный ([tvdbid-…]). Пусто → тег берётся из Provider/ID.
|
||||||
type Candidate struct {
|
type Candidate struct {
|
||||||
Provider string // "tmdb" | "tvdb"
|
Provider string // "tmdb" | "tvdb" | "tvmaze"
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
OriginalTitle string
|
OriginalTitle string
|
||||||
Year int
|
Year int
|
||||||
|
TagProvider string // напр. "tvdb"/"imdb" (опц.)
|
||||||
|
TagID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider — одна база метаданных.
|
// Provider — одна база метаданных.
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tvmazeDefaultBaseURL = "https://api.tvmaze.com"
|
||||||
|
|
||||||
|
// TVMazeConfig — настройки клиента TVMaze.
|
||||||
|
type TVMazeConfig struct {
|
||||||
|
Proxy string
|
||||||
|
Timeout time.Duration
|
||||||
|
BaseURL string // пусто → api.tvmaze.com; задаётся в тестах
|
||||||
|
}
|
||||||
|
|
||||||
|
// TVMaze — клиент TVMaze. Открытое API без ключа (лимит ~20 запросов/10с).
|
||||||
|
// Покрывает только сериалы; для фильмов поиск возвращает пусто. В externals
|
||||||
|
// отдаёт TVDB/IMDb-id, который используем как тег папки Jellyfin.
|
||||||
|
type TVMaze struct {
|
||||||
|
baseURL string
|
||||||
|
hc *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTVMaze собирает клиент TVMaze (ключ не нужен).
|
||||||
|
func NewTVMaze(cfg TVMazeConfig) (*TVMaze, error) {
|
||||||
|
hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
base := cfg.BaseURL
|
||||||
|
if base == "" {
|
||||||
|
base = tvmazeDefaultBaseURL
|
||||||
|
}
|
||||||
|
return &TVMaze{baseURL: strings.TrimRight(base, "/"), hc: hc}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TVMaze) Name() string { return "tvmaze" }
|
||||||
|
|
||||||
|
type tvmazeShow struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Premiered string `json:"premiered"`
|
||||||
|
Externals struct {
|
||||||
|
TheTVDB int `json:"thetvdb"`
|
||||||
|
IMDb string `json:"imdb"`
|
||||||
|
} `json:"externals"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search ищет сериал по названию. Фильмы TVMaze не покрывает — для них
|
||||||
|
// возвращает пусто. Год не сужаем в запросе (TVMaze не фильтрует по году),
|
||||||
|
// отбор по году делает вызывающий.
|
||||||
|
func (t *TVMaze) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||||
|
if q.Type != Series {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var resp []struct {
|
||||||
|
Show tvmazeShow `json:"show"`
|
||||||
|
}
|
||||||
|
rawURL := t.baseURL + "/search/shows?q=" + url.QueryEscape(q.Title)
|
||||||
|
if err := getJSON(ctx, t.hc, rawURL, nil, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("tvmaze search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Candidate, 0, len(resp))
|
||||||
|
for _, r := range resp {
|
||||||
|
s := r.Show
|
||||||
|
c := Candidate{
|
||||||
|
Provider: "tvmaze",
|
||||||
|
ID: strconv.Itoa(s.ID),
|
||||||
|
Title: s.Name,
|
||||||
|
Year: yearOf(s.Premiered),
|
||||||
|
}
|
||||||
|
// Тег папки — привычный TVDB-id, если есть; иначе IMDb.
|
||||||
|
switch {
|
||||||
|
case s.Externals.TheTVDB != 0:
|
||||||
|
c.TagProvider, c.TagID = "tvdb", strconv.Itoa(s.Externals.TheTVDB)
|
||||||
|
case s.Externals.IMDb != "":
|
||||||
|
c.TagProvider, c.TagID = "imdb", s.Externals.IMDb
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tvmazeEpisode struct {
|
||||||
|
Season int `json:"season"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeasonEpisodeCounts считает число серий по сезонам (нативный id TVMaze).
|
||||||
|
func (t *TVMaze) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
|
||||||
|
var eps []tvmazeEpisode
|
||||||
|
rawURL := t.baseURL + "/shows/" + url.PathEscape(id) + "/episodes"
|
||||||
|
if err := getJSON(ctx, t.hc, rawURL, nil, &eps); err != nil {
|
||||||
|
return nil, fmt.Errorf("tvmaze episodes %s: %w", id, err)
|
||||||
|
}
|
||||||
|
out := map[int]int{}
|
||||||
|
for _, e := range eps {
|
||||||
|
out[e.Season]++
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTVMaze(t *testing.T, url string) *TVMaze {
|
||||||
|
t.Helper()
|
||||||
|
c, err := NewTVMaze(TVMazeConfig{BaseURL: url})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTVMaze: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTVMaze_SearchSeries(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/search/shows" || r.URL.Query().Get("q") != "Fargo" {
|
||||||
|
t.Errorf("request = %s?%s", r.URL.Path, r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"score":0.9,"show":{"id":1,"name":"Fargo","premiered":"2014-04-15",
|
||||||
|
"externals":{"thetvdb":269613,"imdb":"tt2802850"}}},
|
||||||
|
{"score":0.1,"show":{"id":2,"name":"Other","premiered":"2010-01-01",
|
||||||
|
"externals":{"thetvdb":0,"imdb":"tt999"}}}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := newTVMaze(t, srv.URL).Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2014})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d candidates", len(got))
|
||||||
|
}
|
||||||
|
c := got[0]
|
||||||
|
if c.Provider != "tvmaze" || c.ID != "1" || c.Title != "Fargo" || c.Year != 2014 {
|
||||||
|
t.Errorf("candidate = %+v", c)
|
||||||
|
}
|
||||||
|
// TVDB-id из externals → тег папки.
|
||||||
|
if c.TagProvider != "tvdb" || c.TagID != "269613" {
|
||||||
|
t.Errorf("tag = %s/%s, want tvdb/269613", c.TagProvider, c.TagID)
|
||||||
|
}
|
||||||
|
// Без thetvdb → фолбэк на imdb.
|
||||||
|
if got[1].TagProvider != "imdb" || got[1].TagID != "tt999" {
|
||||||
|
t.Errorf("fallback tag = %s/%s", got[1].TagProvider, got[1].TagID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTVMaze_SearchMovieEmpty(t *testing.T) {
|
||||||
|
// Для фильмов TVMaze не вызывается вовсе.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Error("movie search must not hit the network")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := newTVMaze(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "X"})
|
||||||
|
if err != nil || got != nil {
|
||||||
|
t.Errorf("movie search = %v, %v; want nil, nil", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTVMaze_SeasonEpisodeCounts(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/shows/1/episodes" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"season":1,"number":1},{"season":1,"number":2},
|
||||||
|
{"season":2,"number":1}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
counts, err := newTVMaze(t, srv.URL).SeasonEpisodeCounts(context.Background(), "1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeasonEpisodeCounts: %v", err)
|
||||||
|
}
|
||||||
|
if counts[1] != 2 || counts[2] != 1 {
|
||||||
|
t.Errorf("counts = %v", counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,16 +39,31 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c := strong[0]
|
c := strong[0]
|
||||||
match := &Match{Provider: c.Provider, ProviderID: c.ID, Title: c.Title, Year: c.Year}
|
|
||||||
|
// Число серий тянем по нативному id провайдера.
|
||||||
|
var counts map[int]int
|
||||||
if mt == metadata.Series {
|
if mt == metadata.Series {
|
||||||
if counts, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil {
|
if got, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil {
|
||||||
match.SeasonEpisodeCounts = counts
|
counts = got
|
||||||
} else {
|
} else {
|
||||||
r.log.Warn("recognize: episode counts failed",
|
r.log.Warn("recognize: episode counts failed",
|
||||||
"provider", p.Name(), "id", c.ID, "err", cerr)
|
"provider", p.Name(), "id", c.ID, "err", cerr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return match
|
|
||||||
|
// Провенанс и тег папки — по внешнему id, если провайдер его дал
|
||||||
|
// (TVMaze отдаёт TVDB/IMDb-id); иначе по самому провайдеру.
|
||||||
|
prov, pid := c.Provider, c.ID
|
||||||
|
if c.TagProvider != "" {
|
||||||
|
prov, pid = c.TagProvider, c.TagID
|
||||||
|
}
|
||||||
|
return &Match{
|
||||||
|
Provider: prov,
|
||||||
|
ProviderID: pid,
|
||||||
|
Title: c.Title,
|
||||||
|
Year: c.Year,
|
||||||
|
SeasonEpisodeCounts: counts,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,30 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchMetadata_TagFromExternal(t *testing.T) {
|
||||||
|
// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
|
||||||
|
p := &fakeProvider{
|
||||||
|
name: "tvmaze",
|
||||||
|
candidates: []metadata.Candidate{
|
||||||
|
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
|
||||||
|
},
|
||||||
|
counts: map[int]int{1: 10},
|
||||||
|
}
|
||||||
|
r := recognizerWith(p)
|
||||||
|
m := r.matchMetadata(context.Background(),
|
||||||
|
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected match")
|
||||||
|
}
|
||||||
|
// Провенанс/тег — внешний TVDB-id, а не нативный tvmaze.
|
||||||
|
if m.Provider != "tvdb" || m.ProviderID != "269613" {
|
||||||
|
t.Errorf("match provider = %s/%s, want tvdb/269613", m.Provider, m.ProviderID)
|
||||||
|
}
|
||||||
|
if m.SeasonEpisodeCounts[1] != 10 {
|
||||||
|
t.Errorf("counts not fetched by native id: %+v", m.SeasonEpisodeCounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
||||||
p := &fakeProvider{
|
p := &fakeProvider{
|
||||||
candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}},
|
candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}},
|
||||||
|
|||||||
@@ -515,6 +515,8 @@ func providerTag(provider, id string) string {
|
|||||||
return "tmdbid-" + id
|
return "tmdbid-" + id
|
||||||
case "tvdb":
|
case "tvdb":
|
||||||
return "tvdbid-" + id
|
return "tvdbid-" + id
|
||||||
|
case "imdb":
|
||||||
|
return "imdbid-" + id
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -591,6 +591,7 @@ func TestProviderTag(t *testing.T) {
|
|||||||
cases := []struct{ provider, id, want string }{
|
cases := []struct{ provider, id, want string }{
|
||||||
{"tmdb", "603", "tmdbid-603"},
|
{"tmdb", "603", "tmdbid-603"},
|
||||||
{"tvdb", "123", "tvdbid-123"},
|
{"tvdb", "123", "tvdbid-123"},
|
||||||
|
{"imdb", "tt2802850", "imdbid-tt2802850"},
|
||||||
{"none", "", ""},
|
{"none", "", ""},
|
||||||
{"tmdb", "", ""},
|
{"tmdb", "", ""},
|
||||||
{"weird", "1", ""},
|
{"weird", "1", ""},
|
||||||
|
|||||||
Reference in New Issue
Block a user