diff --git a/client/core/webtorrent.js b/client/core/webtorrent.js index 07d10911..cc2ba984 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 { sleep, matchFontFiles, 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' @@ -198,6 +198,26 @@ export default class TorrentClient extends WebTorrent { timeout.unref?.() } + /** + * Detaches whatever was playing: the external player, the streamed file and its metadata parser. + * Shared with debrid playback, which has the same to let go of and nothing to attach after. + * @param {number} [progress] - Progress of the file taking over, which decides whether the + * outgoing one keeps downloading. Defaults to the outgoing file's own progress. + */ + releaseCurrentFile(progress = this.currentFile?.progress) { + 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 && progress < 1) this.currentFile.deselect() + } + this.metadata?.destroy?.() + this.metadata = null + } + /** * Searches the current torrent for embedded font files and sends them to the renderer for use. * @param {object} targetFile - File currently being processed. @@ -205,17 +225,11 @@ export default class TorrentClient extends WebTorrent { async findFontFiles(targetFile) { const currentTorrent = this.torrents.find(torrent => torrent.current) if (!currentTorrent?.files) return - const fontFiles = currentTorrent.files.filter(file => fontRx.test(file.name)) - const map = {} + // shared with debrid playback so both lanes deduplicate a release's fonts the same way + const fontFiles = matchFontFiles(currentTorrent.files) + debug(`Found ${fontFiles.length} font files`) - // deduplicate fonts - // some releases have duplicate fonts for diff languages - // if they have different chars, we can't find that out anyways - // so some chars might fail, on REALLY bad releases - for (const file of fontFiles) map[file.name] = file - debug(`Found ${Object.keys(map).length} font files`) - - for (const file of Object.values(map)) { + for (const file of fontFiles) { const data = await file.arrayBuffer() if (targetFile !== this.currentFile) return this.dispatch('file', { data: new Uint8Array(data) }, [data]) @@ -229,10 +243,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() @@ -491,19 +503,26 @@ export default class TorrentClient extends WebTorrent { break } case 'current': { if (data.data) { + if (data.data.current.debrid) { + // debrid files stream straight from the service over HTTPS and never join the client, + // so there is nothing to attach, only the previous playback to release + this.releaseCurrentFile() + this.currentFile = 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 + } 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) if (!found || found._destroyed) return - 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 && found.progress < 1) this.currentFile.deselect() - } + this.releaseCurrentFile(found.progress) if (this.settings.torrentStreamedDownload && found.progress < 1) { this.torrents.filter(_torrent => (_torrent.staging || _torrent.seeding) && Array.isArray(_torrent.files)).forEach(_torrent => { _torrent.files.forEach(file => { @@ -511,8 +530,6 @@ export default class TorrentClient extends WebTorrent { }) }) } - this.metadata?.destroy?.() - this.metadata = null found.select() if (this.settings.torrentStreamedDownload && (found.length > await this.storageQuota(torrent.path))) this.dispatchError('File Too Big! This File Exceeds The Selected Drive\'s Available Space. Change Download Location In Torrent Settings To A Drive With More Space And Restart The App!') @@ -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) { @@ -561,8 +579,10 @@ export default class TorrentClient extends WebTorrent { this.playerProcess.kill() this.playerProcess = null } + // a debrid file carries its own HTTPS url, a torrent file streams from the local server + const url = current?.debrid ? found.url : `http://localhost:${this.server.address().port}${found.streamURL}` 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(url)]) this.playerProcess.stdout.on('data', () => {}) this.playerProcess.once('close', () => { if (this.destroyed) return @@ -570,7 +590,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', `intent://${url.replace(/^https?:\/\//, '')}#Intent;type=video/any;scheme=${url.startsWith('https:') ? 'https' : '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..d8e3aa4f 100644 --- a/common/modals/torrent/components/TorrentCard.svelte +++ b/common/modals/torrent/components/TorrentCard.svelte @@ -6,8 +6,19 @@ import { getEpisodeMetadataForMedia, getKitsuMappings } from '@/modules/anime/anime.js' import { copyToClipboard } from '@/modules/lib/clipboard.js' import { malDubs } from '@/modules/anime/animedubs.js' + import { debridEnabled, debridAvailability, debridTransport } from '@/modules/debrid/debrid.js' + import { Availability, availabilityOf, describeAvailability } from '@/modules/debrid/availability.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, CloudDownload, CloudOff, CloudAlert } from 'lucide-svelte' + + // one badge per availability state, so a glance separates what streams instantly from what the + // service would have to fetch, from what it cannot serve at all + const availabilityBadges = { + [Availability.CACHED]: { icon: Cloud, style: 'background: hsla(var(--primary-color-dim-hsl), .15); border-color: var(--primary-color-light) !important; color: var(--primary-color-light)' }, + [Availability.AVAILABLE]: { icon: CloudDownload, style: 'background: hsla(var(--warning-color-dim-hsl), .15); border-color: var(--warning-color-dim) !important; color: var(--warning-color-dim)' }, + [Availability.UNAVAILABLE]: { icon: CloudOff, style: 'background: hsla(var(--danger-color-dim-hsl), .15); border-color: var(--danger-color-light) !important; color: var(--danger-color-light)' }, + [Availability.UNKNOWN]: { icon: CloudAlert, style: 'background: hsla(var(--white-color-dim-hsl), .08); border-color: var(--white-color-very-dim) !important; color: var(--white-color-dim)' } + } const { reactive, init } = createListener(['torrent-button', 'torrent-safe-area']) init(true) @@ -354,6 +365,10 @@ $: errorType = type === 'error' ? (result.title?.match(/no results/i) || result.title?.match(/extension is not enabled/i) ? 'warning' : 'error') : '' + $: availability = availabilityOf($debridAvailability, result.hash) + $: availabilityBadge = availabilityBadges[availability] + $: availabilityTitle = describeAvailability(availability, $debridTransport?.title).description + let card $: updateGlowColor(countdown) function updateGlowColor(value) { @@ -447,6 +462,11 @@
{since(new Date(result.date))}
+ {#if $debridEnabled && result.hash} +
+ +
+ {/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..66edc536 100644 --- a/common/modals/torrent/components/TorrentResults.svelte +++ b/common/modals/torrent/components/TorrentResults.svelte @@ -10,13 +10,16 @@ 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, debridAvailability, debridTransport, debridChecking, refreshDebridAvailability, checkDebridAvailability, cancelDebridAvailability } from '@/modules/debrid/debrid.js' + import { Availability, AVAILABILITY_ORDER, availabilityOf, describeAvailability } from '@/modules/debrid/availability.js' + import { listResult } from '@/modules/debrid/route.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') @@ -88,27 +91,56 @@ * @param {boolean} batch */ function sortResults(results, sort, batch) { - if (!results) return { results: [], hiddenResults: [] } + if (!results) return [] 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 }) - return { - results: deduped.filter(entry => entry.seeders > 0 || entry.source?.managed).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 - case 'batch': { - if (!batch) return b.seeders - a.seeders - return ((b.type === 'batch') - (a.type === 'batch')) || b.seeders - a.seeders - } - case 'new': return new Date(b.date) - new Date(a.date) - case 'old': return new Date(a.date) - new Date(b.date) - case 'seeders': - default: return b.seeders - a.seeders + return deduped.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 + case 'batch': { + if (!batch) return b.seeders - a.seeders + return ((b.type === 'batch') - (a.type === 'batch')) || b.seeders - a.seeders } - }), - hiddenResults: deduped.filter(entry => !entry.seeders && !entry.source?.managed) + case 'new': return new Date(b.date) - new Date(a.date) + case 'old': return new Date(a.date) - new Date(b.date) + case 'seeders': + default: return b.seeders - a.seeders + } + }) + } + + const sameOrder = (a, b) => a.length === b.length && a.every((entry, index) => entry === b[index]) + + /** + * Splits sorted results into what is listed and what is hidden, and tallies what the debrid + * service said about each. Kept apart from the sorting above so an answer landing only redoes + * these passes, not the dedupe and sort. The previous split lives in the closure rather than a + * component variable to stay out of the reactive graph. + * @returns {(sorted: Result[], availability?: Map, filters?: { cachedOnly?: boolean, only?: boolean }) => any} + */ + function createListResults() { + let previous = null + return function listResults(sorted, availability, filters) { + const results = [] + const hiddenResults = [] + const counts = Object.fromEntries(AVAILABILITY_ORDER.map(state => [state, 0])) + for (const entry of sorted) { + const state = availability ? availabilityOf(availability, entry.hash) : Availability.UNKNOWN + counts[state]++ + // narrows what the rest of the modal sees, so the best pick and autoplay follow it too + if (listResult(entry, state, filters)) results.push(entry) + else hiddenResults.push(entry) + } + // most answers only move the counts, since a seeded release was listed either way. Handing + // back the same arrays keeps the best-release pick from being redone, which reparses every + // result and is what made answers landing feel like a freeze + if (previous && sameOrder(previous.results, results) && sameOrder(previous.hiddenResults, hiddenResults)) { + return { ...previous, counts } + } + return (previous = { sorted, counts, results, hiddenResults }) } } @@ -164,6 +196,7 @@ export let search export let close + const listResults = createListResults() let container let containerEl let countdown = 5 @@ -247,6 +280,7 @@ async function queryExtensions(request, resolution) { scrollTop() + if ($debridEnabled) refreshDebridAvailability() $results = {} const cachedHashes = [] for (const resolvedHash of getHash(search?.media?.id, { episode: search?.episode, client: true, batchGuess: true }, false, true, true) ?? []) { @@ -329,7 +363,16 @@ $: resolution = $settings.rssQuality $: queries = queryExtensions({...search}, resolution) $: errors = getErrors({...search}, queries) - $: queryResults = sortResults($results?.torrents, $settings.torrentSort, batch) + $: cachedOnly = $debridEnabled && $settings.debridCachedOnly + $: debridFilters = { cachedOnly, only: $debridEnabled && Boolean($debridTransport?.only) } + $: sortedResults = sortResults($results?.torrents, $settings.torrentSort, batch) + // ask about the results from the top of the list down, which is where the releases worth + // playing are. How far it reaches is the service's call: one request for a service with a + // cache endpoint, a handful of probes for one without. + $: if ($debridEnabled && $results?.resolved) checkDebridAvailability(sortedResults.map(result => result.hash)) + $: queryResults = listResults(sortedResults, $debridEnabled ? $debridAvailability : undefined, debridFilters) + // every state and its count, for the tooltip on the cached filter + $: availabilitySummary = AVAILABILITY_ORDER.map(state => `${queryResults?.counts?.[state] ?? 0} ${describeAvailability(state, $debridTransport?.title).label}`).join(' ยท ') $: lookup = queryResults?.results $: (episodeSearch || resolution || $settings.torrentSort || $settings.audioLanguage) && scrollTop() @@ -389,6 +432,7 @@ onDestroy(() => { clearTimeout(timeoutHandle) + cancelDebridAvailability() // nobody is looking at these results any more viewHidden = false $results = {} search = null @@ -476,6 +520,19 @@
+ {#if $debridTransport} +
+ + {$debridTransport.label} +
+ {/if} + {#if $debridEnabled} + + {/if}
@@ -555,7 +612,7 @@ {/if} {#if lookupHidden?.length && $results?.torrents?.length && filterResults(lookupHidden, searchText)?.length} {#if viewHidden} diff --git a/common/modules/debrid/alldebrid.js b/common/modules/debrid/alldebrid.js new file mode 100644 index 00000000..3d6a4fc7 --- /dev/null +++ b/common/modules/debrid/alldebrid.js @@ -0,0 +1,259 @@ +// relative import keeps this module loadable under plain Node for API tests +import DebridService, { DebridError, DebridAuthError, DebridNotCachedError, DebridUnavailableError } from './service.js' +import { Availability } from './availability.js' +import Debug from 'debug' +const debug = Debug('ui:debrid') + +const API = 'https://api.alldebrid.com' +// magnet status and the file tree moved to v4.1, everything else is still v4 +const V4 = `${API}/v4` +const V41 = `${API}/v4.1` + +// error codes worth explaining, anything else falls back to the API's own message +const errorMessages = { + AUTH_BAD_APIKEY: 'Invalid AllDebrid API key', + AUTH_MISSING_APIKEY: 'AllDebrid requires an API key for this request', + AUTH_BLOCKED: 'AllDebrid has blocked this API key', + AUTH_USER_BANNED: 'This AllDebrid account is banned', + MUST_BE_PREMIUM: 'AllDebrid premium is required for this', + MAGNET_MUST_BE_PREMIUM: 'AllDebrid premium is required to stream torrents', + MAGNET_TOO_MANY_ACTIVE: 'Too many active AllDebrid magnets, wait for one to finish', + MAGNET_TOO_LARGE: 'This release is larger than the AllDebrid plan allows', + MAGNET_INVALID_URI: 'AllDebrid would not accept this magnet', + MAGNET_NO_SERVER: 'No AllDebrid server is available right now', + NO_SERVER: 'No AllDebrid server is available right now', + LINK_TOO_MANY_DOWNLOADS: 'Too many active AllDebrid downloads, wait for one to finish', + LINK_HOST_UNAVAILABLE: 'AllDebrid cannot serve this file right now', + LINK_DOWN: 'AllDebrid reports this file as dead', + FREE_TRIAL_LIMIT_REACHED: 'This AllDebrid trial has reached its limit' +} +// only these mean the key or plan is the problem, the rest are per-request +const authCodes = ['AUTH_BAD_APIKEY', 'AUTH_MISSING_APIKEY', 'AUTH_BLOCKED', 'AUTH_USER_BANNED', 'MUST_BE_PREMIUM', 'MAGNET_MUST_BE_PREMIUM'] +// the account cannot take on more work right now, rather than this release being a problem +const throttleCodes = ['MAGNET_TOO_MANY_ACTIVE', 'LINK_TOO_MANY_DOWNLOADS', 'MAGNET_NO_SERVER', 'NO_SERVER'] +// AllDebrid will never take this release, whoever asks and whenever +const deadCodes = ['MAGNET_INVALID_URI', 'MAGNET_INVALID_FILE', 'MAGNET_TOO_LARGE'] + +// statusCode from /magnet/status: 4 is finished, below it the magnet is still being worked on, +// above it every value is a way of having failed. See the status code table in the API docs. +const READY = 4 + +/** + * AllDebrid implementation, see https://docs.alldebrid.com/ + * + * Two API quirks shape this client: + * - `/magnet/instant` is gone, so availability is read off the upload response, which answers + * `ready` per magnet and takes many at once. Cheap in requests, but every hash checked lands on + * the account for a moment, hence the small caps below. + * - `/magnet/status` never says which info hash a magnet came from, so the account is only good + * for telling this client's uploads from the user's own, never for badges. + */ +export default class AllDebrid extends DebridService { + static id = 'alldebrid' + static title = 'AllDebrid' + static available = true + // one upload answers many hashes, but each one lands on the account, so the caps stay small + static availabilityCheck = 'batch' + static checkAddsMagnets = true + static maxBatch = 10 + static maxAsk = 10 + static limits = { maxConcurrent: 3, minTime: 250 } // no documented allowance, so be modest + + /** AllDebrid wraps every response in `{ status, data, error }` and reports failures with a 200. */ + unwrap (json) { + if (!json || typeof json !== 'object' || !('status' in json)) return json + if (json.status !== 'success') throw this.mapError(200, json) + return json.data + } + + mapError (status, json) { + const code = json?.error?.code + const message = errorMessages[code] || json?.error?.message || `Request failed with status ${status}` + if (authCodes.includes(code) || ((status === 401 || status === 403) && !code)) return new DebridAuthError(message, { status, code }) + return new DebridError(message, { status, code }) + } + + /** @param {any} error */ + throttled (error) { + return super.throttled(error) || throttleCodes.includes(error?.code) + } + + async validate () { + const user = (await this.request(`${V4}/user`))?.user + if (!user) throw new DebridAuthError('AllDebrid did not recognise this API key') + if (!user.isPremium && !user.isTrial) throw new DebridAuthError('AllDebrid premium is required to stream torrents') + return { username: user.username || user.email || 'AllDebrid user', expires: user.premiumUntil ? new Date(user.premiumUntil * 1_000).toISOString() : undefined } + } + + /** Read only to tell this client's magnets from the user's own, never for badges. */ + async fetchListing () { + return this.#magnets() + } + + /** Nothing to read: account entries carry no info hash, so none can be matched to a release. */ + async listAvailability () { + return new Map() + } + + /** + * Uploads the hashes, reads the `ready` flag back, and removes everything this call added. + * The account is read first because an upload of a magnet it already holds answers with the + * existing entry, so without that read the cleanup would delete the user's own magnet. + * @param {string[]} hashes + */ + async checkAvailabilityBatch (hashes) { + const existing = await this.#accountIds({ fresh: true }) + const uploaded = await this.#upload(hashes) + const answers = new Map() + const ours = [] + for (const entry of uploaded) { + if (entry?.id != null && !existing.has(String(entry.id))) ours.push(entry.id) + const hash = AllDebrid.parseHash(entry?.hash || entry?.magnet) + if (!hash) continue + // a rejection about the release is an answer, one about the account being busy is not + if (entry.error) { + if (deadCodes.includes(entry.error.code)) answers.set(hash, Availability.UNAVAILABLE) + else debug(`AllDebrid did not answer for ${hash}: ${entry.error.code}`) + continue + } + answers.set(hash, entry.ready ? Availability.CACHED : Availability.AVAILABLE) + } + await this.#deleteAll(ours) + return answers + } + + async resolve (magnet, { fileFilter = () => true, pickFile, maxFiles = this.config.maxFiles } = {}) { + const hash = AllDebrid.parseHash(magnet) + const magnetURI = AllDebrid.toMagnet(magnet) + if (!magnetURI) throw new DebridError('AllDebrid needs a magnet link or info hash to resolve') + // as in the check: only ids that were not here a moment ago are ours to remove again + const existing = await this.#accountIds({ fresh: true }) + const [uploaded] = await this.#upload([magnetURI]) + if (uploaded?.error) throw uploadError(uploaded.error) + const id = uploaded?.id + if (id == null) throw new DebridError('AllDebrid did not report the magnet back after adding it') + const added = !existing.has(String(id)) + try { + if (!uploaded.ready) throw new DebridNotCachedError() + const magnetInfo = (await this.#magnets(id))[0] + // the upload already said ready, so an empty read is the request failing, not an answer + if (!magnetInfo) throw new DebridError('AllDebrid did not report the magnet back after adding it') + const state = magnetAvailability(magnetInfo) + if (state === Availability.UNAVAILABLE) throw new DebridUnavailableError(`AllDebrid could not process this torrent (${magnetInfo.status || 'failed'})`) + if (state !== Availability.CACHED) throw new DebridNotCachedError() + + const wanted = flattenFiles(await this.#files(magnetInfo)).filter(file => fileFilter(file.path)) + if (!wanted.length) throw new DebridError('No playable files in this torrent') + const target = pickFile ? await pickFile(wanted) : [...wanted].sort((a, b) => b.size - a.size)[0] + const files = await this.#unlockLinks(AllDebrid.windowFiles(wanted, target, maxFiles)) + if (!files.length) throw new DebridError('AllDebrid returned no links for this torrent') + debug(`Resolved ${files.length} files for ${magnetInfo.filename}`) + return { hash, name: magnetInfo.filename, files } + } catch (error) { + // only clean up a magnet this call put on the account, never the user's own + if (added) await this.#delete(id) + throw error + } + } + + /** + * Adds magnets, which is also how AllDebrid is asked whether it holds them. + * @param {string[]} magnetsOrHashes + * @returns {Promise} + */ + async #upload (magnetsOrHashes) { + const uploaded = await this.request(`${V4}/magnet/upload`, { method: 'POST', body: { 'magnets[]': magnetsOrHashes } }) + this.forgetListing() // the account has magnets the remembered listing does not + return uploaded?.magnets || [] + } + + /** + * The account's magnets, or one of them by id. + * @param {string | number} [id] + * @returns {Promise} + */ + async #magnets (id) { + const data = await this.request(`${V41}/magnet/status`, { method: 'POST', body: id != null ? { id } : {} }) + // asking for one id has answered with a bare object rather than a list + const magnets = data?.magnets + return !magnets ? [] : Array.isArray(magnets) ? magnets : [magnets] + } + + /** + * A magnet's file tree. Status answers with it inline; the files endpoint is the fallback. + * @param {any} magnetInfo + * @returns {Promise} + */ + async #files (magnetInfo) { + if (magnetInfo.files?.length) return magnetInfo.files + const data = await this.request(`${V4}/magnet/files`, { method: 'POST', body: { 'id[]': [magnetInfo.id] } }) + return data?.magnets?.[0]?.files || [] + } + + /** + * The magnet ids on the account, as strings so they compare however the API types them. + * @param {{ fresh?: boolean }} [opts] + * @returns {Promise>} + */ + async #accountIds (opts) { + return new Set((await this.listing(opts)).map(entry => String(entry?.id))) + } + + /** + * Turns the wanted files into direct stream links, skipping dead ones. + * @param {{ path: string, size: number, link: string }[]} wanted + */ + async #unlockLinks (wanted) { + return this.mapFiles(wanted, async file => { + const unlocked = await this.request(`${V4}/link/unlock?link=${encodeURIComponent(file.link)}`) + if (!unlocked?.link) return null + return { name: file.path.split('/').pop(), path: file.path, size: unlocked.filesize || file.size, url: unlocked.link } + }) + } + + /** @param {(string | number)[]} ids */ + async #deleteAll (ids) { + await Promise.all(ids.map(id => this.#delete(id))) + } + + /** @param {string | number} id */ + async #delete (id) { + await this.release(`${V4}/magnet/delete`, { method: 'POST', body: { id } }) + } +} + +/** + * The typed answer a rejected upload stands for. Only a release it will never take is an answer. + * @param {{ code?: string, message?: string }} error + */ +function uploadError (error) { + const message = errorMessages[error?.code] || error?.message || 'AllDebrid would not accept this magnet' + if (deadCodes.includes(error?.code)) return new DebridUnavailableError(message, { code: error?.code }) + return new DebridError(message, { code: error?.code }) +} + +/** + * What the account says about one magnet, per the status code table in the API docs. + * @param {any} magnet + * @returns {string} + */ +function magnetAvailability (magnet) { + const code = Number(magnet?.statusCode) + if (!Number.isFinite(code)) return Availability.UNKNOWN + if (code === READY) return Availability.CACHED + return code < READY ? Availability.AVAILABLE : Availability.UNAVAILABLE +} + +/** + * Flattens the file tree into rooted paths. `n` name, `s` size, `l` link, `e` folder children. + * @param {any[]} entries + * @param {string} [prefix] + * @returns {{ path: string, size: number, link: string }[]} + */ +function flattenFiles (entries, prefix = '') { + return (entries || []).flatMap(entry => { + const path = `${prefix}/${entry?.n || ''}` + if (Array.isArray(entry?.e)) return flattenFiles(entry.e, path) + return entry?.l ? [{ path, size: Number(entry.s) || 0, link: entry.l }] : [] + }) +} diff --git a/common/modules/debrid/availability.js b/common/modules/debrid/availability.js new file mode 100644 index 00000000..044f5b30 --- /dev/null +++ b/common/modules/debrid/availability.js @@ -0,0 +1,90 @@ +// The vocabulary the whole debrid layer uses to describe a release. Pure data and pure functions, +// free of UI and network imports, so it runs under plain Node for tests. + +/** + * What a debrid service can do with a release right now. Services differ wildly in how well they + * can answer, but all speak these four values, so the rest of the app never has to care which + * kind it is talking to. + */ +export const Availability = Object.freeze({ + /** The service holds it and streams it immediately. */ + CACHED: 'cached', + /** The service does not hold it but can fetch it, which takes longer than playback will wait. */ + AVAILABLE: 'available', + /** The service cannot serve it at all: a dead magnet, a rejected release, a failed download. */ + UNAVAILABLE: 'unavailable', + /** Nobody asked, or nothing came back. Never report this as "not cached", it is an absence of an answer. */ + UNKNOWN: 'unknown' +}) + +/** Every state, best first. The order badges and counters are shown in. */ +export const AVAILABILITY_ORDER = Object.freeze([Availability.CACHED, Availability.AVAILABLE, Availability.UNKNOWN, Availability.UNAVAILABLE]) + +/** + * How long an answer stays trusted. A hit lasts far longer than a miss: anyone can pull a release + * into a cache at any moment, but one already held rarely disappears. + */ +export const AVAILABILITY_TTL = Object.freeze({ + [Availability.CACHED]: 6 * 60 * 60_000, + [Availability.AVAILABLE]: 20 * 60_000, + [Availability.UNAVAILABLE]: 30 * 60_000, + [Availability.UNKNOWN]: 0 +}) + +const states = new Set(Object.values(Availability)) + +/** + * @param {any} value + * @returns {value is Availability[keyof Availability]} + */ +export function isAvailability (value) { + return states.has(value) +} + +/** + * Anything unrecognised reads as unknown, so a service answering something unexpected degrades to + * "no answer" rather than poisoning the badges. + * @param {any} value + * @returns {string} + */ +export function normalizeAvailability (value) { + return isAvailability(value) ? value : Availability.UNKNOWN +} + +/** + * Whether playback can start on this release now. The one question the player asks. + * @param {string} state + */ +export function streamsInstantly (state) { + return state === Availability.CACHED +} + +/** + * A hash's state out of an availability map, defaulting to unknown. The map is keyed by + * lowercase info hash. + * @param {Map | undefined} availability + * @param {string | undefined} hash + * @returns {string} + */ +export function availabilityOf (availability, hash) { + return (hash && availability?.get(String(hash).toLowerCase())) || Availability.UNKNOWN +} + +/** + * How a state is worded for the user, kept here so it reads the same wherever it appears. + * @param {string} state + * @param {string} [title] - The service's display name. + * @returns {{ label: string, description: string }} + */ +export function describeAvailability (state, title = 'your debrid service') { + switch (state) { + case Availability.CACHED: + return { label: 'Cached', description: `Cached on ${title}, streams instantly with no torrent peers involved.` } + case Availability.AVAILABLE: + return { label: 'Available', description: `${title} can fetch this release but does not hold it yet, so it cannot stream right now.` } + case Availability.UNAVAILABLE: + return { label: 'Unavailable', description: `${title} cannot serve this release at all.` } + default: + return { label: 'Unchecked', description: `${title} has not been asked about this release. It may still stream.` } + } +} diff --git a/common/modules/debrid/debrid.js b/common/modules/debrid/debrid.js new file mode 100644 index 00000000..c7ff6770 --- /dev/null +++ b/common/modules/debrid/debrid.js @@ -0,0 +1,349 @@ +import { files } from '@/components/MediaHandler.svelte' +import { settings } from '@/modules/settings.js' +import { status } from '@/modules/networking.js' +import { videoRx, subRx, fontRx } 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 DebridService, { availabilityFromError, secureFiles } from '@/modules/debrid/service.js' +import { debridServices, debridService } from '@/modules/debrid/services.js' +import { Availability, describeAvailability } from '@/modules/debrid/availability.js' +import { routeDebrid, debridKey } from '@/modules/debrid/route.js' +import Debug from 'debug' +const debug = Debug('ui:debrid') + +/** Selectable services for the settings menu, as plain data. */ +export const debridOptions = Object.values(debridServices).map(Service => ({ id: Service.id, title: Service.title })) + +/** Files worth resolving: the video, its subtitles, and the fonts those subtitles need. */ +const playbackRx = new RegExp(`${videoRx.source}|${subRx.source}|${fontRx.source}`, 'i') + +const REFRESH_INTERVAL = 60_000 + +// how long before asking again about releases a check could not answer, and how far that backs +// off while it keeps not answering. Without it a bad minute leaves a results list half badged +const RETRY_DELAY = 10_000 +const MAX_RETRY_DELAY = 4 * 60_000 + +/** What the user is told when the routing policy blocks playback. */ +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 +let lastRefresh = 0 + +/** Whether a debrid service is selected and has an API key. */ +export const debridEnabled = derived(settings, value => Boolean(debridService(value.debridService) && debridKey(value))) + +/** How playback is routed right now, or null when no service is selected. The UI reads this to describe the active transport. */ +export const debridTransport = derived(settings, value => { + const Service = debridService(value.debridService) + if (!Service) return null + const only = value.debridMode === 'only' + return { + title: Service.title, + only, + checksAddMagnets: Service.checkAddsMagnets, + label: only ? 'Debrid Only' : 'Debrid First', + description: only + ? `Debrid Only: playback always uses ${Service.title}, torrents never start.` + : `Debrid First: releases cached on ${Service.title} stream from it, anything uncached falls back to torrents.` + } +}) + +/** + * What the service can do with each release, keyed by lowercase info hash. The only place the UI + * reads cache state from; anything absent is unknown rather than uncached. + * @type {import('simple-store-svelte').Writable>} + */ +export const debridAvailability = writable(new Map()) + +/** Availability checks in flight, so the UI can show that badges are still filling in. */ +export const debridChecking = writable(0) + +/** + * Whether debrid owns what the player is showing. Set the moment playback is routed rather than + * once it resolves, since the player opens straight away and must not look like a torrent. + */ +export const debridPlayback = writable(false) + +// switching service or key takes effect immediately: tear the old instance down and drop the +// badges, which described a different account +settings.subscribe(value => { + const key = `${value.debridService}:${debridKey(value)}` + if (serviceKey !== null && serviceKey !== key) { + service?.destroy() + service = null + lastRefresh = 0 + cancelDebridAvailability() + debridAvailability.set(new Map()) + } + serviceKey = key +}) + +function getService () { + if (service) return service + const Service = debridService(settings.value.debridService) + const apiKey = debridKey(settings.value) + if (!Service || !apiKey) return null + debug(`Initializing debrid service ${Service.title}`) + service = new Service(apiKey) + 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() +} + +/** + * Streams a torrent through the configured debrid service. Returns true when playback was + * handled, false to fall back to torrents. Routing policy lives in route.js. + * @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 serviceSelected = Boolean(debridService(settings.value.debridService)) + const route = routeDebrid({ + torrentID, + hash, + serviceSelected, + // only build the service once the selection alone cannot decide the outcome + serviceReady: serviceSelected && Boolean(getService()), + offline: status.value === 'offline', + mode: settings.value.debridMode + }) + if (route.action === 'torrent') return handOver(false) + const debridOnly = route.only + if (route.action === 'block') { + toast.error('Debrid', { description: blockedMessages[route.reason]() }) + return handOver(true) // nothing plays, so nothing is owned + } + // claimed before the resolve, which takes seconds the player spends already open + debridPlayback.set(true) + try { + files.set(await resolveDebridFiles(route.id, search)) + return true + } catch (error) { + // playback is the most authoritative answer there is, worth more than the badge + const proven = availabilityFromError(error) + if (proven) { + debug(`${serviceTitle()} cannot stream this release: ${error.message}`) + recordAvailability(route.id, proven) + const { description } = describeAvailability(proven, serviceTitle()) + if (!debridOnly) { + toast('Debrid', { description: `${description}\nStreaming via torrent instead.` }) + return handOver(false) + } + toast.error('Debrid', { description: `${description}\nPick 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 handOver(false) + } + toast.error('Debrid Error', { description: '' + (error.message || error) }) + } + return handOver(true) // handled, only mode never falls back to the torrent client + } +} + +/** + * Releases the player back to the torrent client and reports the routing outcome. + * @param {boolean} handled - What streamDebrid returns to its caller. + */ +function handOver (handled) { + debridPlayback.set(false) + return handled +} + +/** + * 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 => playbackRx.test(name), + pickFile: Number.isFinite(episode) ? files => pickEpisodeFile(files, episode) : undefined + }) + const secure = secureFiles(resolved.files, serviceTitle()) + if (secure.length !== resolved.files.length) debug(`Discarded ${resolved.files.length - secure.length} non-HTTPS links from ${serviceTitle()}`) + // playing it proves the service holds it, which is the best answer there is + recordAvailability(resolved.hash, Availability.CACHED) + return Promise.all(secure.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 + }))) +} + +/** Refreshes what the account itself says, which is free. One request a minute at most. */ +export function refreshDebridAvailability () { + const active = getService() + if (!active) return + if (Date.now() - lastRefresh < REFRESH_INTERVAL || status.value === 'offline') return + lastRefresh = Date.now() + active.listAvailability().then(known => { + for (const [hash, state] of known) active.remember(hash, state) + if (current(active)) publishAvailability(known) + }).catch(error => { + lastRefresh = 0 + debug('Failed to list debrid torrents:', error) + }) +} + +/** + * Whether answers from this instance still describe the configured account. A request in flight + * outlives a settings change, and badging a new account with the old one's answers is worse than + * no badges at all. + * @param {import('@/modules/debrid/service.js').default} instance + */ +function current (instance) { + return instance === service +} + +/** @type {ReturnType | null} A retry waiting to ask about what went unanswered. */ +let retry = null +let retryDelay = RETRY_DELAY + +/** + * Asks the service about the releases on screen, so badges say what it can actually do with them + * rather than only what the account has touched. Answers are remembered, so browsing the same show + * again is free. A service may answer only part of the list, so whatever is left is asked about + * again on a backing off timer until it is done or the user moves on. + * @param {string[]} hashes - Candidates, most relevant first, since probing bites from the front. + */ +export async function checkDebridAvailability (hashes) { + cancelDebridAvailability() // this list supersedes whatever the last one was waiting to retry + const active = getService() + if (!active || !settings.value.debridCacheCheck || status.value === 'offline') return + const pending = active.unknownHashes(hashes) + if (!pending.length) return // everything here already has an answer + // a check already running owns the service, so this call only reads back what is remembered + const busy = active.sweeping + debridChecking.update(count => count + 1) + try { + // badge each release as its answer lands rather than when the sweep ends, so a probing + // service marks the list up as it goes instead of all at once a minute later + await active.checkAvailability(hashes, { onAnswer: (hash, state) => { if (current(active)) queueAvailability(hash, state) } }) + } catch (error) { + debug('Availability check failed:', error) + } finally { + debridChecking.update(count => count - 1) + } + if (!current(active)) return + const left = active.unknownHashes(hashes) + if (!left.length) { + retryDelay = RETRY_DELAY + return + } + // any progress means the service is willing to talk, so start over at the short wait. Only a + // round that got nowhere backs off + if (busy || left.length < pending.length) retryDelay = RETRY_DELAY + else retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY) + debug(`${left.length} of ${pending.length} releases unanswered, asking again in ${retryDelay}ms`) + retry = setTimeout(() => checkDebridAvailability(hashes), retryDelay) +} + +/** Drops a pending retry, for when the results it described are no longer on screen. */ +export function cancelDebridAvailability () { + if (retry) clearTimeout(retry) + retry = null +} + +/** + * Picks the requested episode out of a pack, using the same anitomy parsing as the rest of the + * app. Falls back to the largest video. + * @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 serviceTitle () { + return debridService(settings.value.debridService)?.title || 'debrid' +} + +/** + * Records one answer in both the service's memory and the badges. + * @param {string} magnetOrHash + * @param {string} state - An `Availability` value. + */ +function recordAvailability (magnetOrHash, state) { + const hash = DebridService.parseHash(magnetOrHash) + if (!hash) return + service?.remember(hash, state) + publishAvailability([[hash, state]]) +} + +/** @type {Map | null} Answers waiting to reach the store. */ +let queued = null + +/** + * Collects answers arriving together into one store write, so a batch answer does not re-render + * the list once per hash. A probing service answers slowly enough that each still lands alone. + * @param {string} hash + * @param {string} state + */ +function queueAvailability (hash, state) { + if (!queued) { + queued = new Map() + queueMicrotask(() => { + const answers = queued + queued = null + publishAvailability(answers) + }) + } + queued.set(hash, state) +} + +/** + * Publishes answers to the UI in one write. + * @param {Iterable<[string, string]>} answers + */ +function publishAvailability (answers) { + const entries = [...answers] + if (!entries.length) return + debridAvailability.update(known => { + const next = new Map(known) + for (const [hash, state] of entries) { + if (state === Availability.UNKNOWN) next.delete(hash) + else next.set(hash, state) + } + return next + }) +} + +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..ea338855 --- /dev/null +++ b/common/modules/debrid/metadata.js @@ -0,0 +1,216 @@ +import Metadata from 'matroska-metadata' +import { arr2hex, hex2bin } from 'uint8-util' +import { fontRx, matroskaRx, matchFontFiles, matchSubtitleFiles, 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 +// land this many seconds of video before a seek, so a rough byte estimate still covers it +const JUMP_BACK_SECONDS = 15 +const RETRIES = 3 +const MAX_ANDROID_FONT = 15_000_000 // matches the torrent client's guard + +/** + * 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 + } catch (error) { + // the only thing that aborts these is our own teardown, so end the stream + // quietly rather than surfacing a failure nobody can act on + if (error?.name !== 'AbortError') throw error + } 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 paced to stay just ahead of the + * play position. + */ +export default class DebridMetadata { + destroyed = false + /** @type {RemoteFile | null} */ + remote = null + /** @type {Metadata | null} */ + metadata = null + + /** + * @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 + + // external subtitles and the fonts they style with, matched exactly like the torrent client + // does so a season pack loads one episode's subs + const subFiles = matchSubtitleFiles(files, file.name) + debug(`Found ${subFiles.length} subtitle files`) + for (const sub of subFiles) { + 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)) + } + const fontFiles = matchFontFiles(files) + debug(`Found ${fontFiles.length} font files`) + for (const font of fontFiles) { + fetch(font.url).then(res => res.arrayBuffer()).then(data => { + if (this.destroyed || (SUPPORTS.isAndroid && data.byteLength > MAX_ANDROID_FONT)) return + subtitles.handleFile(hex2bin(arr2hex(new Uint8Array(data)))) + }).catch(error => debug(`Failed to fetch font file ${font.name}:`, error)) + } + + // everything below reads the Matroska container, which only these formats have + if (!matroskaRx.test(file.name)) { + debug('Not a Matroska container, skipping embedded metadata: ' + file.name) + return + } + this.remote = new RemoteFile(file.url, file.size, file.name) + this.metadata = new Metadata(this.remote) + // the parser starts several reads in its constructor, settle the ones nothing may ever await + // (duration when no tracks stream) so they cannot reject unhandled + for (const pending of [this.metadata.segment, this.metadata.seekHead, this.metadata.duration]) Promise.resolve(pending).catch(() => {}) + + this.metadata.getTracks().then(tracks => { + if (this.destroyed) return + debug(`Found ${tracks?.length} subtitle tracks`) + // nothing embedded to stream, drop the parser but stay alive for external subtitle files + if (!tracks.length) return this.#releaseParser() + 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 > MAX_ANDROID_FONT) continue + 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 }) + }) + } + + /** 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 + // the window of bytes fed to the parser so far, seeks outside it restart the stream + let start = 0 + let offset = 0 + for (let attempt = 0; attempt < RETRIES && !this.destroyed; ++attempt) { + try { + const stream = this.remote.slice(offset).stream() + let jumped = false + for await (const chunk of this.metadata.parseStream(stream, offset === 0)) { + if (this.destroyed) return + offset += chunk.length + const next = await this.#pace(byteRate, start, offset) + if (next !== offset) { + debug(`Seek left the parsed subtitle window, restarting stream at ${next}`) + start = offset = next + jumped = true + break + } + } + if (!jumped) return // parsed to the end of the file + attempt = -1 // following a seek is progress, not a failed attempt + } catch (error) { + if (this.destroyed) return + debug(`Subtitle stream interrupted at ${offset}, retrying:`, error) + await sleep(1_000 * (attempt + 1)) + } + } + } + + /** + * Waits until the parser should read further, and says where from: the current offset while + * playback approaches it, or a fresh one when a seek left the parsed window, where reading on + * sequentially would download everything in between for nothing. The renderer deduplicates + * events, so overlapping parses are safe. + */ + async #pace (byteRate, start, offset) { + if (!byteRate) return offset // no duration to estimate from, so read straight through + while (!this.destroyed) { + const position = this.getTime() * byteRate + const jump = Math.max(0, Math.floor(position - JUMP_BACK_SECONDS * byteRate)) + // a target beyond the file means the estimate overshot, sequential reading covers it + if (jump < this.file.size && (position < start || jump > offset)) return jump + // wait for playback to catch up before buffering further ahead + if (offset <= position + AHEAD_SECONDS * byteRate) return offset + await sleep(1_000) + } + return offset + } + + /** Releases the container parser and its range requests, leaving external subtitles alone. */ + #releaseParser () { + this.metadata?.removeAllListeners() + this.metadata?.destroy() + this.remote?.destroy() + this.metadata = null + this.remote = null + } + + destroy () { + if (this.destroyed) return + debug('Destroying debrid metadata parser') + this.destroyed = true + this.#releaseParser() + this.file = null + } +} diff --git a/common/modules/debrid/premiumize.js b/common/modules/debrid/premiumize.js new file mode 100644 index 00000000..b2c63554 --- /dev/null +++ b/common/modules/debrid/premiumize.js @@ -0,0 +1,148 @@ +// relative import keeps this module loadable under plain Node for API tests +import DebridService, { DebridError, DebridAuthError, DebridNotCachedError, DebridUnavailableError } from './service.js' +import { Availability } from './availability.js' +import Debug from 'debug' +const debug = Debug('ui:debrid') + +const API = 'https://www.premiumize.me/api' + +// error codes worth explaining, anything else falls back to the API's own message +const errorMessages = { + authentication_failed: 'Invalid Premiumize API key', + permission_denied: 'Premiumize denied the request, check the account', + account_limit_reached: 'This Premiumize account has used up its fair use points or active jobs', + service_limit_reached: 'This Premiumize account has reached its limit for this source', + rate_limit_reached: 'Premiumize is rate limiting this account, try again shortly', + service_down: 'Premiumize cannot reach this source right now', + service_unsupported: 'Premiumize cannot process this kind of source', + link_generation_failed: 'Premiumize could not generate a stream link, try again shortly' +} +// only these mean the key or account is the problem, the rest are per-request +const authCodes = ['authentication_failed', 'permission_denied'] +// the account cannot take more work right now, rather than this release being a problem +const throttleCodes = ['rate_limit_reached', 'account_limit_reached', 'service_limit_reached'] +// the same request will keep failing, so this release is not one Premiumize can serve +const deadCodes = ['service_unsupported', 'permanent_error'] + +/** + * Premiumize implementation, see https://www.premiumize.me/api + * + * The easiest service to support, having kept the endpoints the others dropped: `/cache/check` + * answers a whole results list for free, and `/transfer/directdl` returns every stream link for a + * magnet in one call without storing anything, so there is no add or cleanup path at all. + * + * `/transfer/list` never says which info hash a transfer came from, so badges come from the cache + * endpoint alone. + */ +export default class Premiumize extends DebridService { + static id = 'premiumize' + static title = 'Premiumize' + static available = true + // a real cache endpoint, so badges cost one request for the whole results list + static availabilityCheck = 'batch' + static maxBatch = 100 + static limits = { maxConcurrent: 3, minTime: 250 } // no documented allowance, so be modest + + /** Failures arrive inside a 200; the payload is top level rather than in an envelope. */ + unwrap (json) { + if (!json || typeof json !== 'object' || !('status' in json)) return json + if (json.status === 'error') throw this.mapError(200, json) + return json + } + + mapError (status, json) { + const code = json?.code + const message = errorMessages[code] || json?.message || `Request failed with status ${status}` + if (authCodes.includes(code) || ((status === 401 || status === 403) && !code)) return new DebridAuthError(message, { status, code }) + return new DebridError(message, { status, code }) + } + + /** @param {any} error */ + throttled (error) { + return super.throttled(error) || throttleCodes.includes(error?.code) + } + + async validate () { + const account = await this.request(`${API}/account/info`) + // free accounts stream cached content through the same endpoints, so premium is not required + if (!account?.customer_id) throw new DebridAuthError('Premiumize did not recognise this API key') + return { username: `Premiumize ${account.customer_id}`, expires: account.premium_until ? new Date(account.premium_until * 1_000).toISOString() : undefined } + } + + /** Nothing to read: transfers carry no info hash. The cache endpoint covers this instead. */ + async listAvailability () { + return new Map() + } + + /** @see listAvailability - nothing reads the account listing. */ + async fetchListing () { + return [] + } + + /** + * One request, however many releases, and it costs the account nothing. + * @param {string[]} hashes + */ + async checkAvailabilityBatch (hashes) { + const checked = await this.request(`${API}/cache/check`, { method: 'POST', body: { 'items[]': hashes.map(hash => Premiumize.toMagnet(hash)) } }) + const cached = checked?.response || [] // parallel arrays indexed by request order, not keyed by hash + return new Map(hashes.map((hash, index) => [hash, cached[index] ? Availability.CACHED : Availability.AVAILABLE])) + } + + async resolve (magnet, { fileFilter = () => true, pickFile, maxFiles = this.config.maxFiles } = {}) { + const hash = Premiumize.parseHash(magnet) + const magnetURI = Premiumize.toMagnet(magnet) + if (!magnetURI) throw new DebridError('Premiumize needs a magnet link or info hash to resolve') + const content = await this.#directdl(magnetURI) + const wanted = content + .map(entry => ({ name: filePath(entry).split('/').pop(), path: filePath(entry), size: Number(entry.size) || 0, url: entry.link })) + .filter(file => fileFilter(file.path)) + if (!wanted.length) throw new DebridError('No playable files in this torrent') + const target = pickFile ? await pickFile(wanted) : [...wanted].sort((a, b) => b.size - a.size)[0] + const files = Premiumize.windowFiles(wanted, target, maxFiles) + const name = torrentName(files) + debug(`Resolved ${files.length} files for ${name}`) + return { hash, name, files } + } + + /** + * Every stream link for a magnet, read out of the cache in one call. Touches nothing, so a + * miss comes back empty or as a code rather than needing a check first. + * @param {string} magnetURI + * @returns {Promise} The transfer's file entries. + */ + async #directdl (magnetURI) { + let transfer = null + try { + transfer = await this.request(`${API}/transfer/directdl`, { method: 'POST', body: { src: magnetURI } }) + } catch (error) { + // the API groups its codes by whether the same request could ever succeed, which maps + // straight onto what playback needs to know + if (deadCodes.includes(error?.code)) throw new DebridUnavailableError(error.message, { status: error.status, code: error.code }) + if (error?.code === 'not_found') throw new DebridNotCachedError() // it can still fetch it, just not now + throw error + } + const content = (transfer?.content || []).filter(entry => entry?.link) + if (!content.length) throw new DebridNotCachedError() // directdl only reads the cache + return content + } +} + +/** + * Paths arrive without a leading slash; Shiru's file objects are rooted like the torrent client's. + * @param {any} entry + */ +function filePath (entry) { + const path = entry?.path || '' + return path.startsWith('/') ? path : `/${path}` +} + +/** + * A name for the release, which directdl never states: the folder a pack sits under, or the file. + * @param {{ path: string, name: string }[]} files + */ +function torrentName (files) { + const [first] = files + const folder = first.path.split('/')[1] + return files.every(file => file.path.startsWith(`/${folder}/`)) ? folder : first.name +} diff --git a/common/modules/debrid/realdebrid.js b/common/modules/debrid/realdebrid.js new file mode 100644 index 00000000..9bd21489 --- /dev/null +++ b/common/modules/debrid/realdebrid.js @@ -0,0 +1,293 @@ +// relative import keeps this module loadable under plain Node for API tests +import DebridService, { DebridError, DebridAuthError, DebridNotCachedError, DebridTimeoutError, DebridUnavailableError, archiveRx } from './service.js' +import { Availability } from './availability.js' +import Debug from 'debug' +const debug = Debug('ui:debrid') + +const API = 'https://api.real-debrid.com/rest/1.0' +const LIST_LIMIT = 1_000 // the whole account in one request, newest first + +/** + * What each torrent status means for playback. Magnet conversion and file selection are left out + * on purpose: they are moments in a torrent's life, not outcomes, so they answer nothing. + * @type {Record} + */ +const statusAvailability = { + downloaded: Availability.CACHED, + // being fetched fresh, so Real-Debrid can serve it eventually but not now + queued: Availability.AVAILABLE, + downloading: Availability.AVAILABLE, + uploading: Availability.AVAILABLE, + compressing: Availability.AVAILABLE, + // will never complete + magnet_error: Availability.UNAVAILABLE, + error: Availability.UNAVAILABLE, + virus: Availability.UNAVAILABLE, + dead: Availability.UNAVAILABLE +} + +// error_code values worth explaining, anything else falls back to the API's own message +const errorMessages = { + 8: 'Invalid Real-Debrid API key', + 9: 'Real-Debrid denied the request, check the account permissions', + 21: 'Too many active Real-Debrid downloads, wait for one to finish', + 23: 'This Real-Debrid account has exhausted its traffic', + 34: 'Real-Debrid is rate limiting this account, try again shortly', + 35: 'Real-Debrid will not serve this file, pick a different release', + 36: 'Real-Debrid fair usage limit reached' +} +// only these mean the key or account is the problem, the rest are per-request +const authCodes = [8, 9] +// the account cannot take more work right now: rate limited, or at its active torrent cap +const throttleCodes = [21, 34] + +/** + * Real-Debrid implementation, see https://api.real-debrid.com/ + * + * Two API quirks shape this client: + * - `/torrents/instantAvailability` is disabled (403 `disabled_endpoint`), so a release can only + * be asked about by adding the magnet and reading the status back. Hence `probeAvailability`, + * and the base class cap on how many of those a search may cost. + * - Selecting multiple files can serve one RAR archive instead of individual links, so when that + * happens the torrent is re-added selecting only the target file. + */ +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 } + // no cache endpoint any more, so availability has to be probed a hash at a time + static availabilityCheck = 'probe' + + /** + * @type {number} Status reads a probe gives a magnet still converting before giving up. + * + * Real-Debrid holds the metadata for anything cached, so a cached release reaches file selection + * within a read or two; one still converting is telling us it is not. Counted in reads rather + * than seconds so a slow link does not change how many chances it gets. Giving up leaves the + * release unknown, so the next sweep asks again. + */ + static probeConversionReads = 2 + + /** @param {any} error */ + throttled (error) { + return super.throttled(error) || throttleCodes.includes(error?.code) + } + + mapError (status, json) { + const code = json?.error_code + const message = errorMessages[code] || json?.error || `Request failed with status ${status}` + // a blocked or unavailable file also answers 403, and must stay a plain error: an auth error + // aborts the whole resolve, where one bad file in a pack should only be skipped + if (authCodes.includes(code) || ((status === 401 || status === 403) && code == null)) return new DebridAuthError(message, { status, code }) + return new DebridError(message, { status, code }) + } + + 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 } + } + + /** The whole account in one request, shared by the base class between badges and playback. */ + async fetchListing () { + return (await this.request(`${API}/torrents?limit=${LIST_LIMIT}`)) || [] + } + + async listAvailability () { + const torrents = await this.listing() + const known = new Map() + for (const torrent of torrents) { + const state = statusAvailability[torrent.status] + if (state && torrent.hash) known.set(torrent.hash.toLowerCase(), state) + } + return known + } + + /** + * The only way Real-Debrid can still be asked about a release: add the magnet and read the + * status it settles on. A cached torrent reports 'downloaded' within a second of file selection. + * Costs about five requests, hence the cap in the base class. + * + * The torrent is always removed again, and adding a hash the account already holds creates a + * separate entry, so this can never delete the user's own download. + * @param {string} hash + * @returns {Promise} + */ + async probeAvailability (hash) { + const magnetURI = RealDebrid.toMagnet(hash) + if (!magnetURI) throw new DebridError('Not a usable info hash') // no answer, rather than a made up one + let torrentId = null + try { + torrentId = await this.#addAndSelect(magnetURI, () => true, { reads: RealDebrid.probeConversionReads }) + await this.#awaitStatus(torrentId, 'downloaded', this.budget('probe')) + return Availability.CACHED + } finally { + // awaited, so the account is as we found it by the time this answers. Playback and teardown + // both wait on it, so neither can trip over a half-finished probe + if (torrentId) await this.release(`${API}/torrents/delete/${torrentId}`) + } + } + + async resolve (magnet, { fileFilter = () => true, pickFile, maxFiles = this.config.maxFiles } = {}) { + const hash = RealDebrid.parseHash(magnet) + const magnetURI = RealDebrid.toMagnet(magnet) + let torrentId = null + let added = false + try { + // a cache probe of this same release is about to delete its own torrent, so let it finish + // first rather than reusing an id that is seconds from disappearing + await this.probes.get(hash)?.catch(() => {}) + // reuse a torrent that is already on the account instead of adding a duplicate + const existing = await this.#existingTorrent(hash) + let info = null + if (existing?.status === 'waiting_files_selection') { + // a stale add that never got its files selected, finish the job + const ids = existing.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 + // mid-conversion counts as not cached here: playback cannot wait for it either way + } else if (existing && existing.status !== 'downloaded') throw unstreamable(existing.status) ?? new DebridNotCachedError() + else if (existing) { + torrentId = existing.id + info = existing // already confirmed downloaded, with its files and links + } else { + torrentId = await this.#addAndSelect(magnetURI, fileFilter) + added = true + } + info ??= await this.#awaitStatus(torrentId, 'downloaded', this.budget('ready')) + + // work out which file playback is after before unrestricting, so a capped pack never drops + // the wanted episode and archives 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 + let files = await this.#unrestrictLinks(info, fileFilter, maxFiles, target) + 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, { fileId: target.id }) + // not awaited: the player is already open waiting on this, cleanup can catch up + if (added) this.release(`${API}/torrents/delete/${torrentId}`) + torrentId = retryId + added = true + info = await this.#awaitStatus(torrentId, 'downloaded', this.budget('ready')) + files = await this.#unrestrictLinks(info, fileFilter, 1, null) + 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) await this.release(`${API}/torrents/delete/${torrentId}`) + // playback has waited as long as it can, so a magnet still converting is one it cannot use. + // A probe leaves the same timeout unanswered instead, since Real-Debrid may just be slow + throw error instanceof DebridTimeoutError ? new DebridNotCachedError() : error + } + } + + /** + * The account's entry for an info hash, or null. Read back by id because the listing can be a + * minute stale, so one deleted elsewhere must read as absent rather than fail the resolve. + * @param {string} hash + * @returns {Promise} + */ + async #existingTorrent (hash) { + if (!hash) return null + const listed = (await this.listing()).find(torrent => torrent.hash?.toLowerCase() === hash) + if (!listed) return null + try { + return await this.request(`${API}/torrents/info/${listed.id}`) + } catch (error) { + if (error.status !== 404) throw error + debug(`Account listing named a torrent that is gone (${listed.id}), adding the magnet instead`) + this.forgetListing() + return null + } + } + + /** + * 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 {{ fileId?: number, reads?: number }} [opts] - `fileId` selects one file outright, + * `reads` caps how many status reads the magnet gets to convert, for probes. + * @returns {Promise} The new torrent id. + */ + async #addAndSelect (magnetURI, fileFilter, { fileId, reads } = {}) { + const torrentId = (await this.request(`${API}/torrents/addMagnet`, { method: 'POST', body: { magnet: magnetURI } }))?.id + this.forgetListing() // the account has a torrent the remembered listing does not + try { + const info = await this.#awaitStatus(torrentId, 'waiting_files_selection', this.budget('select'), reads) + 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) { + // awaited, so this call has undone itself by the time it reports failure + if (torrentId) await this.release(`${API}/torrents/delete/${torrentId}`) + throw error + } + } + + /** + * Unrestricts a torrent's links into direct stream files. The cached copy may serve fewer links + * than files selected, so filter by path when the lists align and by filename otherwise. + * Archives are dropped; the caller recovers via single file selection. + * @param {any} info + * @param {(name: string) => boolean} fileFilter + * @param {number} maxFiles + * @param {{ path: string } | null} [target] - The file playback wants, kept inside the cap. + */ + async #unrestrictLinks (info, fileFilter, maxFiles, target) { + 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 = RealDebrid.windowFiles(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 })), target, maxFiles) + // dead files are skipped by mapFiles; the caller checks the wanted episode came back + return this.mapFiles(candidates, 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 } + }, candidate => candidate.path || candidate.link) + } + + /** + * Polls torrent info until it reaches the wanted status. + * @param {string} id + * @param {string} wanted + * @param {number} timeout - Deadline in milliseconds. + * @param {number} [reads] - Status reads allowed, for callers that want a fixed number of chances. + */ + async #awaitStatus (id, wanted, timeout, reads = Infinity) { + const started = Date.now() + for (let read = 1; ; read++) { + const info = await this.request(`${API}/torrents/info/${id}`) + if (info.status === wanted || (wanted === 'waiting_files_selection' && info.status === 'downloaded')) return info + const settled = unstreamable(info.status) + if (settled) throw settled + // a rare release can sit in magnet_conversion a while, so running out of time proves nothing + // about it. Callers decide: playback treats it as uncached, a probe leaves it re-checkable + if (read >= reads || Date.now() - started > timeout) throw new DebridTimeoutError(`Timed out waiting for Real-Debrid (${info.status})`) + await new Promise(resolve => setTimeout(resolve, this.config.timeouts.poll).unref?.()) + } + } +} + +/** + * The typed answer a settled status stands for, or null while the torrent is still deciding. + * @param {string} status + * @returns {import('./service.js').DebridUnstreamableError | null} + */ +function unstreamable (status) { + switch (statusAvailability[status]) { + case Availability.UNAVAILABLE: return new DebridUnavailableError(`Real-Debrid could not process this torrent (${status})`) + case Availability.AVAILABLE: return new DebridNotCachedError() + default: return null + } +} diff --git a/common/modules/debrid/route.js b/common/modules/debrid/route.js new file mode 100644 index 00000000..d3323e9c --- /dev/null +++ b/common/modules/debrid/route.js @@ -0,0 +1,63 @@ +// Pure debrid policy, free of UI imports so it can be tested under plain Node: how a play +// request is routed, and which search results are listed. +import { Availability, streamsInstantly } from './availability.js' + +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', only: boolean } | { action: 'block', reason: 'key' | 'offline' | 'source', only: boolean } | { action: 'resolve', id: string, only: boolean }} + * `only` reports whether debrid only mode governs the decision, so callers need not re-derive it. + */ +export function routeDebrid ({ torrentID, hash, serviceSelected, serviceReady, offline, mode }) { + // with no service selected debrid is entirely out of the picture, only mode included + if (!serviceSelected) return { action: 'torrent', only: false } + const only = mode === 'only' + if (!serviceReady) return only ? { action: 'block', reason: 'key', only } : { action: 'torrent', only } + if (offline) return only ? { action: 'block', reason: 'offline', only } : { action: 'torrent', only } + const id = usable(torrentID) || usable(hash) + if (!id) return only ? { action: 'block', reason: 'source', only } : { action: 'torrent', only } + return { action: 'resolve', id, only } +} + +/** + * The API key stored for a debrid service. Every service keeps its own, so switching in settings + * swaps the key rather than losing it, and one service's key can never reach another's API. + * @param {{ debridApiKeys?: Record }} settings + * @param {string} [service] - Service id, defaulting to the selected one. + * @returns {string} Empty when that service has no key yet. + */ +export function debridKey (settings, service = settings?.debridService) { + return (service && settings?.debridApiKeys?.[service]) || '' +} + +/** + * Whether a search result belongs in the listed results rather than the hidden ones. With no + * filters this is upstream's rule, widened only because a cached release streams without seeders. + * The cached filter narrows it to confirmed hits, and debrid only mode hides releases the service + * cannot serve. An *available* release is deliberately not widened in: the service would still + * have to pull it from the swarm first. + * @param {{ seeders?: number, source?: { managed?: boolean } }} result + * @param {string} [availability] - What the service said about this release. + * @param {{ cachedOnly?: boolean, only?: boolean }} [options] - The debrid filters in force. + * @returns {boolean} + */ +export function listResult (result, availability, { cachedOnly, only } = {}) { + const cached = streamsInstantly(availability) + if (cachedOnly) return cached + if (only && availability === Availability.UNAVAILABLE) return false + return result?.seeders > 0 || Boolean(result?.source?.managed) || cached +} diff --git a/common/modules/debrid/service.js b/common/modules/debrid/service.js new file mode 100644 index 00000000..24db4d15 --- /dev/null +++ b/common/modules/debrid/service.js @@ -0,0 +1,722 @@ +// No UI imports here, so this module also runs under plain Node for testing. +import Bottleneck from 'bottleneck' +import { Availability, AVAILABILITY_TTL, normalizeAvailability } from './availability.js' +import Debug from 'debug' +const debug = Debug('ui:debrid') + +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 + } +} + +/** 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' + } +} + +/** 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' + } +} + +/** + * The service kept us waiting past the budget. It says nothing about the release, so whoever + * asked decides what to make of it: playback cannot wait, a badge check can come back later. + */ +export class DebridTimeoutError extends DebridError { + constructor (message, opts) { + super(message, opts) + this.name = 'DebridTimeoutError' + } +} + +/** + * Playback cannot use this release now, carrying the availability it proves. Callers read + * `availability` off the error rather than matching on error types. + * @abstract + */ +export class DebridUnstreamableError extends DebridError { + /** @type {string} What this error proves about the release. */ + availability = Availability.UNKNOWN +} + +/** The service would have to download the torrent before it could stream it. */ +export class DebridNotCachedError extends DebridUnstreamableError { + constructor (message = 'Torrent is not cached on the debrid service', opts) { + super(message, opts) + this.name = 'DebridNotCachedError' + this.availability = Availability.AVAILABLE + } +} + +/** The service cannot serve this release at all: a dead magnet, a rejected or failed torrent. */ +export class DebridUnavailableError extends DebridUnstreamableError { + constructor (message = 'The debrid service cannot serve this torrent', opts) { + super(message, opts) + this.name = 'DebridUnavailableError' + this.availability = Availability.UNAVAILABLE + } +} + +/** Thrown by the base class for any method a service has not filled in yet. */ +export class DebridNotImplementedError extends DebridError { + /** @param {string} title - The service's display name. */ + constructor (title) { + super(`${title || 'This debrid service'} support is not implemented yet`) + this.name = 'DebridNotImplementedError' + } +} + +/** + * What an error proves about a release, or null when it proves nothing. A timeout or a rate limit + * describes the moment, not the release, so it leaves it unknown and re-checkable. + * @param {any} error + * @returns {string | null} + */ +export function availabilityFromError (error) { + return error instanceof DebridUnstreamableError ? normalizeAvailability(error.availability) : null +} + +/** + * @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 stream URL, must be HTTPS, the player streams it as is. + * @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. + */ + +/** + * Drops any link that is not HTTPS. Debrid links are account bound, so a cleartext one would put + * the user's traffic and their link on the wire in the clear. + * @param {DebridFile[]} files + * @param {string} title - Service name, for the message the user sees. + * @returns {DebridFile[]} + */ +export function secureFiles (files, title) { + const secure = (files || []).filter(file => /^https:\/\//i.test(file?.url)) + if (!secure.length) throw new DebridError(`${title} returned no secure stream links`) + return secure +} + +/** Archives a service may serve instead of streamable files, when it repacks a selection. */ +export const archiveRx = /\.(rar|zip|7z)$/i + +const magnetHashRx = /urn:btih:([a-f\d]{40})/i +const bareHashRx = /^[a-f\d]{40}$/i + +const RATE_LIMIT_RETRIES = 2 +const RATE_LIMIT_FALLBACK = 5 // seconds to wait when a 429 carries no retry-after header +const NETWORK_RETRY_DELAY = 3_000 +const MAX_PROBE_FAILURES = 3 // consecutive unanswered probes before a sweep gives up +const MAX_STRETCH = 3 // how far a poll budget may stretch on a slow link +const MAX_CLEANUP_ATTEMPTS = 3 + +/** + * Base class for debrid services: rate limited requests, typed errors, availability bookkeeping. + * Implementations only talk HTTP; state is per-instance so services stay swappable. + * + * Adding a service means subclassing this, setting the statics below, and implementing the + * abstract methods. Nothing outside the new file changes apart from one entry in `services.js`. + * @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 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 } + /** @type {'bearer' | 'query'} How the API key travels: an Authorization header or a query parameter. */ + static auth = 'bearer' + /** @type {string} Query parameter name used when `auth` is 'query'. */ + static authParam = 'apikey' + /** @type {'form' | 'json' | 'multipart'} How request bodies are encoded, overridable per request. */ + static encoding = 'form' + + /** Time limits in milliseconds. All but `request` are poll budgets, read through `budget()`. */ + static timeouts = { + request: 30_000, // hard ceiling on one round trip, deliberately does not stretch + select: 12_000, // waiting for the service to accept a magnet and expose its file list + ready: 5_000, // waiting for a cached torrent to report ready, anything slower is a fresh download + poll: 1_000, // gap between status polls + probe: 10_000 // tighter than `select`: a probe that drags on spends requests playback needs + } + + /** @type {number} Round trip time the limits above are written for, in milliseconds. */ + static nominalLatency = 300 + + /** @type {number} Probes running at once. Small, since each briefly owns a torrent on the account. */ + static maxProbeConcurrency = 3 + + /** @type {number} Most files one resolve turns into stream links, guards against huge season packs. */ + static maxFiles = 60 + + /** + * @type {'batch' | 'probe' | 'none'} How the service can be asked about a release it has not + * seen. 'batch' answers many hashes per request, 'probe' adds the magnet and reads the status + * back, 'none' leaves badges to `listAvailability`. + */ + static availabilityCheck = 'none' + + /** @type {number} Hashes per batch request. */ + static maxBatch = 100 + + /** + * Whether asking about a release puts a magnet on the account rather than reading a cache index. + * Two things follow: only one such check may be in flight, and a hash the answer leaves out is + * unasked rather than "not cached". + * @returns {boolean} + */ + static get checkAddsMagnets () { + return this.availabilityCheck === 'probe' + } + + /** @type {number} Most probes one results list may cost, since each is several requests. */ + static maxProbes = 10 + + /** How far down a results list this service looks. Override where a batch still pays per hash. */ + static get maxAsk () { + return this.availabilityCheck === 'probe' ? this.maxProbes : Infinity + } + + /** @type {Record} How long each answer stays trusted, overridable per service. */ + static availabilityTTL = AVAILABILITY_TTL + + /** @type {number} How long the account listing is reused. Matched to the badge refresh. */ + static listingTTL = 60_000 + + /** @type {{ at: number, promise: Promise } | null} The listing read, shared by everyone waiting on it. */ + #listing = null + + /** @type {Map} Removals that failed, to try again. */ + #orphans = new Map() + + /** @param {string} apiKey */ + constructor (apiKey) { + this.apiKey = apiKey + this.rateLimitPromise = null + /** @type {number} Rolling estimate of one round trip, 0 until the first answer. */ + this.latency = 0 + /** @type {Map} What the service has already said about a hash. */ + this.availabilityState = new Map() + /** @type {Map>} Probes in flight, so a hash is never asked about twice at once. */ + this.probes = new Map() + /** @type {boolean} Whether a check that adds magnets is running, since only one may be. */ + this.sweeping = false + this.limiter = new Bottleneck(this.config.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 < RATE_LIMIT_RETRIES) { + const time = (Number(error.retryAfter) || RATE_LIMIT_FALLBACK) * 1_000 + debug(`Rate limited by ${this.config.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 NETWORK_RETRY_DELAY // single retry for network hiccups + }) + this.request = this.limiter.wrap(this.#request.bind(this)) + } + + /** + * Undoes something this client created on the account. Never throws: it runs from `finally`, + * where it would mask the real failure. A removal that fails is remembered and retried, since + * the limiter does not retry the offline case that most often causes one. + * @param {string} url + * @param {Parameters[1]} [opts] + */ + async release (url, opts) { + try { + await this.request(url, { method: 'DELETE', ...opts }) + this.#orphans.delete(url) + } catch (error) { + if (error?.status === 404) return this.#orphans.delete(url) // already gone is what we wanted + const attempts = (this.#orphans.get(url)?.attempts ?? 0) + 1 + debug(`Cleanup failed for ${url} (attempt ${attempts}): ${error.message}`) + if (attempts < MAX_CLEANUP_ATTEMPTS) this.#orphans.set(url, { url, opts, attempts }) + else this.#orphans.delete(url) + } finally { + this.forgetListing() // the account just changed, whether or not the removal worked + } + } + + /** + * Retries removals that failed earlier. Only ever replays a removal `release()` was already + * asked to make, so it can no more reach a torrent the user wanted than the original call could. + * Never persisted: a stale id would eventually name something else. + */ + async retryCleanup () { + const pending = [...this.#orphans.values()] + if (!pending.length) return + debug(`Retrying ${pending.length} removal(s) that failed earlier`) + await Promise.all(pending.map(entry => this.release(entry.url, entry.opts))) + } + + /** How many removals are still outstanding. */ + get orphaned () { + return this.#orphans.size + } + + /** + * The account's own torrent listing, read at most once per `listingTTL` and shared by every + * caller. Both the badge refresh and every resolve want it, and reading it per play put a full + * listing on the play path. An entry can be a minute stale, so callers confirm it before acting + * on its id. + * @param {{ fresh?: boolean }} [opts] - `fresh` forces a read, for polling a change just made. + * @returns {Promise} + */ + listing ({ fresh = false } = {}) { + const known = this.#listing + if (!fresh && known && Date.now() - known.at < this.config.listingTTL) return known.promise + const entry = { at: Date.now(), promise: this.fetchListing() } + entry.promise.catch(() => { if (this.#listing === entry) this.#listing = null }) // never remember a failed read + this.#listing = entry + return entry.promise + } + + /** Drops the remembered listing, because the account just changed. */ + forgetListing () { + this.#listing = null + } + + /** + * Reads the account's torrents. Everything else goes through `listing()`. + * @abstract + * @returns {Promise} + */ + async fetchListing () { throw new DebridNotImplementedError(this.config.title) } + + /** The subclass's static configuration, typed so implementations get completions. */ + get config () { + return /** @type {typeof DebridService} */ (this.constructor) + } + + /** + * The lowercase info hash of a magnet URI or bare hash, empty when there is none. + * @param {any} magnetOrHash + * @returns {string} + */ + static parseHash (magnetOrHash) { + if (typeof magnetOrHash !== 'string') return '' + return (magnetHashRx.exec(magnetOrHash)?.[1] || (bareHashRx.test(magnetOrHash) ? magnetOrHash : '')).toLowerCase() + } + + /** + * A magnet URI to hand to the API, from a magnet URI or bare info hash. + * @param {any} magnetOrHash + * @returns {string} Empty when the input holds no usable hash. + */ + static toMagnet (magnetOrHash) { + if (typeof magnetOrHash === 'string' && magnetOrHash.startsWith('magnet:')) return magnetOrHash + const hash = this.parseHash(magnetOrHash) + return hash ? `magnet:?xt=urn:btih:${hash}` : '' + } + + /** + * Applies the service's authentication scheme. Some APIs authenticate one odd endpoint + * differently, hence the per-request override. + * @param {string} url + * @param {{ auth?: 'bearer' | 'query', authParam?: string }} [override] + * @returns {{ url: string, headers: Record }} + */ + authorize (url, { auth = this.config.auth, authParam = this.config.authParam } = {}) { + if (auth !== 'query') return { url, headers: { Authorization: `Bearer ${this.apiKey}` } } + const target = new URL(url) + target.searchParams.set(authParam, this.apiKey) + return { url: target.href, headers: {} } + } + + /** + * Encodes a request body. An array value becomes the same key repeated per item, which is how + * `name[]` parameters are read; joining them into one value is silently taken as one item. + * @param {Record} body + * @param {'form' | 'json' | 'multipart'} encoding + * @returns {{ body: any, headers: Record }} + */ + static encodeBody (body, encoding) { + if (encoding === 'json') return { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } } + const fields = Object.entries(body).flatMap(([key, value]) => Array.isArray(value) ? value.map(item => [key, item]) : [[key, value]]) + if (encoding === 'multipart') { + const form = new FormData() + for (const [key, value] of fields) form.append(key, String(value)) + return { body: form, headers: {} } // fetch sets the content type, boundary included + } + const params = new URLSearchParams() + for (const [key, value] of fields) params.append(key, String(value)) + return { body: params.toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + } + + /** + * @param {string} url - Absolute request URL. + * @param {{ method?: string, body?: Record, encoding?: 'form' | 'json' | 'multipart', auth?: 'bearer' | 'query', authParam?: string, timeout?: number }} [opts] + */ + async #request (url, { method = 'GET', body, encoding = this.config.encoding, auth, authParam, timeout = this.config.timeouts.request } = {}) { + await this.rateLimitPromise + if (!this.apiKey) throw new DebridAuthError('No debrid API key configured') + const authorized = this.authorize(url, { auth, authParam }) + const encoded = body ? DebridService.encodeBody(body, encoding) : null + debug(`${method} ${url}`) // the caller's url, never the authorized one, so a key in a query parameter stays out of the log + const sent = Date.now() + const res = await fetch(authorized.url, { + method, + headers: { ...authorized.headers, ...encoded?.headers }, + body: encoded?.body, + signal: AbortSignal.timeout(timeout) + }) + this.observeLatency(Date.now() - sent) // only round trips that came back, so a timeout cannot inflate it + // 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 this.unwrap(await res.json().catch(() => null)) // some endpoints return an empty body on success + } + + /** + * Folds one round trip into the latency estimate, weighted towards recent requests. + * @param {number} ms + */ + observeLatency (ms) { + this.latency = this.latency ? Math.round(this.latency * 0.7 + ms * 0.3) : ms + } + + /** + * A poll budget stretched to fit the connection in use, up to `MAX_STRETCH`. The defaults are + * written for a healthy link; on a slow one the same budget buys a single request, so every poll + * loop times out and reports no answer. + * @param {string} kind - A key of the service's `timeouts`. + * @returns {number} + */ + budget (kind) { + const base = this.config.timeouts[kind] + return Math.round(base * Math.min(Math.max(1, this.latency / this.config.nominalLatency), MAX_STRETCH)) + } + + /** + * Whether an error means the service wants fewer requests rather than that this release is a + * problem. A sweep stops on one. Override for service specific codes. + * @param {any} error + * @returns {boolean} + */ + throttled (error) { + return error?.status === 429 + } + + /** + * Unpacks a successful response body. Override for APIs that wrap everything in an envelope and + * report failures inside a 200 โ€” throwing from here routes those through the same typed errors. + * @param {any} json + * @returns {any} + */ + unwrap (json) { + return json + } + + /** + * 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 }) + } + + /** + * Caps a pack's file list around the file playback wants rather than taking the first N. The + * wanted episode is often a late one, and dropping it forces an expensive re-add. Torrent order + * is preserved so in-player next/previous still works. + * @template T + * @param {T[]} files - Candidate files, in torrent order. + * @param {T | null} target - The file playback asked for, or null when there is none. + * @param {number} [maxFiles] + * @param {(file: T) => any} [key] - Identity used to locate the target among the candidates. + * @returns {T[]} + */ + static windowFiles (files, target, maxFiles = this.maxFiles, key = file => file?.path) { + if (files.length <= maxFiles) return files + const index = target ? files.findIndex(file => key(file) === key(target)) : -1 + const start = index < 0 ? 0 : Math.min(Math.max(0, index - (maxFiles >> 1)), files.length - maxFiles) + debug(`Pack holds ${files.length} files, taking ${maxFiles} from index ${start}`) + return files.slice(start, start + maxFiles) + } + + /** + * Turns candidates into stream links concurrently, skipping ones the service cannot serve โ€” + * packs do contain dead files, and one must not fail the whole resolve. Auth failures still + * abort, since every other link would fail the same way. + * @template T + * @param {T[]} candidates + * @param {(candidate: T) => Promise} toFile - Unrestricts one file, may return null to drop it. + * @param {(candidate: T) => string} [describe] - Names a candidate for the debug log. + * @returns {Promise} + */ + async mapFiles (candidates, toFile, describe = candidate => candidate?.path || 'file') { + const files = await Promise.all(candidates.map(async candidate => { + try { + return await toFile(candidate) + } catch (error) { + if (error instanceof DebridAuthError) throw error + debug(`Skipping ${describe(candidate)}: ${error.message}`) + return null + } + })) + return files.filter(Boolean) + } + + /** + * Records what is known about a release so later checks are free. Playback feeds this too. + * Unknown is not an answer, so recording it forgets what was there. + * @param {string} magnetOrHash + * @param {string} state - An `Availability` value. + */ + remember (magnetOrHash, state) { + const hash = DebridService.parseHash(magnetOrHash) + if (!hash) return + const known = normalizeAvailability(state) + if (known === Availability.UNKNOWN) this.availabilityState.delete(hash) + else this.availabilityState.set(hash, { state: known, at: Date.now() }) + } + + /** + * A remembered answer that has not expired, or undefined when the hash needs asking about. + * @param {string} hash + * @returns {string | undefined} + */ + #recall (hash) { + const known = this.availabilityState.get(hash) + if (!known) return undefined + if (Date.now() - known.at < (this.config.availabilityTTL[known.state] ?? 0)) return known.state + this.availabilityState.delete(hash) // stale, ask again + return undefined + } + + /** + * The given hashes nothing is known about yet, in the order supplied. Callers use this to skip + * work entirely, not to decide what to ask about. + * @param {string[]} magnetsOrHashes + * @returns {string[]} + */ + unknownHashes (magnetsOrHashes) { + return DebridService.#normalize(magnetsOrHashes, this.config.maxAsk).filter(hash => this.#recall(hash) === undefined && !this.probes.has(hash)) + } + + /** + * Lowercase, deduplicated hashes, order preserved. + * @param {string[]} magnetsOrHashes + * @param {number} [limit] - Stop after this many, so a long list costs nothing to trim. + */ + static #normalize (magnetsOrHashes, limit = Infinity) { + const hashes = new Set() + for (const entry of magnetsOrHashes || []) { + const hash = DebridService.parseHash(entry) + if (hash) hashes.add(hash) + if (hashes.size >= limit) break + } + return [...hashes] + } + + /** + * What the service can do with each of the given releases. Remembered answers come back free, + * the rest are asked about the cheapest way the service supports. Hashes that stay unanswered + * are absent from the result, which callers must read as unknown rather than "not cached". + * @param {string[]} magnetsOrHashes - Candidates, most relevant first, since probing bites from the front. + * @param {{ onAnswer?: (hash: string, state: string) => void }} [opts] - Fires as each answer lands. + * @returns {Promise>} Hash to `Availability`, answered hashes only. + */ + async checkAvailability (magnetsOrHashes, { onAnswer } = {}) { + const answers = new Map() + const mode = this.config.availabilityCheck + const candidates = DebridService.#normalize(magnetsOrHashes, this.config.maxAsk) + const unknown = [] + for (const hash of candidates) { + const known = this.#recall(hash) + if (known === undefined) unknown.push(hash) + else answers.set(hash, known) + } + if (!unknown.length || mode === 'none') return answers + + const answer = (hash, state) => { + answers.set(hash, state) + onAnswer?.(hash, state) + } + + // one at a time where asking adds magnets: services rate limit adding far harder than reading, + // so overlapping checks do not answer faster, they get refused + if (this.config.checkAddsMagnets) { + if (this.sweeping) return answers + this.sweeping = true + } + try { + if (this.orphaned) await this.retryCleanup() // clear our own leftovers before adding more + if (mode === 'batch') await this.#batch(unknown, answer) + else await this.#sweep(unknown, answer) + } finally { + this.sweeping = false + } + return answers + } + + /** + * Asks about the hashes in as few requests as the service allows. + * @param {string[]} hashes + * @param {(hash: string, state: string) => void} answer + */ + async #batch (hashes, answer) { + for (let index = 0; index < hashes.length; index += this.config.maxBatch) { + const chunk = hashes.slice(index, index + this.config.maxBatch) + const states = await this.checkAvailabilityBatch(chunk) + for (const hash of chunk) { + // a cache endpoint that answered without mentioning a hash has said it does not hold it. + // A check that adds magnets has only said it never got to it + const state = normalizeAvailability(states?.get(hash) ?? (this.config.checkAddsMagnets ? Availability.UNKNOWN : Availability.AVAILABLE)) + this.remember(hash, state) + if (state !== Availability.UNKNOWN) answer(hash, state) + } + } + } + + /** + * Probes hashes a few at a time, stopping early once the service is in no state to answer more. + * Stopping is not finishing: what is left stays unknown and the caller comes back to it. + * @param {string[]} hashes + * @param {(hash: string, state: string) => void} answer + */ + async #sweep (hashes, answer) { + const queue = [...hashes] + let failures = 0 + let stopped = null + const worker = async () => { + while (queue.length && !stopped) { + const hash = queue.shift() + try { + answer(hash, await this.#probe(hash)) + failures = 0 + } catch (error) { + if (error instanceof DebridAuthError) { stopped = error; return } // every other probe would fail too + debug(`Availability probe failed for ${hash}: ${error.message}`) + if (this.throttled(error) || ++failures >= MAX_PROBE_FAILURES) stopped = error + } + } + } + await Promise.all(Array.from({ length: Math.min(this.config.maxProbeConcurrency, queue.length) }, worker)) + if (stopped) debug(`Probe sweep stopped with ${queue.length} hashes left to ask about: ${stopped.message}`) + if (stopped instanceof DebridAuthError) throw stopped + } + + /** + * Runs one probe, shared with any caller already waiting on that hash. Only a reported state or + * a definite error counts as an answer; anything else throws, so the release stays re-checkable. + * @param {string} hash + * @returns {Promise} Never resolves to `unknown`. + */ + #probe (hash) { + let pending = this.probes.get(hash) + if (!pending) { + pending = this.probeAvailability(hash) + .then(normalizeAvailability, error => { + const proven = availabilityFromError(error) + if (!proven) throw error + return proven + }) + .then(state => { + if (state === Availability.UNKNOWN) throw new DebridError(`${this.config.title} gave no usable answer for ${hash}`) + this.remember(hash, state) + return state + }) + .finally(() => this.probes.delete(hash)) + this.probes.set(hash, pending) + } + return pending + } + + /** + * What the service can do with one release, for APIs with no cache endpoint. Must leave the + * account exactly as it found it. Return an `Availability`, or throw `DebridNotCachedError` / + * `DebridUnavailableError`. Let everything else throw untyped: the base class reads any other + * error as "no answer". + * @abstract + * @param {string} hash - Lowercase info hash. + * @returns {Promise} + */ + async probeAvailability (hash) { throw new DebridNotImplementedError(this.config.title) } + + /** + * Asks about many releases at once, for APIs with a cache endpoint. Chunking to `maxBatch` is + * already done. Hashes left out are recorded as available unless `checkAddsMagnets`. + * @abstract + * @param {string[]} hashes - Lowercase info hashes. + * @returns {Promise>} Hash to `Availability`. + */ + async checkAvailabilityBatch (hashes) { throw new DebridNotImplementedError(this.config.title) } + + /** + * Verifies the API key and that the account can stream torrents. + * @abstract + * @returns {Promise<{ username: string, expires?: string }>} + */ + async validate () { throw new DebridNotImplementedError(this.config.title) } + + /** + * What the account itself says: what it holds is cached, what it is fetching is available, what + * failed on it is unavailable. The free badge source, where the API exposes info hashes. + * @abstract + * @returns {Promise>} Lowercase info hash to `Availability`. + */ + async listAvailability () { throw new DebridNotImplementedError(this.config.title) } + + /** + * Resolves a magnet to direct stream URLs. Throws `DebridNotCachedError` when the service would + * have to download it first, `DebridUnavailableError` when it cannot serve it. URLs must be HTTPS. + * @abstract + * @param {string} magnet - Magnet URI or bare info hash. + * @param {{ fileFilter?: (name: string) => boolean, pickFile?: (files: { id: number, path: string, size: number }[]) => Promise, maxFiles?: number }} [opts] + * @returns {Promise} + */ + async resolve (magnet, opts) { throw new DebridNotImplementedError(this.config.title) } + + /** Cancels queued requests, the instance must not be used afterwards. */ + destroy () { + this.availabilityState.clear() + this.sweeping = false + // running probes own a torrent until they tear it down, so the limiter has to outlive them, + // and anything left unremoved gets a last attempt โ€” nothing else holds those ids + const pending = [...this.probes.values()] + this.probes.clear() + Promise.allSettled(pending) + .then(() => this.retryCleanup()) + .catch(() => {}) + .finally(() => this.limiter.stop({ dropWaitingJobs: true }).catch(() => {})) + } +} diff --git a/common/modules/debrid/services.js b/common/modules/debrid/services.js new file mode 100644 index 00000000..3a9fed77 --- /dev/null +++ b/common/modules/debrid/services.js @@ -0,0 +1,20 @@ +// The service registry. Adding a debrid service means writing its file and listing it here; +// nothing else in the app names a service. No UI imports, so tests can read it under plain Node. +import AllDebrid from './alldebrid.js' +import Premiumize from './premiumize.js' +import RealDebrid from './realdebrid.js' +import TorBox from './torbox.js' + +/** In the order the settings menu offers them. Unfinished services stay hidden. */ +export const debridServices = Object.fromEntries([AllDebrid, Premiumize, RealDebrid, TorBox] + .filter(Service => Service.available) + .map(Service => [Service.id, Service])) + +/** + * The service class for an id, or null. + * @param {string} [id] + * @returns {typeof import('./service.js').default | null} + */ +export function debridService (id) { + return debridServices[id] || null +} diff --git a/common/modules/debrid/torbox.js b/common/modules/debrid/torbox.js new file mode 100644 index 00000000..c7de0062 --- /dev/null +++ b/common/modules/debrid/torbox.js @@ -0,0 +1,256 @@ +// relative import keeps this module loadable under plain Node for API tests +import DebridService, { DebridError, DebridAuthError, DebridNotCachedError, DebridUnavailableError } from './service.js' +import { Availability } from './availability.js' +import Debug from 'debug' +const debug = Debug('ui:debrid') + +const API = 'https://api.torbox.app/v1/api' +const LIST_LIMIT = 1_000 // the whole account in one request; the API pages at 1000 +const SEED_NEVER = 3 // 1 auto, 2 always, 3 never. Shiru only streams, so never seed + +// error codes worth explaining, anything else falls back to the API's own detail line +const errorMessages = { + BAD_TOKEN: 'Invalid TorBox API key', + AUTH_ERROR: 'TorBox rejected the API key', + NO_AUTH: 'TorBox requires an API key for this request', + PLAN_RESTRICTED_FEATURE: 'This TorBox plan does not include the feature Shiru needs', + ACTIVE_LIMIT: 'Too many active TorBox downloads, wait for one to finish', + MONTHLY_LIMIT: 'This TorBox account has reached its monthly limit', + COOLDOWN_LIMIT: 'TorBox is cooling this account down, try again shortly', + DOWNLOAD_TOO_LARGE: 'This release is larger than the TorBox plan allows', + DOWNLOAD_SERVER_ERROR: 'TorBox could not reach its download server, try again shortly', + NO_SERVERS_AVAILABLE_ERROR: 'No TorBox download servers are available right now' +} +// only these mean the key or plan is the problem, the rest are per-request +const authCodes = ['BAD_TOKEN', 'AUTH_ERROR', 'NO_AUTH', 'PLAN_RESTRICTED_FEATURE'] +// download_state values that mean the torrent will never finish on its own +const deadStates = /(stalled|error|failed|missing)/i + +/** + * TorBox implementation, see https://api-docs.torbox.app/ + * + * Three things shape this client: `/torrents/checkcached` answers many hashes in one request, + * every response is wrapped in `{ success, data }` with failures arriving inside a 200, and + * `/torrents/requestdl` authenticates with a `token` query parameter rather than a bearer header. + */ +export default class TorBox extends DebridService { + static id = 'torbox' + static title = 'TorBox' + static available = true + // documented allowance is 300 a minute per endpoint; spending it as if it covered all of them + // keeps a season pack's worth of link requests well inside it + static limits = { reservoir: 300, reservoirRefreshAmount: 300, reservoirRefreshInterval: 60_000, maxConcurrent: 3, minTime: 200 } + // a real cache endpoint, so badges cost one request for the whole results list + static availabilityCheck = 'batch' + // hashes travel as repeated query parameters, so the chunk size keeps the URL sane + static maxBatch = 75 + + /** Failures arrive inside a 200, so success is decided here rather than by the status code. */ + unwrap (json) { + if (!json || typeof json !== 'object' || !('success' in json)) return json + if (!json.success) throw this.mapError(200, json) + return json.data + } + + mapError (status, json) { + const code = json?.error + // `detail` is usually a sentence, but a rejected request answers with a list of field + // problems instead, which is for the log rather than the user + const detail = typeof json?.detail === 'string' ? json.detail : '' + const message = errorMessages[code] || detail || (typeof code === 'string' && code) || `Request failed with status ${status}` + if (authCodes.includes(code) || ((status === 401 || status === 403) && !code)) return new DebridAuthError(message, { status, code }) + return new DebridError(message, { status, code }) + } + + async validate () { + const user = await this.request(`${API}/user/me?settings=false`) + if (!user) throw new DebridAuthError('TorBox did not recognise this API key') + return { username: user.email || user.customer || 'TorBox user', expires: user.premium_expires_at } + } + + /** + * The account's torrents. TorBox caches this itself, which is faster for the badge listing; + * `bypass_cache` is only worth its latency when polling a change just made. + * @param {{ id?: string | number, fresh?: boolean }} [opts] + * @returns {Promise} + */ + async #accountTorrents ({ id, fresh } = {}) { + const query = `limit=${LIST_LIMIT}${id != null ? `&id=${id}` : ''}${fresh ? '&bypass_cache=true' : ''}` + const data = await this.request(`${API}/torrents/mylist?${query}`) + // asking for one id answers with a bare object rather than a list + return !data ? [] : Array.isArray(data) ? data : [data] + } + + /** The whole account in one request, shared by the base class between badges and playback. */ + async fetchListing () { + return this.#accountTorrents() + } + + async listAvailability () { + const known = new Map() + for (const torrent of await this.listing()) { + if (torrent?.hash) known.set(String(torrent.hash).toLowerCase(), torrentAvailability(torrent)) + } + return known + } + + /** + * One request answers the whole results list. Hashes left out are not cached, which the base + * class records as available. + * @param {string[]} hashes + */ + async checkAvailabilityBatch (hashes) { + const query = hashes.map(hash => `hash=${hash}`).join('&') + const data = await this.request(`${API}/torrents/checkcached?${query}&format=list`) + // the endpoint has answered in both shapes over its life: a list of entries, or an object + // keyed by hash. Either way it only ever says "TorBox holds this one" + const cached = Array.isArray(data) ? data.map(entry => entry?.hash) : Object.keys(data || {}) + const answers = new Map(hashes.map(hash => [hash, Availability.AVAILABLE])) + for (const hash of cached) { + const key = String(hash || '').toLowerCase() + if (answers.has(key)) answers.set(key, Availability.CACHED) + } + return answers + } + + async resolve (magnet, { fileFilter = () => true, pickFile, maxFiles = this.config.maxFiles } = {}) { + const hash = TorBox.parseHash(magnet) + if (!hash) throw new DebridError('TorBox needs a magnet link or info hash to resolve') + const magnetURI = TorBox.toMagnet(magnet) + let torrent = await this.#existingTorrent(hash) + let added = false + try { + if (!torrent) { + // ask before adding: the answer is free, and adding an uncached torrent spends from a + // much tighter allowance (60 an hour) than a cached add does + if ((await this.checkAvailabilityBatch([hash])).get(hash) !== Availability.CACHED) throw new DebridNotCachedError() + ;({ torrent, added } = await this.#add(magnetURI, hash)) + } + const state = torrentAvailability(torrent) + if (state === Availability.UNAVAILABLE) throw new DebridUnavailableError(`TorBox could not process this torrent (${torrent.download_state || 'failed'})`) + if (state !== Availability.CACHED) throw new DebridNotCachedError() + + const wanted = (torrent.files || []).filter(file => fileFilter(filePath(file))).map(file => ({ id: file.id, path: filePath(file), size: file.size, type: file.mimetype })) + if (!wanted.length) throw new DebridError('No playable files in this torrent') + const target = pickFile ? await pickFile(wanted) : [...wanted].sort((a, b) => b.size - a.size)[0] + const files = await this.#requestLinks(torrent, TorBox.windowFiles(wanted, target, maxFiles)) + if (!files.length) throw new DebridError('TorBox returned no links for this torrent') + debug(`Resolved ${files.length} files for ${torrent.name}`) + return { hash: String(torrent.hash || hash).toLowerCase(), name: torrent.name, files } + } catch (error) { + // only clean up a torrent this call put on the account, never the user's own + if (added && torrent?.id) await this.#delete(torrent.id) + throw error + } + } + + /** + * Adds a magnet and reads back the account entry for it. `added` reports whether this call is + * what put it there, which is the only thing that makes it ours to remove again. + * @param {string} magnetURI + * @param {string} hash + * @returns {Promise<{ torrent: any, added: boolean }>} + */ + async #add (magnetURI, hash) { + // allow_zip off keeps a pack as individual files rather than one archive the player cannot seek + const created = await this.request(`${API}/torrents/createtorrent`, { + method: 'POST', + encoding: 'multipart', + body: { magnet: magnetURI, seed: SEED_NEVER, allow_zip: false } + }).catch(error => { + // the account already held it, which is an answer rather than a failure + if (error.code !== 'DUPLICATE_ITEM') throw error + return null + }) + this.forgetListing() // the account has a torrent the remembered listing does not + const id = created?.torrent_id + try { + return { torrent: await this.#awaitTorrent(id, hash), added: id != null } + } catch (error) { + // awaited, so this call has undone itself by the time it reports failure + if (id != null) await this.#delete(id) + throw error + } + } + + /** + * Waits for a freshly added torrent to show up and settle. A cached release is complete the + * moment TorBox accepts it, so anything slower reads as a fresh download. + * @param {string | number | undefined} id + * @param {string} hash + */ + async #awaitTorrent (id, hash) { + const started = Date.now() + while (true) { + // deliberately unshared and uncached: this is polling a change made a moment ago + const torrent = id != null ? (await this.#accountTorrents({ id, fresh: true }))[0] : (await this.#accountTorrents({ fresh: true })).find(entry => String(entry?.hash || '').toLowerCase() === hash) + if (torrent && torrentAvailability(torrent) !== Availability.AVAILABLE) return torrent + if (Date.now() - started > this.budget('ready')) { + if (torrent) return torrent + throw new DebridError('TorBox did not report the torrent back after adding it') + } + await new Promise(resolve => setTimeout(resolve, this.config.timeouts.poll).unref?.()) + } + } + + /** + * The account's entry for an info hash, which is how a release already there is reused rather + * than added twice. Read back by id because the listing can be a minute stale, so one deleted + * elsewhere must read as absent rather than fail the resolve. + * @param {string} hash + * @returns {Promise} + */ + async #existingTorrent (hash) { + if (!hash) return null + const known = (await this.listing()).find(torrent => String(torrent?.hash || '').toLowerCase() === hash) + if (!known) return null + // a failed read reads as "gone": the caller then checks the cache and adds the magnet, which + // fails loudly enough if the API is really unreachable + const [confirmed] = await this.#accountTorrents({ id: known.id, fresh: true }).catch(() => []) + if (!confirmed) { + debug(`Account listing named a torrent that is gone (${known.id}), adding the magnet instead`) + this.forgetListing() + } + return confirmed || null + } + + /** + * Turns the wanted files into direct stream links, skipping dead ones. + * @param {any} torrent + * @param {{ id: number, path: string, size: number, type?: string }[]} wanted + */ + async #requestLinks (torrent, wanted) { + return this.mapFiles(wanted, async file => { + // this one endpoint takes the key as a query parameter instead of a bearer header + const url = await this.request(`${API}/torrents/requestdl?torrent_id=${torrent.id}&file_id=${file.id}&redirect=false`, { auth: 'query', authParam: 'token' }) + if (typeof url !== 'string') return null + return { name: file.path.split('/').pop(), path: file.path, size: file.size, url, type: file.type } + }) + } + + /** @param {string | number} id */ + async #delete (id) { + await this.release(`${API}/torrents/controltorrent`, { method: 'POST', encoding: 'json', body: { torrent_id: id, operation: 'delete' } }) + } +} + +/** + * What the account says about one of its torrents. `download_present` means the data really is on + * TorBox's servers, which is the only thing that makes a release streamable now. + * @param {any} torrent + * @returns {string} + */ +function torrentAvailability (torrent) { + if (torrent?.download_present || (torrent?.download_finished && torrent?.progress === 1)) return Availability.CACHED + if (deadStates.test(torrent?.download_state || '')) return Availability.UNAVAILABLE + return Availability.AVAILABLE +} + +/** + * Full path is in `name`, bare filename in `short_name`, neither rooted. Shiru's file objects are. + * @param {any} file + */ +function filePath (file) { + const path = file?.name || file?.short_name || '' + return path.startsWith('/') ? path : `/${path}` +} diff --git a/common/modules/networking.js b/common/modules/networking.js index 1d4c4c34..7aa3f966 100644 --- a/common/modules/networking.js +++ b/common/modules/networking.js @@ -73,7 +73,9 @@ window.fetch = async (...args) => { if (status.value === 'offline') return { message: 'failed to fetch: client is offline' } try { - const res = await fetch(url, { ...options, signal: offlineController.signal }) + // keep the caller's own signal working, timeouts and teardown aborts depend on it + const signal = options.signal ? AbortSignal.any([options.signal, offlineController.signal]) : offlineController.signal + const res = await fetch(url, { ...options, signal }) if (!res?.ok && res?.status !== 404 && res?.status !== 429 && res?.status !== 451) fetchError({ response: res?.response, status: res?.status, message: res?.message }) return res } catch (error) { diff --git a/common/modules/torrent.js b/common/modules/torrent.js index e05a7108..187b4368 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, debridPlayback } from '@/modules/debrid/debrid.js' import { setHash } from '@/modules/anime/animehash.js' import { TORRENT, ELECTRON } from '@/modules/bridge.js' import { get } from 'svelte/store' @@ -79,6 +80,10 @@ TORRENT.portRequest(_settings).then(() => { TORRENT.onFiles(_files => { debug(`Got files request:`, _files?.length) + // the client announces the files of every torrent it loads, including the one restored at + // startup, which would swap out what debrid is playing. Playing a torrent hands the player + // back first, so this never blocks a real request + if (debridPlayback.value) return debug('Ignoring torrent files, debrid owns playback') files.set(_files) }) @@ -138,6 +143,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..fc49c098 100644 --- a/common/modules/util.js +++ b/common/modules/util.js @@ -696,6 +696,11 @@ export const defaults = { showLabels: true, expandingSidebar: false, torrentPathNew: undefined, + debridService: 'none', + debridApiKeys: {}, // one key per service, so switching between them keeps both + debridMode: 'prefer', + debridCachedOnly: false, + debridCacheCheck: true, donate: true, w2g: false, font: undefined, @@ -825,3 +830,38 @@ export const videoRx = new RegExp(`.(${videoExtensions.join('|')})$`, 'i') // freetype supported export const fontExtensions = ['ttf', 'ttc', 'woff', 'woff2', 'otf', 'cff', 'otc', 'pfa', 'pfb', 'pcf', 'fnt', 'bdf', 'pfr', 'eot'] export const fontRx = new RegExp(`.(${fontExtensions.join('|')})$`, 'i') + +// containers the Matroska parser can read embedded tracks, fonts and chapters out of +export const matroskaRx = /\.(mkv|webm)$/i + +/** + * The subtitle files belonging to one video. Season packs ship a subtitle per episode, so they + * are matched against the playing file's name unless the release holds a single video, in which + * case they all belong to it. Shared by the torrent client and debrid playback. + * @param {{ name: string }[]} files - Every file in the release. + * @param {string} videoName - Name of the video being played, with extension. + * @param {number} [videoCount] - Videos in the release, counted from `files` when omitted. + * @returns {any[]} The matching subtitle files, in the order given. + */ +export function matchSubtitleFiles (files, videoName, videoCount) { + if (!files?.length || !videoName) return [] + const videos = videoCount ?? files.filter(file => videoRx.test(file.name)).length + const stem = videoName.substring(0, videoName.lastIndexOf('.')) || videoName + return files.filter(file => subRx.test(file.name) && (videos <= 1 || file.name.includes(stem))) +} + +/** + * The font files a release ships alongside its subtitles, deduplicated by name. Some releases + * carry the same font once per language, and there is no way to tell whether they differ in + * coverage, so on really bad releases a few glyphs may still fail. + * Shared by the torrent client and debrid playback. + * @param {{ name: string }[]} files - Every file in the release. + * @returns {any[]} + */ +export function matchFontFiles (files) { + const fonts = new Map() + for (const file of files || []) { + if (fontRx.test(file.name)) fonts.set(file.name, file) + } + return [...fonts.values()] +} diff --git a/common/package.json b/common/package.json index 1e863539..a4f89749 100644 --- a/common/package.json +++ b/common/package.json @@ -5,6 +5,7 @@ "@fontsource-variable/nunito": "^5.3.0", "anitomyscript": "github:ThaUnknown/anitomyscript#42290c4b3f256893be08a4e89051f448ff5e9d00", "bottleneck": "^2.19.5", + "buffer": "^6.0.3", "comlink": "^4.4.2", "css-loader": "^7.1.4", "dompurify": "^3.4.13", @@ -14,6 +15,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..db98c890 100644 --- a/common/routes/player/PlayerPage.svelte +++ b/common/routes/player/PlayerPage.svelte @@ -11,6 +11,8 @@ import { writable } from 'simple-store-svelte' import { createEventDispatcher } from 'svelte' import Subtitles from '@/modules/subtitles.js' + import DebridMetadata from '@/modules/debrid/metadata.js' + import { debridTransport, debridPlayback } from '@/modules/debrid/debrid.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 +30,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 +69,13 @@ let container = null let current = null let subs = null + let debridMeta = null + let buffer = 0 // percent of the file reachable for seeking and thumbnails + // true from the moment playback is routed to debrid, not just once its files have resolved, + // so the player never shows torrent peers and speeds during the seconds a resolve takes + $: isDebrid = current?.debrid || $debridPlayback + // the player shows the service name in place of peers and speeds, which debrid streams have none of + $: debridTitle = `Streaming from ${$debridTransport?.title ?? 'your debrid service'}, no torrent peers involved` let duration = 0.1 let muted = false let wasPaused = null @@ -226,6 +235,10 @@ subs.destroy() subs = null } + if (debridMeta) { + debridMeta.destroy() + debridMeta = null + } } } @@ -279,7 +292,14 @@ subs.destroy() subs = null } + if (debridMeta) { + debridMeta.destroy() + debridMeta = null + } current = file + // a debrid stream is served over HTTP ranges, so every byte is reachable immediately. + // Torrent playback starts at nothing and is filled in by the client's progress events. + buffer = file.debrid ? 100 : 0 setCurrent(file) } } @@ -290,6 +310,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() @@ -1174,8 +1196,8 @@ } return 0 } - let buffer = 0 TORRENT.onProgress(progress => { + if (isDebrid) return // a background torrent's download says nothing about this stream buffer = progress * 100 }) @@ -1459,6 +1481,9 @@ const torrent = {} TORRENT.onCurrentStats(updateStats) function updateStats (detail) { + // no torrent backs a debrid stream, and the client keeps its own session running in the + // background, so its numbers must never be shown against what is actually playing + if (isDebrid) return torrent.peers = detail.numPeers || 0 torrent.up = detail.uploadSpeed || 0 torrent.down = detail.downloadSpeed || 0 @@ -1731,12 +1756,17 @@
{/if}
- - {torrent.peers || 0} - - {fastPrettyBytes(torrent.down)}/s - - {fastPrettyBytes(torrent.up)}/s + {#if isDebrid} + + {$debridTransport?.title ?? '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..4e17204f 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..b72773df --- /dev/null +++ b/common/routes/settings/tabs/DebridTab.svelte @@ -0,0 +1,88 @@ + + +

Debrid Settings

+ + + +{#if settings.debridService !== 'none'} + +
+ setApiKey(event.target.value.trim())} + on:keydown|stopPropagation + /> +
+ + +
+
+
+ + + + +
+ + +
+
+{/if} diff --git a/common/webpack.config.cjs b/common/webpack.config.cjs index 68fe4d47..de632813 100644 --- a/common/webpack.config.cjs +++ b/common/webpack.config.cjs @@ -2,6 +2,7 @@ const { join, resolve } = require('path') const mode = process.env.NODE_ENV?.trim() || 'development' const isDev = mode === 'development' +const webpack = require('webpack') const HtmlWebpackPlugin = require('html-webpack-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const CopyWebpackPlugin = require('copy-webpack-plugin') @@ -87,6 +88,8 @@ module.exports = (parentDir, alias = {}, aliasFields = 'browser', filename = 'ap extensions: ['.mjs', '.js', '.svelte'] }, plugins: [ + // matroska-metadata (debrid subtitle parsing) expects Node's Buffer global, which target 'web' lacks + new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }), new MiniCssExtractPlugin({ filename: '[name].css' }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcfa072e..2e520fb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ importers: bottleneck: specifier: ^2.19.5 version: 2.19.5 + buffer: + specifier: ^6.0.3 + version: 6.0.3 comlink: specifier: ^4.4.2 version: 4.4.2 @@ -237,6 +240,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