all repos

moviefeed @ 7e6ccaf9442a46c4765c6c4a185ae53cfa141b0f

rss feed server for tracking new tv show episodes
3 files changed, 221 insertions(+), 3 deletions(-)
refactoring
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-01-01 00:53:44 +0200
Change ID: ottwovoxzpnquyspqkkxsqppxysozpwq
Parent: 6d7ce55
A api.go

@@ -0,0 +1,154 @@

+package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" +) + +type TMDBShow struct { + ID int `json:"id"` + Name string `json:"name"` + Overview string `json:"overview"` + FirstAirDate string `json:"first_air_date"` +} + +type tmdbShowDetails struct { + TMDBShow + NumberOfSeasons int `json:"number_of_seasons"` +} + +type TMDBEpisode struct { + ID int `json:"id"` + Name string `json:"name"` + Overview string `json:"overview"` + AirDate string `json:"air_date"` + EpisodeNumber int `json:"episode_number"` + SeasonNumber int `json:"season_number"` + ShowName string + ShowID string +} + +type tmdbFindResponse struct { + TvResults []TMDBShow `json:"tv_results"` +} + +type tmdbSeasonResponse struct { + Episodes []TMDBEpisode `json:"episodes"` +} + +func fetchNewEpisodes(config *Config) ([]TMDBEpisode, error) { + var allEpisodes []TMDBEpisode + for _, showID := range config.Shows { + episodes, err := fetchEpisodesForShow(showID, config.APIKey) + if err != nil { + slog.Warn("failed to fetch episodes for show", "show", showID, "err", err) + continue + } + allEpisodes = append(allEpisodes, episodes...) + } + return allEpisodes, nil +} + +func getTMDBID(showID, apiKey string) (string, error) { + imdbIDPrefix := "tt" + if len(showID) > len(imdbIDPrefix) && showID[:len(imdbIDPrefix)] == imdbIDPrefix { + url := fmt.Sprintf( + "https://api.themoviedb.org/3/find/%s?api_key=%s&external_source=imdb_id", + showID, + apiKey, + ) + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("failed to fetch TMDB ID: %w", err) + } + defer resp.Body.Close() + + var result tmdbFindResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if len(result.TvResults) == 0 { + return "", fmt.Errorf("no TMDB show found for IMDB ID %s", showID) + } + + return fmt.Sprintf("%d", result.TvResults[0].ID), nil + } + return showID, nil +} + +func fetchEpisodesForShow(showID, apiKey string) ([]TMDBEpisode, error) { + tmdbID, err := getTMDBID(showID, apiKey) + if err != nil { + return nil, err + } + + // Get show details + url := fmt.Sprintf("https://api.themoviedb.org/3/tv/%s?api_key=%s", tmdbID, apiKey) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var show tmdbShowDetails + if err := json.NewDecoder(resp.Body).Decode(&show); err != nil { + return nil, err + } + + seasonsToFetch := []int{1} + if show.NumberOfSeasons > 1 { + seasonsToFetch = append(seasonsToFetch, show.NumberOfSeasons) + } + + var allEpisodes []TMDBEpisode + for _, season := range seasonsToFetch { + seasonURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%s/season/%d?api_key=%s", + tmdbID, season, apiKey) + resp, err := http.Get(seasonURL) + if err != nil { + slog.Warn("failed to fetch season", "season", season, "show", tmdbID, "err", err) + continue + } + defer resp.Body.Close() + + var seasonData tmdbSeasonResponse + if err := json.NewDecoder(resp.Body).Decode(&seasonData); err != nil { + slog.Warn("failed to decode season", "season", season, "show", tmdbID, "err", err) + continue + } + + for _, ep := range seasonData.Episodes { + ep.ShowName = show.Name + ep.ShowID = tmdbID + allEpisodes = append(allEpisodes, ep) + } + } + + return filterRecentEpisodes(allEpisodes), nil +} + +func filterRecentEpisodes(episodes []TMDBEpisode) []TMDBEpisode { + var recent []TMDBEpisode + now := time.Now() + cutoff := now.AddDate(0, 0, -30) + + for _, ep := range episodes { + if ep.AirDate == "" { + continue + } + + airDate, err := time.Parse(dateFormat, ep.AirDate) + if err != nil { + continue + } + + if airDate.Before(now) && airDate.After(cutoff) { + recent = append(recent, ep) + } + } + return recent +}
A config.go

@@ -0,0 +1,56 @@

+package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v2" +) + +type Config struct { + APIKey string `json:"api_key" yaml:"api_key"` + Port string `json:"port" yaml:"port"` + Shows []string `json:"shows" yaml:"shows"` +} + +func loadConfig(path string) (*Config, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var config Config + + switch strings.ToLower(filepath.Ext(path)) { + case ".yaml", ".yml": + err = yaml.NewDecoder(file).Decode(&config) + case ".json": + err = json.NewDecoder(file).Decode(&config) + default: + return nil, errors.New("unsupported config file format") + } + + if err != nil { + return nil, errors.New("failed to decode config") + } + + // defaults + if config.Port == "" { + config.Port = "8000" + } + + // validate + if config.APIKey == "" { + return nil, errors.New("api_key is required") + } + + if len(config.Shows) == 0 { + return nil, errors.New("at least one show must be specified") + } + + return &config, nil +}
M main.go

@@ -11,6 +11,8 @@

"github.com/gorilla/feeds" ) +const dateFormat = "2006-01-02" + func main() { configFile := flag.String("config", "config.yaml", "Path to config file") flag.Parse()

@@ -25,7 +27,8 @@ http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

episodes, err := fetchNewEpisodes(config) if err != nil { slog.Error("failed to fetch episodes", "err", err) - os.Exit(1) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return } rssFeed := generateRSS(episodes)

@@ -33,8 +36,12 @@ w.Header().Set("Content-Type", "application/rss+xml")

w.Write([]byte(rssFeed)) }) + addr := ":" + config.Port slog.Info("server starting", "port", config.Port) - http.ListenAndServe(":"+config.Port, nil) + if err := http.ListenAndServe(addr, nil); err != nil { + slog.Error("server failed", "err", err) + os.Exit(1) + } } func generateRSS(episodes []TMDBEpisode) string {

@@ -45,7 +52,8 @@ Description: "Latest episodes from followed shows",

Created: time.Now(), } - for _, ep := range episodes { + for i := len(episodes) - 1; i >= 0; i-- { + ep := episodes[i] airDate, _ := time.Parse("2006-01-02", ep.AirDate) feed.Items = append(feed.Items, &feeds.Item{ Id: fmt.Sprintf("%s-%d-%d", ep.ShowID, ep.SeasonNumber, ep.EpisodeNumber),