Replace JSON/file logging with a logs.db (WAL) store. Thread the logger through the encoder and metadata client for debug instrumentation of every ffmpeg/ffprobe/opusenc invocation and OMDb/TVmaze request. API keys are redacted before request URLs are logged. Retention defaults to 7 days, overridable via log_retention_days in config.
285 lines
7.1 KiB
Go
285 lines
7.1 KiB
Go
package metadata
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"videnc-vibe/internal/logger"
|
|
"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
|
|
log *logger.Logger
|
|
}
|
|
|
|
func NewClient(apiKey string, log *logger.Logger) *Client {
|
|
return &Client{
|
|
omdbAPIKey: apiKey,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|