485 lines
14 KiB
TypeScript
485 lines
14 KiB
TypeScript
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<string, string>) {
|
|
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' });
|
|
}
|
|
} |