import {FastifyReply, FastifyRequest} from 'fastify'; import * as animeService from './anime.service'; import { setActivity } from '../rpc/rp.service'; import { upsertListEntry } from '../list/list.service'; import {getExtension} from '../../shared/extensions'; import { getConfig as loadConfig } from '../../shared/config'; import {Anime, AnimeRequest, SearchRequest, WatchStreamRequest} from '../types'; import {spawn} from "node:child_process"; import net from 'net'; import fs from 'fs'; import os from 'os'; import path from 'path'; import jwt from "jsonwebtoken"; export async function getAnime(req: AnimeRequest, reply: FastifyReply) { try { const { id } = req.params; const source = req.query.source; let anime: Anime | { error: string }; if (source === 'anilist') { anime = await animeService.getAnimeById(id); } else { const ext = getExtension(source); anime = await animeService.getAnimeInfoExtension(ext, id) } return anime; } catch (err) { return { error: "Database error" }; } } export async function getAnimeEpisodes(req: AnimeRequest, reply: FastifyReply) { try { const { id } = req.params; const source = req.query.source || 'anilist'; const ext = getExtension(source); return await animeService.searchEpisodesInExtension( ext, source, id ); } catch (err) { return { error: "Database error" }; } } export async function getTrending(req: FastifyRequest, reply: FastifyReply) { try { const results = await animeService.getTrendingAnime(); return { results }; } catch (err) { return { results: [] }; } } export async function getTopAiring(req: FastifyRequest, reply: FastifyReply) { try { const results = await animeService.getTopAiringAnime(); return { results }; } catch (err) { return { results: [] }; } } export async function search(req: SearchRequest, reply: FastifyReply) { try { const query = req.query.q; const results = await animeService.searchAnimeLocal(query); if (results.length > 0) { return { results: results }; } } catch (err) { return { results: [] }; } } export async function searchInExtension(req: any, reply: FastifyReply) { try { const extensionName = req.params.extension; const query = req.query.q; const ext = getExtension(extensionName); if (!ext) return { results: [] }; const results = await animeService.searchAnimeInExtension(ext, extensionName, query); return { results }; } catch { return { results: [] }; } } export async function getWatchStream(req: WatchStreamRequest, reply: FastifyReply) { try { const { animeId, episode, server, category, ext, source, extensionAnimeId } = req.query; const extension = getExtension(ext); if (!extension) return { error: "Extension not found" }; return await animeService.getStreamData( extension, episode, animeId, source, server, category, extensionAnimeId ); } catch (err) { const error = err as Error; return { error: error.message }; } } export async function openInMPV(req: any, reply: any) { try { const { title, video, subtitles = [], chapters = [], animeId, episode, entrySource, token } = req.body; if (!video?.url) return { error: 'Missing video url' }; const isLocalPath = (p: string) => p.startsWith('file://') || p.startsWith('/') || /^[a-zA-Z]:\\/.test(p); const toFileUrl = (p: string) => p.startsWith('file://') ? p : `file://${p.replace(/\\/g, '/')}`; const PORT = 54322; const proxyBase = `http://localhost:${PORT}/api/proxy`; const mediaTitle = title || 'Anime'; const videoUrl = isLocalPath(video.url) ? toFileUrl(video.url) : `${proxyBase}?url=${encodeURIComponent(video.url)}` + `&referer=${encodeURIComponent(video.headers?.Referer ?? '')}` + `&origin=${encodeURIComponent(video.headers?.Origin ?? '')}` + `&userAgent=${encodeURIComponent(video.headers?.['User-Agent'] ?? '')}`; const subsUrls = subtitles.map((s: any) => isLocalPath(s.src) ? toFileUrl(s.src) : `${proxyBase}?url=${encodeURIComponent(s.src)}` + `&referer=${encodeURIComponent(video.headers?.Referer ?? '')}` + `&origin=${encodeURIComponent(video.headers?.Origin ?? '')}` + `&userAgent=${encodeURIComponent(video.headers?.['User-Agent'] ?? '')}` ); const pipe = `\\\\.\\pipe\\mpv-${Date.now()}`; const { values } = loadConfig(); const MPV_PATH = values.paths?.mpv || 'mpv'; let chaptersArg: string[] = []; if (chapters.length) { chapters.sort((a: any, b: any) => a.startTime - b.startTime); const lines = [';FFMETADATA1']; for (let i = 0; i < chapters.length; i++) { const c = chapters[i]; const start = Math.floor(c.startTime * 1000); const end = Math.floor(c.endTime * 1000); const title = (c.type || 'chapter').toUpperCase(); lines.push( `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${start}`, `END=${end}`, `title=${title}` ); if (i < chapters.length - 1) { const nextStart = Math.floor(chapters[i + 1].startTime * 1000); if (nextStart - end > 1000) { lines.push( `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${end}`, `END=${nextStart}`, `title=Episode` ); } } else { lines.push( `[CHAPTER]`, `TIMEBASE=1/1000`, `START=${end}`, `title=Episode` ); } } const chaptersFile = path.join(os.tmpdir(), `mpv-chapters-${Date.now()}.txt`); fs.writeFileSync(chaptersFile, lines.join('\n')); chaptersArg = [`--chapters-file=${chaptersFile}`]; } if (!MPV_PATH) { return { error: 'MPV_NOT_CONFIGURED' }; } spawn( MPV_PATH, [ '--force-window=yes', '--idle=yes', '--keep-open=yes', '--no-terminal', '--hwdec=auto', `--force-media-title=Loading...`, `--input-ipc-server=${pipe}`, ...chaptersArg ], { stdio: 'ignore', windowsHide: true, detached: true } ).unref(); await new Promise(r => setTimeout(r, 400)); const socket = net.connect(pipe); let duration = 0; let currentTime = 0; let isPaused = false; let titleChanged = false; let progressUpdated = false; const sendRPC = (paused: boolean) => { let startTimestamp = undefined; let endTimestamp = undefined; if (!paused && duration > 0) { const now = Math.floor(Date.now() / 1000); const elapsed = Math.floor(currentTime); startTimestamp = now - elapsed; endTimestamp = startTimestamp + Math.floor(duration); } setActivity({ details: mediaTitle, state: `Episode ${episode}`, mode: "watching", paused: paused, startTimestamp: startTimestamp, endTimestamp: endTimestamp }) }; const updateProgress = async () => { if (!token || progressUpdated) return; try { const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any; const userId = decoded.id; await upsertListEntry({ user_id: userId, entry_id: animeId, source: entrySource, entry_type: "ANIME", status: "CURRENT", progress: episode }); progressUpdated = true; } catch (e) { console.error("[MPV] Progress update failed", e); } }; const loadingTimeout = setTimeout(() => { if (!socket.destroyed) socket.end(); }, 15000); socket.on('data', (data) => { const chunks = data.toString().split('\n'); for (const chunk of chunks) { if (!chunk.trim()) continue; try { const json = JSON.parse(chunk); if (json.event === 'property-change' && json.name === 'duration' && typeof json.data === 'number') { duration = json.data; if (!titleChanged) { socket.write(JSON.stringify({ command: ['set_property', 'force-media-title', mediaTitle] }) + '\n'); sendRPC(false); titleChanged = true; clearTimeout(loadingTimeout); } } if (json.event === 'property-change' && json.name === 'time-pos' && typeof json.data === 'number') { currentTime = json.data; if (duration > 0 && !progressUpdated && (currentTime / duration) >= 0.8) { updateProgress(); } } if (json.event === 'property-change' && json.name === 'pause') { isPaused = json.data === true; sendRPC(isPaused); } if (json.event === 'seek') { setTimeout(() => sendRPC(isPaused), 100); } if (json.event === 'end-file' || json.event === 'shutdown') { sendRPC(true); if (!socket.destroyed) socket.destroy(); } } catch (err) {} } }); const commands = [ { command: ['observe_property', 1, 'duration'] }, { command: ['observe_property', 2, 'time-pos'] }, { command: ['observe_property', 3, 'pause'] }, { command: ['loadfile', videoUrl, 'replace'] } ]; commands.forEach(cmd => socket.write(JSON.stringify(cmd) + '\n')); subtitles.forEach((s: any, i: number) => { socket.write(JSON.stringify({ command: [ 'sub-add', subsUrls[i], 'auto', s.label || 'Subtitle', s.srclang || '' ] }) + '\n'); }); return { success: true }; } catch (e) { return { error: (e as Error).message }; } }