tubearr/src/server/routes/feed.ts
jlightner 54e9041058 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
2026-04-04 07:38:34 +00:00

257 lines
8.4 KiB
TypeScript

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