799c6dfa4b17e0816a6a107f32bb731835ece760

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add support for live streams with an indicator while it's live

Diff

This diff is truncated to protect this page.

  1diff --git a/.gitignore b/.gitignore
  2index 82009254414ac175f140ffd4d0b10fbd5ef42930..456dd41db6df8483997d7c8d2af356a33cca2051 100644
  3--- a/.gitignore
  4+++ b/.gitignore
  5@@ -1,17 +1,6 @@
  6-# Binaries for programs and plugins
  7-*.exe
  8-*.exe~
  9-*.dll
 10-*.so
 11-*.dylib
 12-
 13 # Test binary, built with `go test -c`
 14 *.test
 15 
 16-# Output of the go coverage tool, specifically when used with LiteIDE
 17-*.out
 18-
 19-debug
 20 # editor temp files
 21 *.swp
 22 *.swo
 23@@ -20,13 +9,11 @@ debug
 24 # Local env files
 25 .env
 26 
 27-# Build artifacts and cache
 28-**/bin
 29-/dist/
 30+# Build artifacts and tools
 31+bin/
 32+dist/
 33+
 34+# Static web assets
 35 assets/vendor/
 36 
 37-# Visual studio code configuration
 38 .vscode/
 39-
 40-# secrets for local use that cannot be encrypted
 41-k8s/local/patches/core-http-config.yaml
 42diff --git a/assets/load.sh b/assets/load.sh
 43index 62f2a9dc5da1f4fadf0bcbd881172743bdaccc6e..a07d9c1ea29912567b6a4d358477c21c35bbdde0 100755
 44--- a/assets/load.sh
 45+++ b/assets/load.sh
 46@@ -2,9 +2,9 @@ set -e
 47 
 48 
 49 mkdir -p "assets/vendor/fonts"
 50-curl -o "assets/vendor/bootstrap.min.css" https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css
 51-curl -o "assets/vendor/bootstrap.bundle.min.js" https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js
 52-curl -o "assets/vendor/bootstrap-icons.min.css" https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css
 53-curl -o "assets/vendor/fonts/bootstrap-icons.woff2" "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a="
 54-curl -o "assets/vendor/fuse.js" https://cdn.jsdelivr.net/npm/fuse.js@7.0.0
 55-curl -o "assets/vendor/datastar.js" https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.8/bundles/datastar.js
 56+curl -sSfo "assets/vendor/bootstrap.min.css" https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css
 57+curl -sSfo "assets/vendor/bootstrap.bundle.min.js" https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js
 58+curl -sSfo "assets/vendor/bootstrap-icons.min.css" https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css
 59+curl -sSfo "assets/vendor/fonts/bootstrap-icons.woff2" "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a="
 60+curl -sSfo "assets/vendor/fuse.js" https://cdn.jsdelivr.net/npm/fuse.js@7.0.0
 61+curl -sSfo "assets/vendor/datastar.js" https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.8/bundles/datastar.js
 62diff --git a/db/videos.go b/db/videos.go
 63index f2054f71de15009e6c9d0411626a2dbce7469691..04a3c733966ef274402def8ad22ea62898e82bd4 100644
 64--- a/db/videos.go
 65+++ b/db/videos.go
 66@@ -14,6 +14,7 @@ func (db *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models
 67 			, title
 68 			, published_timestamp
 69 			, is_short
 70+			, is_live
 71 			, duration
 72 			, progress
 73 			, is_discarded
 74@@ -48,6 +49,7 @@ func (db *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models
 75 			&video.Title,
 76 			&video.PublishedTime,
 77 			&video.IsShort,
 78+			&video.IsLive,
 79 			&video.DurationSeconds,
 80 			&video.ProgressSeconds,
 81 			&video.IsDiscarded,
 82@@ -78,6 +80,7 @@ func (db *postgresDB) GetWatchedVideos(
 83 			, published_timestamp
 84 			, watch_timestamp
 85 			, is_short
 86+			, is_live
 87 			, duration
 88 			, progress
 89 			, is_discarded
 90@@ -114,6 +117,7 @@ func (db *postgresDB) GetWatchedVideos(
 91 			&video.PublishedTime,
 92 			&video.WatchTime,
 93 			&video.IsShort,
 94+			&video.IsLive,
 95 			&video.DurationSeconds,
 96 			&video.ProgressSeconds,
 97 			&video.IsDiscarded,
 98@@ -156,9 +160,10 @@ func (db *postgresDB) AddVideo(ctx context.Context, video models.Video, channelI
 99 			, published_timestamp
100 			, duration
101 			, is_short
102+			, is_live
103 			, channel_id
104 			, is_discarded
105-		) VALUES ($1, $2, $3, $4, $5, $6, $7)
106+		) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
107 		ON CONFLICT DO NOTHING
108 	`
109 
110@@ -170,6 +175,7 @@ func (db *postgresDB) AddVideo(ctx context.Context, video models.Video, channelI
111 		video.PublishedTime,
112 		video.DurationSeconds,
113 		video.IsShort,
114+		video.IsLive,
115 		channelID,
116 		isDiscarded,
117 	)
118@@ -219,6 +225,7 @@ func (db *postgresDB) SetVideoProgress(ctx context.Context, videoID string, prog
119 			, title
120 			, published_timestamp
121 			, is_short
122+			, is_live
123 			, duration
124 			, progress
125 			, channels.name
126@@ -234,6 +241,7 @@ func (db *postgresDB) SetVideoProgress(ctx context.Context, videoID string, prog
127 		&video.Title,
128 		&video.PublishedTime,
129 		&video.IsShort,
130+		&video.IsLive,
131 		&video.DurationSeconds,
132 		&video.ProgressSeconds,
133 		&video.ChannelName,
134@@ -254,6 +262,7 @@ func (db *postgresDB) GetVideo(ctx context.Context, videoID string) (*models.Vid
135 			, title
136 			, published_timestamp
137 			, is_short
138+			, is_live
139 			, duration
140 			, progress
141 			, watch_timestamp
142@@ -276,6 +285,7 @@ func (db *postgresDB) GetVideo(ctx context.Context, videoID string) (*models.Vid
143 		&video.Title,
144 		&video.PublishedTime,
145 		&video.IsShort,
146+		&video.IsLive,
147 		&video.DurationSeconds,
148 		&video.ProgressSeconds,
149 		&video.WatchTime,
150diff --git a/lib/clients/youtube/get_durations.go b/lib/clients/youtube/get_durations.go
151index bad0c3c1eec49432f17c50ee3a22c622feaf1d9e..c604921f0a4c88594931f481836b35766e1d7d16 100644
152--- a/lib/clients/youtube/get_durations.go
153+++ b/lib/clients/youtube/get_durations.go
154@@ -19,17 +19,23 @@ type APIVideoListResponse struct {
155 }
156 
157 type APIVideo struct {
158-	ID             string            `json:"id"`
159-	Snippet        APISnippet        `json:"snippet"`
160-	ContentDetails APIContentDetails `json:"contentDetails"`
161-	Player         APIPlayer         `json:"player"`
162+	ID                   string                   `json:"id"`
163+	Snippet              APISnippet               `json:"snippet"`
164+	ContentDetails       APIContentDetails        `json:"contentDetails"`
165+	Player               APIPlayer                `json:"player"`
166+	LiveStreamingDetails *APILiveStreamingDetails `json:"liveStreamingDetails"`
167 }
168 
169 type APISnippet struct {
170-	PublishedAt string `json:"publishedAt"`
171-	ChannelID   string `json:"channelId"`
172-	Title       string `json:"title"`
173-	ChannelName string `json:"channelTitle"`
174+	PublishedAt          string `json:"publishedAt"`
175+	ChannelID            string `json:"channelId"`
176+	Title                string `json:"title"`
177+	ChannelName          string `json:"channelTitle"`
178+	LiveBroadcastContent string `json:"liveBroadcastContent"`
179+}
180+
181+type APILiveStreamingDetails struct {
182+	ActualEndTime string `json:"actualEndTime"`
183 }
184 
185 type APIPlayer struct {
186@@ -63,7 +69,7 @@ func (c *youTubeClient) GetVideoDurations(ctx context.Context, videos map[string
187 	videoIDs := ids.String()
188 	query := url.Values{}
189 	query.Add("id", videoIDs)
190-	query.Add("part", "contentDetails")
191+	query.Add("part", "contentDetails,liveStreamingDetails")
192 	query.Add("key", c.apiKey)
193 
194 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
195@@ -115,6 +121,7 @@ func (c *youTubeClient) GetVideoDurations(ctx context.Context, videos map[string
196 			return fmt.Errorf("failed to parse video duration [%v]: %w", v.ContentDetails.Duration, err)
197 		}
198 		videos[v.ID].DurationSeconds = int(math.Round(duration.Seconds()))
199+		videos[v.ID].IsLive = v.LiveStreamingDetails != nil && v.LiveStreamingDetails.ActualEndTime == ""
200 	}
201 
202 	return nil
203diff --git a/lib/clients/youtube/get_video_metadata.go b/lib/clients/youtube/get_video_metadata.go
204index 46f8488c8abf59a657afea055536820f6b73984d..39fa0e1f886092b588b266138743e354de4ef88c 100644
205--- a/lib/clients/youtube/get_video_metadata.go
206+++ b/lib/clients/youtube/get_video_metadata.go
207@@ -16,7 +16,7 @@ import (
208 func (c *youTubeClient) GetVideoMetadata(ctx context.Context, videoID string) (*models.Video, error) {
209 	query := url.Values{}
210 	query.Add("id", videoID)
211-	query.Add("part", "snippet,contentDetails,player")
212+	query.Add("part", "snippet,contentDetails,player,liveStreamingDetails")
213 	query.Add("key", c.apiKey)
214 
215 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
216@@ -85,5 +85,7 @@ func (c *youTubeClient) GetVideoMetadata(ctx context.Context, videoID string) (*
217 		return nil, err
218 	}
219 
220+	video.IsLive = videoData.LiveStreamingDetails != nil && videoData.LiveStreamingDetails.ActualEndTime == ""
221+
222 	return video, nil
223 }
224diff --git a/migrations/000009_add_is_live.down.sql b/migrations/000009_add_is_live.down.sql
225new file mode 100644
226index 0000000000000000000000000000000000000000..923c4a5ced2cb8440397555cc4a97360745e5734
227--- /dev/null
228+++ b/migrations/000009_add_is_live.down.sql
229@@ -0,0 +1 @@
230+ALTER TABLE videos DROP COLUMN is_live;
231diff --git a/migrations/000009_add_is_live.up.sql b/migrations/000009_add_is_live.up.sql
232new file mode 100644
233index 0000000000000000000000000000000000000000..f00579b55925f55c1e2ccaf2dbbb4be9e946afac
234--- /dev/null
235+++ b/migrations/000009_add_is_live.up.sql
236@@ -0,0 +1 @@
237+ALTER TABLE videos ADD COLUMN is_live BOOLEAN DEFAULT false;
238diff --git a/models/video.go b/models/video.go
239index 45e92040a39dc163eea9fa2a4eb19004e886b89d..73fb15b7edea7324797e80a49df04b2bfd0d0ecf 100644
240--- a/models/video.go
241+++ b/models/video.go
242@@ -26,6 +26,8 @@ type Video struct {
243 	ProgressSeconds int `json:"progress"`
244 	// IsShort indicates if a video is a YouTube short
245 	IsShort bool `json:"short"`
246+	// IsLive indicates if the video is a currently active live stream
247+	IsLive bool `json:"is_live"`
248 	// IsDiscarded indicates if the video was discarded from the feed without watching
249 	// This happens to shorts videos for channels that have shorts disabled
250 	IsDiscarded bool `json:"is_discarded"`
251diff --git a/pages/new_videos.templ b/pages/new_videos.templ
252index 6b2f3fe61d8b95479f3c20b136e63c86afe407ca..1984d007575ad7e6c8b428ea89e56403b80a6d47 100644
253--- a/pages/new_videos.templ
254+++ b/pages/new_videos.templ
255@@ -49,14 +49,23 @@ templ thumbnail(video models.Video) {
256 				style="width: 100%; aspect-ratio: 16/9; object-fit: cover;"
257 			/>
258 		}
259-		<span
260-			focusable="false"
261-			class="text-body bg-body bg-opacity-75 px-1 py-1 rounded"
262-			style="pointer-events: none;  z-index: 2; position: absolute; bottom: 0.2rem; right: 0.2rem;"
263-			fill="white"
264-		>
265-			{ video.Duration() }
266-		</span>
267+		if video.IsLive {
268+			<span
269+				focusable="false"
270+				class="text-white bg-danger px-1 py-1 rounded fw-bold"
271+				style="pointer-events: none; z-index: 2; position: absolute; bottom: 0.2rem; right: 0.2rem;"
272+			>
273+				LIVE
274+			</span>
275+		} else {
276+			<span
277+				focusable="false"
278+				class="text-body bg-body bg-opacity-75 px-1 py-1 rounded"
279+				style="pointer-events: none; z-index: 2; position: absolute; bottom: 0.2rem; right: 0.2rem;"
280+			>
281+				{ video.Duration() }
282+			</span>
283+		}
284 	</a>
285 	@ProgressBar(video)
286 }
287diff --git a/pages/new_videos_templ.go b/pages/new_videos_templ.go
288index 239551f3a1ff51b441a79dd6a495099ea9a887ee..0f60c5e7292e0e8332b0dded741bd3be6623c5f3 100644
289--- a/pages/new_videos_templ.go
290+++ b/pages/new_videos_templ.go
291@@ -200,20 +200,31 @@ func thumbnail(video models.Video) templ.Component {
292 				return templ_7745c5c3_Err
293 			}
294 		}