From 92edb75de91eebbf283b645eff51899cf9f7e81a Mon Sep 17 00:00:00 2001 From: IRHM Date: Sun, 2 Aug 2026 23:53:12 +0100 Subject: [PATCH] Games: Support searching with Year or FirstYear filters --- CHANGELOG.md | 1 + server/feature/search/search.go | 23 +++++++++---- server/media/igdb/igdb.go | 57 ++++++++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3dfb5aa..6bc3db88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ These changes are awaiting release: - Import(text list): Support parsing a rating (out of 10, supporting decimals) in square brackets. - Show `End Year` for TV Shows that have ended or been canceled. - Add full release date and last release date to their respective year elements as titles (browser tooltip). +- Search: Support inline filters for Non-Multi searches (ex: You can now search `Spider Man year:2026` or `Spider Man fyear:2026` if you care strictly about the first release year). ## Changed diff --git a/server/feature/search/search.go b/server/feature/search/search.go index 20b42015..4c5befbd 100644 --- a/server/feature/search/search.go +++ b/server/feature/search/search.go @@ -11,6 +11,7 @@ import ( "github.com/sbondCo/Watcharr/config" "github.com/sbondCo/Watcharr/database/entity" "github.com/sbondCo/Watcharr/domain" + "github.com/sbondCo/Watcharr/media/igdb" "github.com/sbondCo/Watcharr/media/tmdb" "github.com/sbondCo/Watcharr/util" "gorm.io/gorm" @@ -80,7 +81,10 @@ func (s *Service) Search( Page: pp.Page, Adult: qfilters.Adult, } - if err := s.searchMulti(sreq, &resp); err != nil { + greq := igdb.SearchOptions{ + Query: query, + } + if err := s.searchMulti(sreq, greq, &resp); err != nil { return resp, errors.New("multi search failed") } case domain.SearchTypeMovie: @@ -119,7 +123,12 @@ func (s *Service) Search( return resp, errors.New("person search failed") } case domain.SearchTypeGame: - if err := s.searchGame(r.Query, pp.Page, &resp); err != nil { + greq := igdb.SearchOptions{ + Query: query, + Year: qfilters.Year, + PrimaryYear: qfilters.FirstYear, + } + if err := s.searchGame(greq, &resp); err != nil { return resp, errors.New("game search failed") } } @@ -132,6 +141,7 @@ func (s *Service) Search( // SearchMulti is TMDB Multi search but with game data added to first page. func (s *Service) searchMulti( req tmdb.SearchUniversalOptions, + igdbReq igdb.SearchOptions, resp *domain.SearchResponse, ) error { slog.Debug("searchMulti: Running.", "req", req) @@ -149,7 +159,7 @@ func (s *Service) searchMulti( } // IGDB (we will only get results for the first page) if req.Page == 1 && s.cfg.TwitchEnabled() { - igdbRes, err := s.cfg.TWITCH.Search(req.Query) + igdbRes, err := s.cfg.TWITCH.Search(igdbReq) if err != nil { slog.Error("SearchMulti: Failed to search igdb!", "error", err) return errors.New("content request failed") @@ -278,12 +288,11 @@ func (s *Service) searchPeople( } func (s *Service) searchGame( - query string, - page int, + req igdb.SearchOptions, resp *domain.SearchResponse, ) error { - slog.Debug("searchGame: Running.", "query", query, "page", page) - igdbRes, err := s.cfg.TWITCH.Search(query) + slog.Debug("searchGame: Running.", "req", req) + igdbRes, err := s.cfg.TWITCH.Search(req) if err != nil { slog.Error("searchGame: Failed to search igdb!", "error", err) return errors.New("content request failed") diff --git a/server/media/igdb/igdb.go b/server/media/igdb/igdb.go index 3ff0249a..668f5322 100644 --- a/server/media/igdb/igdb.go +++ b/server/media/igdb/igdb.go @@ -183,19 +183,68 @@ func (i *IGDB) Init() error { return nil } -func (i *IGDB) Search(q string) (GameSearchResponse, error) { - slog.Debug("Search:", "query", q) +type SearchOptions struct { + Query string + Year int + PrimaryYear int +} + +// Turn supported filters from the SearchOptions struct into a `where` we can +// send to APICalypse. +func (o SearchOptions) AsWhere() string { + f := []string{} + if o.Year != 0 { + y := strconv.Itoa(o.Year) + // If `any` release of the game was on this year. + f = append(f, "release_dates.y = "+y) + } + if o.PrimaryYear != 0 { + start := strconv.FormatInt( + time. + Date(o.PrimaryYear, time.January, 1, 0, 0, 0, 0, time.UTC). + Unix(), + 10) + end := strconv.FormatInt( + time. + // We add one to the year and get the first day of the next year + // BUT because day=0, we go back one day and end up with the + // very last day of `PrimaryYear`. + Date(o.PrimaryYear+1, time.January, 0, 23, 59, 59, 0, time.UTC). + Unix(), + 10) + // If `first` release of the game was on this year. + f = append(f, "first_release_date > "+start) + f = append(f, "first_release_date < "+end) + } + if len(f) <= 0 { + return "" + } + return "where " + strings.Join(f, " & ") + ";" + +} + +func (i *IGDB) Search(o SearchOptions) (GameSearchResponse, error) { + slog.Debug("Search:", "query", o.Query) var resp GameSearchResponse - cacheKey := cache.CreateCacheKey("Search", q) + cacheKey := cache.CreateCacheKey( + "Search", + o.Query, + o.Year, + o.PrimaryYear) if cache.GetCache(GameStore, cacheKey, &resp) { slog.Debug("Search: Returning cache.") return resp, nil } + apiQuery := "fields " + fieldsForSearch + "; search \"" + o.Query + "\"; limit 40;" + whereQ := o.AsWhere() + if whereQ != "" { + apiQuery += whereQ + } err := i.req( igdbHost, "/games", map[string]string{}, - "fields "+fieldsForSearch+"; search \""+q+"\"; limit 40;", + apiQuery, &resp, ) if err != nil {