Files
av1dae/internal/metadata/metadata.go
T
Esa Kataja 244bdce586 add: WebDL / TVRip media types and filename-token override
Source kind is now declared per-file via a `dvd` / `bluray` / `webdl` /
`tvrip` token in the basename (case-insensitive, word-bounded). Matches
the existing `tt…` / `tvm…` filename convention. When no token is
present, falls back to the pixel-count guess in DetectMediaType, which
still only chooses between DVD and Blu-ray.

The parsed media type drives both ORIGINAL_MEDIA_TYPE in the muxed
metadata and the CRF/preset profile used for encoding. EncodingConfig
gains `webdl` and `tvrip` sub-blocks with conservative defaults
(WebDL 30/3, TVRip 32/2); user can override in config.yaml.

Manual updated with the new tokens, config fields, and pipeline
description.
2026-05-16 20:17:10 +03:00

236 lines
5.8 KiB
Go

package metadata
import (
"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(imdbID string) (*types.Metadata, error) {
url := fmt.Sprintf("%s?i=%s&apikey=%s", omdbAPIURL, imdbID, c.omdbAPIKey)
resp, err := c.httpClient.Get(url)
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(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(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(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(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 ""
}
t, err := time.Parse("02 Jan 2006", date)
if err != nil {
return date
}
return t.Format("2006-01-02")
}