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); } ); }