all repos

rss-tools @ 58cc1bd492af31e6c9acf65e690474c8c1edf164

get rss feed from sources that(i need and) dont provide one

rss-tools/sources/moviefeed/moviefeed.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
imrpove movies id parsing, 10 days ago
1
package moviefeed
2
3
import (
4
	"fmt"
5
	"html"
6
	"log/slog"
7
	"net/http"
8
	"strings"
9
	"time"
10
11
	"olexsmir.xyz/rss-tools/app"
12
	"olexsmir.xyz/rss-tools/app/atom"
13
)
14
15
type moviefeed struct {
16
	api       episodeFetcher
17
	shows     []string
18
	nameCache map[string]string
19
}
20
21
type episodeFetcher interface {
22
	FetchEpisodesForShow(showID string) ([]TMDBEpisode, error)
23
	SearchShow(query string) (*tmdbShow, error)
24
}
25
26
func Register(a *app.App) error {
27
	if a.Config.MoviefeedAPIKey == "" {
28
		return nil
29
	}
30
31
	mf := &moviefeed{
32
		api:       NewTMDBAPI(a.Config.MoviefeedAPIKey, a.Client),
33
		shows:     a.Config.MoviefeedShows,
34
		nameCache: map[string]string{},
35
	}
36
37
	a.Route("GET /movies", mf.handleMovies)
38
	a.Route("GET /movies/", mf.handleMovies)
39
40
	a.Logger.Info("moviefeed source registered")
41
	return nil
42
}
43
44
func (mf *moviefeed) handleMovies(w http.ResponseWriter, r *http.Request) {
45
	episodes, err := mf.fetchNewEpisodes()
46
	if err != nil {
47
		slog.Error("failed to fetch episodes", "err", err)
48
		http.Error(w, "Internal server error", http.StatusInternalServerError)
49
		return
50
	}
51
52
	feed := generateFeed(episodes)
53
	if err := feed.Render(w); err != nil {
54
		http.Error(w, "failed to render feed", http.StatusInternalServerError)
55
	}
56
}
57
58
func (mf *moviefeed) fetchNewEpisodes() ([]TMDBEpisode, error) {
59
	var allEpisodes []TMDBEpisode
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
67
		episodes, err := mf.api.FetchEpisodesForShow(showID)
68
		if err != nil {
69
			slog.Warn("failed to fetch episodes for show", "show", showID, "entry", entry, "err", err)
70
			continue
71
		}
72
		allEpisodes = append(allEpisodes, episodes...)
73
	}
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
114
}
115
116
func generateFeed(episodes []TMDBEpisode) *atom.Feed {
117
	feed := atom.NewFeed("moviefeed", "moviefeed")
118
	for i := len(episodes) - 1; i >= 0; i-- {
119
		ep := episodes[i]
120
		airDate, _ := time.Parse(dateFormat, ep.AirDate)
121
		content, contentType := episodeContent(ep)
122
		links := []atom.Link{
123
			{
124
				Rel:  "alternate",
125
				Href: fmt.Sprintf("https://www.themoviedb.org/tv/episode/%d", ep.ID),
126
			},
127
		}
128
		if ep.StillPath != "" {
129
			links = append(links, atom.Link{
130
				Rel:    "enclosure",
131
				Type:   "image/jpeg",
132
				Length: 0,
133
				Href:   tmdbImageBaseURL + ep.StillPath,
134
			})
135
		}
136
137
		feed.Add(&atom.Entry{
138
			ID: fmt.Sprintf("%s-%d-%d", ep.ShowID, ep.SeasonNumber, ep.EpisodeNumber),
139
			Title: fmt.Sprintf(
140
				"%s S%dE%d: %s",
141
				ep.ShowName,
142
				ep.SeasonNumber,
143
				ep.EpisodeNumber,
144
				ep.Name,
145
			),
146
			Content: atom.NewText(content, contentType),
147
			Updated: atom.Time(airDate),
148
			Link:    links,
149
		})
150
	}
151
	return feed
152
}
153
154
func episodeContent(ep TMDBEpisode) (string, string) {
155
	if ep.StillPath == "" {
156
		return ep.Overview, ""
157
	}
158
159
	imageURL := tmdbImageBaseURL + ep.StillPath
160
	parts := make([]string, 0, 4)
161
	parts = append(parts, "<body>")
162
	if text := strings.TrimSpace(ep.Overview); text != "" {
163
		parts = append(parts, "<p>"+html.EscapeString(text)+"</p>")
164
	}
165
	parts = append(parts,
166
		fmt.Sprintf(`<p><img src="%s" alt="%s"/></p>`, html.EscapeString(imageURL), html.EscapeString(ep.Name)))
167
	parts = append(parts, "</body>")
168
169
	return strings.Join(parts, ""), "xhtml"
170
}