import {FastifyReply, FastifyRequest} from 'fastify'; import fs from 'fs'; import * as service from './local.service'; import * as downloadService from './download.service'; import * as streamingService from './streaming.service'; type ScanQuery = { mode?: 'full' | 'incremental'; }; type Params = { type: 'anime' | 'manga' | 'novels'; id?: string; }; type MatchBody = { source: 'anilist'; matched_id: number | null; }; type DownloadAnimeBody = | { anilist_id: number; episode_number: number; stream_url: string; is_master?: false; subtitles?: { language: string; url: string; }[]; duration?: number; chapters?: { title: string; start_time: number; end_time: number; }[]; } | { anilist_id: number; episode_number: number; stream_url: string; duration?: number; is_master: true; variant: { resolution: string; bandwidth?: number; codecs?: string; playlist_url: string; }; audio?: { group?: string; language?: string; name?: string; playlist_url: string; }[]; subtitles?: { language: string; url: string; }[]; chapters?: { title: string; start_time: number; end_time: number; }[]; }; type DownloadBookBody = { anilist_id: number; chapter_number: number; format: 'manga' | 'novel'; content?: string; images?: Array<{ index: number; url: string; }>; }; export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) { try { const mode = request.query.mode || 'incremental'; return await service.performLibraryScan(mode); } catch (err: any) { if (err.message === 'NO_LIBRARY_CONFIGURED') { return reply.status(400).send({ error: 'NO_LIBRARY_CONFIGURED' }); } return reply.status(500).send({ error: 'FAILED_TO_SCAN_LIBRARY' }); } } export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { try { const { type } = request.params; return await service.getEntriesByType(type); } catch { return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' }); } } export async function getEntry(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { try { const { type, id } = request.params as { type: string, id: string }; const entry = await service.getEntryDetails(type, id); if (!entry) { return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); } return entry; } catch { return reply.status(500).send({ error: 'FAILED_TO_GET_ENTRY' }); } } export async function streamUnit(request: FastifyRequest, reply: FastifyReply) { const { id, unit } = request.params as any; const fileInfo = await service.getFileForStreaming(id, unit); if (!fileInfo) { return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); } const { filePath, stat } = fileInfo; const range = request.headers.range; if (!range) { reply .header('Content-Length', stat.size) .header('Content-Type', 'video/mp4'); return fs.createReadStream(filePath); } const parts = range.replace(/bytes=/, '').split('-'); const start = Number(parts[0]); const end = parts[1] ? Number(parts[1]) : stat.size - 1; if ( Number.isNaN(start) || Number.isNaN(end) || start < 0 || start >= stat.size || end < start || end >= stat.size ) { return reply.status(416).send({ error: 'INVALID_RANGE' }); } const contentLength = end - start + 1; reply .status(206) .header('Content-Range', `bytes ${start}-${end}/${stat.size}`) .header('Accept-Ranges', 'bytes') .header('Content-Length', contentLength) .header('Content-Type', 'video/mp4'); return fs.createReadStream(filePath, { start, end }); } export async function matchEntry( request: FastifyRequest<{ Body: MatchBody }>, reply: FastifyReply ) { const { id, type } = request.params as any; const { source, matched_id } = request.body; const result = await service.updateEntryMatch(id, type, source, matched_id); if (!result) { return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); } return result; } export async function getUnits(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { try { const { id } = request.params as { id: string }; const units = await service.getEntryUnits(id); if (!units) { return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); } return units; } catch (err) { console.error('Error getting units:', err); return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' }); } } export async function getManifest(request: FastifyRequest, reply: FastifyReply) { const { unitId } = request.params as any; try { const manifest = await service.getUnitManifest(unitId); if (!manifest) { return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); } return manifest; } catch (err: any) { if (err.message === 'UNSUPPORTED_FORMAT') { return reply.status(400).send({ error: 'UNSUPPORTED_FORMAT' }); } return reply.status(500).send({ error: 'FAILED_TO_GET_MANIFEST' }); } } export async function getPage(request: FastifyRequest, reply: FastifyReply) { const { unitId, resId } = request.params as any; const resource = await service.getUnitResource(unitId, resId); if (!resource) { return reply.status(404).send(); } if (resource.type === 'image') { if (resource.data) { return reply .header('Content-Type', 'image/jpeg') .send(resource.data); } if (resource.path && resource.size) { reply .header('Content-Length', resource.size) .header('Content-Type', 'image/jpeg'); return fs.createReadStream(resource.path); } } if (resource.type === 'html') { return reply .header('Content-Type', 'text/html; charset=utf-8') .send(resource.data); } return reply.status(400).send(); } function buildProxyUrl(rawUrl: string, headers: Record) { const params = new URLSearchParams({ url: rawUrl }); for (const [key, value] of Object.entries(headers)) { params.set(key.toLowerCase(), value); } return `http://localhost:54322/api/proxy?${params.toString()}`; } export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnimeBody }>, reply: FastifyReply) { try { const { anilist_id, episode_number, stream_url, duration, is_master, subtitles, chapters } = request.body; const clientHeaders = (request.body as any).headers || {}; if (!anilist_id || !episode_number || !stream_url) { return reply.status(400).send({ error: 'MISSING_REQUIRED_FIELDS', required: ['anilist_id', 'episode_number', 'stream_url'] }); } const proxyUrl = buildProxyUrl(stream_url, clientHeaders); console.log('Stream URL:', proxyUrl); const proxiedSubs = subtitles?.map(sub => ({ ...sub, url: buildProxyUrl(sub.url, clientHeaders) })); const downloadParams: any = { anilistId: anilist_id, episodeNumber: episode_number, streamUrl: proxyUrl, subtitles: proxiedSubs, chapters, totalDuration: duration }; if (is_master === true) { const { variant, audio } = request.body as any; if (!variant || !variant.playlist_url) { return reply.status(400).send({ error: 'MISSING_VARIANT', message: 'variant with playlist_url is required when is_master is true' }); } downloadParams.is_master = true; downloadParams.variant = { ...variant, playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders) }; if (audio && audio.length > 0) { downloadParams.audio = audio.map((a: any) => ({ ...a, playlist_url: buildProxyUrl(a.playlist_url, clientHeaders) })); } console.log('Master playlist detected'); console.log('Variant:', downloadParams.variant.resolution); console.log('Audio tracks:', downloadParams.audio?.length || 0); } const result = await downloadService.downloadAnimeEpisode(downloadParams); if (result.status === 'ALREADY_EXISTS') { return reply.status(409).send(result); } return result; } catch (err: any) { console.error('Error downloading anime:', err); if (err.message === 'METADATA_NOT_FOUND') { return reply.status(404).send({ error: 'ANIME_NOT_FOUND_IN_ANILIST' }); } if (err.message === 'VARIANT_REQUIRED_FOR_MASTER') { return reply.status(400).send({ error: 'VARIANT_REQUIRED_FOR_MASTER' }); } if (err.message === 'DOWNLOAD_FAILED') { return reply.status(500).send({ error: 'DOWNLOAD_FAILED', details: err.details }); } return reply.status(500).send({ error: 'FAILED_TO_DOWNLOAD_ANIME' }); } } export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookBody }>, reply: FastifyReply) { try { const { anilist_id, chapter_number, format, content, images } = request.body; if (!anilist_id || !chapter_number || !format) { return reply.status(400).send({ error: 'MISSING_REQUIRED_FIELDS', required: ['anilist_id', 'chapter_number', 'format'] }); } if (format === 'novel' && !content) { return reply.status(400).send({ error: 'MISSING_CONTENT', message: 'content field is required for novels' }); } if (format === 'manga' && (!images || images.length === 0)) { return reply.status(400).send({ error: 'MISSING_IMAGES', message: 'images field is required for manga' }); } const result = await downloadService.downloadBookChapter({ anilistId: anilist_id, chapterNumber: chapter_number, format, content, images }); if (result.status === 'ALREADY_EXISTS') { return reply.status(409).send(result); } return result; } catch (err: any) { console.error('Error downloading book:', err); if (err.message === 'METADATA_NOT_FOUND') { return reply.status(404).send({ error: 'BOOK_NOT_FOUND_IN_ANILIST' }); } if (err.message === 'DOWNLOAD_FAILED') { return reply.status(500).send({ error: 'DOWNLOAD_FAILED', details: err.details }); } return reply.status(500).send({ error: 'FAILED_TO_DOWNLOAD_BOOK' }); } } export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) { try { const downloads = downloadService.getActiveDownloads(); const streams = streamingService.getActiveStreamsStatus(); return { downloads: { total: downloads.length, active: downloads.filter(d => d.status === 'downloading').length, completed: downloads.filter(d => d.status === 'completed').length, failed: downloads.filter(d => d.status === 'failed').length, list: downloads }, streams: { total: streams.length, active: streams.filter(s => !s.isComplete).length, completed: streams.filter(s => s.isComplete).length, list: streams } }; } catch (err) { console.error('Error getting download status:', err); return reply.status(500).send({ error: 'FAILED_TO_GET_DOWNLOAD_STATUS' }); } } export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) { try { const { type, id, unit } = request.params as any; if (type !== 'anime') { return reply.status(400).send({ error: 'ONLY_ANIME_SUPPORTED' }); } const fileInfo = await service.getFileForStreaming(id, unit); if (!fileInfo) { return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); } const manifest = await streamingService.getStreamingManifest(fileInfo.filePath); if (!manifest) { return reply.status(500).send({ error: 'FAILED_TO_GENERATE_MANIFEST' }); } return manifest; } catch (err: any) { console.error('Error getting stream manifest:', err); return reply.status(500).send({ error: 'FAILED_TO_GET_STREAM_MANIFEST' }); } } export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) { try { const { hash, filename } = request.params as any; const file = await streamingService.getHLSFile(hash, filename); if (!file) { return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); } const contentType = filename.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : filename.endsWith('.vtt') ? 'text/vtt' : 'video/mp2t'; reply .header('Content-Type', contentType) .header('Content-Length', file.stat.size) .header('Access-Control-Allow-Origin', '*'); return fs.createReadStream(file.path); } catch (err) { console.error('Error serving HLS file:', err); return reply.status(500).send({ error: 'FAILED_TO_SERVE_HLS_FILE' }); } }