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 { StatusBadge } from './StatusBadge';
|
||||||
import { DownloadProgressBar } from './DownloadProgressBar';
|
import { DownloadProgressBar } from './DownloadProgressBar';
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||||
|
|
@ -219,6 +219,27 @@ export function ContentCard({ item, selected, onSelect, onToggleMonitored, onDow
|
||||||
</button>
|
</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
|
<a
|
||||||
href={item.url}
|
href={item.url}
|
||||||
target="_blank"
|
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 { StatusBadge } from './StatusBadge';
|
||||||
import { RatingBadge } from './RatingBadge';
|
import { RatingBadge } from './RatingBadge';
|
||||||
import { QualityLabel } from './QualityLabel';
|
import { QualityLabel } from './QualityLabel';
|
||||||
|
|
@ -296,6 +296,27 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
</button>
|
</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
|
<a
|
||||||
href={item.url}
|
href={item.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
Filter,
|
Filter,
|
||||||
Film,
|
Film,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
|
HardDriveDownload,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
List,
|
List,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
|
|
@ -736,6 +737,32 @@ export function ChannelDetail() {
|
||||||
</span>
|
</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],
|
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll, updateContentRating],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
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 { parseIdParam } from './helpers';
|
||||||
import {
|
import {
|
||||||
getAllContentItems,
|
getAllContentItems,
|
||||||
|
|
@ -9,6 +12,8 @@ import {
|
||||||
bulkSetMonitored,
|
bulkSetMonitored,
|
||||||
updateContentItem,
|
updateContentItem,
|
||||||
} from '../../db/repositories/content-repository';
|
} 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 { PaginatedResponse, ApiResponse, ContentTypeCounts } from '../../types/api';
|
||||||
import type { ContentItem, ContentStatus, ContentType } from '../../types/index';
|
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