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:
parent
e1d5ef80b4
commit
5d9cf148aa
4 changed files with 165 additions and 2 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue