Spaces:
Paused
Paused
| import { z } from 'zod'; | |
| import { | |
| constants, | |
| createLogger, | |
| BuiltinServiceId, | |
| Env, | |
| appConfig, | |
| Cache, | |
| getSimpleTextHash, | |
| encryptString, | |
| toUrlSafeBase64, | |
| ParsedMediaInfo, | |
| } from '../utils/index.js'; | |
| import { promises as fs } from 'fs'; | |
| import path from 'path'; | |
| import { createHash } from 'crypto'; | |
| import { | |
| DebridFile, | |
| DebridDownload, | |
| PlaybackInfo, | |
| ServiceAuth, | |
| FileInfo, | |
| TitleMetadata, | |
| } from './base.js'; | |
| import { | |
| normaliseTitle, | |
| preprocessTitle, | |
| titleMatch, | |
| } from '../parser/utils.js'; | |
| import { partial_ratio } from 'fuzzball'; | |
| import { ParsedResult } from '@viren070/parse-torrent-title'; | |
| const logger = createLogger('debrid'); | |
| /** | |
| * Clean an NZB URL by stripping query parameters, ampersand parameters, and fragments. | |
| */ | |
| export function cleanNzbUrl(url: string): string { | |
| let cleaned = url; | |
| const qIndex = cleaned.indexOf('?'); | |
| if (qIndex !== -1) { | |
| cleaned = cleaned.substring(0, qIndex); | |
| } else { | |
| const aIndex = cleaned.indexOf('&'); | |
| if (aIndex !== -1) { | |
| cleaned = cleaned.substring(0, aIndex); | |
| } | |
| } | |
| const hIndex = cleaned.indexOf('#'); | |
| if (hIndex !== -1) { | |
| cleaned = cleaned.substring(0, hIndex); | |
| } | |
| return cleaned; | |
| } | |
| /** | |
| * Compute an MD5 hash of a cleaned NZB URL. | |
| */ | |
| export function hashNzbUrl(url: string, clean: boolean = true): string { | |
| try { | |
| const u = new URL(url); | |
| const pathName = u.pathname.replace(/\/$/, ''); | |
| if (pathName.endsWith('/api')) { | |
| const t = u.searchParams.get('t'); | |
| const id = u.searchParams.get('id'); | |
| if (t === 'get' && id) { | |
| const keys = Array.from(u.searchParams.keys()); | |
| for (const key of keys) { | |
| if (key !== 't' && key !== 'id') { | |
| u.searchParams.delete(key); | |
| } | |
| } | |
| return createHash('md5').update(u.toString()).digest('hex'); | |
| } | |
| } | |
| } catch {} | |
| return createHash('md5') | |
| .update(clean ? cleanNzbUrl(url) : url) | |
| .digest('hex'); | |
| } | |
| /** | |
| * Build the cache key used to store / look up NZB failover entries. | |
| */ | |
| export function buildFallbackKey( | |
| uuid: string | undefined, | |
| nzbUrl: string | |
| ): string { | |
| return getSimpleTextHash((uuid ?? '') + ':' + nzbUrl); | |
| } | |
| /** | |
| * Build a compact, deterministic key for DistributedLock and playback-link | |
| * cache lookups. All absent values serialise to `-` so keys remain stable | |
| * across call sites. The NZB URL is MD5-hashed before inclusion to keep the | |
| * key short and free of embedded tokens. | |
| * | |
| * @param prefix Short use-site tag, e.g. `'st:lock'` or `'st:cache'`. | |
| * @param serviceName Service identifier. | |
| * @param playbackInfo The resolved playback info (provides type, hash, nzb, fileIndex, metadata). | |
| * @param filename Display filename passed into the resolve call. | |
| * @param credential Service API token / credential. | |
| * @param clientIp Optional client IP. | |
| * @param flags Operational flags — pass when building a *lock* key so | |
| * that requests with different behaviours are not coalesced | |
| * under the same lock; omit for inner cache keys. | |
| */ | |
| export function buildResolveKey( | |
| prefix: string, | |
| serviceName: string, | |
| playbackInfo: PlaybackInfo, | |
| filename: string, | |
| credential: string, | |
| clientIp?: string, | |
| flags?: { cacheAndPlay?: boolean; autoRemoveDownloads?: boolean } | |
| ): string { | |
| const { type, hash, fileIndex } = playbackInfo; | |
| const nzb = playbackInfo.type === 'usenet' ? playbackInfo.nzb : undefined; | |
| const { season, episode, absoluteEpisode } = playbackInfo.metadata ?? {}; | |
| const parts: (string | number)[] = [ | |
| prefix, | |
| serviceName, | |
| type, | |
| getSimpleTextHash(credential), | |
| clientIp ?? '-', | |
| hash, | |
| nzb ? hashNzbUrl(nzb, false) : '-', | |
| fileIndex ?? '-', | |
| season ?? '-', | |
| episode ?? '-', | |
| absoluteEpisode ?? '-', | |
| filename ?? '-', | |
| ]; | |
| if (flags !== undefined) { | |
| parts.push(String(flags.cacheAndPlay ?? '-')); | |
| parts.push(String(flags.autoRemoveDownloads ?? '-')); | |
| } | |
| return parts.join(':'); | |
| } | |
| export const BuiltinDebridServices = z.array( | |
| z.object({ | |
| id: z.enum(constants.BUILTIN_SUPPORTED_SERVICES), | |
| credential: z.string(), | |
| }) | |
| ); | |
| export type BuiltinDebridServices = z.infer<typeof BuiltinDebridServices>; | |
| interface BaseFile { | |
| confirmed?: boolean; // whether the file has been confirmed to be the correct file for the media | |
| title?: string; | |
| size: number; | |
| index?: number; | |
| indexer?: string; | |
| seeders?: number; | |
| group?: string; | |
| parsedMediaInfo?: ParsedMediaInfo; | |
| age?: number; // age in hours | |
| downloadvolumefactor?: number; // multiplier for the download volume that counts toward the user’s account on the tracker | |
| duration?: number; // duration in seconds | |
| library?: boolean; // whether the file is already in the user's library | |
| } | |
| export interface Torrent extends BaseFile { | |
| type: 'torrent'; | |
| downloadUrl?: string; | |
| sources: string[]; | |
| hash: string; | |
| files?: DebridFile[]; | |
| // magnet?: string; | |
| private?: boolean; | |
| } | |
| export interface UnprocessedTorrent extends BaseFile { | |
| type: 'torrent'; | |
| hash?: string; | |
| downloadUrl?: string; | |
| sources: string[]; | |
| private?: boolean; | |
| serviceItemId?: string; | |
| } | |
| export interface NZB extends BaseFile { | |
| type: 'usenet'; | |
| hash: string; | |
| nzb: string; | |
| easynewsUrl?: string; | |
| zyclopsHealth?: string; | |
| serviceItemId?: string; | |
| } | |
| export interface TorrentWithSelectedFile extends Torrent { | |
| file: DebridFile; | |
| service?: { | |
| id: BuiltinServiceId; | |
| cached: boolean; | |
| library: boolean; | |
| }; | |
| serviceItemId?: string; | |
| } | |
| export interface NZBWithSelectedFile extends NZB { | |
| file: DebridFile; | |
| service?: { | |
| id: BuiltinServiceId; | |
| cached: boolean; | |
| library: boolean; | |
| }; | |
| serviceItemId?: string; | |
| } | |
| interface SelectionOptions { | |
| chosenFilename?: string; | |
| chosenIndex?: number; | |
| useLevenshteinMatching?: boolean; | |
| skipSeasonEpisodeCheck?: boolean; | |
| printReport?: boolean; | |
| saveReport?: boolean; | |
| } | |
| interface SelectionReport { | |
| torrentTitle: string | undefined; | |
| timestamp: string; | |
| metadata: TitleMetadata | null; | |
| options: SelectionOptions | null; | |
| files: Array<{ | |
| index: number; | |
| name: string | undefined; | |
| size: number; | |
| isVideo: boolean; | |
| isNotVideo: boolean; | |
| parsed: ParsedResult | null; | |
| scoreBreakdown: Record<string, number | string>; | |
| finalScore?: number; | |
| skipped: boolean; | |
| skipReason: string | null; | |
| }>; | |
| selectedFile: { | |
| name: string | undefined; | |
| index: number; | |
| score: number; | |
| size: number; | |
| } | null; | |
| skipped: boolean; | |
| skipReason: string | null; | |
| } | |
| // helpers | |
| export const isSeasonWrong = ( | |
| parsed: { seasons?: number[]; episodes?: number[] }, | |
| metadata?: { season?: number; absoluteEpisode?: number } | |
| ) => { | |
| if ( | |
| parsed.seasons?.length && | |
| metadata?.season && | |
| !parsed.seasons.includes(metadata.season) | |
| ) { | |
| // allow if season is "wrong" with value of 1 but absolute episode is correct | |
| if ( | |
| parsed.seasons.length === 1 && | |
| parsed.seasons[0] === 1 && | |
| parsed.episodes?.length && | |
| metadata.absoluteEpisode && | |
| parsed.episodes.includes(metadata.absoluteEpisode) | |
| ) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| return false; | |
| }; | |
| export const isEpisodeWrong = ( | |
| parsed: ParsedResult, | |
| metadata?: TitleMetadata | |
| ) => { | |
| if ( | |
| parsed.episodes?.length && | |
| metadata?.episode && | |
| !( | |
| parsed.episodes.includes(metadata.episode) || | |
| (metadata.absoluteEpisode && | |
| parsed.episodes.includes(metadata.absoluteEpisode)) || | |
| (metadata.relativeAbsoluteEpisode && | |
| parsed.episodes.includes(metadata.relativeAbsoluteEpisode)) | |
| ) | |
| ) { | |
| return true; | |
| } | |
| return false; | |
| }; | |
| export const isTitleWrong = ( | |
| parsed: { title?: string }, | |
| metadata?: { titles?: string[] } | |
| ) => { | |
| if ( | |
| parsed.title && | |
| metadata?.titles && | |
| !titleMatch( | |
| normaliseTitle(parsed.title), | |
| metadata.titles.map(normaliseTitle), | |
| { threshold: 0.8 } | |
| ) | |
| ) { | |
| return true; | |
| } | |
| return false; | |
| }; | |
| export const isTitleWrongN = ( | |
| parsed: { title?: string }, | |
| metadata?: { titles?: string[] } | |
| ) => { | |
| if (parsed.title && metadata?.titles) { | |
| const normalisedParsedTitle = normaliseTitle(parsed.title); | |
| return !metadata.titles | |
| .map(normaliseTitle) | |
| .some((title) => title == normalisedParsedTitle); | |
| } | |
| return false; | |
| }; | |
| export async function selectFileInTorrentOrNZB( | |
| torrentOrNZB: Torrent | NZB, | |
| debridDownload: DebridDownload, | |
| parsedFiles: Map<string, ParsedResult>, | |
| metadata?: TitleMetadata, | |
| options?: SelectionOptions | |
| ): Promise<DebridFile | undefined> { | |
| const report: SelectionReport = { | |
| torrentTitle: torrentOrNZB.title, | |
| timestamp: new Date().toISOString(), | |
| metadata: metadata || null, | |
| options: options || null, | |
| files: [], | |
| selectedFile: null, | |
| skipped: false, | |
| skipReason: null, | |
| }; | |
| const handleReport = async () => { | |
| if (options?.printReport) { | |
| logger.debug( | |
| `Selection report for ${torrentOrNZB.title}: ${JSON.stringify(report, null, 2)}` | |
| ); | |
| } | |
| if (options?.saveReport) { | |
| await saveReport(torrentOrNZB.title, report); | |
| } | |
| }; | |
| if (!debridDownload.files?.length) { | |
| report.skipped = true; | |
| report.skipReason = 'No files in debrid download'; | |
| await handleReport(); | |
| return { | |
| name: torrentOrNZB.title, | |
| size: torrentOrNZB.size, | |
| index: -1, | |
| }; | |
| } | |
| const isVideo = debridDownload.files.map((file) => isVideoFile(file)); | |
| const isNotVideo = debridDownload.files.map((file) => isNotVideoFile(file)); | |
| const videoExists = isVideo.map((f) => f == true); | |
| const normTitles: Set<string> | null = metadata?.titles?.length | |
| ? new Set(metadata.titles.map(normaliseTitle)) | |
| : null; | |
| const titleCache = new Map<string, string>(); | |
| const files = debridDownload.files; | |
| const maxSize = | |
| torrentOrNZB.size || files.reduce((max, f) => Math.max(max, f.size), 0); | |
| // Score each file | |
| const fileScores = []; | |
| for (let index = 0; index < debridDownload.files.length; index++) { | |
| const file = debridDownload.files[index]; | |
| let score = 0; | |
| const parsed = parsedFiles.get(file.name ?? ''); | |
| const fileReport: SelectionReport['files'][number] = { | |
| index, | |
| name: file.name, | |
| size: file.size, | |
| isVideo: isVideo[index], | |
| isNotVideo: isNotVideo[index], | |
| parsed: parsed || null, | |
| scoreBreakdown: {}, | |
| finalScore: 0, | |
| skipped: false, | |
| skipReason: null, | |
| }; | |
| if (isNotVideo[index]) { | |
| fileReport.skipped = true; | |
| fileReport.skipReason = 'Not a video file'; | |
| report.files.push(fileReport); | |
| continue; | |
| } | |
| if (!parsed) { | |
| logger.warn(`Parsed file not found for ${file.name}`); | |
| fileReport.skipped = true; | |
| fileReport.skipReason = 'No parsed metadata available'; | |
| report.files.push(fileReport); | |
| continue; | |
| } | |
| if ( | |
| file.name && | |
| ['sample', 'trailer', 'preview'].some((keyword) => | |
| file.name!.toLowerCase().includes(keyword) | |
| ) | |
| ) { | |
| score -= 500; | |
| fileReport.scoreBreakdown.sampleTrailerPenalty = -500; | |
| } | |
| if (file.name && /nc(ed|op)/i.test(file.name)) { | |
| score -= 500; | |
| fileReport.scoreBreakdown.ncedNcopPenalty = -500; | |
| } | |
| // Base score from video file status (highest priority) | |
| if (isVideo[index]) { | |
| score += 1000; | |
| fileReport.scoreBreakdown.videoFileBonus = 1000; | |
| } | |
| if ( | |
| // !(metadata?.season && metadata?.episode && metadata?.absoluteEpisode) && | |
| metadata?.year && | |
| parsed?.year | |
| ) { | |
| if (metadata.year === Number(parsed.year)) { | |
| score += 500; | |
| fileReport.scoreBreakdown.yearMatch = 500; | |
| } | |
| } | |
| // Season year matching (for anime) | |
| if (metadata?.seasonYear && parsed?.year) { | |
| if (metadata.seasonYear === Number(parsed.year)) { | |
| score += 750; | |
| fileReport.scoreBreakdown.seasonYearMatch = 750; | |
| } | |
| } | |
| // Season/Episode matching (second highest priority) | |
| // Season bonus is ONLY awarded when the season is explicitly present in the | |
| // filename and matches. This prevents seasonless files (extras, OVAs, NCED/NCOP) | |
| // from getting a net-zero season score via the old +500/-500 cancellation. | |
| const hasSeason = (parsed.seasons?.length ?? 0) > 0; | |
| if (metadata?.season) { | |
| if (hasSeason && !isSeasonWrong(parsed, metadata)) { | |
| // Season explicitly present and correct | |
| score += 500; | |
| fileReport.scoreBreakdown.seasonMatch = 500; | |
| } else if (hasSeason) { | |
| // Season explicitly present but wrong | |
| score -= 800; | |
| fileReport.scoreBreakdown.wrongSeasonPenalty = -800; | |
| } else { | |
| // Season expected but not present in file (e.g. extras, absolute-numbered) | |
| score -= 300; | |
| fileReport.scoreBreakdown.missingSeasonPenalty = -300; | |
| } | |
| } | |
| if (parsed && !isEpisodeWrong(parsed, metadata)) { | |
| const parsedEpisodesCount = parsed.episodes?.length || 0; | |
| const parsedHasSeason = parsed.seasons && parsed.seasons.length > 0; | |
| const isExactMatch = parsedEpisodesCount === 1; | |
| const isBatchMatch = parsedEpisodesCount > 1; | |
| // For files without season info: prefer absolute episode matches over regular episode | |
| if ( | |
| !parsedHasSeason && | |
| metadata?.season && | |
| metadata?.absoluteEpisode && | |
| metadata?.episode | |
| ) { | |
| const matchesAbsolute = parsed.episodes?.includes( | |
| metadata.absoluteEpisode | |
| ); | |
| const matchesRelativeAbsolute = metadata.relativeAbsoluteEpisode | |
| ? parsed.episodes?.includes(metadata.relativeAbsoluteEpisode) | |
| : false; | |
| const matchesRegular = parsed.episodes?.includes(metadata.episode); | |
| if (matchesAbsolute && isExactMatch) { | |
| score += 600; | |
| fileReport.scoreBreakdown.episodeMatchType = 'exactAbsolute'; | |
| fileReport.scoreBreakdown.episodeScore = 600; | |
| } else if (matchesAbsolute && isBatchMatch) { | |
| score += 200; | |
| fileReport.scoreBreakdown.episodeMatchType = 'batchAbsolute'; | |
| fileReport.scoreBreakdown.episodeScore = 200; | |
| } else if (matchesRelativeAbsolute && isExactMatch) { | |
| score += 400; | |
| fileReport.scoreBreakdown.episodeMatchType = 'exactRelativeAbsolute'; | |
| fileReport.scoreBreakdown.episodeScore = 400; | |
| } else if (matchesRelativeAbsolute && isBatchMatch) { | |
| score += 100; | |
| fileReport.scoreBreakdown.episodeMatchType = 'batchRelativeAbsolute'; | |
| fileReport.scoreBreakdown.episodeScore = 100; | |
| } else if (matchesRegular && isExactMatch) { | |
| score += 200; | |
| fileReport.scoreBreakdown.episodeMatchType = 'exactRegular'; | |
| fileReport.scoreBreakdown.episodeScore = 200; | |
| } else if (matchesRegular && isBatchMatch) { | |
| score += 75; | |
| fileReport.scoreBreakdown.episodeMatchType = 'batchRegular'; | |
| fileReport.scoreBreakdown.episodeScore = 75; | |
| } | |
| } else if ( | |
| parsedHasSeason && | |
| metadata?.season && | |
| metadata?.absoluteEpisode && | |
| metadata?.episode && | |
| metadata.absoluteEpisode !== metadata.episode | |
| ) { | |
| // File has season info: prefer regular episode over absolute. | |
| const matchesRegular = parsed.episodes?.includes(metadata.episode); | |
| const matchesAbsolute = parsed.episodes?.includes( | |
| metadata.absoluteEpisode | |
| ); | |
| const matchesRelativeAbsolute = metadata.relativeAbsoluteEpisode | |
| ? parsed.episodes?.includes(metadata.relativeAbsoluteEpisode) | |
| : false; | |
| if (matchesRegular && isExactMatch) { | |
| score += 800; | |
| fileReport.scoreBreakdown.episodeMatchType = 'exact'; | |
| fileReport.scoreBreakdown.episodeScore = 800; | |
| } else if (matchesRegular && isBatchMatch) { | |
| score += 250; | |
| fileReport.scoreBreakdown.episodeMatchType = 'batch'; | |
| fileReport.scoreBreakdown.episodeScore = 250; | |
| } else if (matchesAbsolute && isExactMatch) { | |
| score += 200; | |
| fileReport.scoreBreakdown.episodeMatchType = | |
| 'exactAbsoluteWithSeason'; | |
| fileReport.scoreBreakdown.episodeScore = 200; | |
| } else if (matchesRelativeAbsolute && isExactMatch) { | |
| score += 150; | |
| fileReport.scoreBreakdown.episodeMatchType = | |
| 'exactRelativeAbsoluteWithSeason'; | |
| fileReport.scoreBreakdown.episodeScore = 150; | |
| } else if (matchesAbsolute && isBatchMatch) { | |
| score += 100; | |
| fileReport.scoreBreakdown.episodeMatchType = | |
| 'batchAbsoluteWithSeason'; | |
| fileReport.scoreBreakdown.episodeScore = 100; | |
| } else if (matchesRelativeAbsolute && isBatchMatch) { | |
| score += 50; | |
| fileReport.scoreBreakdown.episodeMatchType = | |
| 'batchRelativeAbsoluteWithSeason'; | |
| fileReport.scoreBreakdown.episodeScore = 50; | |
| } | |
| } else { | |
| // Standard scoring: strongly prefer exact episodes over batches | |
| if (isExactMatch) { | |
| score += 800; | |
| fileReport.scoreBreakdown.episodeMatchType = 'exact'; | |
| fileReport.scoreBreakdown.episodeScore = 800; | |
| } else if (isBatchMatch) { | |
| score += 250; | |
| fileReport.scoreBreakdown.episodeMatchType = 'batch'; | |
| fileReport.scoreBreakdown.episodeScore = 250; | |
| } | |
| } | |
| } | |
| if ( | |
| !parsed?.episodes?.length && | |
| (metadata?.episode || metadata?.absoluteEpisode) | |
| ) { | |
| score -= 500; | |
| fileReport.scoreBreakdown.missingEpisodePenalty = -500; | |
| } | |
| // Title matching (third priority) | |
| if (parsed?.title && (videoExists ? isVideo[index] : true)) { | |
| let preprocessed = titleCache.get(parsed.title); | |
| if (preprocessed === undefined) { | |
| preprocessed = preprocessTitle( | |
| parsed.title, | |
| torrentOrNZB.title ?? '', | |
| metadata?.titles ?? [] | |
| ); | |
| titleCache.set(parsed.title, preprocessed); | |
| } | |
| const titleMatches = | |
| normTitles === null | |
| ? true | |
| : normTitles.has(normaliseTitle(preprocessed)); | |
| if (titleMatches) { | |
| score += 200; | |
| fileReport.scoreBreakdown.titleMatch = 200; | |
| } | |
| } | |
| // Size based score (lowest priority but still relevant) | |
| // We normalize the size to be between 0 and 50 points | |
| const sizeScore = maxSize > 0 ? (file.size / maxSize) * 50 : 0; | |
| score += sizeScore; | |
| fileReport.scoreBreakdown.sizeScore = sizeScore; | |
| // Small boost for chosen index/filename if provided | |
| if (options?.chosenIndex === index) { | |
| score += 25; | |
| fileReport.scoreBreakdown.chosenIndexBonus = 25; | |
| } | |
| if ( | |
| options?.chosenFilename && | |
| torrentOrNZB.title?.includes(options.chosenFilename) | |
| ) { | |
| score += 25; | |
| fileReport.scoreBreakdown.chosenFilenameBonus = 25; | |
| } | |
| fileReport.finalScore = Math.max(score, 0); | |
| report.files.push(fileReport); | |
| fileScores.push({ | |
| file, | |
| score: Math.max(score, 0), | |
| index, | |
| }); | |
| } | |
| if (fileScores.length === 0) { | |
| logger.warn(`Torrent ${torrentOrNZB.title} had no files selected`, { | |
| files: debridDownload.files.map((f) => f.name), | |
| }); | |
| report.skipped = true; | |
| report.skipReason = 'No valid video files with scores'; | |
| await handleReport(); | |
| return undefined; | |
| } | |
| // Sort by score descending | |
| fileScores.sort((a, b) => b.score - a.score); | |
| // Select the best matching file | |
| const bestMatch = fileScores[0]; | |
| const parsedFile = parsedFiles.get(bestMatch.file.name ?? ''); | |
| const parsedTitle = parsedFiles.get(torrentOrNZB.title ?? ''); | |
| if ( | |
| metadata && | |
| parsedFile && | |
| parsedTitle && | |
| !options?.skipSeasonEpisodeCheck | |
| ) { | |
| if ( | |
| isEpisodeWrong(parsedFile, metadata) || | |
| isEpisodeWrong(parsedTitle, metadata) | |
| ) { | |
| logger.debug( | |
| `Episode ${metadata.episode} or ${metadata.absoluteEpisode} not found in ${torrentOrNZB.title} and ${bestMatch.file.name}, skipping...` | |
| ); | |
| report.skipped = true; | |
| report.skipReason = `Invalid episode number - Expected ${metadata.episode} or ${metadata.absoluteEpisode}, not found in file or torrent`; | |
| await handleReport(); | |
| return undefined; | |
| } | |
| } | |
| report.selectedFile = { | |
| name: bestMatch.file.name, | |
| index: bestMatch.index, | |
| score: bestMatch.score, | |
| size: bestMatch.file.size, | |
| }; | |
| handleReport(); | |
| return bestMatch.file; | |
| } | |
| async function saveReport( | |
| torrentTitle: string | undefined, | |
| report: any | |
| ): Promise<void> { | |
| try { | |
| const reportsDir = path.join(process.cwd(), 'reports'); | |
| await fs.mkdir(reportsDir, { recursive: true }); | |
| const sanitizedTitle = (torrentTitle || 'unknown').replace( | |
| /[^a-z0-9\.]/gi, | |
| '' | |
| ); | |
| const filename = `${sanitizedTitle}_${Date.now()}.json`; | |
| const filepath = path.join(reportsDir, filename); | |
| await fs.writeFile(filepath, JSON.stringify(report, null, 2), 'utf-8'); | |
| } catch (error) { | |
| logger.error('Failed to save selection report:', error); | |
| } | |
| } | |
| export const VIDEO_FILE_EXTENSIONS = [ | |
| '.3g2', | |
| '.3gp', | |
| '.amv', | |
| '.asf', | |
| '.avi', | |
| '.drc', | |
| '.f4a', | |
| '.f4b', | |
| '.f4p', | |
| '.f4v', | |
| '.flv', | |
| '.gif', | |
| '.gifv', | |
| '.iso', | |
| '.m2v', | |
| '.m4p', | |
| '.m4v', | |
| '.mkv', | |
| '.mov', | |
| '.mp2', | |
| '.mp4', | |
| '.mpg', | |
| '.mpeg', | |
| '.mpv', | |
| '.mng', | |
| '.mpe', | |
| '.mxf', | |
| '.nsv', | |
| '.ogg', | |
| '.ogv', | |
| '.qt', | |
| '.rm', | |
| '.rmvb', | |
| '.roq', | |
| '.svi', | |
| '.webm', | |
| '.wmv', | |
| '.yuv', | |
| '.m3u8', | |
| '.m2ts', | |
| ]; | |
| export function isVideoFile(file: DebridFile): boolean { | |
| return ( | |
| file.mimeType?.includes('video') || | |
| VIDEO_FILE_EXTENSIONS.some((ext) => file.name?.endsWith(ext) ?? false) | |
| ); | |
| } | |
| export function isNotVideoFile(file: DebridFile): boolean { | |
| const nonVideoExtensions = [ | |
| '.txt', | |
| '.jpg', | |
| '.jpeg', | |
| '.png', | |
| '.gif', | |
| '.bmp', | |
| '.svg', | |
| '.webp', | |
| '.nfo', | |
| '.sfv', | |
| '.srt', | |
| '.ass', | |
| '.sub', | |
| '.idx', | |
| '.cue', | |
| '.log', | |
| '.doc', | |
| '.docx', | |
| '.xls', | |
| '.xlsx', | |
| '.ppt', | |
| '.pptx', | |
| '.pdf', | |
| '.rtf', | |
| '.odt', | |
| '.ods', | |
| '.odp', | |
| '.csv', | |
| '.tsv', | |
| '.exe', | |
| '.bat', | |
| '.apk', | |
| '.dll', | |
| '.zip', | |
| '.rar', | |
| '.7z', | |
| '.tar', | |
| '.gz', | |
| '.bz2', | |
| '.xz', | |
| '.md', | |
| '.json', | |
| '.xml', | |
| '.ini', | |
| '.dat', | |
| '.db', | |
| '.dbf', | |
| '.bak', | |
| '.par2', | |
| '.clpi', | |
| '.jar', | |
| '.mpls', | |
| '.otf', | |
| '.properties', | |
| '.bdjo', | |
| '.bdmv', | |
| '.crt', | |
| '.crl', | |
| '.sig', | |
| ]; | |
| const patterns = [/\.7z\.\d+$/]; | |
| return ( | |
| (file.mimeType && !file.mimeType.includes('video')) || | |
| nonVideoExtensions.some((ext) => file.name?.endsWith(ext) ?? false) || | |
| patterns.some((pattern) => pattern.test(file.name || '')) | |
| ); | |
| } | |
| export const metadataStore = () => { | |
| const prefix = 'mds'; | |
| const store: 'redis' | 'sql' | 'memory' = | |
| appConfig.builtins.debrid.metadataStore || | |
| (appConfig.bootstrap.redisUri ? 'redis' : 'sql'); | |
| return Cache.getInstance<string, TitleMetadata>(prefix, 1_000_000_000, store); | |
| }; | |
| export const fileInfoStore = () => { | |
| const prefix = 'fis'; | |
| let store: 'redis' | 'sql' | 'memory' | undefined; | |
| if (appConfig.builtins.debrid.fileinfoStore === true) { | |
| store = appConfig.bootstrap.redisUri ? 'redis' : 'sql'; | |
| } else if (!appConfig.builtins.debrid.fileinfoStore) { | |
| return undefined; | |
| } else { | |
| store = appConfig.builtins.debrid.fileinfoStore; | |
| } | |
| return Cache.getInstance<string, FileInfo>(prefix, 1_000_000_000, store); | |
| }; | |
| // export function generatePlaybackUrl( | |
| // storeAuth: ServiceAuth, | |
| // playbackInfo: MinimisedPlaybackInfo, | |
| // filename: string | |
| // ) { | |
| // const encryptedStoreAuth = encryptString(JSON.stringify(storeAuth)); | |
| // if (!encryptedStoreAuth.success) { | |
| // throw new Error('Failed to encrypt store auth'); | |
| // } | |
| // const playbackId = getSimpleTextHash(JSON.stringify(playbackInfo)); | |
| // pbiCache().set(playbackId, playbackInfo, appConfig.builtins.debrid.playbackLinkValidity); | |
| // return `${appConfig.bootstrap.baseUrl}/api/v1/debrid/playback/${encryptedStoreAuth.data}/${playbackId}/${encodeURIComponent(filename)}`; | |
| // } | |
| export function generatePlaybackUrl( | |
| encryptedStoreAuth: string, | |
| metadataId: string, | |
| fileInfo: FileInfo, | |
| filename?: string | |
| ): string { | |
| const fileInfoCache = fileInfoStore(); | |
| let fileInfoStr: string = toUrlSafeBase64(JSON.stringify(fileInfo)); | |
| if (fileInfoCache && fileInfoStr.length > 500) { | |
| fileInfoStr = getSimpleTextHash(JSON.stringify(fileInfo)); | |
| fileInfoCache.set( | |
| fileInfoStr, | |
| fileInfo, | |
| appConfig.builtins.debrid.playbackLinkValidity | |
| ); | |
| } | |
| return `${appConfig.bootstrap.baseUrl}/api/v1/debrid/playback/${encryptedStoreAuth}/${fileInfoStr}/${metadataId}/${encodeURIComponent(filename ?? 'unknown')}`; | |
| } | |