feat: Added GET /api/v1/content/:id/download endpoint with Content-Disp…

- "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
This commit is contained in:
jlightner 2026-04-04 10:30:44 +00:00
parent e1d5ef80b4
commit 5d9cf148aa
4 changed files with 165 additions and 2 deletions

View file

@ -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
</button>
)}
{item.status === 'downloaded' && item.filePath && (
<a
href={`/api/v1/content/${item.id}/download`}
onClick={(e) => 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)',
}}
>
<HardDriveDownload size={14} />
</a>
)}
<a
href={item.url}
target="_blank"

View file

@ -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 { RatingBadge } from './RatingBadge';
import { QualityLabel } from './QualityLabel';
@ -296,6 +296,27 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
</button>
)}
{item.status === 'downloaded' && item.filePath && (
<a
href={`/api/v1/content/${item.id}/download`}
onClick={(e) => 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)',
}}
>
<HardDriveDownload size={14} />
</a>
)}
<a
href={item.url}
target="_blank"

View file

@ -15,6 +15,7 @@ import {
Filter,
Film,
Grid3X3,
HardDriveDownload,
LayoutList,
List,
ListMusic,
@ -736,6 +737,32 @@ export function ChannelDetail() {
</span>
),
},
{
key: 'download',
label: '',
width: '36px',
render: (item) =>
item.status === 'downloaded' && item.filePath ? (
<a
href={`/api/v1/content/${item.id}/download`}
onClick={(e) => 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)',
}}
>
<HardDriveDownload size={14} />
</a>
) : null,
},
],
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll, updateContentRating],
);

View file

@ -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<void> {
});
}
});
// ── 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<string, string> = {
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);
}
);
}