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