3 files changed,
238 insertions(+),
8 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-05-27 20:05:08 +0300
Authored at:
2026-05-27 20:02:36 +0300
Change ID:
ooyttkkmtrnrnotyqppzssmzmmqpmyol
Parent:
70f8cb6
M
sources/moviefeed/api.go
··· 44 44 TvResults []tmdbShow `json:"tv_results"` 45 45 } 46 46 47 +type tmdbSearchResponse struct { 48 + Results []tmdbShow `json:"results"` 49 +} 50 + 47 51 type tmdbSeasonResponse struct { 48 52 Episodes []TMDBEpisode `json:"episodes"` 49 53 } ··· 105 109 return fmt.Sprintf("%d", result.TvResults[0].ID), nil 106 110 } 107 111 return showID, nil 112 +} 113 + 114 +func (a *TMDBAPI) SearchShow(query string) (*tmdbShow, error) { 115 + encoded := url.QueryEscape(query) 116 + result, err := makeRequest[tmdbSearchResponse](a, "/search/tv?query=%s", encoded) 117 + if err != nil { 118 + return nil, err 119 + } 120 + if len(result.Results) == 0 { 121 + return nil, fmt.Errorf("no TMDB show found for %q", query) 122 + } 123 + return &result.Results[0], nil 108 124 } 109 125 110 126 func makeRequest[T any](a *TMDBAPI, endpoint string, args ...any) (*T, error) {
M
sources/moviefeed/moviefeed.go
··· 13 13 ) 14 14 15 15 type moviefeed struct { 16 - api episodeFetcher 17 - shows []string 16 + api episodeFetcher 17 + shows []string 18 + nameCache map[string]string 18 19 } 19 20 20 21 type episodeFetcher interface { 21 22 FetchEpisodesForShow(showID string) ([]TMDBEpisode, error) 23 + SearchShow(query string) (*tmdbShow, error) 22 24 } 23 25 24 26 func Register(a *app.App) error { ··· 27 29 } 28 30 29 31 mf := &moviefeed{ 30 - api: NewTMDBAPI(a.Config.MoviefeedAPIKey, a.Client), 31 - shows: a.Config.MoviefeedShows, 32 + api: NewTMDBAPI(a.Config.MoviefeedAPIKey, a.Client), 33 + shows: a.Config.MoviefeedShows, 34 + nameCache: map[string]string{}, 32 35 } 33 36 34 37 a.Route("GET /movies", mf.handleMovies) ··· 54 57 55 58 func (mf *moviefeed) fetchNewEpisodes() ([]TMDBEpisode, error) { 56 59 var allEpisodes []TMDBEpisode 57 - for _, showID := range mf.shows { 60 + for _, entry := range mf.shows { 61 + showID, err := mf.resolveShowID(entry) 62 + if err != nil { 63 + slog.Warn("failed to resolve show", "entry", entry, "err", err) 64 + continue 65 + } 66 + 58 67 episodes, err := mf.api.FetchEpisodesForShow(showID) 59 68 if err != nil { 60 - slog.Warn("failed to fetch episodes for show", "show", showID, "err", err) 69 + slog.Warn("failed to fetch episodes for show", "show", showID, "entry", entry, "err", err) 61 70 continue 62 71 } 63 72 allEpisodes = append(allEpisodes, episodes...) 64 73 } 65 74 return allEpisodes, nil 75 +} 76 + 77 +func (mf *moviefeed) resolveShowID(entry string) (string, error) { 78 + if cached, ok := mf.nameCache[entry]; ok { 79 + return cached, nil 80 + } 81 + 82 + label, id, hasSep := strings.Cut(entry, "::") 83 + if hasSep && id != "" { 84 + mf.nameCache[entry] = id 85 + return id, nil 86 + } 87 + 88 + name := strings.TrimSpace(label) 89 + if isDirectID(name) { 90 + return name, nil 91 + } 92 + 93 + show, err := mf.api.SearchShow(name) 94 + if err != nil { 95 + return "", fmt.Errorf("searching %q: %w", name, err) 96 + } 97 + 98 + tmdbID := fmt.Sprintf("%d", show.ID) 99 + mf.nameCache[entry] = tmdbID 100 + slog.Info("resolved show name", "name", name, "tmdb_id", tmdbID, "first_air", show.FirstAirDate) 101 + return tmdbID, nil 102 +} 103 + 104 +func isDirectID(s string) bool { 105 + if strings.HasPrefix(s, "tt") { 106 + return true 107 + } 108 + for _, c := range s { 109 + if c < '0' || c > '9' { 110 + return false 111 + } 112 + } 113 + return len(s) > 0 66 114 } 67 115 68 116 func generateFeed(episodes []TMDBEpisode) *atom.Feed {
M
sources/moviefeed/moviefeed_test.go
··· 3 3 import ( 4 4 "encoding/xml" 5 5 "errors" 6 + "fmt" 6 7 "net/http" 7 8 "net/http/httptest" 8 9 "strings" ··· 16 17 type fakeEpisodeAPI struct { 17 18 episodes map[string][]TMDBEpisode 18 19 errs map[string]error 20 + searches map[string]tmdbShow 19 21 } 20 22 21 23 func (f fakeEpisodeAPI) FetchEpisodesForShow(showID string) ([]TMDBEpisode, error) { ··· 28 30 return nil, nil 29 31 } 30 32 33 +func (f fakeEpisodeAPI) SearchShow(query string) (*tmdbShow, error) { 34 + if f.searches == nil { 35 + return nil, fmt.Errorf("no search results for %q", query) 36 + } 37 + s, ok := f.searches[query] 38 + if !ok { 39 + return nil, fmt.Errorf("no search results for %q", query) 40 + } 41 + return &s, nil 42 +} 43 + 31 44 func TestHandleMoviesRendersFeedFromConfiguredShows(t *testing.T) { 32 45 episodes := []TMDBEpisode{ 33 46 { ··· 60 73 "tt123": episodes, 61 74 }, 62 75 }, 63 - shows: []string{"tt123"}, 76 + shows: []string{"tt123"}, 77 + nameCache: map[string]string{}, 64 78 } 65 79 66 80 mux := http.NewServeMux() ··· 124 138 "bad-show": errors.New("boom"), 125 139 }, 126 140 }, 127 - shows: []string{"bad-show", "tt123"}, 141 + shows: []string{"bad-show", "tt123"}, 142 + nameCache: map[string]string{}, 128 143 } 129 144 130 145 mux := http.NewServeMux() ··· 168 183 is.Equal(t, strings.Contains(content, `<img src="https://image.tmdb.org/t/p/w500/e1.jpg" alt="Episode 1"`), true) 169 184 is.Equal(t, strings.Contains(content, "</body>"), true) 170 185 } 186 + 187 +func TestIsDirectID(t *testing.T) { 188 + is.Equal(t, true, isDirectID("tt1190634")) 189 + is.Equal(t, true, isDirectID("101")) 190 + is.Equal(t, true, isDirectID("1")) 191 + is.Equal(t, false, isDirectID("")) 192 + is.Equal(t, false, isDirectID("The Boys")) 193 + is.Equal(t, false, isDirectID("101a")) 194 +} 195 + 196 +func TestResolveShowIDDirectPassthrough(t *testing.T) { 197 + mf := &moviefeed{nameCache: map[string]string{}, api: fakeEpisodeAPI{}} 198 + id, err := mf.resolveShowID("tt123") 199 + is.Err(t, err, nil) 200 + is.Equal(t, "tt123", id) 201 + 202 + id, err = mf.resolveShowID("42") 203 + is.Err(t, err, nil) 204 + is.Equal(t, "42", id) 205 +} 206 + 207 +func TestResolveShowIDNameIDSkipsSearch(t *testing.T) { 208 + mf := &moviefeed{nameCache: map[string]string{}, api: fakeEpisodeAPI{}} 209 + id, err := mf.resolveShowID("The Boys::101") 210 + is.Err(t, err, nil) 211 + is.Equal(t, "101", id) 212 +} 213 + 214 +func TestResolveShowIDNameIDSkipsSearchWithIMDB(t *testing.T) { 215 + mf := &moviefeed{nameCache: map[string]string{}, api: fakeEpisodeAPI{}} 216 + id, err := mf.resolveShowID("The Boys::tt1190634") 217 + is.Err(t, err, nil) 218 + is.Equal(t, "tt1190634", id) 219 +} 220 + 221 +func TestResolveShowIDSearchesAndCaches(t *testing.T) { 222 + mf := &moviefeed{ 223 + nameCache: map[string]string{}, 224 + api: fakeEpisodeAPI{ 225 + searches: map[string]tmdbShow{ 226 + "The Boys": {ID: 1398, Name: "The Boys"}, 227 + }, 228 + }, 229 + } 230 + 231 + id, err := mf.resolveShowID("The Boys") 232 + is.Err(t, err, nil) 233 + is.Equal(t, "1398", id) 234 + 235 + cached, ok := mf.nameCache["The Boys"] 236 + is.Equal(t, true, ok) 237 + is.Equal(t, "1398", cached) 238 +} 239 + 240 +func TestResolveShowIDCacheHit(t *testing.T) { 241 + mf := &moviefeed{ 242 + nameCache: map[string]string{"The Boys": "1398"}, 243 + api: fakeEpisodeAPI{}, // empty — would fail if search called 244 + } 245 + 246 + id, err := mf.resolveShowID("The Boys") 247 + is.Err(t, err, nil) 248 + is.Equal(t, "1398", id) 249 +} 250 + 251 +func TestResolveShowIDSearchFails(t *testing.T) { 252 + mf := &moviefeed{ 253 + nameCache: map[string]string{}, 254 + api: fakeEpisodeAPI{}, // no searches registered 255 + } 256 + 257 + _, err := mf.resolveShowID("Nonexistent Show") 258 + if err == nil { 259 + t.Fatal("expected error for unresolvable name") 260 + } 261 +} 262 + 263 +func TestFetchNewEpisodesResolvesNames(t *testing.T) { 264 + episodes := []TMDBEpisode{ 265 + { 266 + ID: 1001, 267 + Name: "E1", 268 + AirDate: time.Now().AddDate(0, 0, -2).Format(dateFormat), 269 + EpisodeNumber: 1, 270 + SeasonNumber: 1, 271 + ShowName: "The Boys", 272 + ShowID: "1398", 273 + }, 274 + } 275 + 276 + mf := &moviefeed{ 277 + nameCache: map[string]string{}, 278 + api: fakeEpisodeAPI{ 279 + searches: map[string]tmdbShow{ 280 + "The Boys": {ID: 1398, Name: "The Boys"}, 281 + }, 282 + episodes: map[string][]TMDBEpisode{ 283 + "1398": episodes, 284 + }, 285 + }, 286 + shows: []string{"The Boys"}, 287 + } 288 + 289 + got, err := mf.fetchNewEpisodes() 290 + is.Err(t, err, nil) 291 + is.Equal(t, 1, len(got)) 292 + is.Equal(t, 1001, got[0].ID) 293 +} 294 + 295 +func TestFetchNewEpisodesNameIDFormat(t *testing.T) { 296 + episodes := []TMDBEpisode{ 297 + { 298 + ID: 1001, 299 + Name: "E1", 300 + AirDate: time.Now().AddDate(0, 0, -2).Format(dateFormat), 301 + EpisodeNumber: 1, 302 + SeasonNumber: 1, 303 + ShowName: "The Boys", 304 + ShowID: "1398", 305 + }, 306 + } 307 + 308 + mf := &moviefeed{ 309 + nameCache: map[string]string{}, 310 + api: fakeEpisodeAPI{ 311 + episodes: map[string][]TMDBEpisode{ 312 + "1398": episodes, 313 + }, 314 + }, 315 + shows: []string{"The Boys::1398"}, 316 + } 317 + 318 + got, err := mf.fetchNewEpisodes() 319 + is.Err(t, err, nil) 320 + is.Equal(t, 1, len(got)) 321 + is.Equal(t, 1001, got[0].ID) 322 +} 323 + 324 +func TestFetchNewEpisodesContinuesOnResolveError(t *testing.T) { 325 + mf := &moviefeed{ 326 + nameCache: map[string]string{}, 327 + api: fakeEpisodeAPI{ 328 + episodes: map[string][]TMDBEpisode{}, 329 + }, 330 + shows: []string{"Does Not Exist", "tt1190634"}, 331 + } 332 + 333 + got, err := mf.fetchNewEpisodes() 334 + is.Err(t, err, nil) 335 + is.Equal(t, 0, len(got)) 336 +}