package metadata import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strconv" "strings" "time" "av1dae/internal/logger" "av1dae/pkg/types" ) const ( omdbAPIURL = "https://www.omdbapi.com/" tvmazeAPIURL = "https://api.tvmaze.com" ) var ( imdbRegex = regexp.MustCompile(`tt(\d+)`) tvmRegex = regexp.MustCompile(`(?i)tvm(\d+)`) seRegex = regexp.MustCompile(`(?i)s(\d+)e(\d+)`) mediaTypeRegex = regexp.MustCompile(`(?i)\b(dvd|bluray|webdl|tvrip)\b`) ) type OMDbResponse struct { Title string `json:"Title"` Year string `json:"Released"` IMDBID string `json:"imdbID"` Response string `json:"Response"` Error string `json:"Error"` } type TVMazeShowResponse struct { Name string `json:"name"` Extern struct { IMDB string `json:"imdb"` } `json:"externals"` } type TVMazeEpisode struct { Name string `json:"name"` Season int `json:"season"` Number int `json:"number"` Airdate string `json:"airdate"` } type Client struct { omdbAPIKey string httpClient *http.Client log *logger.Logger } func NewClient(apiKey string, log *logger.Logger) *Client { return &Client{ omdbAPIKey: apiKey, httpClient: &http.Client{Timeout: 10 * time.Second}, log: log, } } // SetAPIKey updates the OMDb key (e.g. after a settings change). Called from the // single-threaded encode loop before a fetch, so no locking is needed. func (c *Client) SetAPIKey(key string) { c.omdbAPIKey = key } // redactURL strips secret query parameters (e.g. apikey) before logging. func redactURL(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { return rawURL } q := u.Query() for _, k := range []string{"apikey", "api_key"} { if q.Has(k) { q.Set(k, "REDACTED") } } u.RawQuery = q.Encode() return u.String() } func (c *Client) logHTTP(label, rawURL string, status int, body []byte) { if c.log == nil { return } extra, _ := json.Marshal(struct { URL string `json:"url"` Status int `json:"status"` Body string `json:"body"` }{ URL: redactURL(rawURL), Status: status, Body: string(body), }) c.log.Debug(label, "", string(extra)) } func ParseFilename(filename string) (isSeries bool, imdbID, tvmazeID, season, episode string) { filename = strings.TrimSuffix(filename, ".mkv") seMatch := seRegex.FindStringSubmatch(filename) tvmMatch := tvmRegex.FindStringSubmatch(filename) imdbMatch := imdbRegex.FindStringSubmatch(filename) if seMatch != nil && tvmMatch != nil { isSeries = true season = fmt.Sprintf("%02d", parseInt(seMatch[1])) episode = fmt.Sprintf("%02d", parseInt(seMatch[2])) tvmazeID = tvmMatch[1] return } if imdbMatch != nil { imdbID = imdbMatch[0] } return } func parseInt(s string) int { var n int fmt.Sscanf(s, "%d", &n) return n } // ParseMediaType looks for a `.dvd.` / `.bluray.` / `.webdl.` / `.tvrip.` // token (case-insensitive) in the filename and returns the matching MediaType. // Returns an empty MediaType if no token is found, so the caller can fall back // to the pixel-count heuristic. func ParseMediaType(filename string) types.MediaType { m := mediaTypeRegex.FindStringSubmatch(strings.TrimSuffix(filename, ".mkv")) if m == nil { return "" } switch strings.ToLower(m[1]) { case "dvd": return types.MediaTypeDVD case "bluray": return types.MediaTypeBluRay case "webdl": return types.MediaTypeWebDL case "tvrip": return types.MediaTypeTVRip } return "" } func (c *Client) FetchMovieMetadata(ctx context.Context, imdbID string) (*types.Metadata, error) { reqURL := fmt.Sprintf("%s?i=%s&apikey=%s", omdbAPIURL, imdbID, c.omdbAPIKey) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, fmt.Errorf("building OMDb request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("fetching OMDb: %w", err) } 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) } c.logHTTP("OMDb GET", reqURL, resp.StatusCode, body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("OMDb HTTP %d: %s", resp.StatusCode, snippet(body)) } var result OMDbResponse 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) return &types.Metadata{ Title: result.Title, DateReleased: dateReleased, IMDBID: result.IMDBID, OriginalMedia: types.MediaTypeBluRay, }, nil } func (c *Client) FetchSeriesMetadata(ctx context.Context, tvmazeID, season, episode string) (*types.Metadata, error) { seasonNum, err := strconv.Atoi(season) if err != nil { 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) } epURL := fmt.Sprintf("%s/shows/%s/episodebynumber?season=%d&number=%d", tvmazeAPIURL, tvmazeID, seasonNum, episodeNum) var ep TVMazeEpisode if err := c.fetchTVMazeJSON(ctx, epURL, &ep); err != nil { return nil, fmt.Errorf("TVmaze episode %s S%dE%d: %w", tvmazeID, seasonNum, episodeNum, err) } showURL := fmt.Sprintf("%s/shows/%s", tvmazeAPIURL, tvmazeID) var show TVMazeShowResponse if err := c.fetchTVMazeJSON(ctx, showURL, &show); err != nil { return nil, fmt.Errorf("TVmaze show %s: %w", tvmazeID, err) } 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: show.Name, Season: season, Episode: episode, DateReleased: formatDate(ep.Airdate), IMDBID: show.Extern.IMDB, TVMazeID: tvmazeID, IsSeries: true, OriginalMedia: types.MediaTypeBluRay, }, nil } func (c *Client) fetchTVMazeJSON(ctx context.Context, reqURL string, v interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return fmt.Errorf("build request: %w", err) } resp, err := c.httpClient.Do(req) 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) } c.logHTTP("TVmaze GET", reqURL, resp.StatusCode, body) 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 "" } t, err := time.Parse("02 Jan 2006", date) if err != nil { return date } return t.Format("2006-01-02") }