From 6289a00e34b84405ef019a000313d76519dd1e6a Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 00:32:11 -0600 Subject: [PATCH 1/7] fix: allow re-adding clips that previously failed to download When a clip download fails, the unique (group_id, original_url) constraint prevented re-submitting the same URL. Now both the clips API and share endpoint detect failed clips and auto-retry the download instead of returning a 409 duplicate error. --- src/routes/api/clips/+server.ts | 101 +++++++++++++++++--------- src/routes/api/clips/share/+server.ts | 40 ++++++---- 2 files changed, 92 insertions(+), 49 deletions(-) diff --git a/src/routes/api/clips/+server.ts b/src/routes/api/clips/+server.ts index 101fb2d..d78427c 100644 --- a/src/routes/api/clips/+server.ts +++ b/src/routes/api/clips/+server.ts @@ -36,6 +36,54 @@ import { createLogger } from '$lib/server/logger'; const log = createLogger('clips'); +/** Set clip status to 'downloading' and trigger the download pipeline. Marks as 'failed' on error. */ +async function startDownload(clipId: string, url: string, contentType: string, label: string) { + await db.update(clips).set({ status: 'downloading' }).where(eq(clips.id, clipId)); + + const onError = async (err: unknown) => { + log.error({ err, clipId }, `download failed (${label})`); + await db + .update(clips) + .set({ status: 'failed' }) + .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); + }; + + if (contentType === 'music') { + downloadMusic(clipId, url).catch(onError); + } else { + downloadVideo(clipId, url).catch(onError); + } +} + +/** Validate a clip URL and return an error response, or null if valid. */ +function validateClipUrl( + videoUrl: string | undefined, + group: { platformFilterMode: string | null; platformFilterList: string | null } +): Response | null { + if (!videoUrl) return json({ error: 'URL required' }, { status: 400 }); + + if (!isSupportedUrl(videoUrl)) { + return json( + { + error: + 'Unsupported URL. Try a link from TikTok, YouTube, Instagram, X, Reddit, Spotify, or other supported platforms.' + }, + { status: 400 } + ); + } + + const platform = detectPlatform(videoUrl)!; + const filterList = group.platformFilterList ? JSON.parse(group.platformFilterList) : null; + if (!isPlatformAllowed(platform, group.platformFilterMode ?? 'all', filterList)) { + return json( + { error: `${platformLabel(videoUrl) || platform} links are not allowed in this group` }, + { status: 400 } + ); + } + + return null; +} + const VALID_FILTERS = ['unwatched', 'watched', 'favorites'] as const; const VALID_SORTS = ['oldest', 'round-robin'] as const; @@ -236,36 +284,29 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } const message = typeof body.message === 'string' ? body.message.trim().slice(0, 500) || null : null; - if (!videoUrl) return json({ error: 'URL required' }, { status: 400 }); - - if (!isSupportedUrl(videoUrl)) { - return json( - { - error: - 'Unsupported URL. Try a link from TikTok, YouTube, Instagram, X, Reddit, Spotify, or other supported platforms.' - }, - { status: 400 } - ); - } - - const platform = detectPlatform(videoUrl)!; - - // Enforce group platform filter - const filterList = group.platformFilterList ? JSON.parse(group.platformFilterList) : null; - if (!isPlatformAllowed(platform, group.platformFilterMode ?? 'all', filterList)) { - return json( - { error: `${platformLabel(videoUrl) || platform} links are not allowed in this group` }, - { status: 400 } - ); - } + const urlError = validateClipUrl(videoUrl, group); + if (urlError) return urlError; + const validUrl = videoUrl!; + const platform = detectPlatform(validUrl)!; const contentType = getContentType(platform); - const normalizedUrl = normalizeUrl(videoUrl); + const normalizedUrl = normalizeUrl(validUrl); // Check if this URL already exists in the group's feed const existing = await db.query.clips.findFirst({ where: and(eq(clips.groupId, user.groupId), eq(clips.originalUrl, normalizedUrl)) }); + if (existing && existing.status === 'failed') { + // Previous attempt failed — retry the download instead of rejecting + if (title) { + await db.update(clips).set({ title }).where(eq(clips.id, existing.id)); + } + await startDownload(existing.id, validUrl, existing.contentType, 're-add retry'); + return json( + { clip: { id: existing.id, status: 'downloading', contentType: existing.contentType } }, + { status: 201 } + ); + } if (existing) { return json({ error: 'This link has already been added to the feed.' }, { status: 409 }); } @@ -301,19 +342,7 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } }); // Route to appropriate download pipeline - const markFailedOnError = async (err: unknown) => { - log.error({ err, clipId }, 'download failed'); - await db - .update(clips) - .set({ status: 'failed' }) - .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); - }; - - if (contentType === 'music') { - downloadMusic(clipId, videoUrl).catch(markFailedOnError); - } else { - downloadVideo(clipId, videoUrl).catch(markFailedOnError); - } + await startDownload(clipId, validUrl, contentType, 'new clip'); // Push notification is sent after download succeeds (see video/download.ts, music/download.ts) diff --git a/src/routes/api/clips/share/+server.ts b/src/routes/api/clips/share/+server.ts index 5494d4b..1b92bc6 100644 --- a/src/routes/api/clips/share/+server.ts +++ b/src/routes/api/clips/share/+server.ts @@ -20,6 +20,25 @@ import { createLogger } from '$lib/server/logger'; const log = createLogger('share'); +/** Set clip status to 'downloading' and trigger the download pipeline. Marks as 'failed' on error. */ +async function startDownload(clipId: string, url: string, contentType: string, label: string) { + await db.update(clips).set({ status: 'downloading' }).where(eq(clips.id, clipId)); + + const onError = async (err: unknown) => { + log.error({ err, clipId }, `download failed (${label})`); + await db + .update(clips) + .set({ status: 'failed' }) + .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); + }; + + if (contentType === 'music') { + downloadMusic(clipId, url).catch(onError); + } else { + downloadVideo(clipId, url).catch(onError); + } +} + /** Shortcut-friendly JSON response. Every response includes `success` (1|0) and `message`. */ function shareResponse( success: boolean, @@ -169,6 +188,13 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { const existing = await db.query.clips.findFirst({ where: and(eq(clips.groupId, group.id), eq(clips.originalUrl, normalizedVideoUrl)) }); + if (existing && existing.status === 'failed') { + // Previous attempt failed — retry the download instead of rejecting + await startDownload(existing.id, videoUrl, existing.contentType, 're-share retry'); + return shareResponse(true, '✅ Clip shared! (retrying download)', 201, { + clipId: existing.id + }); + } if (existing) { return shareResponse(false, '❌ This clip has already been shared!', 409); } @@ -196,19 +222,7 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { }); // 10. Async download - const markFailedOnError = async (err: unknown) => { - log.error({ err, clipId }, 'download failed'); - await db - .update(clips) - .set({ status: 'failed' }) - .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); - }; - - if (contentType === 'music') { - downloadMusic(clipId, videoUrl).catch(markFailedOnError); - } else { - downloadVideo(clipId, videoUrl).catch(markFailedOnError); - } + await startDownload(clipId, videoUrl, contentType, 'new clip'); // Push notification is sent after download succeeds (see video/download.ts, music/download.ts) From 2483398eeeab21d967ec0b2b3f71c623787950c5 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:05:05 -0600 Subject: [PATCH 2/7] fix: enrich GET /api/clips/[id] with full FeedClip data The endpoint returned minimal fields missing addedByUsername, reactions, watched status, etc. This caused a crash in ReelOverlay when rendering a single clip via "View in feed". Now returns the same shape as the feed list endpoint using parallel queries. --- src/routes/api/clips/[id]/+server.ts | 69 +++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/src/routes/api/clips/[id]/+server.ts b/src/routes/api/clips/[id]/+server.ts index b4fecc6..68d0c27 100644 --- a/src/routes/api/clips/[id]/+server.ts +++ b/src/routes/api/clips/[id]/+server.ts @@ -12,31 +12,76 @@ import { notifications } from '$lib/server/db/schema'; import { eq, and, ne, count, inArray } from 'drizzle-orm'; -import { withClipAuth, parseBody, isResponse } from '$lib/server/api-utils'; +import { + withClipAuth, + parseBody, + isResponse, + mapUsersByIds, + groupReactions +} from '$lib/server/api-utils'; import { cleanupClipFiles } from '$lib/server/download-utils'; export const GET: RequestHandler = withClipAuth(async ({ params }, { user, clip }) => { - // Check if anyone other than the uploader has watched this clip - let canEditCaption = false; - if (clip.addedBy === user.id) { - const [watchResult] = await db - .select({ count: count() }) - .from(watched) - .where(and(eq(watched.clipId, params.id), ne(watched.userId, clip.addedBy))); - canEditCaption = watchResult.count === 0; - } + const clipId = params.id; + const userId = user.id; + + // Fetch all related data in parallel + const [watchedRows, favRow, clipReactions, clipComments, userCommentView, uploaderInfo] = + await Promise.all([ + db.query.watched.findMany({ where: eq(watched.clipId, clipId) }), + db.query.favorites.findFirst({ + where: and(eq(favorites.clipId, clipId), eq(favorites.userId, userId)) + }), + db.query.reactions.findMany({ where: eq(reactions.clipId, clipId) }), + db.query.comments.findMany({ where: eq(comments.clipId, clipId) }), + db.query.commentViews.findFirst({ + where: and(eq(commentViews.clipId, clipId), eq(commentViews.userId, userId)) + }), + mapUsersByIds([clip.addedBy]) + ]); + + const isWatched = watchedRows.some((w) => w.userId === userId); + const isFavorited = !!favRow; + const viewCount = watchedRows.length; + const seenByOthers = watchedRows.some((w) => w.userId !== clip.addedBy); + const canEditCaption = clip.addedBy === userId && !seenByOthers; + + const reactionData = groupReactions(clipReactions, userId); + + const commentCount = clipComments.length; + const unreadCommentCount = userCommentView + ? clipComments.filter((c) => c.createdAt > userCommentView.viewedAt).length + : commentCount; + + const uploaderUser = uploaderInfo.get(clip.addedBy); return json({ id: clip.id, - status: clip.status, + originalUrl: clip.originalUrl, videoPath: clip.videoPath, audioPath: clip.audioPath, thumbnailPath: clip.thumbnailPath, title: clip.title, artist: clip.artist, albumArt: clip.albumArt, - contentType: clip.contentType, + spotifyUrl: clip.spotifyUrl, + appleMusicUrl: clip.appleMusicUrl, + youtubeMusicUrl: clip.youtubeMusicUrl, + addedBy: clip.addedBy, + addedByUsername: uploaderUser?.username || 'Unknown', + addedByAvatar: uploaderUser?.avatarPath || null, platform: clip.platform, + status: clip.status, + contentType: clip.contentType, + durationSeconds: clip.durationSeconds, + watched: isWatched, + favorited: isFavorited, + reactions: reactionData, + commentCount, + unreadCommentCount, + viewCount, + seenByOthers, + createdAt: clip.createdAt, canEditCaption }); }); From ea112036dae0df694e4cfbde747571f17ec426e3 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:05:12 -0600 Subject: [PATCH 3/7] fix: enforce single reaction per user per clip Previously users could add multiple different emoji reactions to the same clip. Now changing your reaction removes the old one first, matching the expected one-reaction-per-user behavior. --- .../api/clips/[id]/reactions/+server.ts | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/routes/api/clips/[id]/reactions/+server.ts b/src/routes/api/clips/[id]/reactions/+server.ts index b3985ae..f64910a 100644 --- a/src/routes/api/clips/[id]/reactions/+server.ts +++ b/src/routes/api/clips/[id]/reactions/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; -import { reactions } from '$lib/server/db/schema'; +import { reactions, notifications } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { @@ -37,18 +37,32 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u return badRequest('Invalid emoji'); } - // Toggle: if exists, delete; if not, insert + // Find any existing reaction by this user on this clip (one reaction per user per clip) const existing = await db.query.reactions.findFirst({ - where: and( - eq(reactions.clipId, clipId), - eq(reactions.userId, userId), - eq(reactions.emoji, emoji) - ) + where: and(eq(reactions.clipId, clipId), eq(reactions.userId, userId)) }); + const sameEmoji = existing?.emoji === emoji; + if (existing) { + // Remove the old reaction await db.delete(reactions).where(eq(reactions.id, existing.id)); - } else { + + // Remove the old notification entry for this reaction + await db + .delete(notifications) + .where( + and( + eq(notifications.clipId, clipId), + eq(notifications.actorId, userId), + eq(notifications.type, 'reaction'), + eq(notifications.emoji, existing.emoji) + ) + ); + } + + if (!sameEmoji) { + // Add the new reaction (either fresh or replacing a different emoji) await db.insert(reactions).values({ id: uuid(), clipId, @@ -77,5 +91,8 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u where: eq(reactions.clipId, clipId) }); - return json({ reactions: groupReactions(allReactions, userId), toggled: !existing }); + return json({ + reactions: groupReactions(allReactions, userId), + toggled: !sameEmoji || !existing + }); }); From 46302db711f7215454ba45fdc51468b95c662e80 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:05:21 -0600 Subject: [PATCH 4/7] feat: reel layout polish and remove speed controls - Remove playback speed controls (SpeedPill, keyboard shortcuts) - Add periodic watch percent updates while viewing - Redesign music disc popout as a dropdown menu with labels - Adjust bottom positions for overlay, sidebar, and progress bar - Move ActionSidebar and MusicDisc outside overlay-content div - Add hideViewBadge prop to ReelItem for overlay use - Send watch percent before opening viewers sheet --- src/lib/components/ActionSidebar.svelte | 2 +- src/lib/components/MusicDisc.svelte | 95 +++++++++++-------- src/lib/components/ProgressBar.svelte | 2 +- src/lib/components/ReelItem.svelte | 118 +++++++++++------------- src/lib/components/ReelMusic.svelte | 1 + src/lib/components/ReelOverlay.svelte | 2 +- src/lib/reelInteractions.ts | 16 +--- src/lib/reelPlayback.ts | 12 +++ 8 files changed, 131 insertions(+), 117 deletions(-) diff --git a/src/lib/components/ActionSidebar.svelte b/src/lib/components/ActionSidebar.svelte index 0db02fe..b0a7232 100644 --- a/src/lib/components/ActionSidebar.svelte +++ b/src/lib/components/ActionSidebar.svelte @@ -167,7 +167,7 @@ .action-sidebar { position: absolute; right: var(--space-lg); - bottom: calc(var(--bottom-nav-height, 64px) + 68px); + bottom: calc(var(--bottom-nav-height, 64px) + 88px); display: flex; flex-direction: column; align-items: center; diff --git a/src/lib/components/MusicDisc.svelte b/src/lib/components/MusicDisc.svelte index 75cccac..7958d43 100644 --- a/src/lib/components/MusicDisc.svelte +++ b/src/lib/components/MusicDisc.svelte @@ -23,7 +23,29 @@ const hasMusicLinks = $derived(!!(spotifyUrl || appleMusicUrl || youtubeMusicUrl)); +{#if showMusicLinks} + +
{serverArtist}
{/if} - -
Loading...
- {:else if feedItems.length === 0} -No comments yet
+Loading...
+ {:else if feedItems.length === 0} +No comments yet
{:else} - {@const comment = item.data} -{platformLabel(url.trim())} links aren't allowed in this group
{/if}