From 5d9cf148aaf439d87080bf2dca65c35de3e86766 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 10:30:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20GET=20/api/v1/content/:id/downl?= =?UTF-8?q?oad=20endpoint=20with=20Content-Disp=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/server/routes/content.ts" - "src/frontend/src/components/ContentCard.tsx" - "src/frontend/src/components/ContentListItem.tsx" - "src/frontend/src/pages/ChannelDetail.tsx" GSD-Task: S08/T01 --- src/frontend/src/components/ContentCard.tsx | 23 ++++- .../src/components/ContentListItem.tsx | 23 ++++- src/frontend/src/pages/ChannelDetail.tsx | 27 ++++++ src/server/routes/content.ts | 94 +++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/ContentCard.tsx b/src/frontend/src/components/ContentCard.tsx index a6424a9..d6048b1 100644 --- a/src/frontend/src/components/ContentCard.tsx +++ b/src/frontend/src/components/ContentCard.tsx @@ -1,4 +1,4 @@ -import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react'; +import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, HardDriveDownload, Music } from 'lucide-react'; import { StatusBadge } from './StatusBadge'; import { DownloadProgressBar } from './DownloadProgressBar'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; @@ -219,6 +219,27 @@ export function ContentCard({ item, selected, onSelect, onToggleMonitored, onDow )} + {item.status === 'downloaded' && item.filePath && ( + e.stopPropagation()} + title="Save to device" + aria-label={`Save ${item.title} to device`} + style={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 28, + height: 28, + borderRadius: 'var(--radius-sm)', + color: 'var(--text-muted)', + transition: 'color var(--transition-fast)', + }} + > + + + )} + )} + {item.status === 'downloaded' && item.filePath && ( + e.stopPropagation()} + title="Save to device" + aria-label={`Save ${item.title} to device`} + style={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 28, + height: 28, + borderRadius: 'var(--radius-sm)', + color: 'var(--text-muted)', + transition: 'color var(--transition-fast)', + }} + > + + + )} + ), }, + { + key: 'download', + label: '', + width: '36px', + render: (item) => + item.status === 'downloaded' && item.filePath ? ( + e.stopPropagation()} + title="Save to device" + aria-label={`Save ${item.title} to device`} + style={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 28, + height: 28, + borderRadius: 'var(--radius-sm)', + color: 'var(--text-muted)', + transition: 'color var(--transition-fast)', + }} + > + + + ) : null, + }, ], [toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll, updateContentRating], ); diff --git a/src/server/routes/content.ts b/src/server/routes/content.ts index 279b49c..28c59ee 100644 --- a/src/server/routes/content.ts +++ b/src/server/routes/content.ts @@ -1,4 +1,7 @@ import { type FastifyInstance } from 'fastify'; +import { createReadStream, statSync } from 'node:fs'; +import { basename } from 'node:path'; +import { eq } from 'drizzle-orm'; import { parseIdParam } from './helpers'; import { getAllContentItems, @@ -9,6 +12,8 @@ import { bulkSetMonitored, updateContentItem, } from '../../db/repositories/content-repository'; +import { contentItems } from '../../db/schema/index'; +import { appConfig } from '../../config/index'; import type { PaginatedResponse, ApiResponse, ContentTypeCounts } from '../../types/api'; import type { ContentItem, ContentStatus, ContentType } from '../../types/index'; @@ -359,4 +364,93 @@ export async function contentRoutes(fastify: FastifyInstance): Promise { }); } }); + + // ── Download-to-Client Endpoint ── + + /** + * GET /api/v1/content/:id/download — Stream a downloaded media file to the browser. + * + * Authenticated (same-origin bypass or API key) — unlike the public /api/v1/media/ + * endpoint used by podcast apps, this triggers a browser download via + * Content-Disposition: attachment. + */ + fastify.get<{ Params: { id: string } }>( + '/api/v1/content/:id/download', + async (request, reply) => { + const id = parseIdParam(request.params.id, reply); + if (id === null) { + return; // reply already sent by parseIdParam + } + + const db = fastify.db; + const rows = await db + .select({ + filePath: contentItems.filePath, + fileSize: contentItems.fileSize, + format: contentItems.format, + status: contentItems.status, + }) + .from(contentItems) + .where(eq(contentItems.id, id)) + .limit(1); + + if (rows.length === 0) { + return reply.status(404).send({ + statusCode: 404, + error: 'Not Found', + message: `Content item ${id} not found`, + }); + } + + const item = rows[0]; + + if (item.status !== 'downloaded' || !item.filePath) { + return reply.status(404).send({ + statusCode: 404, + error: 'Not Found', + message: `No downloaded file for content item ${id}`, + }); + } + + // Resolve file path (may be relative to media path) + const filePath = item.filePath.startsWith('/') + ? item.filePath + : `${appConfig.mediaPath}/${item.filePath}`; + + let stat; + try { + stat = statSync(filePath); + } catch { + request.log.warn({ contentId: id, filePath }, '[content] Download file not found on disk'); + return reply.status(404).send({ + statusCode: 404, + error: 'Not Found', + message: 'Media file not found on disk', + }); + } + + // Determine MIME type from extension or format field + const ext = basename(item.filePath).split('.').pop()?.toLowerCase(); + const mimeMap: Record = { + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + opus: 'audio/opus', + ogg: 'audio/ogg', + wav: 'audio/wav', + flac: 'audio/flac', + mp4: 'video/mp4', + mkv: 'video/x-matroska', + webm: 'video/webm', + }; + const mimeType = (ext && mimeMap[ext]) || 'application/octet-stream'; + const fileName = basename(item.filePath); + + const stream = createReadStream(filePath); + return reply + .header('Content-Length', stat.size) + .header('Content-Type', mimeType) + .header('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`) + .send(stream); + } + ); }