diff --git a/src/__tests__/feed-api.test.ts b/src/__tests__/feed-api.test.ts new file mode 100644 index 0000000..050f3e1 --- /dev/null +++ b/src/__tests__/feed-api.test.ts @@ -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; + 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 { + 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('Tubearr Audio Feed'); + }); + + 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('1:01:01'); + expect(res.body).toContain(`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 & "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(''); + }); + }); + + // ── 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); + }); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts index b4e515a..b7bbcec 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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 { 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}`); diff --git a/src/server/routes/feed.ts b/src/server/routes/feed.ts new file mode 100644 index 0000000..299ad32 --- /dev/null +++ b/src/server/routes/feed.ts @@ -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 = { + '.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, '''); +} + +/** + * 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; 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 { + /** + * 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 ` + ${escapeXml(row.title)} + ${escapeXml(row.title)} + + ${pubDate} + ${escapeXml(guid)}${duration ? `\n ${duration}` : ''} + `; + }).join('\n'); + + const feedUrl = `${baseUrl}/api/v1/feed/rss`; + + const xml = ` + + + Tubearr Audio Feed + ${escapeXml(baseUrl)} + Downloaded audio from Tubearr + en + ${now} + + Tubearr + Downloaded audio from Tubearr + false +${items} + +`; + + 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); + } + ); +}