From 4cfd13db3c37f8126064c1c443db86871d023781 Mon Sep 17 00:00:00 2001 From: zeroz Date: Sat, 8 Aug 2026 01:08:28 -0400 Subject: [PATCH 01/11] initial debrid support main commit --- client/core/webtorrent.js | 24 ++- .../torrent/components/TorrentCard.svelte | 6 + .../torrent/components/TorrentResults.svelte | 20 +- common/modules/debrid/alldebrid.js | 29 +++ common/modules/debrid/debrid.js | 203 ++++++++++++++++++ common/modules/debrid/metadata.js | 149 +++++++++++++ common/modules/debrid/premiumize.js | 29 +++ common/modules/debrid/realdebrid.js | 162 ++++++++++++++ common/modules/debrid/route.js | 32 +++ common/modules/debrid/service.js | 161 ++++++++++++++ common/modules/debrid/torbox.js | 30 +++ common/modules/torrent.js | 2 + common/modules/util.js | 5 + common/package.json | 1 + common/routes/player/PlayerPage.svelte | 31 ++- common/routes/settings/SettingsPage.svelte | 14 +- common/routes/settings/tabs/AppTab.svelte | 2 +- common/routes/settings/tabs/DebridTab.svelte | 58 +++++ pnpm-lock.yaml | 17 +- 19 files changed, 955 insertions(+), 20 deletions(-) create mode 100644 common/modules/debrid/alldebrid.js create mode 100644 common/modules/debrid/debrid.js create mode 100644 common/modules/debrid/metadata.js create mode 100644 common/modules/debrid/premiumize.js create mode 100644 common/modules/debrid/realdebrid.js create mode 100644 common/modules/debrid/route.js create mode 100644 common/modules/debrid/service.js create mode 100644 common/modules/debrid/torbox.js create mode 100644 common/routes/settings/tabs/DebridTab.svelte diff --git a/client/core/webtorrent.js b/client/core/webtorrent.js index 07d10911..3ed6e240 100644 --- a/client/core/webtorrent.js +++ b/client/core/webtorrent.js @@ -491,6 +491,23 @@ export default class TorrentClient extends WebTorrent { break } case 'current': { if (data.data) { + if (data.data.current.debrid) { + // debrid streams play over HTTP and never join the client, just detach the previous torrent file + if (this.playerProcess) { + this.playerProcess.kill() + this.playerProcess = null + } + if (this.currentFile) { + this.currentFile.removeAllListeners('stream') + this.currentFile.removeAllListeners('iterator') + if (this.settings.torrentStreamedDownload && !this.currentFile._destroyed && this.currentFile.progress < 1) this.currentFile.deselect() + this.currentFile = null + } + this.metadata?.destroy?.() + this.metadata = null + if (data.data.external && (SUPPORTS.isAndroid || this.player)) this.dispatch('externalReady') + break + } const torrent = await this.get(data.data.current.infoHash) if (!torrent || torrent.destroyed) return const found = torrent.files.find(file => file.path === data.data.current.path) @@ -553,7 +570,8 @@ export default class TorrentClient extends WebTorrent { break } case 'externalPlay': { const startTime = Date.now() - const found = this.torrents.find(_torrent => _torrent.current)?.files?.find(file => file.path === data.data.current.path) + const current = data.data.current + const found = current?.debrid ? current : this.torrents.find(_torrent => _torrent.current)?.files?.find(file => file.path === current.path) if (!found) return this.ipc.removeAllListeners('external-close') if (this.playerProcess) { @@ -562,7 +580,7 @@ export default class TorrentClient extends WebTorrent { this.playerProcess = null } if (this.player) { - this.playerProcess = spawn(this.player, ['' + new URL('http://localhost:' + this.server.address().port + found.streamURL)]) + this.playerProcess = spawn(this.player, ['' + new URL(current?.debrid ? found.url : 'http://localhost:' + this.server.address().port + found.streamURL)]) this.playerProcess.stdout.on('data', () => {}) this.playerProcess.once('close', () => { if (this.destroyed) return @@ -570,7 +588,7 @@ export default class TorrentClient extends WebTorrent { const seconds = (Date.now() - startTime) / 1000 this.dispatch('externalWatched', seconds) }) - } else if (SUPPORTS.isAndroid) this.dispatch('androidExternal', `intent://localhost:${this.server.address().port}${found.streamURL}#Intent;type=video/any;scheme=http;end;`) + } else if (SUPPORTS.isAndroid) this.dispatch('androidExternal', current?.debrid ? `intent://${found.url.replace(/^https?:\/\//, '')}#Intent;type=video/any;scheme=${found.url.startsWith('https') ? 'https' : 'http'};end;` : `intent://localhost:${this.server.address().port}${found.streamURL}#Intent;type=video/any;scheme=http;end;`) break } case 'torrent': { const hash = data.data && data.data.hash diff --git a/common/modals/torrent/components/TorrentCard.svelte b/common/modals/torrent/components/TorrentCard.svelte index f25f7d6d..5a00b84c 100644 --- a/common/modals/torrent/components/TorrentCard.svelte +++ b/common/modals/torrent/components/TorrentCard.svelte @@ -6,6 +6,7 @@ import { getEpisodeMetadataForMedia, getKitsuMappings } from '@/modules/anime/anime.js' import { copyToClipboard } from '@/modules/lib/clipboard.js' import { malDubs } from '@/modules/anime/animedubs.js' + import { debridEnabled, debridCachedHashes } from '@/modules/debrid/debrid.js' import { settings } from '@/modules/settings.js' import { Database, BadgeCheck, HardDrive, FileQuestion, AlertCircle, TriangleAlert } from 'lucide-svelte' @@ -447,6 +448,11 @@
{since(new Date(result.date))}
+ {#if $debridEnabled && $debridCachedHashes.has(result.hash?.toLowerCase())} +
+ Debrid Cached +
+ {/if} {#if result.type === 'best'}
Best Release diff --git a/common/modals/torrent/components/TorrentResults.svelte b/common/modals/torrent/components/TorrentResults.svelte index 46a26837..f6c962a0 100644 --- a/common/modals/torrent/components/TorrentResults.svelte +++ b/common/modals/torrent/components/TorrentResults.svelte @@ -10,13 +10,14 @@ import { anitomyscript, getMediaMaxEp, getKitsuMappings, getEpisodeMetadataForMedia } from '@/modules/anime/anime.js' import { loadedTorrent, completedTorrents, seedingTorrents, stagingTorrents } from '@/modules/torrent.js' import { dedupe, getTorrentResults, updatePeerCounts } from '@/modules/extensions/handler.js' + import { debridEnabled, debridCachedHashes, debridServices, refreshDebridCache } from '@/modules/debrid/debrid.js' import { getId, getHash } from '@/modules/anime/animehash.js' import AnimeResolver from '@/modules/anime/animeresolver.js' import { anilistClient } from '@/modules/providers/anilist/anilist.js' import { click } from '@/modules/lib/click.js' import { toast } from 'svelte-sonner' import NestedDropdown from '@/components/overlays/NestedDropdown.svelte' - import { X, Search, EllipsisVertical, Timer, Clapperboard, MonitorCog, ArrowDownWideNarrow, Paintbrush, ListMusic, ChevronUp, ChevronDown, Radio, RefreshCw } from 'lucide-svelte' + import { X, Search, EllipsisVertical, Timer, Clapperboard, MonitorCog, ArrowDownWideNarrow, Paintbrush, ListMusic, ChevronUp, ChevronDown, Radio, RefreshCw, Cloud } from 'lucide-svelte' import Debug from 'debug' const debug = Debug('ui:torrents') @@ -86,15 +87,17 @@ * @param {Result[]} results * @param {string} sort * @param {boolean} batch + * @param {Set} [debridHashes] - Hashes that play instantly via debrid, listed even without seeders. */ - function sortResults(results, sort, batch) { + function sortResults(results, sort, batch, debridHashes) { if (!results) return { results: [], hiddenResults: [] } const deduped = Array.from(dedupe(results)).map(result => { if (!(result.parseObject?.release_group && result.parseObject.release_group.length < 20)) result.parseObject = { ...result.parseObject, release_group: 'No Group' } return result }) + const available = entry => entry.seeders > 0 || entry.source?.managed || debridHashes?.has(entry.hash?.toLowerCase()) return { - results: deduped.filter(entry => entry.seeders > 0 || entry.source?.managed).sort((a, b) => { + results: deduped.filter(available).sort((a, b) => { switch (sort) { case 'smallest': return a.size - b.size case 'best': return ((b.type === 'best') - (a.type === 'best') || (b.type === 'alt') - (a.type === 'alt')) || b.seeders - a.seeders @@ -108,7 +111,7 @@ default: return b.seeders - a.seeders } }), - hiddenResults: deduped.filter(entry => !entry.seeders && !entry.source?.managed) + hiddenResults: deduped.filter(entry => !available(entry)) } } @@ -247,6 +250,7 @@ async function queryExtensions(request, resolution) { scrollTop() + if ($debridEnabled) refreshDebridCache() $results = {} const cachedHashes = [] for (const resolvedHash of getHash(search?.media?.id, { episode: search?.episode, client: true, batchGuess: true }, false, true, true) ?? []) { @@ -329,7 +333,7 @@ $: resolution = $settings.rssQuality $: queries = queryExtensions({...search}, resolution) $: errors = getErrors({...search}, queries) - $: queryResults = sortResults($results?.torrents, $settings.torrentSort, batch) + $: queryResults = sortResults($results?.torrents, $settings.torrentSort, batch, $debridEnabled ? $debridCachedHashes : undefined) $: lookup = queryResults?.results $: (episodeSearch || resolution || $settings.torrentSort || $settings.audioLanguage) && scrollTop() @@ -476,6 +480,12 @@
+ {#if debridServices[$settings.debridService]} +
+ + {$settings.debridMode === 'only' ? 'Debrid Only' : 'Debrid First'} +
+ {/if}
diff --git a/common/modules/debrid/alldebrid.js b/common/modules/debrid/alldebrid.js new file mode 100644 index 00000000..77d35f4f --- /dev/null +++ b/common/modules/debrid/alldebrid.js @@ -0,0 +1,29 @@ +// relative import keeps this module loadable under plain Node for API tests +import DebridService, { DebridError } from './service.js' + +/** + * AllDebrid stub, see https://docs.alldebrid.com/ + * Untested skeleton kept out of the settings menu until someone with an account + * implements and verifies it. The relevant endpoints are: + * - GET /user?apikey= -> account and premium status for validate() + * - GET /magnet/status?apikey= -> account magnets for listCachedHashes() + * - GET /magnet/upload?apikey=&magnets[]= -> add a magnet, `instant` flags cache state + * - GET /link/unlock?apikey=&link= -> direct stream URL per file + * Note: AllDebrid authenticates through a query parameter instead of a header. + */ +export default class AllDebrid extends DebridService { + static id = 'alldebrid' + static title = 'AllDebrid' + + async validate () { + throw new DebridError(`${AllDebrid.title} support is not implemented yet`) + } + + async listCachedHashes () { + throw new DebridError(`${AllDebrid.title} support is not implemented yet`) + } + + async resolve (magnet, opts) { + throw new DebridError(`${AllDebrid.title} support is not implemented yet`) + } +} diff --git a/common/modules/debrid/debrid.js b/common/modules/debrid/debrid.js new file mode 100644 index 00000000..1a1adec3 --- /dev/null +++ b/common/modules/debrid/debrid.js @@ -0,0 +1,203 @@ +import { files } from '@/components/MediaHandler.svelte' +import { settings } from '@/modules/settings.js' +import { cache, caches } from '@/modules/cache.js' +import { status } from '@/modules/networking.js' +import { videoRx, subRx } from '@/modules/util.js' +import { anitomyscript } from '@/modules/anime/anime.js' +import { writable } from 'simple-store-svelte' +import { derived } from 'svelte/store' +import { toast } from 'svelte-sonner' +import RealDebrid from '@/modules/debrid/realdebrid.js' +import AllDebrid from '@/modules/debrid/alldebrid.js' +import TorBox from '@/modules/debrid/torbox.js' +import Premiumize from '@/modules/debrid/premiumize.js' +import { DebridNotCachedError } from '@/modules/debrid/service.js' +import { routeDebrid } from '@/modules/debrid/route.js' +import Debug from 'debug' +const debug = Debug('ui:debrid') + +// register new services here, stubs stay hidden from the settings menu until +// their `available` flag is flipped after being implemented and tested +export const debridServices = Object.fromEntries([RealDebrid, AllDebrid, TorBox, Premiumize].filter(Service => Service.available).map(Service => [Service.id, Service])) + +const MAX_REMEMBERED = 300 + +// user facing messages for the routing policy's blocked outcomes +const blockedMessages = { + key: () => 'Debrid only mode is on but no API key is set. Add your key in the debrid settings or disable debrid only mode.', + offline: () => 'Shiru is currently offline, so ' + serviceTitle() + ' cannot be reached.', + source: () => 'This source only provides a torrent file which debrid cannot resolve yet. Pick a different release or disable debrid only mode.' +} + +/** @type {import('@/modules/debrid/service.js').default | null} */ +let service = null +let serviceKey = null + +/** Whether a debrid service is selected and has an API key. */ +export const debridEnabled = derived(settings, value => Boolean(debridServices[value.debridService] && value.debridApiKey)) + +/** Lowercase info hashes known to play instantly on the configured service. */ +export const debridCachedHashes = writable(new Set()) + +settings.subscribe(value => { + const key = `${value.debridService}:${value.debridApiKey}` + if (serviceKey !== null && serviceKey !== key) { + service?.destroy() + service = null + lastRefresh = 0 + debridCachedHashes.set(new Set()) + } + serviceKey = key +}) + +function getService () { + if (service) return service + const Service = debridServices[settings.value.debridService] + if (!Service || !settings.value.debridApiKey) return null + debug(`Initializing debrid service ${Service.title}`) + service = new Service(settings.value.debridApiKey) + return service +} + +/** Validates the configured service and API key, used by the settings test button. */ +export async function testDebrid () { + const service = getService() + if (!service) throw new Error('No debrid service configured') + if (status.value === 'offline') throw new Error(`Shiru is currently offline, so ${serviceTitle()} cannot be reached`) + return service.validate() +} + +/** + * Attempts to stream a torrent through the configured debrid service. + * Returns true when playback was handled, either with resolved files or a + * final error in debrid only mode. Returns false to fall back to torrents. + * The routing policy lives in route.js, debrid only never reaches the torrent client. + * @param {string} torrentID - Magnet URI, info hash, or .torrent link. + * @param {string} [hash] - Info hash when known. + * @param {{ episode?: number }} [search] - Playback context for picking the right file in packs. + */ +export async function streamDebrid (torrentID, hash, search) { + const debridOnly = Boolean(debridServices[settings.value.debridService]) && settings.value.debridMode === 'only' + const route = routeDebrid({ + torrentID, + hash, + serviceSelected: Boolean(debridServices[settings.value.debridService]), + serviceReady: Boolean(getService()), + offline: status.value === 'offline', + mode: settings.value.debridMode + }) + if (route.action === 'torrent') return false + if (route.action === 'block') { + toast.error('Debrid', { description: blockedMessages[route.reason]() }) + return true + } + try { + files.set(await resolveDebridFiles(route.id, search)) + return true + } catch (error) { + if (error instanceof DebridNotCachedError) { + debug(`Torrent not cached: ${error.message}`) + if (!debridOnly) { + toast('Debrid', { description: 'Not cached on ' + serviceTitle() + ', streaming via torrent instead.' }) + return false + } + toast.error('Debrid', { description: 'This torrent is not cached on ' + serviceTitle() + '. Pick a different release or disable debrid only mode.' }) + } else { + debug('Debrid resolve failed:', error) + if (!debridOnly) { + toast.warning('Debrid Error', { description: `${error.message || error}\nStreaming via torrent instead.` }) + return false + } + toast.error('Debrid Error', { description: '' + (error.message || error) }) + } + return true // handled, only mode never falls back to the torrent client + } +} + +/** + * Resolves a magnet to player ready file objects shaped like the torrent client's. + * @param {string} torrentID - Magnet URI or info hash. + * @param {{ episode?: number }} [search] + */ +export async function resolveDebridFiles (torrentID, search) { + const service = getService() + const episode = Number(search?.episode) + const resolved = await service.resolve(torrentID, { + fileFilter: name => videoRx.test(name) || subRx.test(name), + pickFile: Number.isFinite(episode) ? files => pickEpisodeFile(files, episode) : undefined + }) + rememberHash(resolved.hash) + return Promise.all(resolved.files.map(async file => ({ + infoHash: resolved.hash, + fileHash: await sha1hex(`${resolved.hash}:${file.name}:${file.size}`), // same key the torrent client uses, so watch progress is shared + torrent_name: resolved.name, + name: file.name, + type: file.type, + size: file.size, + path: file.path, + url: file.url, + debrid: true + }))) +} + +let lastRefresh = 0 +/** + * Updates the cached hash set from remembered resolves and, at most once a + * minute, the list of torrents already downloaded on the account. + */ +export function refreshDebridCache () { + const service = getService() + if (!service) return + const remembered = Object.keys(cache.getEntry(caches.GENERAL, 'debridResolvedHashes')?.[serviceId()] || {}) + if (remembered.length) debridCachedHashes.update(set => new Set([...set, ...remembered])) + if (Date.now() - lastRefresh < 60_000 || status.value === 'offline') return + lastRefresh = Date.now() + service.listCachedHashes().then(hashes => { + debridCachedHashes.update(set => new Set([...set, ...hashes])) + }).catch(error => { + lastRefresh = 0 + debug('Failed to list debrid torrents:', error) + }) +} + +/** + * Picks the file for the requested episode out of a pack using the same + * anitomy parsing the rest of the app relies on, largest file as fallback. + * @param {{ id: number, path: string, size: number }[]} files + * @param {number} episode + */ +async function pickEpisodeFile (files, episode) { + const videoFiles = files.filter(({ path }) => videoRx.test(path)) + if (videoFiles.length <= 1) return videoFiles[0] || files[0] + try { + const parsed = await anitomyscript(videoFiles.map(({ path }) => path.split('/').pop())) + const match = parsed?.findIndex(parse => Number(parse.episode_number) === episode) + if (match >= 0) return videoFiles[match] + } catch (error) { + debug('Failed to parse pack file names:', error) + } + return videoFiles.sort((a, b) => b.size - a.size)[0] +} + +function serviceId () { + return settings.value.debridService +} + +function serviceTitle () { + return debridServices[serviceId()]?.title || 'debrid' +} + +/** Remembers a successfully resolved hash per service so results can be badged instantly. */ +function rememberHash (hash) { + debridCachedHashes.update(set => new Set([...set, hash])) + cache.setEntry(caches.GENERAL, 'debridResolvedHashes', (current = {}) => { + const hashes = { ...(current[serviceId()] || {}), [hash]: Date.now() } + const pruned = Object.fromEntries(Object.entries(hashes).sort(([, a], [, b]) => b - a).slice(0, MAX_REMEMBERED)) + return { ...current, [serviceId()]: pruned } + }) +} + +async function sha1hex (data) { + const buffer = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(data)) + return Array.from(new Uint8Array(buffer)).map(byte => byte.toString(16).padStart(2, '0')).join('') +} diff --git a/common/modules/debrid/metadata.js b/common/modules/debrid/metadata.js new file mode 100644 index 00000000..bd873b80 --- /dev/null +++ b/common/modules/debrid/metadata.js @@ -0,0 +1,149 @@ +import Metadata from 'matroska-metadata' +import { arr2hex, hex2bin } from 'uint8-util' +import { fontRx, subRx, sleep } from '@/modules/util.js' +import { SUPPORTS } from '@/modules/support.js' +import Debug from 'debug' +const debug = Debug('ui:debrid') + +// stay this many seconds of video ahead of playback when streaming subtitles +const AHEAD_SECONDS = 120 +const RETRIES = 3 + +/** + * Blob-like wrapper around a remote URL so matroska-metadata can read it + * through HTTP range requests, mirroring how it reads torrent files. + */ +class RemoteFile { + /** + * @param {string} url + * @param {number} size + * @param {string} name + */ + constructor (url, size, name) { + this.url = url + this.size = size + this.name = name + this.controllers = new Set() + } + + /** @param {number} [start] @param {number} [end] */ + slice (start = 0, end) { + const { url, controllers } = this + const range = `bytes=${start}-${end ? end - 1 : ''}` + return { + stream () { + const controller = new AbortController() + controllers.add(controller) + return (async function * () { + try { + const res = await fetch(url, { headers: { Range: range }, signal: controller.signal }) + if (!res.ok && res.status !== 206) throw new Error(`Failed to fetch stream: ${res.status}`) + yield * res.body + } finally { + controllers.delete(controller) + controller.abort() + } + })() + } + } + } + + destroy () { + for (const controller of this.controllers) controller.abort() + this.controllers.clear() + } +} + +/** + * Extracts embedded tracks, fonts, chapters and subtitles from a debrid HTTP + * stream and feeds them into the same Subtitles pipeline torrents use. Unlike + * torrents the playback bytes can't be tapped, so subtitle events come from a + * second range request that is paced to stay just ahead of the play position. + */ +export default class DebridMetadata { + destroyed = false + + /** + * @param {any} file - The playing debrid file object. + * @param {any[]} files - All resolved files of the torrent, used to find external subtitles. + * @param {import('@/modules/subtitles.js').default} subtitles - Player subtitle instance to feed. + * @param {{ getTime?: () => number, onChapters?: (chapters: any[]) => void }} [opts] + */ + constructor (file, files, subtitles, { getTime = () => 0, onChapters } = {}) { + debug('Initializing debrid metadata parser for: ' + file?.name) + this.file = file + this.getTime = getTime + this.remote = new RemoteFile(file.url, file.size, file.name) + this.metadata = new Metadata(this.remote) + + this.metadata.getTracks().then(tracks => { + if (this.destroyed) return + debug(`Found ${tracks?.length} subtitle tracks`) + if (!tracks.length) return this.destroy() + subtitles.handleTracks(tracks) + this.#streamSubtitles() + }).catch(error => debug('Failed to read tracks:', error)) + + this.metadata.getChapters().then(chapters => { + if (this.destroyed || !chapters?.length) return + debug(`Found ${chapters.length} chapters`) + onChapters?.(chapters) + }).catch(error => debug('Failed to read chapters:', error)) + + this.metadata.getAttachments().then(attachments => { + if (this.destroyed) return + debug(`Found ${attachments?.length} attachments`) + for (const attachment of attachments) { + if (fontRx.test(attachment.filename) || attachment.mimetype?.toLowerCase().includes('font')) { + if (SUPPORTS.isAndroid && attachment.data.length > 15_000_000) continue // matches the torrent client's large font guard + subtitles.handleFile(hex2bin(arr2hex(attachment.data))) + } + } + }).catch(error => debug('Failed to read attachments:', error)) + + this.metadata.on('subtitle', (subtitle, trackNumber) => { + if (!this.destroyed) subtitles.handleSubtitle({ subtitle, trackNumber }) + }) + + // external subtitle files that were resolved alongside the video + for (const sub of (files || []).filter(({ name }) => subRx.test(name))) { + fetch(sub.url).then(res => res.arrayBuffer()).then(data => { + if (!this.destroyed) subtitles.handleSubtitleFile({ name: sub.name, data }) + }).catch(error => debug(`Failed to fetch subtitle file ${sub.name}:`, error)) + } + } + + /** Streams the file through the parser, throttled against the playback position. */ + async #streamSubtitles () { + const durationMs = await this.metadata.duration + const byteRate = durationMs > 0 ? this.file.size / (durationMs / 1_000) : 0 + for (let attempt = 0, offset = 0; attempt < RETRIES && !this.destroyed; ++attempt) { + try { + const stream = this.remote.slice(offset).stream() + for await (const chunk of this.metadata.parseStream(stream, offset === 0)) { + if (this.destroyed) return + offset += chunk.length + // wait for playback to catch up before buffering further, seeks resume instantly + while (!this.destroyed && byteRate && offset > (this.getTime() + AHEAD_SECONDS) * byteRate) await sleep(1_000) + } + return + } catch (error) { + if (this.destroyed) return + debug(`Subtitle stream interrupted at ${offset}, retrying:`, error) + await sleep(1_000 * (attempt + 1)) + } + } + } + + destroy () { + if (this.destroyed) return + debug('Destroying debrid metadata parser') + this.destroyed = true + this.metadata?.removeAllListeners() + this.metadata?.destroy() + this.remote?.destroy() + this.metadata = null + this.remote = null + this.file = null + } +} diff --git a/common/modules/debrid/premiumize.js b/common/modules/debrid/premiumize.js new file mode 100644 index 00000000..25c9d5ce --- /dev/null +++ b/common/modules/debrid/premiumize.js @@ -0,0 +1,29 @@ +// relative import keeps this module loadable under plain Node for API tests +import DebridService, { DebridError } from './service.js' + +/** + * Premiumize stub, see https://www.premiumize.me/api + * Untested skeleton kept out of the settings menu until someone with an account + * implements and verifies it. The relevant endpoints are: + * - GET /account/info?apikey= -> account and premium status for validate() + * - GET /transfer/list?apikey= -> account transfers for listCachedHashes() + * - GET /cache/check?apikey=&items[]= -> Premiumize still offers a real cache check + * - POST /transfer/directdl?apikey=&src= -> direct stream URLs for a magnet in one call + * Note: Premiumize authenticates through a query parameter instead of a header. + */ +export default class Premiumize extends DebridService { + static id = 'premiumize' + static title = 'Premiumize' + + async validate () { + throw new DebridError(`${Premiumize.title} support is not implemented yet`) + } + + async listCachedHashes () { + throw new DebridError(`${Premiumize.title} support is not implemented yet`) + } + + async resolve (magnet, opts) { + throw new DebridError(`${Premiumize.title} support is not implemented yet`) + } +} diff --git a/common/modules/debrid/realdebrid.js b/common/modules/debrid/realdebrid.js new file mode 100644 index 00000000..3df51494 --- /dev/null +++ b/common/modules/debrid/realdebrid.js @@ -0,0 +1,162 @@ +// relative import keeps this module loadable under plain Node for API tests +import DebridService, { DebridError, DebridAuthError, DebridNotCachedError } from './service.js' +import Debug from 'debug' +const debug = Debug('ui:debrid') + +const API = 'https://api.real-debrid.com/rest/1.0' +const hashRx = /urn:btih:([a-f\d]{40})/i +const archiveRx = /\.(rar|zip|7z)$/i +// statuses that mean the torrent will never complete +const deadStatuses = ['magnet_error', 'error', 'virus', 'dead'] + +/** + * Real-Debrid implementation, see https://api.real-debrid.com/ + * + * Two quirks discovered against the live API shape this client: + * - There is no instant availability endpoint anymore, cache state is only found + * out by adding a magnet: a cached torrent reports 'downloaded' right after file + * selection, anything queued for a fresh download is not cached and gets removed. + * - Selecting multiple files can serve a single RAR archive instead of individual + * links, so when that happens the torrent is re-added selecting only the target + * file, which always yields a direct streamable link. + */ +export default class RealDebrid extends DebridService { + static id = 'realdebrid' + static title = 'Real-Debrid' + static available = true + // documented allowance is 250 requests per minute, keep some headroom + static limits = { reservoir: 200, reservoirRefreshAmount: 200, reservoirRefreshInterval: 60_000, maxConcurrent: 4, minTime: 150 } + + mapError (status, json) { + // https://api.real-debrid.com/ error_code 8 = bad_token, 9 = permission_denied + if (json?.error_code === 8 || json?.error_code === 9 || status === 401 || status === 403) return new DebridAuthError(json?.error === 'bad_token' ? 'Invalid Real-Debrid API key' : (json?.error || 'Real-Debrid denied the request'), { status, code: json?.error_code }) + return super.mapError(status, json) + } + + async validate () { + const user = await this.request(`${API}/user`) + if (user?.type !== 'premium') throw new DebridAuthError('Real-Debrid premium is required to stream torrents') + return { username: user.username, expires: user.expiration } + } + + async listCachedHashes () { + const torrents = await this.request(`${API}/torrents?limit=100`) + return (torrents || []).filter(torrent => torrent.status === 'downloaded').map(torrent => torrent.hash.toLowerCase()) + } + + async resolve (magnet, { fileFilter = () => true, pickFile, maxFiles = 60 } = {}) { + const hash = (hashRx.exec(magnet)?.[1] || (/^[a-f\d]{40}$/i.test(magnet) ? magnet : '')).toLowerCase() + const magnetURI = magnet.startsWith('magnet:') ? magnet : `magnet:?xt=urn:btih:${hash}` + let torrentId = null + let added = false + try { + // reuse a torrent that is already on the account instead of adding a duplicate + const existing = hash && (await this.request(`${API}/torrents?limit=100`))?.find(torrent => torrent.hash.toLowerCase() === hash) + if (existing?.status === 'waiting_files_selection') { + // a stale add that never got its files selected, finish the job + const info = await this.request(`${API}/torrents/info/${existing.id}`) + const ids = info.files.filter(file => fileFilter(file.path)).map(file => file.id) + await this.request(`${API}/torrents/selectFiles/${existing.id}`, { method: 'POST', body: { files: ids.length ? ids.join(',') : 'all' } }) + torrentId = existing.id + } else if (existing && existing.status !== 'downloaded') throw new DebridNotCachedError() + else if (existing) torrentId = existing.id + else { + torrentId = await this.#addAndSelect(magnetURI, fileFilter) + added = true + } + let info = await this.#awaitStatus(torrentId, 'downloaded', 5_000) + let files = await this.#unrestrictLinks(info, fileFilter, maxFiles) + + // figure out which single file playback is really after, so archives and + // reused torrents that are missing it can be recovered from + const wanted = info.files.filter(file => fileFilter(file.path)).map(file => ({ id: file.id, path: file.path, size: file.bytes })) + const target = wanted.length ? (pickFile ? await pickFile(wanted) : wanted.sort((a, b) => b.size - a.size)[0]) : null + if (target && !files.some(file => file.name === target.path.split('/').pop())) { + debug(`Re-adding torrent to select only ${target.path}`) + const retryId = await this.#addAndSelect(magnetURI, null, target.id) + if (added) this.request(`${API}/torrents/delete/${torrentId}`, { method: 'DELETE' }).catch(() => {}) + torrentId = retryId + added = true + info = await this.#awaitStatus(torrentId, 'downloaded', 5_000) + files = await this.#unrestrictLinks(info, fileFilter, 1) + if (!files.length) throw new DebridError('Real-Debrid only serves this torrent as an archive') + } + if (!files.length) throw new DebridError('No playable files in this torrent') + debug(`Resolved ${files.length} files for ${info.filename}`) + return { hash: info.hash.toLowerCase(), name: info.filename, files } + } catch (error) { + // only clean up torrents this call added, never the user's own downloads + if (added && torrentId) this.request(`${API}/torrents/delete/${torrentId}`, { method: 'DELETE' }).catch(() => {}) + throw error + } + } + + /** + * Adds a magnet and selects either the files matching the filter or one specific file. + * @param {string} magnetURI + * @param {((name: string) => boolean) | null} fileFilter + * @param {number} [fileId] + * @returns {Promise} The new torrent id. + */ + async #addAndSelect (magnetURI, fileFilter, fileId) { + const torrentId = (await this.request(`${API}/torrents/addMagnet`, { method: 'POST', body: { magnet: magnetURI } }))?.id + try { + const info = await this.#awaitStatus(torrentId, 'waiting_files_selection', 12_000) + if (info.status === 'waiting_files_selection') { + const ids = fileId ? [fileId] : info.files.filter(file => fileFilter(file.path)).map(file => file.id) + await this.request(`${API}/torrents/selectFiles/${torrentId}`, { method: 'POST', body: { files: ids.length ? ids.join(',') : 'all' } }) + } + return torrentId + } catch (error) { + if (torrentId) this.request(`${API}/torrents/delete/${torrentId}`, { method: 'DELETE' }).catch(() => {}) + throw error + } + } + + /** + * Unrestricts a torrent's links into direct stream files. The cached copy may + * serve fewer links than the files selected: when the lists align filter by + * path up front, otherwise unrestrict and filter by the reported filename. + * RD-generated archives are dropped, the caller recovers via single file selection. + * @param {any} info + * @param {(name: string) => boolean} fileFilter + * @param {number} maxFiles + */ + async #unrestrictLinks (info, fileFilter, maxFiles) { + if (!info.links?.length) throw new DebridError('Real-Debrid returned no links for this torrent') + const selected = info.files.filter(file => file.selected) + const aligned = info.links.length === selected.length + const candidates = (aligned + ? selected.map((file, index) => ({ link: info.links[index], path: file.path, size: file.bytes })).filter(file => fileFilter(file.path)) + : info.links.map(link => ({ link }))).slice(0, maxFiles) + return (await Promise.all(candidates.map(async ({ link, path, size }) => { + const unrestricted = await this.request(`${API}/unrestrict/link`, { method: 'POST', body: { link } }) + const name = path?.split('/').pop() || unrestricted.filename + if (!path && !fileFilter(name)) return null + if (archiveRx.test(name) && !selected.some(file => file.path.endsWith(name))) return null // RD packed the selection into an archive + return { name, path: path || `/${name}`, size: unrestricted.filesize || size, url: unrestricted.download, type: unrestricted.mimeType } + }))).filter(Boolean) + } + + /** + * Polls torrent info until it reaches the wanted status. Anything queued for a + * fresh download means the torrent is not in the instant cache. + * @param {string} id + * @param {string} wanted + * @param {number} timeout + */ + async #awaitStatus (id, wanted, timeout) { + const started = Date.now() + while (true) { + const info = await this.request(`${API}/torrents/info/${id}`) + if (info.status === wanted || (wanted === 'waiting_files_selection' && info.status === 'downloaded')) return info + if (deadStatuses.includes(info.status)) throw new DebridError(`Real-Debrid could not process this torrent (${info.status})`) + if (['queued', 'downloading', 'uploading', 'compressing'].includes(info.status)) throw new DebridNotCachedError() + if (Date.now() - started > timeout) { + if (info.status === 'magnet_conversion') throw new DebridNotCachedError('Real-Debrid does not recognize this torrent') + throw new DebridError(`Timed out waiting for Real-Debrid (${info.status})`) + } + await new Promise(resolve => setTimeout(resolve, 1_000).unref?.()) + } + } +} diff --git a/common/modules/debrid/route.js b/common/modules/debrid/route.js new file mode 100644 index 00000000..f3e81aa2 --- /dev/null +++ b/common/modules/debrid/route.js @@ -0,0 +1,32 @@ +// Pure playback routing policy, free of UI imports so it can be tested under +// plain Node. This is the single decision point for debrid vs torrent playback: +// debrid only mode must never route to the torrent client, whatever the input. + +const magnetRx = /^magnet:.*urn:btih:[a-f\d]{40}/i +const hexRx = /^[a-f\d]{40}$/i + +/** @param {any} torrentID */ +function usable (torrentID) { + return (typeof torrentID === 'string' && (magnetRx.test(torrentID) || hexRx.test(torrentID)) && torrentID) || null +} + +/** + * Decides how a play request should be handled. + * @param {Object} options + * @param {any} options.torrentID - Magnet URI, info hash, .torrent link, or torrent file bytes. + * @param {any} [options.hash] - Info hash when known, used when the link itself is not resolvable. + * @param {boolean} options.serviceSelected - A debrid service is selected in settings. + * @param {boolean} options.serviceReady - The service has an API key configured. + * @param {boolean} options.offline - The client has no network connection. + * @param {string} options.mode - The debridMode setting, 'prefer' or 'only'. + * @returns {{ action: 'torrent' } | { action: 'block', reason: 'key' | 'offline' | 'source' } | { action: 'resolve', id: string }} + */ +export function routeDebrid ({ torrentID, hash, serviceSelected, serviceReady, offline, mode }) { + if (!serviceSelected) return { action: 'torrent' } + const debridOnly = mode === 'only' + if (!serviceReady) return debridOnly ? { action: 'block', reason: 'key' } : { action: 'torrent' } + if (offline) return debridOnly ? { action: 'block', reason: 'offline' } : { action: 'torrent' } + const id = usable(torrentID) || usable(hash) + if (!id) return debridOnly ? { action: 'block', reason: 'source' } : { action: 'torrent' } + return { action: 'resolve', id } +} diff --git a/common/modules/debrid/service.js b/common/modules/debrid/service.js new file mode 100644 index 00000000..86745b81 --- /dev/null +++ b/common/modules/debrid/service.js @@ -0,0 +1,161 @@ +import Bottleneck from 'bottleneck' +import Debug from 'debug' +const debug = Debug('ui:debrid') + +// This module is intentionally free of UI imports so it can also run under plain Node for testing. + +export class DebridError extends Error { + /** + * @param {string} message + * @param {{ status?: number, code?: string | number }} [opts] + */ + constructor (message, { status, code } = {}) { + super(message) + this.name = 'DebridError' + this.status = status + this.code = code + } +} + +/** Thrown when the API key is missing, invalid or the account lacks the required plan. */ +export class DebridAuthError extends DebridError { + constructor (message, opts) { + super(message, opts) + this.name = 'DebridAuthError' + } +} + +/** Thrown when the service could not be reached at all, usually because the client is offline. */ +export class DebridNetworkError extends DebridError { + constructor (message, opts) { + super(message, opts) + this.name = 'DebridNetworkError' + } +} + +/** Thrown when a torrent is not present in the service's instant cache. */ +export class DebridNotCachedError extends DebridError { + constructor (message = 'Torrent is not cached on the debrid service', opts) { + super(message, opts) + this.name = 'DebridNotCachedError' + } +} + +/** + * @typedef {Object} DebridFile + * @property {string} name - File name without directories. + * @property {string} path - Path within the torrent, always starting with a slash. + * @property {number} size - File size in bytes. + * @property {string} url - Direct HTTPS stream URL. + * @property {string} [type] - MIME type when the service reports one. + */ + +/** + * @typedef {Object} DebridResolved + * @property {string} hash - Lowercase info hash. + * @property {string} name - Torrent name. + * @property {DebridFile[]} files - Streamable files, in torrent order. + */ + +/** + * Base class for debrid services, providing rate limited requests and typed errors. + * Implementations only talk HTTP, state is per-instance so services stay swappable. + * @abstract + */ +export default class DebridService { + /** @type {string} Unique lowercase identifier, e.g. 'realdebrid'. */ + static id = '' + /** @type {string} Human readable service name. */ + static title = '' + /** @type {boolean} Only implemented and tested services are offered in the settings menu. */ + static available = false + /** @type {import('bottleneck').ConstructorOptions} Request rate limits for the service API. */ + static limits = { maxConcurrent: 4, minTime: 250 } + + /** @param {string} apiKey */ + constructor (apiKey) { + this.apiKey = apiKey + this.rateLimitPromise = null + this.limiter = new Bottleneck(/** @type {typeof DebridService} */(this.constructor).limits) + this.limiter.on('failed', (error, jobInfo) => { + if (error instanceof DebridNetworkError) return // offline, retrying just delays the error + if (error instanceof DebridError && error.status === 429 && jobInfo.retryCount < 2) { + const time = (Number(error.retryAfter) || 5) * 1_000 + debug(`Rate limited by ${/** @type {typeof DebridService} */(this.constructor).title}, retrying in ${time}ms`) + if (!this.rateLimitPromise) this.rateLimitPromise = new Promise(resolve => setTimeout(resolve, time).unref?.()).then(() => { this.rateLimitPromise = null }) + return time + } + if (!(error instanceof DebridError) && jobInfo.retryCount < 1) return 3_000 // single retry for network hiccups + }) + this.request = this.limiter.wrap(this.#request.bind(this)) + } + + /** + * @param {string} url - Absolute request URL. + * @param {{ method?: string, body?: Record, timeout?: number }} [opts] - Body is sent form-encoded. + */ + async #request (url, { method = 'GET', body, timeout = 30_000 } = {}) { + await this.rateLimitPromise + if (!this.apiKey) throw new DebridAuthError('No debrid API key configured') + debug(`${method} ${url}`) + const res = await fetch(url, { + method, + headers: { Authorization: `Bearer ${this.apiKey}`, ...(body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}) }, + body: body && new URLSearchParams(body).toString(), + signal: AbortSignal.timeout(timeout) + }) + // the app short-circuits external requests while it considers itself offline, + // handing back a plain object instead of a Response + if (typeof res?.json !== 'function') throw new DebridNetworkError(res?.message?.replace(/^failed to fetch: /i, '') || 'Network request failed') + if (!res?.ok) { + let json = null + try { json = await res.json() } catch {} + const error = this.mapError(res.status, json) + if (error.status === 429) error.retryAfter = res.headers?.get('retry-after') + throw error + } + if (res.status === 204) return null + return res.json().catch(() => null) // some endpoints return an empty body on success + } + + /** + * Maps an HTTP error response to a typed error, override for service specific codes. + * @param {number} status + * @param {any} json - Parsed error body, may be null. + * @returns {DebridError} + */ + mapError (status, json) { + const message = json?.error || json?.message || `Request failed with status ${status}` + if (status === 401 || status === 403) return new DebridAuthError(message, { status, code: json?.error_code }) + return new DebridError(message, { status, code: json?.error_code }) + } + + /** + * Verifies the API key and that the account can stream torrents. + * @abstract + * @returns {Promise<{ username: string, expires?: string }>} + */ + async validate () { throw new Error('Not implemented') } + + /** + * Lists info hashes that are already downloaded on the account, used for instant playback badges. + * @abstract + * @returns {Promise} Lowercase info hashes. + */ + async listCachedHashes () { throw new Error('Not implemented') } + + /** + * Resolves a magnet to direct stream URLs, throws DebridNotCachedError when the + * service would have to download the torrent first. + * @abstract + * @param {string} magnet - Magnet URI or bare info hash. + * @param {{ fileFilter?: (name: string) => boolean, maxFiles?: number }} [opts] + * @returns {Promise} + */ + async resolve (magnet, opts) { throw new Error('Not implemented') } + + /** Cancels queued requests, the instance must not be used afterwards. */ + destroy () { + this.limiter.stop({ dropWaitingJobs: true }).catch(() => {}) + } +} diff --git a/common/modules/debrid/torbox.js b/common/modules/debrid/torbox.js new file mode 100644 index 00000000..918c238b --- /dev/null +++ b/common/modules/debrid/torbox.js @@ -0,0 +1,30 @@ +// relative import keeps this module loadable under plain Node for API tests +import DebridService, { DebridError } from './service.js' + +/** + * TorBox stub, see https://api-docs.torbox.app/ + * Untested skeleton kept out of the settings menu until someone with an account + * implements and verifies it. The relevant endpoints are: + * - GET /user/me -> account and plan for validate() + * - GET /torrents/mylist -> account torrents for listCachedHashes() + * - GET /torrents/checkcached?hash= -> TorBox still offers a real cache check + * - POST /torrents/createtorrent -> add a magnet + * - GET /torrents/requestdl?torrent_id=&file_id= -> direct stream URL per file + * Auth is a Bearer token like Real-Debrid, so the base request wrapper works as is. + */ +export default class TorBox extends DebridService { + static id = 'torbox' + static title = 'TorBox' + + async validate () { + throw new DebridError(`${TorBox.title} support is not implemented yet`) + } + + async listCachedHashes () { + throw new DebridError(`${TorBox.title} support is not implemented yet`) + } + + async resolve (magnet, opts) { + throw new DebridError(`${TorBox.title} support is not implemented yet`) + } +} diff --git a/common/modules/torrent.js b/common/modules/torrent.js index e05a7108..1c543ab8 100644 --- a/common/modules/torrent.js +++ b/common/modules/torrent.js @@ -8,6 +8,7 @@ import { writable } from 'simple-store-svelte' import { toast } from 'svelte-sonner' import { capitalize } from '@/modules/util.js' import clipboard from '@/modules/lib/clipboard.js' +import { streamDebrid } from '@/modules/debrid/debrid.js' import { setHash } from '@/modules/anime/animehash.js' import { TORRENT, ELECTRON } from '@/modules/bridge.js' import { get } from 'svelte/store' @@ -138,6 +139,7 @@ export async function add(torrentID, search, hash, magnet, base64 = false) { media.value = search ? { media: (search.media || media.value?.media), episode: (search.episode || media.value?.episode), ...(media.value?.torrent ? { torrent: true } : { feed: true }) } : { torrent: true } if (hash && search) setHash(hash, { mediaId: search.media?.id, episode: search.episode, client: true }) if (SUPPORTS.isAndroid && !settings.value.enableExternal) document.querySelector('.content-wrapper').requestFullscreen() // this WILL not work with auto-select torrents due to permissions check. + if (await streamDebrid(torrentID, hash, search)) return TORRENT.stream(torrentID, (hash === torrentID && torrentID) || false, magnet, base64) } } diff --git a/common/modules/util.js b/common/modules/util.js index 554db0fc..e3de9019 100644 --- a/common/modules/util.js +++ b/common/modules/util.js @@ -696,6 +696,9 @@ export const defaults = { showLabels: true, expandingSidebar: false, torrentPathNew: undefined, + debridService: 'none', + debridApiKey: '', + debridMode: 'prefer', donate: true, w2g: false, font: undefined, @@ -742,6 +745,7 @@ export const defaults = { * @property {any} [stagingTorrents] * @property {any} [seedingTorrents] * @property {any} [completedTorrents] + * @property {Record>} [debridResolvedHashes] * @property {string} posMiniplayer * @property {string} widthMiniplayer */ @@ -754,6 +758,7 @@ export const generalDefaults = { stagingTorrents: [], seedingTorrents: [], completedTorrents: [], + debridResolvedHashes: {}, posMiniplayer: 'bottom right', widthMiniplayer: '0px' } diff --git a/common/package.json b/common/package.json index 1e863539..1e9feafb 100644 --- a/common/package.json +++ b/common/package.json @@ -14,6 +14,7 @@ "js-levenshtein": "^1.1.6", "lucide-svelte": "0.455.0", "marked": "^18.0.9", + "matroska-metadata": "github:cedarpolar/matroska-metadata#b36dd003c41da5acd11c70144c13aef16a5603ee", "ms": "^2.1.3", "rvfc-polyfill": "^1.0.8", "p2pt": "github:ThaUnknown/p2pt#modernise", diff --git a/common/routes/player/PlayerPage.svelte b/common/routes/player/PlayerPage.svelte index 6647b7f6..e7ed6c6c 100644 --- a/common/routes/player/PlayerPage.svelte +++ b/common/routes/player/PlayerPage.svelte @@ -11,6 +11,7 @@ import { writable } from 'simple-store-svelte' import { createEventDispatcher } from 'svelte' import Subtitles from '@/modules/subtitles.js' + import DebridMetadata from '@/modules/debrid/metadata.js' import { toTS, fastPrettyBytes, capitalize, matchPhrase, videoRx, isValidNumber, debounce } from '@/modules/util.js' import { toast } from 'svelte-sonner' import { getChaptersAniSkip } from '@/modules/anime/anime.js' @@ -28,7 +29,7 @@ import 'rvfc-polyfill' import { ELECTRON, ANDROID, TORRENT } from '@/modules/bridge.js' import { unload } from '@/modules/torrent.js' - import { Settings, Gauge, Timer, X, Minus, ArrowDown, ArrowUp, Captions, CaptionsOff, CircleHelp, Contrast, FastForward, Keyboard, EllipsisVertical, SquareArrowOutUpRight, List, Eye, FilePlus2, ListMusic, ListVideo, Maximize, Minimize, Pause, PictureInPicture, PictureInPicture2, Play, Proportions, RefreshCcw, Rewind, RotateCcw, RotateCw, ScreenShare, SkipBack, SkipForward, Users, Volume1, Volume2, VolumeX, SlidersVertical, SquarePen, Milestone, ClockArrowDown, ClockArrowUp } from 'lucide-svelte' + import { Settings, Gauge, Timer, X, Minus, ArrowDown, ArrowUp, Captions, CaptionsOff, CircleHelp, Contrast, FastForward, Keyboard, EllipsisVertical, SquareArrowOutUpRight, List, Eye, FilePlus2, ListMusic, ListVideo, Maximize, Minimize, Pause, PictureInPicture, PictureInPicture2, Play, Proportions, RefreshCcw, Rewind, RotateCcw, RotateCw, ScreenShare, SkipBack, SkipForward, Users, Volume1, Volume2, VolumeX, SlidersVertical, SquarePen, Milestone, ClockArrowDown, ClockArrowUp, Cloud } from 'lucide-svelte' import Debug from 'debug' const debug = Debug('ui:player') @@ -67,6 +68,7 @@ let container = null let current = null let subs = null + let debridMeta = null let duration = 0.1 let muted = false let wasPaused = null @@ -226,6 +228,10 @@ subs.destroy() subs = null } + if (debridMeta) { + debridMeta.destroy() + debridMeta = null + } } } @@ -279,6 +285,10 @@ subs.destroy() subs = null } + if (debridMeta) { + debridMeta.destroy() + debridMeta = null + } current = file setCurrent(file) } @@ -290,6 +300,8 @@ src = file.url if (!launchExternal) { subs = new Subtitles(video, files, current, handleHeaders) + // debrid streams never pass through the torrent client, parse metadata from the remote file instead + if (current?.debrid) debridMeta = new DebridMetadata(current, files, subs, { getTime: () => currentTime, onChapters: _chapters => { chapters = _chapters; embeddedChapters = _chapters } }) video.load() await loadAnimeProgress() } else video.load() @@ -1731,12 +1743,17 @@
{/if}
- - {torrent.peers || 0} - - {fastPrettyBytes(torrent.down)}/s - - {fastPrettyBytes(torrent.up)}/s + {#if current?.debrid} + + Debrid + {:else} + + {torrent.peers || 0} + + {fastPrettyBytes(torrent.down)}/s + + {fastPrettyBytes(torrent.up)}/s + {/if} {#if resolvePrompt}
diff --git a/common/routes/settings/SettingsPage.svelte b/common/routes/settings/SettingsPage.svelte index 6ae53ace..fb3a4953 100644 --- a/common/routes/settings/SettingsPage.svelte +++ b/common/routes/settings/SettingsPage.svelte @@ -56,10 +56,11 @@ import AppTab from '@/routes/settings/tabs/AppTab.svelte' import ChangelogTab from '@/routes/settings/tabs/ChangelogTab.svelte' import ExtensionTab from '@/routes/settings/tabs/ExtensionTab.svelte' + import DebridTab from '@/routes/settings/tabs/DebridTab.svelte' import { status } from '@/modules/networking.js' import { modal } from '@/modules/navigation.js' import semver from 'semver' - import { AppWindow, Puzzle, User, Heart, Logs, Play, Rss, LayoutDashboard } from 'lucide-svelte' + import { AppWindow, Puzzle, User, Heart, Logs, Play, Rss, LayoutDashboard, Cloud } from 'lucide-svelte' export let statusTransition = false @@ -80,6 +81,10 @@ name: 'Extensions', icon: Puzzle }, + debrid: { + name: 'Debrid', + icon: Cloud + }, login: { name: 'Profiles', icon: User, @@ -155,6 +160,13 @@
+ +
+
+ +
+
+
diff --git a/common/routes/settings/tabs/AppTab.svelte b/common/routes/settings/tabs/AppTab.svelte index 62c854b8..ed661c72 100644 --- a/common/routes/settings/tabs/AppTab.svelte +++ b/common/routes/settings/tabs/AppTab.svelte @@ -182,7 +182,7 @@
- + resetSettings()} class='btn btn-danger mt-5 d-flex align-items-center justify-content-center' confirmText='Confirm Reset' confirmClass='btn-danger-dim long-button' cancelClass='btn-secondary long-button' actionClass='d-inline-flex d-md-block' dataToggle='tooltip' dataPlacement='top' dataTitle='Restores All Settings Back To Their Recommended Defaults'> Reset to Defaults diff --git a/common/routes/settings/tabs/DebridTab.svelte b/common/routes/settings/tabs/DebridTab.svelte new file mode 100644 index 00000000..4aae7902 --- /dev/null +++ b/common/routes/settings/tabs/DebridTab.svelte @@ -0,0 +1,58 @@ + + +

Debrid Settings

+ + + +{#if settings.debridService !== 'none'} + +
+ { settings.debridApiKey = event.target.value.trim() }} + on:keydown|stopPropagation + /> +
+ + +
+
+
+ + + +{/if} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcfa072e..be947302 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,6 +237,9 @@ importers: marked: specifier: ^18.0.9 version: 18.0.9 + matroska-metadata: + specifier: github:cedarpolar/matroska-metadata#b36dd003c41da5acd11c70144c13aef16a5603ee + version: https://codeload.github.com/cedarpolar/matroska-metadata/tar.gz/b36dd003c41da5acd11c70144c13aef16a5603ee ms: specifier: ^2.1.3 version: 2.1.3 @@ -1968,10 +1971,12 @@ packages: conventional-changelog-atom@2.0.8: resolution: {integrity: sha512-xo6v46icsFTK3bb7dY/8m2qvc8sZemRgdqLb/bjpBsH2UyOS8rKNTgcb5025Hri6IpANPApbXMg15QLb1LJpBw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-codemirror@2.0.8: resolution: {integrity: sha512-z5DAsn3uj1Vfp7po3gpt2Boc+Bdwmw2++ZHa5Ak9k0UKsYAO5mH1UBTN0qSCuJZREIhX6WU4E1p3IW2oRCNzQw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-conventionalcommits@4.6.3: resolution: {integrity: sha512-LTTQV4fwOM4oLPad317V/QNQ1FY4Hju5qeBIM1uTHbrnCE+Eg4CdRZ3gO2pUeR+tzWdp80M2j3qFFEDWVqOV4g==} @@ -1980,26 +1985,32 @@ packages: conventional-changelog-core@4.2.4: resolution: {integrity: sha512-gDVS+zVJHE2v4SLc6B0sLsPiloR0ygU7HaDW14aNJE1v4SlqJPILPl/aJC7YdtRE4CybBf8gDwObBvKha8Xlyg==} engines: {node: '>=10'} + deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead. conventional-changelog-ember@2.0.9: resolution: {integrity: sha512-ulzIReoZEvZCBDhcNYfDIsLTHzYHc7awh+eI44ZtV5cx6LVxLlVtEmcO+2/kGIHGtw+qVabJYjdI5cJOQgXh1A==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-eslint@3.0.9: resolution: {integrity: sha512-6NpUCMgU8qmWmyAMSZO5NrRd7rTgErjrm4VASam2u5jrZS0n38V7Y9CzTtLT2qwz5xEChDR4BduoWIr8TfwvXA==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-express@2.0.6: resolution: {integrity: sha512-SDez2f3iVJw6V563O3pRtNwXtQaSmEfTCaTBPCqn0oG0mfkq0rX4hHBq5P7De2MncoRixrALj3u3oQsNK+Q0pQ==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jquery@3.0.11: resolution: {integrity: sha512-x8AWz5/Td55F7+o/9LQ6cQIPwrCjfJQ5Zmfqi8thwUEKHstEn4kTIofXub7plf1xvFA2TqhZlq7fy5OmV6BOMw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jshint@2.0.9: resolution: {integrity: sha512-wMLdaIzq6TNnMHMy31hql02OEQ8nCQfExw1SE0hYL5KvU+JCTuPaDO+7JiogGT2gJAxiUGATdtYYfh+nT+6riA==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-preset-loader@2.3.4: resolution: {integrity: sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g==} @@ -2333,7 +2344,7 @@ packages: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} ebml-iterator@https://codeload.github.com/cedarpolar/ebml-iterator/tar.gz/10bd04f977cdd747c7cc41135cf04f8b91fb024d: - resolution: {gitHosted: true, tarball: https://codeload.github.com/cedarpolar/ebml-iterator/tar.gz/10bd04f977cdd747c7cc41135cf04f8b91fb024d} + resolution: {gitHosted: true, integrity: sha512-fogU5IZc6+Hay7Hg0XNIsrby9sRsAWC0cUTBc2rNVAawu7OMHNa2cAvAZS7k2O+BTrUF+mP7HCYjcz86kqH0Mw==, tarball: https://codeload.github.com/cedarpolar/ebml-iterator/tar.gz/10bd04f977cdd747c7cc41135cf04f8b91fb024d} version: 1.0.5 ee-first@1.1.1: @@ -2899,7 +2910,7 @@ packages: git-raw-commits@2.0.11: resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-remote-origin-url@2.0.0: @@ -2909,7 +2920,7 @@ packages: git-semver-tags@4.1.1: resolution: {integrity: sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true gitconfiglocal@1.0.0: From 0d71678c7fa254aa181518cc961a922e00a9462d Mon Sep 17 00:00:00 2001 From: zeroz Date: Sat, 8 Aug 2026 07:18:24 -0400 Subject: [PATCH 02/11] better interface debrid handling, unit testing service routes with debrid and subtitles. Fixes from pr comments --- client/core/webtorrent.js | 18 +- .../torrent/components/TorrentCard.svelte | 9 +- .../torrent/components/TorrentResults.svelte | 8 +- common/modules/debrid/alldebrid.js | 33 ++-- common/modules/debrid/debrid.js | 48 +++-- common/modules/debrid/metadata.js | 47 +++-- common/modules/debrid/premiumize.js | 33 ++-- common/modules/debrid/realdebrid.js | 59 +++--- common/modules/debrid/route.js | 17 +- common/modules/debrid/service.js | 169 ++++++++++++++++-- common/modules/debrid/torbox.js | 36 ++-- common/modules/util.js | 20 +++ common/routes/player/PlayerPage.svelte | 7 +- common/routes/settings/tabs/DebridTab.svelte | 4 +- 14 files changed, 357 insertions(+), 151 deletions(-) diff --git a/client/core/webtorrent.js b/client/core/webtorrent.js index 3ed6e240..c331faa6 100644 --- a/client/core/webtorrent.js +++ b/client/core/webtorrent.js @@ -3,7 +3,7 @@ import HTTPTracker from 'http-tracker' import Client from 'bittorrent-tracker' import { hex2bin, arr2hex, text2arr } from 'uint8-util' import { makeHash, getInfoHash, hasIntegrity, getProgressAndSize, stringifyQuery, errorToString, TMP } from '@client/lib/util.js' -import { fontRx, sleep, subRx, videoRx, isValidNumber } from '@/modules/util.js' +import { fontRx, sleep, matchSubtitleFiles, isValidNumber } from '@/modules/util.js' import { SUPPORTS } from '@/modules/support.js' import { spawn } from 'node:child_process' import Metadata from '@client/lib/metadata.js' @@ -229,10 +229,8 @@ export default class TorrentClient extends WebTorrent { async findSubtitleFiles(targetFile) { const currentTorrent = this.torrents.find(torrent => torrent.current) if (!currentTorrent?.files) return - const videoFiles = currentTorrent.files.filter(file => videoRx.test(file.name)) - const videoName = targetFile.name.substring(0, targetFile.name.lastIndexOf('.')) || targetFile.name - // array of subtitle files that match video name, or all subtitle files when only 1 vid file - const subfiles = currentTorrent.files.filter(file => subRx.test(file.name) && (videoFiles.length === 1 ? true : file.name.includes(videoName))) + // shared with debrid playback so both lanes match subtitles to the video the same way + const subfiles = matchSubtitleFiles(currentTorrent.files, targetFile.name) debug(`Found ${subfiles?.length} subtitle files`) for (const file of subfiles) { const data = await file.arrayBuffer() @@ -492,7 +490,8 @@ export default class TorrentClient extends WebTorrent { } case 'current': { if (data.data) { if (data.data.current.debrid) { - // debrid streams play over HTTP and never join the client, just detach the previous torrent file + // debrid files stream straight from the service over HTTPS and never join the + // torrent client, so there is nothing to attach, only the previous playback to release if (this.playerProcess) { this.playerProcess.kill() this.playerProcess = null @@ -505,6 +504,13 @@ export default class TorrentClient extends WebTorrent { } this.metadata?.destroy?.() this.metadata = null + // nothing is playing from the client anymore, so stop reporting the old torrent + // as current or its peers and speeds keep flowing to the player and torrent manager + const lastTorrent = this.torrents.find(_torrent => _torrent.current) + if (lastTorrent) { + lastTorrent.current = false + this.bumpTorrent(lastTorrent) + } if (data.data.external && (SUPPORTS.isAndroid || this.player)) this.dispatch('externalReady') break } diff --git a/common/modals/torrent/components/TorrentCard.svelte b/common/modals/torrent/components/TorrentCard.svelte index 5a00b84c..9edd2ac3 100644 --- a/common/modals/torrent/components/TorrentCard.svelte +++ b/common/modals/torrent/components/TorrentCard.svelte @@ -6,9 +6,9 @@ import { getEpisodeMetadataForMedia, getKitsuMappings } from '@/modules/anime/anime.js' import { copyToClipboard } from '@/modules/lib/clipboard.js' import { malDubs } from '@/modules/anime/animedubs.js' - import { debridEnabled, debridCachedHashes } from '@/modules/debrid/debrid.js' + import { debridEnabled, debridCachedHashes, debridTransport } from '@/modules/debrid/debrid.js' import { settings } from '@/modules/settings.js' - import { Database, BadgeCheck, HardDrive, FileQuestion, AlertCircle, TriangleAlert } from 'lucide-svelte' + import { Database, BadgeCheck, HardDrive, FileQuestion, AlertCircle, TriangleAlert, Cloud } from 'lucide-svelte' const { reactive, init } = createListener(['torrent-button', 'torrent-safe-area']) init(true) @@ -449,8 +449,9 @@