Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 49 additions & 29 deletions client/core/webtorrent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -198,24 +198,38 @@ 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.
*/
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])
Expand All @@ -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()
Expand Down Expand Up @@ -491,28 +503,33 @@ 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 => {
if (!file._destroyed) file.deselect()
})
})
}
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!')

Expand Down Expand Up @@ -553,24 +570,27 @@ 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) {
this.playerProcess.removeAllListeners('close')
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
this.playerProcess = null
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
Expand Down
22 changes: 21 additions & 1 deletion common/modals/torrent/components/TorrentCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -447,6 +462,11 @@
<div class='text-light d-flex align-items-center text-nowrap'>{since(new Date(result.date))}</div>
</div>
<div class='secondary-metadata d-flex flex-wrap ml-auto justify-content-end gap-5'>
{#if $debridEnabled && result.hash}
<div class='rounded px-10 py-5 border text-nowrap d-flex align-items-center' style={availabilityBadge.style} title={availabilityTitle}>
<svelte:component this={availabilityBadge.icon} size='1.6rem' />
</div>
{/if}
{#if result.type === 'best'}
<div class='rounded px-15 py-5 border text-nowrap font-weight-bold d-flex align-items-center' style='background: var(--success-color-very-dim); border-color: var(--success-color-light) !important; color: var(--success-color-light)'>
Best Release
Expand Down
Loading
Loading