From 719528a06b6f64fbe88508959e1f0f0e2c30e855 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 3 Mar 2026 16:53:54 +0000 Subject: [PATCH] feat: enrich RSS items without IDs via TMDB title search Items from RSS feeds that have no media ID (tmdb/imdb/tvdb) are now enriched automatically using TMDB's search API. The release name is parsed to extract a clean title and year, then searched against TMDB to retrieve the TMDB ID before validation. - Add media/tmdb package with SearchMovies, SearchShows, and ExtractTitleAndYear (parses torrent release names) - Add EnrichFeedItemWithTmdbId to media.Client, called in rss/process.go before the ID validation switch - Add --run-now flag to nabarr run to trigger all feeds immediately - Wire media.Client through rss.Client and rssJob - Merge feature/add-tag-option (tag support for Sonarr/Radarr) Co-Authored-By: Claude Sonnet 4.6 --- cmd/nabarr/main.go | 10 ++++- media/client.go | 54 +++++++++++++++++++++++++++ media/config.go | 2 + media/tmdb/config.go | 7 ++++ media/tmdb/media.go | 87 ++++++++++++++++++++++++++++++++++++++++++++ media/tmdb/parse.go | 25 +++++++++++++ media/tmdb/struct.go | 13 +++++++ media/tmdb/tmdb.go | 33 +++++++++++++++++ media/trakt/parse.go | 1 + rss/job.go | 8 ++-- rss/process.go | 5 +++ rss/rss.go | 13 ++++++- rss/struct.go | 8 ++-- 13 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 media/tmdb/config.go create mode 100644 media/tmdb/media.go create mode 100644 media/tmdb/parse.go create mode 100644 media/tmdb/struct.go create mode 100644 media/tmdb/tmdb.go create mode 100644 media/trakt/parse.go diff --git a/cmd/nabarr/main.go b/cmd/nabarr/main.go index b2702ae..e8583df 100644 --- a/cmd/nabarr/main.go +++ b/cmd/nabarr/main.go @@ -39,7 +39,9 @@ var ( Verbosity int `type:"counter" default:"0" short:"v" env:"APP_VERBOSITY" help:"Log level verbosity"` // commands - Run struct{} `cmd help:"Run"` + Run struct { + RunNow bool `type:"bool" default:"0" short:"n" help:"Run all feeds immediately without waiting for cron"` + } `cmd help:"Run"` Test struct { Pvr string `type:"string" required:"1" help:"PVR to test item against" placeholder:"sonarr"` Id string `type:"string" required:"1" help:"Metadata ID of item to test" placeholder:"tvdb:121361"` @@ -171,7 +173,7 @@ func main() { if ctx.Command() == "run" { // rss log.Trace().Msg("Initialising rss") - r := rss.New(cfg.Rss, c, pvrs) + r := rss.New(cfg.Rss, c, pvrs, m) for _, feed := range cfg.Rss.Feeds { if err := r.AddJob(feed); err != nil { log.Error(). @@ -182,6 +184,10 @@ func main() { } rssState = r.Start() + if cli.Run.RunNow { + r.RunAll() + } + // wait for shutdown signal waitShutdown() } else { diff --git a/media/client.go b/media/client.go index 543ee9b..16539b1 100644 --- a/media/client.go +++ b/media/client.go @@ -2,33 +2,87 @@ package media import ( "fmt" + "strconv" + "strings" "github.com/rs/zerolog" "github.com/l3uddz/nabarr/logger" "github.com/l3uddz/nabarr/media/omdb" + "github.com/l3uddz/nabarr/media/tmdb" "github.com/l3uddz/nabarr/media/trakt" "github.com/l3uddz/nabarr/media/tvdb" + "github.com/l3uddz/nabarr/util" ) type Client struct { trakt *trakt.Client omdb *omdb.Client tvdb *tvdb.Client + tmdb *tmdb.Client log zerolog.Logger } +func (c *Client) EnrichFeedItemWithTmdbId(item *FeedItem) { + if c.tmdb == nil { + return + } + + // skip if a valid ID is already present + hasId := (item.TvdbId != "" && !util.StringSliceContains([]string{"0", "1"}, item.TvdbId)) || + (item.ImdbId != "" && strings.HasPrefix(item.ImdbId, "tt")) || + (item.TmdbId != "" && !util.StringSliceContains([]string{"0", "1"}, item.TmdbId)) + if hasId { + return + } + + title, year := tmdb.ExtractTitleAndYear(item.Title) + if title == "" { + return + } + + var tmdbId int + switch { + case util.ContainsMovieCategory(item.Categories): + if id, err := c.tmdb.SearchMovies(title, year); err == nil && id > 0 { + tmdbId = id + } + case util.ContainsTvCategory(item.Categories): + if id, err := c.tmdb.SearchShows(title, year); err == nil && id > 0 { + tmdbId = id + } + default: + return + } + + if tmdbId > 0 { + item.TmdbId = strconv.Itoa(tmdbId) + c.log.Info(). + Str("release", item.Title). + Str("clean_title", title). + Int("year", year). + Int("tmdb_id", tmdbId). + Msg("Enriched item with TMDB ID via title search") + } +} + func New(cfg *Config) (*Client, error) { // validate trakt configured (it is mandatory) if cfg.Trakt.ClientId == "" { return nil, fmt.Errorf("trakt: no client_id specified") } + var tmdbClient *tmdb.Client + if cfg.Tmdb.ApiKey != "" { + tmdbClient = tmdb.New(&cfg.Tmdb) + } + return &Client{ trakt: trakt.New(&cfg.Trakt), omdb: omdb.New(&cfg.Omdb), tvdb: tvdb.New(&cfg.Tvdb), + tmdb: tmdbClient, log: logger.Child(logger.WithLevel(cfg.Verbosity)), }, nil diff --git a/media/config.go b/media/config.go index e5d2706..ca6693e 100644 --- a/media/config.go +++ b/media/config.go @@ -2,6 +2,7 @@ package media import ( "github.com/l3uddz/nabarr/media/omdb" + "github.com/l3uddz/nabarr/media/tmdb" "github.com/l3uddz/nabarr/media/trakt" "github.com/l3uddz/nabarr/media/tvdb" ) @@ -10,6 +11,7 @@ type Config struct { Trakt trakt.Config `yaml:"trakt"` Omdb omdb.Config `yaml:"omdb"` Tvdb tvdb.Config `yaml:"tvdb"` + Tmdb tmdb.Config `yaml:"tmdb"` Verbosity string `yaml:"verbosity,omitempty"` } diff --git a/media/tmdb/config.go b/media/tmdb/config.go new file mode 100644 index 0000000..aadafac --- /dev/null +++ b/media/tmdb/config.go @@ -0,0 +1,7 @@ +package tmdb + +type Config struct { + ApiKey string `yaml:"api_key"` + + Verbosity string `yaml:"verbosity,omitempty"` +} diff --git a/media/tmdb/media.go b/media/tmdb/media.go new file mode 100644 index 0000000..1002152 --- /dev/null +++ b/media/tmdb/media.go @@ -0,0 +1,87 @@ +package tmdb + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + + "github.com/lucperkins/rek" + + "github.com/l3uddz/nabarr/util" +) + +var ErrItemNotFound = errors.New("not found") + +func (c *Client) SearchMovies(title string, year int) (int, error) { + vals := url.Values{ + "api_key": []string{c.apiKey}, + "query": []string{title}, + } + if year > 0 { + vals.Set("year", strconv.Itoa(year)) + } + + reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, "search", "movie"), vals) + if err != nil { + return 0, fmt.Errorf("generate search movie url: %w", err) + } + + resp, err := rek.Get(reqUrl, rek.Client(c.http)) + if err != nil { + return 0, fmt.Errorf("request search movie: %w", err) + } + defer resp.Body().Close() + + if resp.StatusCode() != 200 { + return 0, fmt.Errorf("validate search movie response: %s", resp.Status()) + } + + b := new(movieSearchResponse) + if err := json.NewDecoder(resp.Body()).Decode(b); err != nil { + return 0, fmt.Errorf("decode search movie response: %w", err) + } + + if len(b.Results) < 1 { + return 0, ErrItemNotFound + } + + return b.Results[0].Id, nil +} + +func (c *Client) SearchShows(title string, year int) (int, error) { + vals := url.Values{ + "api_key": []string{c.apiKey}, + "query": []string{title}, + } + if year > 0 { + vals.Set("first_air_date_year", strconv.Itoa(year)) + } + + reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, "search", "tv"), vals) + if err != nil { + return 0, fmt.Errorf("generate search tv url: %w", err) + } + + resp, err := rek.Get(reqUrl, rek.Client(c.http)) + if err != nil { + return 0, fmt.Errorf("request search tv: %w", err) + } + defer resp.Body().Close() + + if resp.StatusCode() != 200 { + return 0, fmt.Errorf("validate search tv response: %s", resp.Status()) + } + + b := new(tvSearchResponse) + if err := json.NewDecoder(resp.Body()).Decode(b); err != nil { + return 0, fmt.Errorf("decode search tv response: %w", err) + } + + if len(b.Results) < 1 { + return 0, ErrItemNotFound + } + + return b.Results[0].Id, nil +} diff --git a/media/tmdb/parse.go b/media/tmdb/parse.go new file mode 100644 index 0000000..bfa5414 --- /dev/null +++ b/media/tmdb/parse.go @@ -0,0 +1,25 @@ +package tmdb + +import ( + "regexp" + "strconv" + "strings" +) + +var yearRegex = regexp.MustCompile(`\b(19|20)\d{2}\b`) + +// ExtractTitleAndYear parses a torrent release name and returns the clean title and year. +// Example: "The.Housemaid.2025.FRENCH.1080p" → ("The Housemaid", 2025) +func ExtractTitleAndYear(releaseName string) (string, int) { + s := strings.ReplaceAll(releaseName, ".", " ") + s = strings.ReplaceAll(s, "_", " ") + + loc := yearRegex.FindStringIndex(s) + if loc == nil { + return strings.TrimSpace(s), 0 + } + + year, _ := strconv.Atoi(s[loc[0]:loc[1]]) + title := strings.TrimSpace(s[:loc[0]]) + return title, year +} diff --git a/media/tmdb/struct.go b/media/tmdb/struct.go new file mode 100644 index 0000000..c6b1eb7 --- /dev/null +++ b/media/tmdb/struct.go @@ -0,0 +1,13 @@ +package tmdb + +type movieSearchResponse struct { + Results []struct { + Id int `json:"id"` + } `json:"results"` +} + +type tvSearchResponse struct { + Results []struct { + Id int `json:"id"` + } `json:"results"` +} diff --git a/media/tmdb/tmdb.go b/media/tmdb/tmdb.go new file mode 100644 index 0000000..2142489 --- /dev/null +++ b/media/tmdb/tmdb.go @@ -0,0 +1,33 @@ +package tmdb + +import ( + "net/http" + "time" + + "github.com/rs/zerolog" + "go.uber.org/ratelimit" + + "github.com/l3uddz/nabarr/logger" + "github.com/l3uddz/nabarr/util" +) + +type Client struct { + log zerolog.Logger + http *http.Client + + apiKey string + apiURL string +} + +func New(cfg *Config) *Client { + l := logger.Child(logger.WithLevel(cfg.Verbosity)).With(). + Str("media", "tmdb").Logger() + + return &Client{ + log: l, + http: util.NewRetryableHttpClient(30*time.Second, ratelimit.New(5, ratelimit.WithoutSlack), &l), + + apiKey: cfg.ApiKey, + apiURL: "https://api.themoviedb.org/3", + } +} diff --git a/media/trakt/parse.go b/media/trakt/parse.go new file mode 100644 index 0000000..0789218 --- /dev/null +++ b/media/trakt/parse.go @@ -0,0 +1 @@ +package trakt diff --git a/rss/job.go b/rss/job.go index 172ad01..a4224e3 100644 --- a/rss/job.go +++ b/rss/job.go @@ -26,9 +26,10 @@ func (c *Client) AddJob(feed feedItem) error { // create job job := &rssJob{ - name: feed.Name, - log: l, - http: util.NewRetryableHttpClient(60*time.Second, nil, &l), + name: feed.Name, + log: l, + http: util.NewRetryableHttpClient(60*time.Second, nil, &l), + mediaClient: c.media, url: feed.URL, pvrs: make(map[string]pvr.PVR, 0), @@ -61,6 +62,7 @@ func (c *Client) AddJob(feed feedItem) error { job.jobID = id } + c.jobs = append(c.jobs, job) job.log.Info().Msg("Initialised") return nil } diff --git a/rss/process.go b/rss/process.go index 6234dad..9b7b202 100644 --- a/rss/process.go +++ b/rss/process.go @@ -128,6 +128,11 @@ func (j *rssJob) getFeed() ([]media.FeedItem, error) { } } + // attempt to enrich items missing IDs via Trakt title search + if j.mediaClient != nil { + j.mediaClient.EnrichFeedItemWithTmdbId(&b.Channel.Items[p]) + } + // validate item switch { case b.Channel.Items[p].TvdbId != "" && !util.StringSliceContains([]string{"0", "1"}, b.Channel.Items[p].TvdbId): diff --git a/rss/rss.go b/rss/rss.go index 9c3dcb8..afdba88 100644 --- a/rss/rss.go +++ b/rss/rss.go @@ -10,28 +10,39 @@ import ( "github.com/l3uddz/nabarr/cache" "github.com/l3uddz/nabarr/cmd/nabarr/pvr" "github.com/l3uddz/nabarr/logger" + "github.com/l3uddz/nabarr/media" ) type Client struct { cron *cron.Cron cache *cache.Client pvrs map[string]pvr.PVR + media *media.Client + jobs []*rssJob log zerolog.Logger } -func New(c Config, cc *cache.Client, pvrs map[string]pvr.PVR) *Client { +func New(c Config, cc *cache.Client, pvrs map[string]pvr.PVR, m *media.Client) *Client { return &Client{ cron: cron.New(cron.WithChain( cron.Recover(cron.DefaultLogger), )), cache: cc, pvrs: pvrs, + media: m, + jobs: make([]*rssJob, 0), log: logger.Child(logger.WithLevel(c.Verbosity)), } } +func (c *Client) RunAll() { + for _, job := range c.jobs { + job.Run() + } +} + func (c *Client) Start() state.State { c.cron.Start() diff --git a/rss/struct.go b/rss/struct.go index 7cee46a..adba36c 100644 --- a/rss/struct.go +++ b/rss/struct.go @@ -9,6 +9,7 @@ import ( "github.com/l3uddz/nabarr/cache" "github.com/l3uddz/nabarr/cmd/nabarr/pvr" + "github.com/l3uddz/nabarr/media" ) type feedItem struct { @@ -26,9 +27,10 @@ type Config struct { } type rssJob struct { - name string - log zerolog.Logger - http *http.Client + name string + log zerolog.Logger + http *http.Client + mediaClient *media.Client url string pvrs map[string]pvr.PVR