- "src/server/routes/feed.ts" - "src/__tests__/feed-api.test.ts" - "src/server/middleware/auth.ts" - "src/server/index.ts" GSD-Task: S08/T04
257 lines
8.4 KiB
TypeScript
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, '&')
|
|
.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);
|
|
}
|
|
);
|
|
}
|