342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
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 };
|
|
}
|
|
} |