6 files changed,
309 insertions(+),
4 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-05-04 15:00:16 +0300
Authored at:
2026-04-26 15:45:58 +0300
Change ID:
vrloumnupnvtutlmwssuvumrmlsqrums
Parent:
1e9eff1
M
app/config.go
··· 6 6 ) 7 7 8 8 type Config struct { 9 - Port int `json:"port"` 10 - AuthToken string `json:"auth_token"` 11 - TGUserID int64 `json:"tg_userid"` 12 - TGToken string `json:"tg_token"` 9 + Port int `json:"port"` 10 + AuthToken string `json:"auth_token"` 11 + TGUserID int64 `json:"tg_userid"` 12 + TGToken string `json:"tg_token"` 13 + MoviefeedAPIKey string `json:"moviefeed_api_key"` 14 + MoviefeedShows []string `json:"moviefeed_shows"` 13 15 } 14 16 15 17 func NewConfig(fpath string) (*Config, error) {
M
main.go
··· 6 6 7 7 "go.etcd.io/bbolt" 8 8 "olexsmir.xyz/rss-tools/app" 9 + "olexsmir.xyz/rss-tools/sources/moviefeed" 9 10 "olexsmir.xyz/rss-tools/sources/telegram" 10 11 "olexsmir.xyz/rss-tools/sources/ztoe" 11 12 ) ··· 36 37 app := app.New(cfg, db) 37 38 _ = ztoe.Register(app) 38 39 _ = telegram.Register(app) 40 + _ = moviefeed.Register(app) 39 41 40 42 return app.Start(ctx) 41 43 }
A
sources/moviefeed/api.go
··· 1 +package moviefeed 2 + 3 +import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + "strings" 10 + "time" 11 +) 12 + 13 +const ( 14 + dateFormat = "2006-01-02" 15 + tmdbBaseURL = "https://api.themoviedb.org/3" 16 + tmdbImageBaseURL = "https://image.tmdb.org/t/p/w500" 17 +) 18 + 19 +type tmdbShow struct { 20 + ID int `json:"id"` 21 + Name string `json:"name"` 22 + Overview string `json:"overview"` 23 + FirstAirDate string `json:"first_air_date"` 24 +} 25 + 26 +type tmdbShowDetails struct { 27 + tmdbShow 28 + NumberOfSeasons int `json:"number_of_seasons"` 29 +} 30 + 31 +type TMDBEpisode struct { 32 + ID int `json:"id"` 33 + Name string `json:"name"` 34 + Overview string `json:"overview"` 35 + AirDate string `json:"air_date"` 36 + EpisodeNumber int `json:"episode_number"` 37 + SeasonNumber int `json:"season_number"` 38 + StillPath string `json:"still_path"` 39 + ShowName string 40 + ShowID string 41 +} 42 + 43 +type tmdbFindResponse struct { 44 + TvResults []tmdbShow `json:"tv_results"` 45 +} 46 + 47 +type tmdbSeasonResponse struct { 48 + Episodes []TMDBEpisode `json:"episodes"` 49 +} 50 + 51 +type TMDBAPI struct { 52 + apiKey string 53 + client *http.Client 54 +} 55 + 56 +func NewTMDBAPI(apiKey string, client *http.Client) *TMDBAPI { 57 + return &TMDBAPI{ 58 + apiKey: apiKey, 59 + client: client, 60 + } 61 +} 62 + 63 +func (a *TMDBAPI) FetchEpisodesForShow(showID string) ([]TMDBEpisode, error) { 64 + tmdbID, err := a.getTMDBID(showID) 65 + if err != nil { 66 + return nil, err 67 + } 68 + 69 + show, err := makeRequest[tmdbShowDetails](a, "/tv/%s", tmdbID) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + if show.NumberOfSeasons == 0 { 75 + return []TMDBEpisode{}, nil 76 + } 77 + 78 + seasonData, err := makeRequest[tmdbSeasonResponse](a, "/tv/%s/season/%d", tmdbID, show.NumberOfSeasons) 79 + 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) 88 + } 89 + 90 + return filterRecentEpisodes(allEpisodes), nil 91 +} 92 + 93 +func (a *TMDBAPI) getTMDBID(showID string) (string, error) { 94 + if strings.HasPrefix(showID, "tt") { 95 + result, err := makeRequest[tmdbFindResponse](a, "/find/%s?external_source=imdb_id", showID) 96 + if err != nil { 97 + return "", err 98 + } 99 + 100 + if len(result.TvResults) == 0 { 101 + return "", fmt.Errorf("no TMDB show found for IMDB ID %s", showID) 102 + } 103 + 104 + return fmt.Sprintf("%d", result.TvResults[0].ID), nil 105 + } 106 + return showID, nil 107 +} 108 + 109 +func makeRequest[T any](a *TMDBAPI, endpoint string, args ...interface{}) (*T, error) { 110 + u, err := url.Parse(fmt.Sprintf(tmdbBaseURL+endpoint, args...)) 111 + if err != nil { 112 + return nil, fmt.Errorf("failed to parse URL: %w", err) 113 + } 114 + q := u.Query() 115 + q.Set("api_key", a.apiKey) 116 + u.RawQuery = q.Encode() 117 + 118 + resp, err := a.client.Get(u.String()) 119 + if err != nil { 120 + return nil, fmt.Errorf("failed to fetch %s: %w", endpoint, err) 121 + } 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 + } 128 + 129 + var result T 130 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 131 + return nil, fmt.Errorf("failed to decode response: %w", err) 132 + } 133 + 134 + return &result, nil 135 +} 136 + 137 +func filterRecentEpisodes(episodes []TMDBEpisode) []TMDBEpisode { 138 + var recent []TMDBEpisode 139 + now := time.Now() 140 + cutoff := now.AddDate(0, 0, -30) 141 + 142 + for _, ep := range episodes { 143 + if ep.AirDate == "" { 144 + continue 145 + } 146 + 147 + airDate, err := time.Parse(dateFormat, ep.AirDate) 148 + if err != nil { 149 + continue 150 + } 151 + 152 + if airDate.Before(now) && airDate.After(cutoff) { 153 + recent = append(recent, ep) 154 + } 155 + } 156 + return recent 157 +}
A
sources/moviefeed/moviefeed.go
··· 1 +package moviefeed 2 + 3 +import ( 4 + "fmt" 5 + "net/http" 6 + "strings" 7 + "time" 8 + 9 + "olexsmir.xyz/rss-tools/app" 10 +) 11 + 12 +type moviefeed struct { 13 + api *TMDBAPI 14 + shows []string 15 + client *http.Client 16 +} 17 + 18 +func Register(a *app.App) error { 19 + if a.Config.MoviefeedAPIKey == "" { 20 + return nil 21 + } 22 + 23 + mf := &moviefeed{ 24 + api: NewTMDBAPI(a.Config.MoviefeedAPIKey, a.Client), 25 + shows: a.Config.MoviefeedShows, 26 + client: a.Client, 27 + } 28 + 29 + a.Route("GET /movies/{rest...}", mf.handleMovies) 30 + 31 + a.Logger.Info("moviefeed source registered") 32 + return nil 33 +} 34 + 35 +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) 40 + return 41 + } 42 + 43 + ids := strings.Split(rest, "/") 44 + var requestedIDs []string 45 + for _, id := range ids { 46 + if id != "" { 47 + requestedIDs = append(requestedIDs, id) 48 + } 49 + } 50 + 51 + if len(requestedIDs) == 0 { 52 + mf.handler(w, r, mf.shows) 53 + return 54 + } 55 + 56 + mf.handler(w, r, requestedIDs) 57 +} 58 + 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 + 65 + var allEpisodes []TMDBEpisode 66 + for _, showID := range requestedIDs { 67 + episodes, err := mf.api.FetchEpisodesForShow(showID) 68 + if err != nil { 69 + continue 70 + } 71 + allEpisodes = append(allEpisodes, episodes...) 72 + } 73 + 74 + sortEpisodes(allEpisodes) 75 + 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) 83 + 84 + feed.Add(app.FeedEntry{ 85 + Title: fmt.Sprintf("%s S%dE%d: %s", 86 + ep.ShowName, 87 + ep.SeasonNumber, 88 + 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 + }, 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 + } 116 + 117 + if err := feed.Render(w); err != nil { 118 + http.Error(w, "failed to render feed", http.StatusInternalServerError) 119 + return 120 + } 121 +} 122 + 123 +func parseAirDate(dateStr string) time.Time { 124 + if dateStr == "" { 125 + return time.Now() 126 + } 127 + t, err := time.Parse(dateFormat, dateStr) 128 + if err != nil { 129 + return time.Now() 130 + } 131 + return t 132 +} 133 + 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 + } 142 +}