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}