feat: enrich RSS items without IDs via TMDB title search
Some checks failed
Build / build (push) Failing after 13m30s

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 <noreply@anthropic.com>
This commit is contained in:
dev
2026-03-03 16:53:54 +00:00
parent 4e09b14318
commit 719528a06b
13 changed files with 257 additions and 9 deletions

View File

@@ -39,7 +39,9 @@ var (
Verbosity int `type:"counter" default:"0" short:"v" env:"APP_VERBOSITY" help:"Log level verbosity"` Verbosity int `type:"counter" default:"0" short:"v" env:"APP_VERBOSITY" help:"Log level verbosity"`
// commands // 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 { Test struct {
Pvr string `type:"string" required:"1" help:"PVR to test item against" placeholder:"sonarr"` 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"` 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" { if ctx.Command() == "run" {
// rss // rss
log.Trace().Msg("Initialising 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 { for _, feed := range cfg.Rss.Feeds {
if err := r.AddJob(feed); err != nil { if err := r.AddJob(feed); err != nil {
log.Error(). log.Error().
@@ -182,6 +184,10 @@ func main() {
} }
rssState = r.Start() rssState = r.Start()
if cli.Run.RunNow {
r.RunAll()
}
// wait for shutdown signal // wait for shutdown signal
waitShutdown() waitShutdown()
} else { } else {

View File

@@ -2,33 +2,87 @@ package media
import ( import (
"fmt" "fmt"
"strconv"
"strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/l3uddz/nabarr/logger" "github.com/l3uddz/nabarr/logger"
"github.com/l3uddz/nabarr/media/omdb" "github.com/l3uddz/nabarr/media/omdb"
"github.com/l3uddz/nabarr/media/tmdb"
"github.com/l3uddz/nabarr/media/trakt" "github.com/l3uddz/nabarr/media/trakt"
"github.com/l3uddz/nabarr/media/tvdb" "github.com/l3uddz/nabarr/media/tvdb"
"github.com/l3uddz/nabarr/util"
) )
type Client struct { type Client struct {
trakt *trakt.Client trakt *trakt.Client
omdb *omdb.Client omdb *omdb.Client
tvdb *tvdb.Client tvdb *tvdb.Client
tmdb *tmdb.Client
log zerolog.Logger 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) { func New(cfg *Config) (*Client, error) {
// validate trakt configured (it is mandatory) // validate trakt configured (it is mandatory)
if cfg.Trakt.ClientId == "" { if cfg.Trakt.ClientId == "" {
return nil, fmt.Errorf("trakt: no client_id specified") return nil, fmt.Errorf("trakt: no client_id specified")
} }
var tmdbClient *tmdb.Client
if cfg.Tmdb.ApiKey != "" {
tmdbClient = tmdb.New(&cfg.Tmdb)
}
return &Client{ return &Client{
trakt: trakt.New(&cfg.Trakt), trakt: trakt.New(&cfg.Trakt),
omdb: omdb.New(&cfg.Omdb), omdb: omdb.New(&cfg.Omdb),
tvdb: tvdb.New(&cfg.Tvdb), tvdb: tvdb.New(&cfg.Tvdb),
tmdb: tmdbClient,
log: logger.Child(logger.WithLevel(cfg.Verbosity)), log: logger.Child(logger.WithLevel(cfg.Verbosity)),
}, nil }, nil

View File

@@ -2,6 +2,7 @@ package media
import ( import (
"github.com/l3uddz/nabarr/media/omdb" "github.com/l3uddz/nabarr/media/omdb"
"github.com/l3uddz/nabarr/media/tmdb"
"github.com/l3uddz/nabarr/media/trakt" "github.com/l3uddz/nabarr/media/trakt"
"github.com/l3uddz/nabarr/media/tvdb" "github.com/l3uddz/nabarr/media/tvdb"
) )
@@ -10,6 +11,7 @@ type Config struct {
Trakt trakt.Config `yaml:"trakt"` Trakt trakt.Config `yaml:"trakt"`
Omdb omdb.Config `yaml:"omdb"` Omdb omdb.Config `yaml:"omdb"`
Tvdb tvdb.Config `yaml:"tvdb"` Tvdb tvdb.Config `yaml:"tvdb"`
Tmdb tmdb.Config `yaml:"tmdb"`
Verbosity string `yaml:"verbosity,omitempty"` Verbosity string `yaml:"verbosity,omitempty"`
} }

7
media/tmdb/config.go Normal file
View File

@@ -0,0 +1,7 @@
package tmdb
type Config struct {
ApiKey string `yaml:"api_key"`
Verbosity string `yaml:"verbosity,omitempty"`
}

87
media/tmdb/media.go Normal file
View File

@@ -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
}

25
media/tmdb/parse.go Normal file
View File

@@ -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
}

13
media/tmdb/struct.go Normal file
View File

@@ -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"`
}

33
media/tmdb/tmdb.go Normal file
View File

@@ -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",
}
}

1
media/trakt/parse.go Normal file
View File

@@ -0,0 +1 @@
package trakt

View File

@@ -26,9 +26,10 @@ func (c *Client) AddJob(feed feedItem) error {
// create job // create job
job := &rssJob{ job := &rssJob{
name: feed.Name, name: feed.Name,
log: l, log: l,
http: util.NewRetryableHttpClient(60*time.Second, nil, &l), http: util.NewRetryableHttpClient(60*time.Second, nil, &l),
mediaClient: c.media,
url: feed.URL, url: feed.URL,
pvrs: make(map[string]pvr.PVR, 0), pvrs: make(map[string]pvr.PVR, 0),
@@ -61,6 +62,7 @@ func (c *Client) AddJob(feed feedItem) error {
job.jobID = id job.jobID = id
} }
c.jobs = append(c.jobs, job)
job.log.Info().Msg("Initialised") job.log.Info().Msg("Initialised")
return nil return nil
} }

View File

@@ -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 // validate item
switch { switch {
case b.Channel.Items[p].TvdbId != "" && !util.StringSliceContains([]string{"0", "1"}, b.Channel.Items[p].TvdbId): case b.Channel.Items[p].TvdbId != "" && !util.StringSliceContains([]string{"0", "1"}, b.Channel.Items[p].TvdbId):

View File

@@ -10,28 +10,39 @@ import (
"github.com/l3uddz/nabarr/cache" "github.com/l3uddz/nabarr/cache"
"github.com/l3uddz/nabarr/cmd/nabarr/pvr" "github.com/l3uddz/nabarr/cmd/nabarr/pvr"
"github.com/l3uddz/nabarr/logger" "github.com/l3uddz/nabarr/logger"
"github.com/l3uddz/nabarr/media"
) )
type Client struct { type Client struct {
cron *cron.Cron cron *cron.Cron
cache *cache.Client cache *cache.Client
pvrs map[string]pvr.PVR pvrs map[string]pvr.PVR
media *media.Client
jobs []*rssJob
log zerolog.Logger 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{ return &Client{
cron: cron.New(cron.WithChain( cron: cron.New(cron.WithChain(
cron.Recover(cron.DefaultLogger), cron.Recover(cron.DefaultLogger),
)), )),
cache: cc, cache: cc,
pvrs: pvrs, pvrs: pvrs,
media: m,
jobs: make([]*rssJob, 0),
log: logger.Child(logger.WithLevel(c.Verbosity)), log: logger.Child(logger.WithLevel(c.Verbosity)),
} }
} }
func (c *Client) RunAll() {
for _, job := range c.jobs {
job.Run()
}
}
func (c *Client) Start() state.State { func (c *Client) Start() state.State {
c.cron.Start() c.cron.Start()

View File

@@ -9,6 +9,7 @@ import (
"github.com/l3uddz/nabarr/cache" "github.com/l3uddz/nabarr/cache"
"github.com/l3uddz/nabarr/cmd/nabarr/pvr" "github.com/l3uddz/nabarr/cmd/nabarr/pvr"
"github.com/l3uddz/nabarr/media"
) )
type feedItem struct { type feedItem struct {
@@ -26,9 +27,10 @@ type Config struct {
} }
type rssJob struct { type rssJob struct {
name string name string
log zerolog.Logger log zerolog.Logger
http *http.Client http *http.Client
mediaClient *media.Client
url string url string
pvrs map[string]pvr.PVR pvrs map[string]pvr.PVR