feat: enrich RSS items without IDs via TMDB title search
Some checks failed
Build / build (push) Failing after 13m30s
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:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
7
media/tmdb/config.go
Normal 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
87
media/tmdb/media.go
Normal 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
25
media/tmdb/parse.go
Normal 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
13
media/tmdb/struct.go
Normal 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
33
media/tmdb/tmdb.go
Normal 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
1
media/trakt/parse.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package trakt
|
||||||
@@ -29,6 +29,7 @@ func (c *Client) AddJob(feed feedItem) error {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
13
rss/rss.go
13
rss/rss.go
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -29,6 +30,7 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user