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);
+ }
+ );
}