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:
Esa Kataja
2026-05-16 19:57:01 +03:00
parent 1dab2befbd
commit b95369168c
2 changed files with 81 additions and 27 deletions
+5 -4
View File
@@ -146,12 +146,13 @@ func processFile(inputPath string, cfg *types.Config, enc *encoder.Encoder, meta
outputPath := filepath.Join(outputDir, "output.mkv") outputPath := filepath.Join(outputDir, "output.mkv")
var outFilename string var outFilename string
if meta.IMDBID == "" || meta.Title == "Unknown" { switch {
outFilename = fmt.Sprintf("%s.nometadata.mkv", generateRandomString(8)) case isSeries && meta.Collection != "":
} else if isSeries {
outFilename = fmt.Sprintf("%s.S%sE%s.mkv", sanitizeFilename(meta.Collection), season, episode) outFilename = fmt.Sprintf("%s.S%sE%s.mkv", sanitizeFilename(meta.Collection), season, episode)
} else { case !isSeries && meta.IMDBID != "" && meta.Title != "Unknown":
outFilename = fmt.Sprintf("%s.%s.mkv", sanitizeFilename(meta.Title), meta.IMDBID) outFilename = fmt.Sprintf("%s.%s.mkv", sanitizeFilename(meta.Title), meta.IMDBID)
default:
outFilename = fmt.Sprintf("%s.nometadata.mkv", generateRandomString(8))
} }
finalOutput := filepath.Join(cfg.Paths.Output, outFilename) finalOutput := filepath.Join(cfg.Paths.Output, outFilename)
+76 -23
View File
@@ -3,8 +3,10 @@ package metadata
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
@@ -13,7 +15,7 @@ import (
const ( const (
omdbAPIURL = "https://www.omdbapi.com/" omdbAPIURL = "https://www.omdbapi.com/"
tvmazeAPIURL = "https://api.tvmaze.com/" tvmazeAPIURL = "https://api.tvmaze.com"
) )
var ( var (
@@ -38,11 +40,10 @@ type TVMazeShowResponse struct {
} }
type TVMazeEpisode struct { type TVMazeEpisode struct {
Name string `json:"name"` Name string `json:"name"`
Season int `json:"season"` Season int `json:"season"`
Number int `json:"number"` Number int `json:"number"`
Airdate string `json:"airdate"` Airdate string `json:"airdate"`
Show TVMazeShowResponse `json:"show"`
} }
type Client struct { type Client struct {
@@ -94,13 +95,29 @@ func (c *Client) FetchMovieMetadata(imdbID string) (*types.Metadata, error) {
} }
defer resp.Body.Close() 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 var result OMDbResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("decoding OMDb response: %w", err) return nil, fmt.Errorf("decoding OMDb response: %w (body: %s)", err, snippet(body))
} }
if result.Response != "True" { 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) 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) { 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) seasonNum, err := strconv.Atoi(season)
resp, err := c.httpClient.Get(url)
if err != nil { 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 var ep TVMazeEpisode
if err := json.NewDecoder(resp.Body).Decode(&ep); err != nil { if err := c.fetchTVMazeJSON(epURL, &ep); err != nil {
return nil, fmt.Errorf("decoding TVmaze response: %w", err) 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.Name == "" || show.Name == "" {
if ep.Show.Extern.IMDB != "" { return nil, fmt.Errorf("TVmaze response missing episode/show name (schema drift?) for %s S%dE%d", tvmazeID, seasonNum, episodeNum)
imdbID = ep.Show.Extern.IMDB
} }
return &types.Metadata{ return &types.Metadata{
Title: ep.Name, Title: ep.Name,
Collection: ep.Show.Name, Collection: show.Name,
Season: season, Season: season,
Episode: episode, Episode: episode,
DateReleased: dateReleased, DateReleased: formatDate(ep.Airdate),
IMDBID: imdbID, IMDBID: show.Extern.IMDB,
TVMazeID: tvmazeID, TVMazeID: tvmazeID,
IsSeries: true, IsSeries: true,
OriginalMedia: types.MediaTypeBluRay, OriginalMedia: types.MediaTypeBluRay,
}, nil }, 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 { func formatDate(date string) string {
if date == "" || date == "N/A" { if date == "" || date == "N/A" {
return "" return ""