all repos

rss-tools @ 50b546d

get rss feed from sources that(i need and) dont provide one
5 files changed, 320 insertions(+), 150 deletions(-)
fix moviefeed
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("![%s](%s%s)", 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)