feat: Added RSS 2.0 podcast feed at /api/v1/feed/rss and media streamin…
- "src/server/routes/feed.ts" - "src/__tests__/feed-api.test.ts" - "src/server/middleware/auth.ts" - "src/server/index.ts" GSD-Task: S08/T04
This commit is contained in:
parent
c012425ceb
commit
54e9041058
4 changed files with 634 additions and 0 deletions
368
src/__tests__/feed-api.test.ts
Normal file
368
src/__tests__/feed-api.test.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { type FastifyInstance } from 'fastify';
|
||||
import { initDatabaseAsync, closeDatabase } from '../db/index';
|
||||
import { runMigrations } from '../db/migrate';
|
||||
import { buildServer } from '../server/index';
|
||||
import { type LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||
import type * as schema from '../db/schema/index';
|
||||
import { createChannel } from '../db/repositories/channel-repository';
|
||||
import { createContentItem, updateContentItem } from '../db/repositories/content-repository';
|
||||
import type { Channel, ContentItem } from '../types/index';
|
||||
|
||||
/**
|
||||
* Integration tests for the RSS feed and media serving endpoints.
|
||||
*
|
||||
* GET /api/v1/feed/rss — RSS 2.0 podcast feed of downloaded audio
|
||||
* GET /api/v1/media/:id/:filename — serve downloaded media files
|
||||
*
|
||||
* Both endpoints are public (no API key required).
|
||||
*/
|
||||
|
||||
describe('Feed API', () => {
|
||||
let server: FastifyInstance;
|
||||
let db: LibSQLDatabase<typeof schema>;
|
||||
let tmpDir: string;
|
||||
let mediaDir: string;
|
||||
let testChannel: Channel;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-feed-api-'));
|
||||
mediaDir = join(tmpDir, 'media');
|
||||
mkdirSync(mediaDir, { recursive: true });
|
||||
|
||||
// Set media path before importing config
|
||||
process.env.TUBEARR_MEDIA_PATH = mediaDir;
|
||||
|
||||
const dbPath = join(tmpDir, 'test.db');
|
||||
db = await initDatabaseAsync(dbPath);
|
||||
await runMigrations(dbPath);
|
||||
server = await buildServer({ db });
|
||||
await server.ready();
|
||||
|
||||
// Create a test channel
|
||||
testChannel = await createChannel(db, {
|
||||
name: 'Feed Test Channel',
|
||||
platform: 'youtube',
|
||||
platformId: 'UC_feed_test_1',
|
||||
url: 'https://www.youtube.com/channel/UC_feed_test_1',
|
||||
monitoringEnabled: true,
|
||||
checkInterval: 360,
|
||||
imageUrl: null,
|
||||
metadata: null,
|
||||
formatProfileId: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
closeDatabase();
|
||||
try {
|
||||
if (tmpDir && existsSync(tmpDir)) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
let contentCounter = 0;
|
||||
async function createTestContent(
|
||||
overrides: Partial<{
|
||||
title: string;
|
||||
contentType: string;
|
||||
format: string;
|
||||
status: string;
|
||||
filePath: string;
|
||||
fileSize: number;
|
||||
duration: number;
|
||||
publishedAt: string;
|
||||
}> = {}
|
||||
): Promise<ContentItem> {
|
||||
contentCounter++;
|
||||
const item = await createContentItem(db, {
|
||||
channelId: testChannel.id,
|
||||
title: overrides.title ?? `Test Audio ${contentCounter}`,
|
||||
platformContentId: `feed_test_${contentCounter}`,
|
||||
url: `https://youtube.com/watch?v=feed_test_${contentCounter}`,
|
||||
contentType: (overrides.contentType ?? 'audio') as 'audio' | 'video' | 'livestream',
|
||||
duration: overrides.duration ?? 300,
|
||||
publishedAt: overrides.publishedAt ?? '2025-01-15T12:00:00Z',
|
||||
});
|
||||
expect(item).not.toBeNull();
|
||||
|
||||
// Apply post-creation fields
|
||||
if (overrides.status === 'downloaded' || overrides.filePath) {
|
||||
await updateContentItem(db, item!.id, {
|
||||
status: 'downloaded',
|
||||
filePath: overrides.filePath ?? `test-channel/test-audio-${contentCounter}.mp3`,
|
||||
fileSize: overrides.fileSize ?? 5000000,
|
||||
format: overrides.format ?? 'mp3',
|
||||
downloadedAt: '2025-01-16T10:00:00Z',
|
||||
});
|
||||
}
|
||||
|
||||
return item!;
|
||||
}
|
||||
|
||||
// ── RSS Feed Tests ──
|
||||
|
||||
describe('GET /api/v1/feed/rss', () => {
|
||||
it('returns valid RSS XML with correct content type', async () => {
|
||||
// Create a downloaded audio item
|
||||
await createTestContent({ status: 'downloaded', format: 'mp3' });
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/feed/rss',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/rss+xml');
|
||||
expect(res.body).toContain('<?xml version="1.0"');
|
||||
expect(res.body).toContain('<rss version="2.0"');
|
||||
expect(res.body).toContain('xmlns:itunes');
|
||||
expect(res.body).toContain('<title>Tubearr Audio Feed</title>');
|
||||
});
|
||||
|
||||
it('does not require authentication', async () => {
|
||||
// No API key, no Origin header — should still work
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/feed/rss',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('includes downloaded audio items as episodes', async () => {
|
||||
const item = await createTestContent({
|
||||
title: 'My Podcast Episode',
|
||||
status: 'downloaded',
|
||||
format: 'mp3',
|
||||
duration: 3661, // 1:01:01
|
||||
fileSize: 12345678,
|
||||
});
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/feed/rss',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain('My Podcast Episode');
|
||||
expect(res.body).toContain('<enclosure');
|
||||
expect(res.body).toContain(`/api/v1/media/${item.id}/`);
|
||||
expect(res.body).toContain('type="audio/mpeg"');
|
||||
expect(res.body).toContain('length="12345678"');
|
||||
expect(res.body).toContain('<itunes:duration>1:01:01</itunes:duration>');
|
||||
expect(res.body).toContain(`<guid isPermaLink="false">tubearr-${item.id}-`);
|
||||
});
|
||||
|
||||
it('includes video items with audio formats (e.g. m4a)', async () => {
|
||||
await createTestContent({
|
||||
title: 'Video With M4A Audio',
|
||||
contentType: 'video',
|
||||
status: 'downloaded',
|
||||
format: 'm4a',
|
||||
});
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/feed/rss',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain('Video With M4A Audio');
|
||||
});
|
||||
|
||||
it('excludes non-downloaded items', async () => {
|
||||
await createTestContent({
|
||||
title: 'Still Monitored Audio',
|
||||
contentType: 'audio',
|
||||
// status defaults to 'monitored' — not downloaded
|
||||
});
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/feed/rss',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).not.toContain('Still Monitored Audio');
|
||||
});
|
||||
|
||||
it('excludes downloaded video items with non-audio formats', async () => {
|
||||
await createTestContent({
|
||||
title: 'Downloaded MP4 Video',
|
||||
contentType: 'video',
|
||||
status: 'downloaded',
|
||||
format: 'mp4',
|
||||
});
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/feed/rss',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).not.toContain('Downloaded MP4 Video');
|
||||
});
|
||||
|
||||
it('escapes XML special characters in titles', async () => {
|
||||
await createTestContent({
|
||||
title: 'Episode with <tags> & "quotes"',
|
||||
status: 'downloaded',
|
||||
format: 'mp3',
|
||||
});
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/feed/rss',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain('<tags>');
|
||||
expect(res.body).toContain('&');
|
||||
expect(res.body).toContain('"quotes"');
|
||||
expect(res.body).not.toContain('<tags>');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Media Serving Tests ──
|
||||
|
||||
describe('GET /api/v1/media/:id/:filename', () => {
|
||||
it('serves a downloaded media file', async () => {
|
||||
// Create a real file on disk
|
||||
const subDir = join(mediaDir, 'test-channel');
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
const filePath = join(subDir, 'served-file.mp3');
|
||||
const fileContent = Buffer.alloc(1024, 0xff); // 1KB dummy audio
|
||||
writeFileSync(filePath, fileContent);
|
||||
|
||||
const item = await createTestContent({
|
||||
title: 'Served Audio',
|
||||
status: 'downloaded',
|
||||
format: 'mp3',
|
||||
fileSize: 1024,
|
||||
});
|
||||
// Update filePath to the absolute path
|
||||
await updateContentItem(db, item.id, { filePath });
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/media/${item.id}/served-file.mp3`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('audio/mpeg');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(res.rawPayload.length).toBe(1024);
|
||||
});
|
||||
|
||||
it('does not require authentication', async () => {
|
||||
// Create a file
|
||||
const subDir = join(mediaDir, 'noauth-test');
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
const filePath = join(subDir, 'noauth.mp3');
|
||||
writeFileSync(filePath, Buffer.alloc(512));
|
||||
|
||||
const item = await createTestContent({
|
||||
title: 'No Auth Test',
|
||||
status: 'downloaded',
|
||||
format: 'mp3',
|
||||
});
|
||||
await updateContentItem(db, item.id, { filePath });
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/media/${item.id}/noauth.mp3`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent content item', async () => {
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/media/99999/nothing.mp3',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid ID', async () => {
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/media/abc/nothing.mp3',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for content item without downloaded file', async () => {
|
||||
const item = await createTestContent({
|
||||
title: 'Not Downloaded',
|
||||
contentType: 'audio',
|
||||
// no status: 'downloaded' update
|
||||
});
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/media/${item.id}/something.mp3`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 when file is missing from disk', async () => {
|
||||
const item = await createTestContent({
|
||||
title: 'File Missing From Disk',
|
||||
status: 'downloaded',
|
||||
format: 'mp3',
|
||||
});
|
||||
// Set filePath to a non-existent file
|
||||
await updateContentItem(db, item.id, {
|
||||
filePath: '/tmp/nonexistent-file-abc123.mp3',
|
||||
});
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/media/${item.id}/missing.mp3`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().message).toContain('not found on disk');
|
||||
});
|
||||
|
||||
it('supports range requests for seeking', async () => {
|
||||
const subDir = join(mediaDir, 'range-test');
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
const filePath = join(subDir, 'range.mp3');
|
||||
const content = Buffer.alloc(2048, 0xab);
|
||||
writeFileSync(filePath, content);
|
||||
|
||||
const item = await createTestContent({
|
||||
title: 'Range Test Audio',
|
||||
status: 'downloaded',
|
||||
format: 'mp3',
|
||||
fileSize: 2048,
|
||||
});
|
||||
await updateContentItem(db, item.id, { filePath });
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/media/${item.id}/range.mp3`,
|
||||
headers: { range: 'bytes=0-511' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-range']).toBe('bytes 0-511/2048');
|
||||
expect(res.rawPayload.length).toBe(512);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -24,6 +24,7 @@ import { collectRoutes } from './routes/collect';
|
|||
import { playlistRoutes } from './routes/playlist';
|
||||
import { adhocDownloadRoutes } from './routes/adhoc-download';
|
||||
import { mediaServerRoutes } from './routes/media-server';
|
||||
import { feedRoutes } from './routes/feed';
|
||||
import { websocketRoutes } from './routes/websocket';
|
||||
import type { SchedulerService } from '../services/scheduler';
|
||||
import type { DownloadService } from '../services/download';
|
||||
|
|
@ -118,6 +119,7 @@ export async function buildServer(opts: BuildServerOptions): Promise<FastifyInst
|
|||
await server.register(playlistRoutes);
|
||||
await server.register(adhocDownloadRoutes);
|
||||
await server.register(mediaServerRoutes);
|
||||
await server.register(feedRoutes);
|
||||
|
||||
// Register WebSocket route (before static file serving so /ws is handled)
|
||||
if (opts.eventBus) {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,13 @@ async function authPluginHandler(fastify: FastifyInstance): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
// Skip auth for public routes that must work without API keys
|
||||
// RSS readers and podcast apps can't easily send API keys
|
||||
const urlPath = request.url.split('?')[0];
|
||||
if (urlPath.startsWith('/api/v1/feed/') || urlPath.startsWith('/api/v1/media/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-origin bypass: browser UI requests are trusted internal clients
|
||||
if (isSameOriginRequest(request)) {
|
||||
request.log.debug(`[auth] same-origin bypass for ${request.url}`);
|
||||
|
|
|
|||
257
src/server/routes/feed.ts
Normal file
257
src/server/routes/feed.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { type FastifyInstance } from 'fastify';
|
||||
import { eq, and, desc, or, inArray, sql } from 'drizzle-orm';
|
||||
import { contentItems } from '../../db/schema/index';
|
||||
import { createReadStream, statSync } from 'node:fs';
|
||||
import { extname, basename } from 'node:path';
|
||||
import { appConfig } from '../../config/index';
|
||||
|
||||
/** Audio file extensions and their MIME types. */
|
||||
const AUDIO_MIME_TYPES: Record<string, string> = {
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.opus': 'audio/opus',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.aac': 'audio/aac',
|
||||
'.flac': 'audio/flac',
|
||||
'.wav': 'audio/wav',
|
||||
'.wma': 'audio/x-ms-wma',
|
||||
'.webm': 'audio/webm',
|
||||
};
|
||||
|
||||
/** Known audio container format strings from yt-dlp. */
|
||||
const AUDIO_FORMATS = ['mp3', 'm4a', 'opus', 'ogg', 'aac', 'flac', 'wav', 'webm'];
|
||||
|
||||
/**
|
||||
* Escape special XML characters in a string.
|
||||
*/
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds into HH:MM:SS for itunes:duration.
|
||||
*/
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) {
|
||||
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess MIME type from a file path or format string.
|
||||
*/
|
||||
function guessMimeType(filePath: string | null, format: string | null): string {
|
||||
if (filePath) {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
if (AUDIO_MIME_TYPES[ext]) return AUDIO_MIME_TYPES[ext];
|
||||
}
|
||||
if (format) {
|
||||
const ext = `.${format.toLowerCase()}`;
|
||||
if (AUDIO_MIME_TYPES[ext]) return AUDIO_MIME_TYPES[ext];
|
||||
}
|
||||
return 'audio/mpeg'; // safe default
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the base URL for constructing absolute feed URLs.
|
||||
* Uses the request's Host header to generate URLs that work
|
||||
* regardless of reverse proxy configuration.
|
||||
*/
|
||||
function buildBaseUrl(request: { headers: Record<string, string | string[] | undefined>; protocol: string }): string {
|
||||
const host = request.headers.host || `localhost:${appConfig.port}`;
|
||||
const proto = request.headers['x-forwarded-proto'] || request.protocol || 'http';
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed & media route plugin.
|
||||
*
|
||||
* Registers:
|
||||
* GET /api/v1/feed/rss — RSS 2.0 podcast feed of downloaded audio (no auth)
|
||||
* GET /api/v1/media/:id/:filename — serves downloaded media files (no auth)
|
||||
*/
|
||||
export async function feedRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* GET /api/v1/feed/rss — RSS 2.0 podcast feed with iTunes namespace.
|
||||
*
|
||||
* Returns all downloaded audio content as podcast episodes.
|
||||
* No authentication required — RSS readers can't send API keys.
|
||||
*/
|
||||
fastify.get('/api/v1/feed/rss', async (request, reply) => {
|
||||
const db = fastify.db;
|
||||
const baseUrl = buildBaseUrl(request);
|
||||
|
||||
// Query downloaded items that are audio (by contentType or format)
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(contentItems)
|
||||
.where(
|
||||
and(
|
||||
eq(contentItems.status, 'downloaded'),
|
||||
or(
|
||||
eq(contentItems.contentType, 'audio'),
|
||||
inArray(contentItems.format, AUDIO_FORMATS)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(contentItems.downloadedAt), desc(contentItems.id))
|
||||
.limit(500);
|
||||
|
||||
const now = new Date().toUTCString();
|
||||
|
||||
// Build RSS XML
|
||||
const items = rows.map((row) => {
|
||||
const fileName = row.filePath ? basename(row.filePath) : `${row.id}.mp3`;
|
||||
const enclosureUrl = `${baseUrl}/api/v1/media/${row.id}/${encodeURIComponent(fileName)}`;
|
||||
const mimeType = guessMimeType(row.filePath, row.format);
|
||||
const fileSize = row.fileSize ?? 0;
|
||||
const pubDate = row.publishedAt
|
||||
? new Date(row.publishedAt).toUTCString()
|
||||
: row.downloadedAt
|
||||
? new Date(row.downloadedAt).toUTCString()
|
||||
: now;
|
||||
const guid = `tubearr-${row.id}-${row.platformContentId}`;
|
||||
const duration = row.duration ? formatDuration(row.duration) : '';
|
||||
|
||||
return ` <item>
|
||||
<title>${escapeXml(row.title)}</title>
|
||||
<description>${escapeXml(row.title)}</description>
|
||||
<enclosure url="${escapeXml(enclosureUrl)}" length="${fileSize}" type="${mimeType}" />
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
<guid isPermaLink="false">${escapeXml(guid)}</guid>${duration ? `\n <itunes:duration>${duration}</itunes:duration>` : ''}
|
||||
</item>`;
|
||||
}).join('\n');
|
||||
|
||||
const feedUrl = `${baseUrl}/api/v1/feed/rss`;
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>Tubearr Audio Feed</title>
|
||||
<link>${escapeXml(baseUrl)}</link>
|
||||
<description>Downloaded audio from Tubearr</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>${now}</lastBuildDate>
|
||||
<atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />
|
||||
<itunes:author>Tubearr</itunes:author>
|
||||
<itunes:summary>Downloaded audio from Tubearr</itunes:summary>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
return reply
|
||||
.type('application/rss+xml; charset=utf-8')
|
||||
.header('Cache-Control', 'public, max-age=300')
|
||||
.send(xml);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/media/:id/:filename — Serve a downloaded media file.
|
||||
*
|
||||
* Looks up the content item by ID, verifies it has a file on disk,
|
||||
* and streams it back. The :filename param is for podcast app display
|
||||
* purposes; the actual file is located via the DB filePath column.
|
||||
*
|
||||
* No authentication required — podcast apps need direct file access.
|
||||
*/
|
||||
fastify.get<{ Params: { id: string; filename: string } }>(
|
||||
'/api/v1/media/:id/:filename',
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
if (isNaN(id) || id < 1) {
|
||||
return reply.status(400).send({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid content item ID',
|
||||
});
|
||||
}
|
||||
|
||||
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 }, '[feed] Media file not found on disk');
|
||||
return reply.status(404).send({
|
||||
statusCode: 404,
|
||||
error: 'Not Found',
|
||||
message: 'Media file not found on disk',
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = guessMimeType(item.filePath, item.format);
|
||||
|
||||
// Support range requests for podcast app seeking
|
||||
const range = request.headers.range;
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
|
||||
const chunkSize = end - start + 1;
|
||||
|
||||
const stream = createReadStream(filePath, { start, end });
|
||||
return reply
|
||||
.status(206)
|
||||
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
|
||||
.header('Accept-Ranges', 'bytes')
|
||||
.header('Content-Length', chunkSize)
|
||||
.header('Content-Type', mimeType)
|
||||
.send(stream);
|
||||
}
|
||||
|
||||
const stream = createReadStream(filePath);
|
||||
return reply
|
||||
.header('Content-Length', stat.size)
|
||||
.header('Content-Type', mimeType)
|
||||
.header('Accept-Ranges', 'bytes')
|
||||
.header('Content-Disposition', `inline; filename="${encodeURIComponent(basename(item.filePath))}"`)
|
||||
.send(stream);
|
||||
}
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue