fix: harden OMDb/TVmaze fetchers and series fallback
TVmaze: drop trailing slash from base URL (was producing //shows/), send
season/number as plain ints (TVmaze rejects zero-padding), check HTTP
status before decoding so 404 bodies stop being decoded as episodes, and
fetch /shows/{id} separately for show name + IMDb mapping —
episodebynumber does not honor embed=show despite what the docs imply.
OMDb: check HTTP status, and include a body snippet when Response is
non-True with an empty Error field so rate-limit and HTML failure modes
are diagnosable.
Both: assert that the fields we actually depend on come back non-empty
after decode. Catches silent field rename/removal without making us
fragile to TVmaze adding new optional fields.
main.processFile: split the nometadata fallback by branch. Series uses
Collection (legitimate shows can have no IMDb mapping); movies keep the
IMDBID/Title check.
This commit is contained in:
@@ -3,8 +3,10 @@ package metadata
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,7 +15,7 @@ import (
|
||||
|
||||
const (
|
||||
omdbAPIURL = "https://www.omdbapi.com/"
|
||||
tvmazeAPIURL = "https://api.tvmaze.com/"
|
||||
tvmazeAPIURL = "https://api.tvmaze.com"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -38,11 +40,10 @@ type TVMazeShowResponse struct {
|
||||
}
|
||||
|
||||
type TVMazeEpisode struct {
|
||||
Name string `json:"name"`
|
||||
Season int `json:"season"`
|
||||
Number int `json:"number"`
|
||||
Airdate string `json:"airdate"`
|
||||
Show TVMazeShowResponse `json:"show"`
|
||||
Name string `json:"name"`
|
||||
Season int `json:"season"`
|
||||
Number int `json:"number"`
|
||||
Airdate string `json:"airdate"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
@@ -94,13 +95,29 @@ func (c *Client) FetchMovieMetadata(imdbID string) (*types.Metadata, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading OMDb body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("OMDb HTTP %d: %s", resp.StatusCode, snippet(body))
|
||||
}
|
||||
|
||||
var result OMDbResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decoding OMDb response: %w", err)
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("decoding OMDb response: %w (body: %s)", err, snippet(body))
|
||||
}
|
||||
|
||||
if result.Response != "True" {
|
||||
return nil, fmt.Errorf("OMDb error: %s", result.Error)
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("OMDb error: %s", result.Error)
|
||||
}
|
||||
return nil, fmt.Errorf("OMDb non-True response: %s", snippet(body))
|
||||
}
|
||||
|
||||
if result.Title == "" || result.IMDBID == "" {
|
||||
return nil, fmt.Errorf("OMDb response missing Title/imdbID (schema drift?): %s", snippet(body))
|
||||
}
|
||||
|
||||
dateReleased := formatDate(result.Year)
|
||||
@@ -114,39 +131,75 @@ func (c *Client) FetchMovieMetadata(imdbID string) (*types.Metadata, error) {
|
||||
}
|
||||
|
||||
func (c *Client) FetchSeriesMetadata(tvmazeID, season, episode string) (*types.Metadata, error) {
|
||||
url := fmt.Sprintf("%s/shows/%s/episodebynumber?season=%s&number=%s", tvmazeAPIURL, tvmazeID, season, episode)
|
||||
|
||||
resp, err := c.httpClient.Get(url)
|
||||
seasonNum, err := strconv.Atoi(season)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching TVmaze: %w", err)
|
||||
return nil, fmt.Errorf("parsing season %q: %w", season, err)
|
||||
}
|
||||
episodeNum, err := strconv.Atoi(episode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing episode %q: %w", episode, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
epURL := fmt.Sprintf("%s/shows/%s/episodebynumber?season=%d&number=%d", tvmazeAPIURL, tvmazeID, seasonNum, episodeNum)
|
||||
var ep TVMazeEpisode
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ep); err != nil {
|
||||
return nil, fmt.Errorf("decoding TVmaze response: %w", err)
|
||||
if err := c.fetchTVMazeJSON(epURL, &ep); err != nil {
|
||||
return nil, fmt.Errorf("TVmaze episode %s S%dE%d: %w", tvmazeID, seasonNum, episodeNum, err)
|
||||
}
|
||||
|
||||
dateReleased := formatDate(ep.Airdate)
|
||||
showURL := fmt.Sprintf("%s/shows/%s", tvmazeAPIURL, tvmazeID)
|
||||
var show TVMazeShowResponse
|
||||
if err := c.fetchTVMazeJSON(showURL, &show); err != nil {
|
||||
return nil, fmt.Errorf("TVmaze show %s: %w", tvmazeID, err)
|
||||
}
|
||||
|
||||
var imdbID string
|
||||
if ep.Show.Extern.IMDB != "" {
|
||||
imdbID = ep.Show.Extern.IMDB
|
||||
if ep.Name == "" || show.Name == "" {
|
||||
return nil, fmt.Errorf("TVmaze response missing episode/show name (schema drift?) for %s S%dE%d", tvmazeID, seasonNum, episodeNum)
|
||||
}
|
||||
|
||||
return &types.Metadata{
|
||||
Title: ep.Name,
|
||||
Collection: ep.Show.Name,
|
||||
Collection: show.Name,
|
||||
Season: season,
|
||||
Episode: episode,
|
||||
DateReleased: dateReleased,
|
||||
IMDBID: imdbID,
|
||||
DateReleased: formatDate(ep.Airdate),
|
||||
IMDBID: show.Extern.IMDB,
|
||||
TVMazeID: tvmazeID,
|
||||
IsSeries: true,
|
||||
OriginalMedia: types.MediaTypeBluRay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchTVMazeJSON(url string, v interface{}) error {
|
||||
resp, err := c.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet(body))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, v); err != nil {
|
||||
return fmt.Errorf("decode: %w (body: %s)", err, snippet(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func snippet(body []byte) string {
|
||||
s := strings.TrimSpace(string(body))
|
||||
const max = 200
|
||||
if len(s) > max {
|
||||
s = s[:max] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func formatDate(date string) string {
|
||||
if date == "" || date == "N/A" {
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user