all repos

rss-tools @ d3bc404

get rss feed from sources that(i need and) dont provide one
6 files changed, 309 insertions(+), 4 deletions(-)
add moviefeed source
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("![%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
        +	}

      
        
        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
        +}

      
M sources/telegram/telegram.go
···
        47
        47
         

      
        48
        48
         	a.AddWorker(t.worker)

      
        49
        49
         	a.Route("GET /telegram", t.handler)

      
        
        50
        +	a.Logger.Info("telegram source registered")

      
        50
        51
         	return nil

      
        51
        52
         }

      
        52
        53
         

      
M sources/ztoe/ztoe.go
···
        26
        26
         func Register(a *app.App) error {

      
        27
        27
         	z := ztoe{get: a.Get}

      
        28
        28
         	a.Route("GET /ztoe/{group}/{subgroup}", z.handler(sourceURL))

      
        
        29
        +	a.Logger.Info("ztoe source registered")

      
        29
        30
         	return nil

      
        30
        31
         }

      
        31
        32