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")
var outFilename string
if meta.IMDBID == "" || meta.Title == "Unknown" {
outFilename = fmt.Sprintf("%s.nometadata.mkv", generateRandomString(8))
} else if isSeries {
switch {
case isSeries && meta.Collection != "":
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)
default:
outFilename = fmt.Sprintf("%s.nometadata.mkv", generateRandomString(8))
}
finalOutput := filepath.Join(cfg.Paths.Output, outFilename)
+71 -18
View File
@@ -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 (
@@ -42,7 +44,6 @@ type TVMazeEpisode struct {
Season int `json:"season"`
Number int `json:"number"`
Airdate string `json:"airdate"`
Show TVMazeShowResponse `json:"show"`
}
type Client struct {
@@ -94,14 +95,30 @@ 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" {
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 ""