5 files changed,
320 insertions(+),
150 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-05-08 20:32:52 +0300
Authored at:
2026-05-08 20:05:55 +0300
Change ID:
lymupvplyswuurvkwoukzsuoppstyumn
Parent:
2bf041f
M
app/atom.go
··· 7 7 "fmt" 8 8 "io" 9 9 "net/http" 10 + "strings" 10 11 "time" 11 12 ) 12 13 14 +const ( 15 + atomNamespace = "http://www.w3.org/2005/Atom" 16 + xhtmlNamespace = "http://www.w3.org/1999/xhtml" 17 + defaultAuthor = "rss-tools" 18 +) 19 + 13 20 type AtomFeed struct { 14 - XMLName xml.Name `xml:"feed"` 15 - XMLNS string `xml:"xmlns,attr"` 16 - Title string `xml:"title"` 17 - ID string `xml:"id"` 18 - Updated string `xml:"updated"` 19 - Subtitle string `xml:"subtitle,omitempty"` 20 - Entries []AtomEntry `xml:"entry"` 21 + XMLName xml.Name `xml:"feed"` 22 + XMLNS string `xml:"xmlns,attr"` 23 + Title string `xml:"title"` 24 + ID string `xml:"id"` 25 + Updated string `xml:"updated"` 26 + Authors []AtomPerson `xml:"author,omitempty"` 27 + Subtitle string `xml:"subtitle,omitempty"` 28 + Entries []AtomEntry `xml:"entry"` 21 29 } 22 30 23 31 type AtomEntry struct { ··· 34 42 Value string `xml:",chardata"` 35 43 } 36 44 45 +func (c AtomContent) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 46 + contentType := c.Type 47 + if contentType == "" { 48 + contentType = "text" 49 + } 50 + 51 + start.Name = xml.Name{Local: "content"} 52 + start.Attr = append(start.Attr, xml.Attr{ 53 + Name: xml.Name{Local: "type"}, 54 + Value: contentType, 55 + }) 56 + 57 + if err := e.EncodeToken(start); err != nil { 58 + return err 59 + } 60 + 61 + if contentType == "xhtml" { 62 + if err := validateXHTMLFragment(c.Value); err != nil { 63 + return err 64 + } 65 + 66 + if err := e.Encode(xhtmlDiv{ 67 + XMLNS: xhtmlNamespace, 68 + Inner: c.Value, 69 + }); err != nil { 70 + return err 71 + } 72 + } else { 73 + if err := e.EncodeToken(xml.CharData([]byte(c.Value))); err != nil { 74 + return err 75 + } 76 + } 77 + 78 + if err := e.EncodeToken(start.End()); err != nil { 79 + return err 80 + } 81 + return e.Flush() 82 +} 83 + 84 +type xhtmlDiv struct { 85 + XMLName xml.Name `xml:"div"` 86 + XMLNS string `xml:"xmlns,attr"` 87 + Inner string `xml:",innerxml"` 88 +} 89 + 90 +func validateXHTMLFragment(fragment string) error { 91 + wrapped := fmt.Sprintf(`<div xmlns="%s">%s</div>`, xhtmlNamespace, fragment) 92 + dec := xml.NewDecoder(strings.NewReader(wrapped)) 93 + for { 94 + _, err := dec.Token() 95 + if err == io.EOF { 96 + return nil 97 + } 98 + if err != nil { 99 + return fmt.Errorf("invalid xhtml content: %w", err) 100 + } 101 + } 102 +} 103 + 104 +type AtomPerson struct { 105 + Name string `xml:"name"` 106 +} 107 + 37 108 type AtomLink struct { 38 - Rel string `xml:"rel,attr,omitempty"` 39 - Type string `xml:"type,attr,omitempty"` 40 - Href string `xml:"href,attr"` 109 + Rel string `xml:"rel,attr,omitempty"` 110 + Type string `xml:"type,attr,omitempty"` 111 + Length string `xml:"length,attr,omitempty"` 112 + Href string `xml:"href,attr"` 41 113 } 42 114 43 115 type FeedEntry struct { ··· 45 117 ID string 46 118 Links []FeedLink 47 119 Content string 48 - ContentType string // "text" or "html", defaults to "text" 120 + ContentType string // "text", "html", or "xhtml"; defaults to "text" 49 121 Updated time.Time 50 122 } 51 123 52 124 type FeedLink struct { 53 - Rel string 54 - Type string 55 - Href string 125 + Rel string 126 + Type string 127 + Length string 128 + Href string 56 129 } 57 130 58 131 type FeedBuilder struct{ f AtomFeed } 59 132 60 133 func NewFeed(title, id string) *FeedBuilder { 61 134 return &FeedBuilder{f: AtomFeed{ 62 - XMLNS: "http://www.w3.org/2005/Atom", 135 + XMLNS: atomNamespace, 63 136 Title: title, 64 137 ID: id, 65 138 Updated: time.Now().Format(time.RFC3339), 139 + Authors: []AtomPerson{{Name: defaultAuthor}}, 66 140 }} 67 141 } 68 142 69 143 func (f *FeedBuilder) WithSubtitle(subtitle string) *FeedBuilder { 70 144 f.f.Subtitle = subtitle 145 + return f 146 +} 147 + 148 +func (f *FeedBuilder) WithAuthor(name string) *FeedBuilder { 149 + name = strings.TrimSpace(name) 150 + if name == "" { 151 + return f 152 + } 153 + f.f.Authors = []AtomPerson{{Name: name}} 71 154 return f 72 155 } 73 156
M
app/atom_test.go
··· 20 20 entry := feed.f.Entries[0] 21 21 is.NotEqual(t, "", entry.ID) 22 22 is.NotEqual(t, "", entry.Updated) 23 + is.Equal(t, 1, len(feed.f.Authors)) 24 + is.Equal(t, "rss-tools", feed.f.Authors[0].Name) 23 25 } 24 26 25 27 func TestFeedBuilderBytesAndWriteTo(t *testing.T) { ··· 37 39 var parsed AtomFeed 38 40 is.Err(t, xml.Unmarshal(raw, &parsed), nil) 39 41 is.Equal(t, "test", parsed.Title) 42 +} 43 + 44 +func TestFeedBuilderWithAuthor(t *testing.T) { 45 + feed := NewFeed("test", "feed-id").WithAuthor("moviefeed") 46 + raw, err := feed.Bytes() 47 + is.Err(t, err, nil) 48 + var parsed AtomFeed 49 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 50 + is.Equal(t, 1, len(parsed.Authors)) 51 + is.Equal(t, "moviefeed", parsed.Authors[0].Name) 40 52 } 41 53 42 54 func TestFeedBuilderRender(t *testing.T) { ··· 106 118 is.Equal(t, htmlContent, entry.Content.Value) 107 119 } 108 120 121 +func TestFeedEntryXHTMLContent(t *testing.T) { 122 + xhtmlContent := `<body><p>Hello <strong>World</strong></p></body>` 123 + feed := NewFeed("test", "feed-id"). 124 + Add(FeedEntry{ 125 + Title: "xhtml entry", 126 + Content: xhtmlContent, 127 + ContentType: "xhtml", 128 + Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), 129 + }) 130 + 131 + raw, err := feed.Bytes() 132 + is.Err(t, err, nil) 133 + if !strings.Contains(string(raw), `<content type="xhtml">`) { 134 + t.Fatalf("expected XHTML content with type='xhtml' attribute in serialized feed") 135 + } 136 + if !strings.Contains(string(raw), `<div xmlns="http://www.w3.org/1999/xhtml"><body><p>Hello <strong>World</strong></p></body></div>`) { 137 + t.Fatalf("expected XHTML div wrapper for content") 138 + } 139 +} 140 + 109 141 func TestFeedEntryLinks(t *testing.T) { 110 142 feed := NewFeed("test", "feed-id"). 111 143 Add(FeedEntry{ ··· 128 160 is.Equal(t, 1, len(parsed.Entries)) 129 161 is.Equal(t, 1, len(parsed.Entries[0].Links)) 130 162 is.Equal(t, "https://example.com/item", parsed.Entries[0].Links[0].Href) 163 +} 164 + 165 +func TestFeedEntryLinksWithLength(t *testing.T) { 166 + feed := NewFeed("test", "feed-id"). 167 + Add(FeedEntry{ 168 + Title: "entry", 169 + Content: "hello", 170 + Links: []FeedLink{ 171 + {Rel: "enclosure", Type: "image/jpeg", Length: "0", Href: "https://example.com/item.jpg"}, 172 + }, 173 + Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), 174 + }) 175 + 176 + raw, err := feed.Bytes() 177 + is.Err(t, err, nil) 178 + if !strings.Contains(string(raw), `rel="enclosure" type="image/jpeg" length="0" href="https://example.com/item.jpg"`) { 179 + t.Fatalf("expected enclosure link with length in serialized feed") 180 + } 131 181 } 132 182 133 183 func TestFeedMultipleEntriesWithMixedContentTypes(t *testing.T) {
M
sources/moviefeed/api.go
··· 3 3 import ( 4 4 "encoding/json" 5 5 "fmt" 6 - "io" 6 + "log/slog" 7 7 "net/http" 8 8 "net/url" 9 9 "strings" ··· 75 75 return []TMDBEpisode{}, nil 76 76 } 77 77 78 - seasonData, err := makeRequest[tmdbSeasonResponse](a, "/tv/%s/season/%d", tmdbID, show.NumberOfSeasons) 78 + var allEpisodes []TMDBEpisode 79 + season := show.NumberOfSeasons 80 + seasonData, err := makeRequest[tmdbSeasonResponse](a, "/tv/%s/season/%d", tmdbID, season) 79 81 if err != nil { 80 - return nil, err 81 - } 82 - 83 - var allEpisodes []TMDBEpisode 84 - for _, ep := range seasonData.Episodes { 85 - ep.ShowName = show.Name 86 - ep.ShowID = tmdbID 87 - allEpisodes = append(allEpisodes, ep) 82 + slog.Warn("failed to fetch season", "season", season, "show", tmdbID, "err", err) 83 + } else { 84 + for _, ep := range seasonData.Episodes { 85 + ep.ShowName = show.Name 86 + ep.ShowID = tmdbID 87 + allEpisodes = append(allEpisodes, ep) 88 + } 88 89 } 89 90 90 91 return filterRecentEpisodes(allEpisodes), nil ··· 106 107 return showID, nil 107 108 } 108 109 109 -func makeRequest[T any](a *TMDBAPI, endpoint string, args ...interface{}) (*T, error) { 110 +func makeRequest[T any](a *TMDBAPI, endpoint string, args ...any) (*T, error) { 110 111 u, err := url.Parse(fmt.Sprintf(tmdbBaseURL+endpoint, args...)) 111 112 if err != nil { 112 113 return nil, fmt.Errorf("failed to parse URL: %w", err) ··· 115 116 q.Set("api_key", a.apiKey) 116 117 u.RawQuery = q.Encode() 117 118 119 + slog.Info("external API request", "endpoint", u.String()) 118 120 resp, err := a.client.Get(u.String()) 119 121 if err != nil { 120 122 return nil, fmt.Errorf("failed to fetch %s: %w", endpoint, err) 121 123 } 122 - defer resp.Body.Close() 123 - 124 - if resp.StatusCode != http.StatusOK { 125 - body, _ := io.ReadAll(resp.Body) 126 - return nil, fmt.Errorf("TMDB API error: %s (status: %d)", string(body), resp.StatusCode) 127 - } 124 + defer func() { 125 + if err := resp.Body.Close(); err != nil { 126 + slog.Error("failed to close response body", "err", err) 127 + } 128 + }() 128 129 129 130 var result T 130 131 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
M
sources/moviefeed/moviefeed.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "html" 6 + "log/slog" 5 7 "net/http" 6 8 "strings" 7 9 "time" ··· 10 12 ) 11 13 12 14 type moviefeed struct { 13 - api *TMDBAPI 14 - shows []string 15 - client *http.Client 15 + api *TMDBAPI 16 + shows []string 16 17 } 17 18 18 19 func Register(a *app.App) error { ··· 21 22 } 22 23 23 24 mf := &moviefeed{ 24 - api: NewTMDBAPI(a.Config.MoviefeedAPIKey, a.Client), 25 - shows: a.Config.MoviefeedShows, 26 - client: a.Client, 25 + api: NewTMDBAPI(a.Config.MoviefeedAPIKey, a.Client), 26 + shows: a.Config.MoviefeedShows, 27 27 } 28 28 29 - a.Route("GET /movies/{rest...}", mf.handleMovies) 29 + a.Route("GET /movies", mf.handleMovies) 30 + a.Route("GET /movies/", mf.handleMovies) 30 31 31 32 a.Logger.Info("moviefeed source registered") 32 33 return nil 33 34 } 34 35 35 36 func (mf *moviefeed) handleMovies(w http.ResponseWriter, r *http.Request) { 36 - rest := r.PathValue("rest") 37 - 38 - if rest == "" { 39 - mf.handler(w, r, mf.shows) 37 + episodes, err := mf.fetchNewEpisodes() 38 + if err != nil { 39 + slog.Error("failed to fetch episodes", "err", err) 40 + http.Error(w, "Internal server error", http.StatusInternalServerError) 40 41 return 41 42 } 42 43 43 - ids := strings.Split(rest, "/") 44 - var requestedIDs []string 45 - for _, id := range ids { 46 - if id != "" { 47 - requestedIDs = append(requestedIDs, id) 48 - } 44 + feed := generateFeed(episodes) 45 + if err := feed.Render(w); err != nil { 46 + http.Error(w, "failed to render feed", http.StatusInternalServerError) 49 47 } 50 - 51 - if len(requestedIDs) == 0 { 52 - mf.handler(w, r, mf.shows) 53 - return 54 - } 55 - 56 - mf.handler(w, r, requestedIDs) 57 48 } 58 49 59 -func (mf *moviefeed) handler(w http.ResponseWriter, r *http.Request, requestedIDs []string) { 60 - if len(requestedIDs) == 0 { 61 - http.Error(w, "no movie IDs provided", http.StatusBadRequest) 62 - return 63 - } 64 - 50 +func (mf *moviefeed) fetchNewEpisodes() ([]TMDBEpisode, error) { 65 51 var allEpisodes []TMDBEpisode 66 - for _, showID := range requestedIDs { 52 + for _, showID := range mf.shows { 67 53 episodes, err := mf.api.FetchEpisodesForShow(showID) 68 54 if err != nil { 55 + slog.Warn("failed to fetch episodes for show", "show", showID, "err", err) 69 56 continue 70 57 } 71 58 allEpisodes = append(allEpisodes, episodes...) 72 59 } 60 + return allEpisodes, nil 61 +} 73 62 74 - sortEpisodes(allEpisodes) 63 +func generateFeed(episodes []TMDBEpisode) *app.FeedBuilder { 64 + feed := app.NewFeed("moviefeed", "moviefeed"). 65 + WithSubtitle("Latest episodes from followed shows") 75 66 76 - feedID := fmt.Sprintf("moviefeed-%s", strings.Join(requestedIDs, "-")) 77 - feedTitle := fmt.Sprintf("Episodes from %s", strings.Join(requestedIDs, ", ")) 78 - feed := app.NewFeed(feedTitle, feedID) 79 - 80 - for _, ep := range allEpisodes { 81 - entryID := fmt.Sprintf("tmdb-%s-s%de%d", ep.ShowID, ep.SeasonNumber, ep.EpisodeNumber) 82 - airDate := parseAirDate(ep.AirDate) 67 + for i := len(episodes) - 1; i >= 0; i-- { 68 + ep := episodes[i] 69 + airDate, _ := time.Parse(dateFormat, ep.AirDate) 70 + content, contentType := episodeContent(ep) 71 + links := []app.FeedLink{ 72 + { 73 + Rel: "alternate", 74 + Href: fmt.Sprintf("https://www.themoviedb.org/tv/episode/%d", ep.ID), 75 + }, 76 + } 77 + if ep.StillPath != "" { 78 + links = append(links, app.FeedLink{ 79 + Rel: "enclosure", 80 + Type: "image/jpeg", 81 + Length: "0", 82 + Href: tmdbImageBaseURL + ep.StillPath, 83 + }) 84 + } 83 85 84 86 feed.Add(app.FeedEntry{ 85 - Title: fmt.Sprintf("%s S%dE%d: %s", 87 + ID: fmt.Sprintf("%s-%d-%d", ep.ShowID, ep.SeasonNumber, ep.EpisodeNumber), 88 + Title: fmt.Sprintf( 89 + "%s S%dE%d: %s", 86 90 ep.ShowName, 87 91 ep.SeasonNumber, 88 92 ep.EpisodeNumber, 89 - ep.Name), 90 - ID: entryID, 91 - Content: ep.Overview, 92 - Updated: airDate, 93 - Links: []app.FeedLink{ 94 - { 95 - Rel: "alternate", 96 - Href: fmt.Sprintf("https://www.themoviedb.org/tv/episode/%d", ep.ID), 97 - }, 98 - }, 93 + ep.Name, 94 + ), 95 + Content: content, 96 + ContentType: contentType, 97 + Updated: airDate, 98 + Links: links, 99 99 }) 100 - 101 - if ep.StillPath != "" { 102 - feed.Add(app.FeedEntry{ 103 - Title: fmt.Sprintf("%s (image)", ep.Name), 104 - ID: entryID + "-img", 105 - Content: fmt.Sprintf("", ep.Name, tmdbImageBaseURL, ep.StillPath), 106 - Updated: airDate, 107 - Links: []app.FeedLink{ 108 - { 109 - Rel: "alternate", 110 - Href: fmt.Sprintf("https://www.themoviedb.org/tv/episode/%d", ep.ID), 111 - }, 112 - }, 113 - }) 114 - } 115 100 } 101 + return feed 102 +} 116 103 117 - if err := feed.Render(w); err != nil { 118 - http.Error(w, "failed to render feed", http.StatusInternalServerError) 119 - return 104 +func episodeContent(ep TMDBEpisode) (string, string) { 105 + if ep.StillPath == "" { 106 + return ep.Overview, "" 120 107 } 121 -} 122 108 123 -func parseAirDate(dateStr string) time.Time { 124 - if dateStr == "" { 125 - return time.Now() 109 + imageURL := tmdbImageBaseURL + ep.StillPath 110 + parts := make([]string, 0, 4) 111 + parts = append(parts, "<body>") 112 + if text := strings.TrimSpace(ep.Overview); text != "" { 113 + parts = append(parts, "<p>"+html.EscapeString(text)+"</p>") 126 114 } 127 - t, err := time.Parse(dateFormat, dateStr) 128 - if err != nil { 129 - return time.Now() 130 - } 131 - return t 132 -} 115 + parts = append(parts, 116 + fmt.Sprintf(`<p><img src="%s" alt="%s"/></p>`, html.EscapeString(imageURL), html.EscapeString(ep.Name))) 117 + parts = append(parts, "</body>") 133 118 134 -func sortEpisodes(episodes []TMDBEpisode) { 135 - for i := range episodes { 136 - for j := i + 1; j < len(episodes); j++ { 137 - if episodes[j].AirDate > episodes[i].AirDate || (episodes[j].AirDate == episodes[i].AirDate && episodes[j].EpisodeNumber > episodes[i].EpisodeNumber) { 138 - episodes[i], episodes[j] = episodes[j], episodes[i] 139 - } 140 - } 141 - } 119 + return strings.Join(parts, ""), "xhtml" 142 120 }
M
sources/moviefeed/moviefeed_test.go
··· 14 14 "olexsmir.xyz/x/is" 15 15 ) 16 16 17 -func TestHandleMoviesRendersFeedForRequestedID(t *testing.T) { 17 +func TestHandleMoviesRendersFeedFromConfiguredShows(t *testing.T) { 18 18 server, client := newTMDBStub(t) 19 19 defer server.Close() 20 20 21 21 mf := &moviefeed{ 22 22 api: NewTMDBAPI("test-key", client), 23 - shows: nil, 23 + shows: []string{"tt123"}, 24 24 } 25 25 26 26 mux := http.NewServeMux() 27 - mux.HandleFunc("GET /movies/{rest...}", mf.handleMovies) 27 + mux.HandleFunc("GET /movies", mf.handleMovies) 28 28 29 - req := httptest.NewRequest(http.MethodGet, "/movies/tt123", nil) 29 + req := httptest.NewRequest(http.MethodGet, "/movies", nil) 30 30 rr := httptest.NewRecorder() 31 31 mux.ServeHTTP(rr, req) 32 32 ··· 37 37 38 38 var feed app.AtomFeed 39 39 is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) 40 - is.Equal(t, feed.Title, "Episodes from tt123") 41 - is.Equal(t, feed.ID, "moviefeed-tt123") 42 - is.Equal(t, len(feed.Entries), 3) // two episodes + one image entry 43 - is.Equal(t, strings.Contains(feed.Entries[0].Title, "S1E2"), true) // newest first 44 - is.Equal(t, feed.Entries[2].ID, "tmdb-101-s1e1-img") 40 + is.Equal(t, feed.Title, "moviefeed") 41 + is.Equal(t, feed.Subtitle, "Latest episodes from followed shows") 42 + is.Equal(t, len(feed.Entries), 2) 43 + is.Equal(t, strings.Contains(feed.Entries[0].Title, "S1E2"), true) 44 + is.Equal(t, feed.Entries[0].Content.Type, "text") 45 + is.Equal(t, len(feed.Entries[1].Links), 2) 46 + is.Equal(t, feed.Entries[1].Links[1].Rel, "enclosure") 47 + is.Equal(t, feed.Entries[1].Links[1].Type, "image/jpeg") 48 + is.Equal(t, feed.Entries[1].Links[1].Length, "0") 49 + is.Equal(t, feed.Entries[1].Links[1].Href, "https://image.tmdb.org/t/p/w500/e1.jpg") 50 + is.Equal(t, feed.Entries[1].Content.Type, "xhtml") 45 51 } 46 52 47 -func TestHandleMoviesUsesConfiguredShowsForEmptyPath(t *testing.T) { 53 +func TestHandleMoviesContinuesWhenOneShowFails(t *testing.T) { 48 54 server, client := newTMDBStub(t) 49 55 defer server.Close() 50 56 51 57 mf := &moviefeed{ 52 58 api: NewTMDBAPI("test-key", client), 53 - shows: []string{"tt123"}, 59 + shows: []string{"bad-show", "tt123"}, 54 60 } 55 61 56 62 mux := http.NewServeMux() 57 - mux.HandleFunc("GET /movies/{rest...}", mf.handleMovies) 63 + mux.HandleFunc("GET /movies", mf.handleMovies) 58 64 59 - req := httptest.NewRequest(http.MethodGet, "/movies/", nil) 65 + req := httptest.NewRequest(http.MethodGet, "/movies", nil) 60 66 rr := httptest.NewRecorder() 61 67 mux.ServeHTTP(rr, req) 62 68 ··· 64 70 65 71 var feed app.AtomFeed 66 72 is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) 67 - is.Equal(t, feed.ID, "moviefeed-tt123") 68 -} 69 - 70 -func TestHandleMoviesReturnsBadRequestWhenNoIDs(t *testing.T) { 71 - mf := &moviefeed{ 72 - api: NewTMDBAPI("test-key", http.DefaultClient), 73 - shows: nil, 74 - } 75 - 76 - mux := http.NewServeMux() 77 - mux.HandleFunc("GET /movies/{rest...}", mf.handleMovies) 78 - 79 - req := httptest.NewRequest(http.MethodGet, "/movies/", nil) 80 - rr := httptest.NewRecorder() 81 - mux.ServeHTTP(rr, req) 82 - 83 - is.Equal(t, rr.Code, http.StatusBadRequest) 84 - is.Equal(t, strings.Contains(rr.Body.String(), "no movie IDs provided"), true) 73 + is.Equal(t, len(feed.Entries), 2) 85 74 } 86 75 87 76 func TestFetchEpisodesForShowFiltersRecentAndMapsFields(t *testing.T) { ··· 92 81 episodes, err := api.FetchEpisodesForShow("tt123") 93 82 is.Err(t, err, nil) 94 83 95 - is.Equal(t, len(episodes), 2) // old episode is filtered out 84 + is.Equal(t, len(episodes), 2) 96 85 is.Equal(t, episodes[0].ShowID, "101") 97 86 is.Equal(t, episodes[0].ShowName, "Test Show") 98 87 } 99 88 89 +func TestFetchEpisodesForShowSeasonErrorDoesNotFailShow(t *testing.T) { 90 + server, client := newTMDBStubWithSeasonError(t) 91 + defer server.Close() 92 + 93 + api := NewTMDBAPI("test-key", client) 94 + episodes, err := api.FetchEpisodesForShow("tt123") 95 + is.Err(t, err, nil) 96 + is.Equal(t, len(episodes), 0) 97 +} 98 + 99 +func TestEpisodeContentIncludesImageInBody(t *testing.T) { 100 + content, contentType := episodeContent(TMDBEpisode{ 101 + Name: "Episode 1", 102 + Overview: "E1", 103 + StillPath: "/e1.jpg", 104 + }) 105 + 106 + is.Equal(t, contentType, "xhtml") 107 + is.Equal(t, strings.Contains(content, "<body>"), true) 108 + is.Equal(t, strings.Contains(content, `<img src="https://image.tmdb.org/t/p/w500/e1.jpg" alt="Episode 1"`), true) 109 + is.Equal(t, strings.Contains(content, "</body>"), true) 110 +} 111 + 100 112 func newTMDBStub(t *testing.T) (*httptest.Server, *http.Client) { 101 113 t.Helper() 102 114 ··· 115 127 _, _ = w.Write([]byte(`{"tv_results":[{"id":101}]}`)) 116 128 }) 117 129 130 + mux.HandleFunc("/3/find/bad-show", func(w http.ResponseWriter, r *http.Request) { 131 + if got := r.URL.Query().Get("external_source"); got != "imdb_id" { 132 + t.Fatalf("unexpected external_source query: %q", got) 133 + } 134 + if got := r.URL.Query().Get("api_key"); got != "test-key" { 135 + t.Fatalf("unexpected api_key query: %q", got) 136 + } 137 + _, _ = w.Write([]byte(`{"tv_results":[]}`)) 138 + }) 139 + 118 140 mux.HandleFunc("/3/tv/101", func(w http.ResponseWriter, r *http.Request) { 119 141 if got := r.URL.Query().Get("api_key"); got != "test-key" { 120 142 t.Fatalf("unexpected api_key query: %q", got) ··· 134 156 ] 135 157 }`, recentDay, newestDay, oldDay) 136 158 _, _ = w.Write([]byte(body)) 159 + }) 160 + 161 + server := httptest.NewServer(mux) 162 + target, err := url.Parse(server.URL) 163 + is.Err(t, err, nil) 164 + 165 + client := &http.Client{ 166 + Transport: rewriteTransport{target: target}, 167 + } 168 + return server, client 169 +} 170 + 171 +func newTMDBStubWithSeasonError(t *testing.T) (*httptest.Server, *http.Client) { 172 + t.Helper() 173 + 174 + mux := http.NewServeMux() 175 + mux.HandleFunc("/3/find/tt123", func(w http.ResponseWriter, r *http.Request) { 176 + if got := r.URL.Query().Get("external_source"); got != "imdb_id" { 177 + t.Fatalf("unexpected external_source query: %q", got) 178 + } 179 + if got := r.URL.Query().Get("api_key"); got != "test-key" { 180 + t.Fatalf("unexpected api_key query: %q", got) 181 + } 182 + _, _ = w.Write([]byte(`{"tv_results":[{"id":101}]}`)) 183 + }) 184 + 185 + mux.HandleFunc("/3/tv/101", func(w http.ResponseWriter, r *http.Request) { 186 + if got := r.URL.Query().Get("api_key"); got != "test-key" { 187 + t.Fatalf("unexpected api_key query: %q", got) 188 + } 189 + _, _ = w.Write([]byte(`{"id":101,"name":"Test Show","number_of_seasons":1}`)) 190 + }) 191 + 192 + mux.HandleFunc("/3/tv/101/season/1", func(w http.ResponseWriter, r *http.Request) { 193 + w.WriteHeader(http.StatusInternalServerError) 194 + _, _ = w.Write([]byte(`bad gateway`)) 137 195 }) 138 196 139 197 server := httptest.NewServer(mux)