Parent directory

videos.go

8602 bytes
  1package handler
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"net/url"
  8	"strconv"
  9	"strings"
 10	"sync"
 11	"time"
 12
 13	"github.com/TheEdgeOfRage/ytrssil-api/db"
 14	"github.com/TheEdgeOfRage/ytrssil-api/feedparser"
 15	"github.com/TheEdgeOfRage/ytrssil-api/models"
 16)
 17
 18var (
 19	ErrInvalidProgress   = errors.New("invalid progress time")
 20	ErrInvalidVideoInput = errors.New("invalid video input")
 21	ErrVideoNotFound     = errors.New("video not found")
 22)
 23
 24func (h *handler) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error) {
 25	return h.db.GetNewVideos(ctx, sortDesc)
 26}
 27
 28const WatchedVideosPageSize = 100
 29
 30func (h *handler) GetWatchedVideos(ctx context.Context, sortDesc bool, page int) ([]models.Video, error) {
 31	if page < 1 {
 32		page = 1
 33	}
 34	offset := (page - 1) * WatchedVideosPageSize
 35	return h.db.GetWatchedVideos(ctx, sortDesc, WatchedVideosPageSize, offset)
 36}
 37
 38func (h *handler) GetVideo(ctx context.Context, videoID string) (*models.Video, error) {
 39	exists, err := h.db.HasVideo(ctx, videoID)
 40	if err != nil {
 41		return nil, err
 42	}
 43	if !exists {
 44		return nil, ErrVideoNotFound
 45	}
 46	return h.db.GetVideo(ctx, videoID)
 47}
 48
 49func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedparser.Channel, enableShorts bool) {
 50	videos := make(map[string]*models.Video, len(parsedChannel.Videos))
 51
 52	for _, parsedVideo := range parsedChannel.Videos {
 53		date, err := parsedVideo.Published.Parse()
 54		if err != nil {
 55			h.log.Error("Failed to parse video information", "call", "feedparser.Parse", "err", err)
 56			continue
 57		}
 58
 59		videoID := strings.Split(parsedVideo.ID, ":")[2]
 60		exists, err := h.db.HasVideo(ctx, videoID)
 61		if err != nil {
 62			h.log.Error("Failed to check if video already exists", "call", "db.HasVideo", "err", err)
 63			continue
 64		}
 65
 66		if exists {
 67			continue
 68		}
 69
 70		videos[videoID] = &models.Video{
 71			ID:            videoID,
 72			Title:         parsedVideo.Title,
 73			PublishedTime: date,
 74			IsShort:       parsedVideo.IsShort,
 75		}
 76	}
 77
 78	if len(videos) == 0 {
 79		return
 80	}
 81
 82	// Get durations for all videos
 83	err := h.youTubeClient.GetVideoDurations(ctx, videos)
 84	if err != nil {
 85		h.log.Error("Failed to get video durations", "call", "handler.getVideoDurations", "err", err)
 86		return
 87	}
 88
 89	// Add videos with appropriate discard flag
 90	for _, video := range videos {
 91		isDiscarded := video.IsShort && !enableShorts
 92		err = h.db.AddVideo(ctx, *video, parsedChannel.ID, isDiscarded)
 93		if err != nil {
 94			if !errors.Is(err, db.ErrVideoExists) {
 95				h.log.Error("Failed to save video to db", "call", "db.AddVideo", "err", err)
 96			}
 97			continue
 98		}
 99	}
100}
101
102type parseResult struct {
103	channel      *feedparser.Channel
104	err          error
105	enableShorts bool
106}
107
108func (h *handler) FetchVideos(ctx context.Context) error {
109	h.log.Info("Fetching new videos for all channels")
110
111	channels, err := h.db.ListChannels(ctx)
112	if err != nil {
113		return err
114	}
115
116	var wg sync.WaitGroup
117	results := make(chan parseResult, 1)
118	for _, channel := range channels {
119		wg.Go(func() {
120			parsedChannel, err := h.parser.Parse(channel.ID)
121			results <- parseResult{channel: parsedChannel, err: err, enableShorts: channel.EnableShorts}
122		})
123	}
124
125	go func() {
126		wg.Wait()
127		close(results)
128	}()
129
130	for result := range results {
131		if result.err != nil {
132			h.log.Error("failed to parse channel feed", "error", result.err)
133			continue
134		}
135
136		h.addVideosForChannel(ctx, result.channel, result.enableShorts)
137	}
138
139	h.recheckLiveVideos(ctx)
140
141	return nil
142}
143
144func (h *handler) recheckLiveVideos(ctx context.Context) {
145	liveVideos, err := h.db.GetLiveVideos(ctx)
146	if err != nil {
147		h.log.Error("Failed to get live videos", "error", err)
148		return
149	}
150	if len(liveVideos) == 0 {
151		return
152	}
153
154	videos := make(map[string]*models.Video, len(liveVideos))
155	for i := range liveVideos {
156		videos[liveVideos[i].ID] = &liveVideos[i]
157	}
158
159	err = h.youTubeClient.GetVideoDurations(ctx, videos)
160	if err != nil {
161		h.log.Error("Failed to recheck live video durations", "error", err)
162		return
163	}
164
165	for _, video := range videos {
166		if video.IsLive {
167			continue
168		}
169		err = h.db.UpdateVideoLiveStatus(ctx, video.ID, false, video.DurationSeconds)
170		if err != nil {
171			h.log.Error("Failed to update video live status", "videoID", video.ID, "error", err)
172		}
173	}
174}
175
176func (h *handler) MarkVideoAsWatched(ctx context.Context, videoID string) error {
177	watchTime := time.Now()
178	return h.db.SetVideoWatchTime(ctx, videoID, &watchTime)
179}
180
181func (h *handler) MarkVideoAsUnwatched(ctx context.Context, videoID string) error {
182	return h.db.SetVideoWatchTime(ctx, videoID, nil)
183}
184
185// parseTimeProgress parses a duration string in  the Go duration format, hh:mm:ss, and mm:ss to a time.Duration
186func parseTimeProgress(progressTime string) (time.Duration, error) {
187	// Try Go duration format first
188	duration, err := time.ParseDuration(progressTime)
189	if err == nil {
190		return duration, nil
191	}
192
193	// Try hh:mm:ss or mm:ss format
194	parts := strings.Split(progressTime, ":")
195	if len(parts) == 2 {
196		// mm:ss format
197		minutes, err := strconv.Atoi(parts[0])
198		if err != nil {
199			return 0, fmt.Errorf("invalid mm:ss format")
200		}
201		seconds, err := strconv.Atoi(parts[1])
202		if err != nil {
203			return 0, fmt.Errorf("invalid mm:ss format")
204		}
205		return time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second, nil
206	} else if len(parts) == 3 {
207		// hh:mm:ss format
208		hours, err := strconv.Atoi(parts[0])
209		if err != nil {
210			return 0, fmt.Errorf("invalid hh:mm:ss format")
211		}
212		minutes, err := strconv.Atoi(parts[1])
213		if err != nil {
214			return 0, fmt.Errorf("invalid hh:mm:ss format")
215		}
216		seconds, err := strconv.Atoi(parts[2])
217		if err != nil {
218			return 0, fmt.Errorf("invalid hh:mm:ss format")
219		}
220		return time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second, nil
221	}
222
223	return 0, fmt.Errorf("unsupported time format: expected Go duration, hh:mm:ss, or mm:ss")
224}
225
226func (h *handler) SetVideoProgress(ctx context.Context, videoID string, progressTime string) (*models.Video, error) {
227	progress, err := parseTimeProgress(progressTime)
228	if err != nil {
229		return nil, fmt.Errorf("%w: %v", ErrInvalidProgress, err.Error())
230	}
231
232	video, err := h.db.SetVideoProgress(ctx, videoID, int(progress.Seconds()))
233	if err != nil {
234		return nil, fmt.Errorf("set video progress: %w", err)
235	}
236
237	return video, nil
238}
239
240type videoInput struct {
241	id              string
242	progressSeconds int
243}
244
245func parseVideoInput(input string) (videoInput, error) {
246	if input == "" {
247		return videoInput{}, fmt.Errorf("missing video ID")
248	}
249
250	if !strings.Contains(input, "/") {
251		return videoInput{id: input}, nil
252	}
253
254	u, err := url.Parse(input)
255	if err != nil {
256		return videoInput{}, fmt.Errorf("invalid URL: %w", err)
257	}
258
259	var videoID string
260	switch {
261	case u.Host == "youtu.be":
262		videoID = strings.TrimPrefix(u.Path, "/")
263	case strings.HasPrefix(u.Path, "/live/"):
264		videoID = strings.TrimPrefix(u.Path, "/live/")
265	default:
266		videoID = u.Query().Get("v")
267	}
268
269	if videoID == "" {
270		return videoInput{}, fmt.Errorf("could not extract video ID from URL")
271	}
272
273	result := videoInput{id: videoID}
274	if t := u.Query().Get("t"); t != "" {
275		result.progressSeconds, err = strconv.Atoi(t)
276		if err != nil {
277			return videoInput{}, fmt.Errorf("invalid t parameter: %w", err)
278		}
279	}
280
281	return result, nil
282}
283
284func (h *handler) AddCustomVideo(ctx context.Context, rawVideoID string) error {
285	input, err := parseVideoInput(strings.TrimSpace(rawVideoID))
286	if err != nil {
287		return fmt.Errorf("%w: %v", ErrInvalidVideoInput, err)
288	}
289
290	exists, err := h.db.HasVideo(ctx, input.id)
291	if err != nil {
292		h.log.Error("Failed to check if video already exists", "call", "db.HasVideo", "err", err)
293		return err
294	}
295
296	if exists {
297		h.log.Warn("Video already in db", "call", "db.HasVideo")
298		return nil
299	}
300
301	video, err := h.youTubeClient.GetVideoMetadata(ctx, input.id)
302	if err != nil {
303		h.log.Error("Failed to get video metadata", "error", err)
304		return err
305	}
306
307	channel := models.Channel{
308		ID:           video.ChannelID,
309		Name:         video.ChannelName,
310		Subscribed:   false,
311		EnableShorts: true,
312	}
313	err = h.db.SubscribeToChannel(ctx, channel)
314	if err != nil {
315		h.log.Error("Failed to insert channel", "error", err)
316		return err
317	}
318
319	err = h.db.AddVideo(ctx, *video, video.ChannelID, false)
320	if err != nil {
321		if !errors.Is(err, db.ErrVideoExists) {
322			h.log.Error("Failed to save video to db", "call", "db.AddVideo", "err", err)
323			return err
324		}
325	}
326
327	if input.progressSeconds > 0 {
328		_, err = h.db.SetVideoProgress(ctx, input.id, input.progressSeconds)
329		if err != nil {
330			h.log.Error("Failed to set video progress", "error", err)
331			return err
332		}
333	}
334
335	return nil
336}