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:
jlightner 2026-04-04 07:38:34 +00:00
parent c012425ceb
commit 54e9041058
4 changed files with 634 additions and 0 deletions

View 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('&lt;tags&gt;');
expect(res.body).toContain('&amp;');
expect(res.body).toContain('&quot;quotes&quot;');
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);
});
});
});

View file

@ -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) {

View file

@ -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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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);
}
);
}