package metadata import ( "context" "encoding/json" "fmt" "io" "net/http" "regexp" "strconv" "strings" "time" "videnc-vibe/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 } func NewClient(apiKey string) *Client { return &Client{ omdbAPIKey: apiKey, httpClient: &http.Client{Timeout: 10 * time.Second}, } } 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) { url := fmt.Sprintf("%s?i=%s&apikey=%s", omdbAPIURL, imdbID, c.omdbAPIKey) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, 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) } 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, url string, v interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, 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) } 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") }