mirror of
https://github.com/NoCrypt/migu.git
synced 2026-03-11 17:45:32 +00:00
feat: extension system
feat: new torrent modal
This commit is contained in:
parent
96609ba479
commit
6fc485143f
25 changed files with 761 additions and 742 deletions
|
|
@ -20,7 +20,7 @@
|
|||
import Sidebar from './components/Sidebar.svelte'
|
||||
import Router from './Router.svelte'
|
||||
import ViewAnime from './views/ViewAnime/ViewAnime.svelte'
|
||||
import RSSView from './views/RSSView.svelte'
|
||||
import TorrentModal from './views/TorrentSearch/TorrentModal.svelte'
|
||||
import Menubar from './components/Menubar.svelte'
|
||||
import IspBlock from './views/IspBlock.svelte'
|
||||
import { Toaster } from 'svelte-sonner'
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
<Sidebar bind:page={$page} />
|
||||
<div class='overflow-hidden content-wrapper h-full z-10'>
|
||||
<Toaster visibleToasts={6} position='top-right' theme='dark' richColors duration={10000} closeButton />
|
||||
<RSSView />
|
||||
<TorrentModal />
|
||||
<Router bind:page={$page} />
|
||||
</div>
|
||||
<Navbar bind:page={$page} />
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@
|
|||
<option>{year}</option>
|
||||
{/each}
|
||||
</datalist>
|
||||
<input type='number' placeholder='Any' min='1940' max='2100' list='search-year' class='bg-dark-light form-control' disabled={search.disableSearch} bind:value={search.year} />
|
||||
<input type='number' inputmode='numeric' pattern='[0-9]*' placeholder='Any' min='1940' max='2100' list='search-year' class='bg-dark-light form-control' disabled={search.disableSearch} bind:value={search.year} />
|
||||
</div>
|
||||
</div>
|
||||
<div class='col p-10 d-flex flex-column justify-content-end'>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import clipboard from './clipboard.js'
|
|||
|
||||
import { search, key } from '@/views/Search.svelte'
|
||||
|
||||
import { playAnime } from '../views/RSSView.svelte'
|
||||
import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte'
|
||||
|
||||
const imageRx = /\.(jpeg|jpg|gif|png|webp)/i
|
||||
|
||||
|
|
|
|||
197
common/modules/extensions/index.js
Normal file
197
common/modules/extensions/index.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { settings } from '@/modules/settings.js'
|
||||
import { exclusions } from '../rss.js'
|
||||
import { sleep } from '../util.js'
|
||||
import { anilistClient } from '../anilist.js'
|
||||
import { anitomyscript } from '../anime.js'
|
||||
import { client } from '@/modules/torrent.js'
|
||||
import { extensionsWorker } from '@/views/Settings/TorrentSettings.svelte'
|
||||
|
||||
/** @typedef {import('@thaunknown/ani-resourced/sources/types.d.ts').Options} Options */
|
||||
/** @typedef {import('@thaunknown/ani-resourced/sources/types.d.ts').Result} Result */
|
||||
|
||||
/**
|
||||
* @param {{media: import('../al.js').Media, episode?: number, batch: boolean, movie: boolean, resolution: string}} opts
|
||||
* @returns {Promise<(Result & { parseObject: import('anitomyscript').AnitomyResult })[]>}
|
||||
* **/
|
||||
export default async function getResultsFromExtensions ({ media, episode, batch, movie, resolution }) {
|
||||
const aniDBMeta = await ALToAniDB(media)
|
||||
const anidbAid = aniDBMeta?.mappings?.anidb_id
|
||||
const anidbEid = anidbAid && (await ALtoAniDBEpisode({ media, episode }, aniDBMeta))?.anidbEid
|
||||
|
||||
const worker = await /** @type {ReturnType<import('@/modules/extensions/worker.js').loadExtensions>} */(extensionsWorker)
|
||||
|
||||
/** @type {Options} */
|
||||
const options = {
|
||||
anilistId: media.id,
|
||||
episodeCount: media.episodes,
|
||||
episode,
|
||||
anidbAid,
|
||||
anidbEid,
|
||||
titles: createTitles(media),
|
||||
resolution,
|
||||
exclusions
|
||||
}
|
||||
|
||||
const results = await worker.query(options, { movie, batch }, settings.value.sources)
|
||||
|
||||
const deduped = dedupe(results)
|
||||
|
||||
if (!deduped?.length) throw new Error('No results found')
|
||||
|
||||
const parseObjects = await anitomyscript(deduped.map(({ title }) => title))
|
||||
// @ts-ignore
|
||||
for (const i in parseObjects) deduped[i].parseObject = parseObjects[i]
|
||||
|
||||
return updatePeerCounts(deduped)
|
||||
}
|
||||
|
||||
async function updatePeerCounts (entries) {
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
const updated = await Promise.race([
|
||||
new Promise(resolve => {
|
||||
function check ({ detail }) {
|
||||
if (detail.id !== id) return
|
||||
client.removeListener('scrape', check)
|
||||
resolve(detail.result)
|
||||
}
|
||||
client.on('scrape', check)
|
||||
client.send('scrape', { id, infoHashes: entries.map(({ hash }) => hash) })
|
||||
}),
|
||||
sleep(5000)
|
||||
])
|
||||
|
||||
for (const { hash, complete, downloaded, incomplete } of updated || []) {
|
||||
const found = entries.find(mapped => mapped.hash === hash)
|
||||
found.downloads = downloaded
|
||||
found.leechers = incomplete
|
||||
found.seeders = complete
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** @param {import('../al.js').Media} media */
|
||||
async function ALToAniDB (media) {
|
||||
const mappingsResponse = await fetch('https://api.ani.zip/mappings?anilist_id=' + media.id)
|
||||
const json = await mappingsResponse.json()
|
||||
if (json.mappings?.anidb_id) return json
|
||||
|
||||
const parentID = getParentForSpecial(media)
|
||||
if (!parentID) return
|
||||
|
||||
const parentResponse = await fetch('https://api.ani.zip/mappings?anilist_id=' + parentID)
|
||||
return parentResponse.json()
|
||||
}
|
||||
|
||||
/** @param {import('../al.js').Media} media */
|
||||
function getParentForSpecial (media) {
|
||||
if (!['SPECIAL', 'OVA', 'ONA'].some(format => media.format === format)) return false
|
||||
const animeRelations = media.relations.edges.filter(({ node }) => node.type === 'ANIME')
|
||||
|
||||
return getRelation(animeRelations, 'PARENT') || getRelation(animeRelations, 'PREQUEL') || getRelation(animeRelations, 'SEQUEL')
|
||||
}
|
||||
|
||||
function getRelation (list, type) {
|
||||
return list.find(({ relationType }) => relationType === type)?.node.id
|
||||
}
|
||||
|
||||
// TODO: https://anilist.co/anime/13055/
|
||||
/**
|
||||
* @param {{media: import('../al.js').Media, episode: number}} param0
|
||||
* @param {{episodes: any, episodeCount: number, specialCount: number}} param1
|
||||
* */
|
||||
async function ALtoAniDBEpisode ({ media, episode }, { episodes, episodeCount, specialCount }) {
|
||||
if (!episode || !Object.values(episodes).length) return
|
||||
// if media has no specials or their episode counts don't match
|
||||
if (!specialCount || (media.episodes && media.episodes === episodeCount && episodes[Number(episode)])) return episodes[Number(episode)]
|
||||
const res = await anilistClient.episodeDate({ id: media.id, ep: episode })
|
||||
// TODO: if media only has one episode, and airdate doesn't exist use start/release/end dates
|
||||
const alDate = new Date((res.data.AiringSchedule?.airingAt || 0) * 1000)
|
||||
|
||||
return episodeByAirDate(alDate, episodes, episode)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} alDate
|
||||
* @param {any} episodes
|
||||
* @param {number} episode
|
||||
**/
|
||||
export function episodeByAirDate (alDate, episodes, episode) {
|
||||
if (!+alDate) return episodes[Number(episode)] || episodes[1] // what the fuck, are you braindead anilist?, the source episode number to play is from an array created from AL ep count, so how come it's missing?
|
||||
// 1 is key for episod 1, not index
|
||||
|
||||
// find closest episodes by air date, multiple episodes can have the same air date distance
|
||||
// ineffcient but reliable
|
||||
const closestEpisodes = Object.values(episodes).reduce((prev, curr) => {
|
||||
if (!prev[0]) return [curr]
|
||||
const prevDate = Math.abs(+new Date(prev[0]?.airdate) - +alDate)
|
||||
const currDate = Math.abs(+new Date(curr.airdate) - +alDate)
|
||||
if (prevDate === currDate) {
|
||||
prev.push(curr)
|
||||
return prev
|
||||
}
|
||||
if (currDate < prevDate) return [curr]
|
||||
return prev
|
||||
}, [])
|
||||
|
||||
return closestEpisodes.reduce((prev, curr) => {
|
||||
return Math.abs(curr.episodeNumber - episode) < Math.abs(prev.episodeNumber - episode) ? curr : prev
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {import('../al.js').Media} media */
|
||||
function createTitles (media) {
|
||||
// group and de-duplicate
|
||||
const grouped = [...new Set(
|
||||
Object.values(media.title)
|
||||
.concat(media.synonyms)
|
||||
.filter(name => name != null && name.length > 3)
|
||||
)]
|
||||
const titles = []
|
||||
/** @param {string} title */
|
||||
const appendTitle = title => {
|
||||
// replace & with encoded
|
||||
// title = title.replace(/&/g, '%26').replace(/\?/g, '%3F').replace(/#/g, '%23')
|
||||
titles.push(title)
|
||||
|
||||
// replace Season 2 with S2, else replace 2nd Season with S2, but keep the original title
|
||||
const match1 = title.match(/(\d)(?:nd|rd|th) Season/i)
|
||||
const match2 = title.match(/Season (\d)/i)
|
||||
|
||||
if (match2) {
|
||||
titles.push(title.replace(/Season \d/i, `S${match2[1]}`))
|
||||
} else if (match1) {
|
||||
titles.push(title.replace(/(\d)(?:nd|rd|th) Season/i, `S${match1[1]}`))
|
||||
}
|
||||
}
|
||||
for (const t of grouped) {
|
||||
appendTitle(t)
|
||||
if (t.includes('-')) appendTitle(t.replaceAll('-', ''))
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
/** @param {Result[]} entries */
|
||||
function dedupe (entries) {
|
||||
/** @type {Record<string, Result>} */
|
||||
const deduped = {}
|
||||
for (const entry of entries) {
|
||||
if (deduped[entry.hash]) {
|
||||
const dupe = deduped[entry.hash]
|
||||
dupe.title ??= entry.title
|
||||
dupe.link ??= entry.link
|
||||
dupe.id ||= entry.id
|
||||
dupe.seeders ||= entry.seeders >= 30000 ? 0 : entry.seeders
|
||||
dupe.leechers ||= entry.leechers >= 30000 ? 0 : entry.leechers
|
||||
dupe.downloads ||= entry.downloads
|
||||
dupe.size ||= entry.size
|
||||
dupe.verified ||= entry.verified
|
||||
dupe.date ||= entry.date
|
||||
dupe.type ??= entry.type
|
||||
} else {
|
||||
deduped[entry.hash] = entry
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(deduped)
|
||||
}
|
||||
53
common/modules/extensions/worker.js
Normal file
53
common/modules/extensions/worker.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { expose, proxy } from 'comlink'
|
||||
|
||||
/** @typedef {import('@thaunknown/ani-resourced/sources/types.d.ts').Options} Options */
|
||||
/** @typedef {import('@thaunknown/ani-resourced/sources/types.d.ts').Result} Result */
|
||||
/** @typedef {import('@thaunknown/ani-resourced/sources/abstract.js').default} AbstractSource */
|
||||
|
||||
class Extensions {
|
||||
sources
|
||||
metadata
|
||||
/** @param {AbstractSource[]} sources */
|
||||
constructor (sources) {
|
||||
this.sources = sources
|
||||
this.metadata = sources.map(({ accuracy, name, description, config }) => ({ accuracy, name, description, config }))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Options} options
|
||||
* @param {{ movie: boolean, batch: boolean }} param1
|
||||
* @param {Record<string, boolean>} sources
|
||||
*/
|
||||
async query (options, { movie, batch }, sources) {
|
||||
/** @type {Promise<Result[]>[]} */
|
||||
const promises = []
|
||||
for (const source of Object.values(this.sources)) {
|
||||
if (!sources[source.name]) continue
|
||||
if (movie) promises.push(source.movie(options))
|
||||
if (batch) promises.push(source.batch(options))
|
||||
promises.push(source.single(options))
|
||||
}
|
||||
/** @type {Result[]} */
|
||||
const results = []
|
||||
for (const result of await Promise.allSettled(promises)) {
|
||||
if (result.status === 'fulfilled') results.push(...result.value)
|
||||
}
|
||||
return results.flat()
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string[]} extensions */
|
||||
export async function loadExtensions (extensions) {
|
||||
// TODO: handle import errors
|
||||
const sources = (await Promise.all(extensions.map(async extension => {
|
||||
try {
|
||||
if (!extension.startsWith('http')) extension = `https://esm.sh/${extension}`
|
||||
return Object.values(await import(/* webpackIgnore: true */extension))
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}))).flat()
|
||||
return proxy(new Extensions(sources))
|
||||
}
|
||||
|
||||
expose(loadExtensions)
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import { findEdge, resolveSeason, getMediaMaxEp, mapBestRelease } from '../anime.js'
|
||||
import { exclusions, getRSSContent, parseRSSNodes } from '../rss.js'
|
||||
|
||||
export default async function getRSSEntries ({ media, episode, mode, ignoreQuality }) {
|
||||
// mode cuts down on the amt of queries made 'check' || 'batch'
|
||||
const titles = createTitle(media).join(')|(')
|
||||
|
||||
const prequel = findEdge(media, 'PREQUEL')?.node
|
||||
const sequel = findEdge(media, 'SEQUEL')?.node
|
||||
const isBatch = media.status === 'FINISHED' && media.episodes !== 1
|
||||
|
||||
// if media has multiple seasons, and this S is > 1, then get the absolute episode number of the episode
|
||||
const absolute = prequel && !mode && (await resolveSeason({ media, episode, force: true }))
|
||||
const absoluteep = absolute?.offset + episode
|
||||
const episodes = [episode]
|
||||
|
||||
// only use absolute episode number if its smaller than max episodes this series has, ex:
|
||||
// looking for E1 of S2, S1 has 12 ep and S2 has 13, absolute will be 13
|
||||
// so this would find the 13th ep of the 2nd season too if this check wasnt here
|
||||
if (absolute && absoluteep < (getMediaMaxEp(media) || episode)) {
|
||||
episodes.push(absoluteep)
|
||||
}
|
||||
|
||||
let ep = ''
|
||||
if (media.episodes !== 1 && mode !== 'batch') {
|
||||
if (isBatch) {
|
||||
const digits = Math.max(2, Math.log(media.episodes) * Math.LOG10E + 1 | 0)
|
||||
ep = `"${zeropad(1, digits)}-${zeropad(media.episodes, digits)}"|"${zeropad(1, digits)}~${zeropad(media.episodes, digits)}"|"Batch"|"Complete"|"${zeropad(episode)}+"|"${zeropad(episode)}v"`
|
||||
} else {
|
||||
ep = `(${episodes.map(epstring).join('|')})`
|
||||
}
|
||||
}
|
||||
|
||||
const excl = exclusions.join('|')
|
||||
const quality = (!ignoreQuality && (`"${settings.value.rssQuality}"` || '"1080"')) || ''
|
||||
const url = new URL(`${settings.value.catURL}/?page=rss&c=1_2&f=0&s=seeders&o=desc&q=(${titles})${ep}${quality}-(${excl})`)
|
||||
|
||||
let nodes = [...(await getRSSContent(url)).querySelectorAll('item')]
|
||||
|
||||
if (absolute) {
|
||||
// if this is S > 1 aka absolute ep number exists get entries for S1title + absoluteEP
|
||||
// the reason this isnt done with recursion like sequelEntries is because that would include the S1 media dates
|
||||
// we want the dates of the target media as the S1 title might be used for SX releases
|
||||
const titles = createTitle(absolute.media).join(')|(')
|
||||
|
||||
const url = new URL(`${settings.value.catURL}/?page=rss&c=1_2&f=0&s=seeders&o=desc&q=(${titles})${epstring(absoluteep)}${quality}-(${excl})`)
|
||||
nodes = [...nodes, ...(await getRSSContent(url)).querySelectorAll('item')]
|
||||
}
|
||||
|
||||
let entries = parseRSSNodes(nodes)
|
||||
|
||||
const checkSequelDate = media.status === 'FINISHED' && (sequel?.status === 'FINISHED' || sequel?.status === 'RELEASING') && sequel.startDate
|
||||
|
||||
const sequelStartDate = checkSequelDate && new Date(Object.values(checkSequelDate).join(' '))
|
||||
|
||||
// recursive, get all entries for media sequel, and its sequel, and its sequel
|
||||
const sequelEntries =
|
||||
(sequel?.status === 'FINISHED' || sequel?.status === 'RELEASING') &&
|
||||
(await getRSSEntries({ media: (await anilistClient.searchIDSingle({ id: sequel.id })).data.Media, episode, mode: mode || 'check' }))
|
||||
|
||||
const checkPrequelDate = (media.status === 'FINISHED' || media.status === 'RELEASING') && prequel?.status === 'FINISHED' && prequel?.endDate
|
||||
|
||||
const prequelEndDate = checkPrequelDate && new Date(Object.values(checkPrequelDate).join(' '))
|
||||
|
||||
// 1 month in MS, a bit of jitter for pre-releases and releasers being late as fuck, lets hope it doesnt cause issues
|
||||
const month = 2674848460
|
||||
|
||||
if (prequelEndDate) {
|
||||
entries = entries.filter(entry => entry.date > new Date(+prequelEndDate + month))
|
||||
}
|
||||
|
||||
if (sequelStartDate && media.format === 'TV') {
|
||||
entries = entries.filter(entry => entry.date < new Date(+sequelStartDate - month))
|
||||
}
|
||||
|
||||
if (sequelEntries?.length) {
|
||||
if (mode === 'check') {
|
||||
entries = [...entries, ...sequelEntries]
|
||||
} else {
|
||||
entries = entries.filter(entry => !sequelEntries.find(sequel => sequel.link === entry.link))
|
||||
}
|
||||
}
|
||||
|
||||
// this gets entries without any episode limiting, and for batches
|
||||
const batchEntries = !mode && isBatch && (await getRSSEntries({ media, episode, ignoreQuality, mode: 'batch' })).filter(entry => {
|
||||
return !epNumRx.test(entry.title)
|
||||
})
|
||||
|
||||
if (batchEntries?.length) {
|
||||
entries = [...entries, ...batchEntries]
|
||||
}
|
||||
|
||||
// some archaic shows only have shit DVD's in weird qualities, so try to look up without any quality restrictions when there are no results
|
||||
if (!entries.length && !ignoreQuality && !mode) {
|
||||
entries = await getRSSEntries({ media, episode, ignoreQuality: true })
|
||||
}
|
||||
|
||||
// dedupe
|
||||
const ids = entries.map(e => e.link)
|
||||
return mapBestRelease(entries.filter(({ link }, index) => !ids.includes(link, index + 1)))
|
||||
}
|
||||
|
||||
// padleft a variable with 0 ex: 1 => '01'
|
||||
function zeropad (v = 1, l = 2) {
|
||||
return (typeof v === 'string' ? v : v.toString()).padStart(l, '0')
|
||||
}
|
||||
|
||||
const epstring = ep => `"E${zeropad(ep)}+"|"E${zeropad(ep)}v"|"+${zeropad(ep)}+"|"+${zeropad(ep)}v"`
|
||||
// [EO]?[-EPD _—]\d{2}(?:[-v _.—]|$)
|
||||
// /[EO]?[-EPD]\d{2}(?:[-v.]|$)|[EO]?[EPD ]\d{2}(?:[v .]|$)|[EO]?[EPD_]\d{2}(?:[v_.]|$)|[EO]?[EPD—]\d{2}(?:[v.—]|$)|\d{2} ?[-~—] ?\d{2}/i
|
||||
// matches: OP01 ED01 EP01 E01 01v 01. -01- _01_ with spaces and stuff
|
||||
const epNumRx = /[EO]?[-EPD]\d{2}(?:[-v.]|$)|[EO]?[EPD ]\d{2}(?:[v .]|$)|[EO]?[EPD_]\d{2}(?:[v_.]|$)|[EO]?[EPD—]\d{2}(?:[v.—]|$)|\d{2} ?[-~—] ?\d{2}/i
|
||||
|
||||
// create an array of potentially valid titles from a given media
|
||||
function createTitle (media) {
|
||||
// group and de-duplicate
|
||||
const grouped = [
|
||||
...new Set(
|
||||
Object.values(media.title)
|
||||
.concat(media.synonyms)
|
||||
.filter(name => name != null && name.length > 3)
|
||||
)
|
||||
]
|
||||
const titles = []
|
||||
const appendTitle = t => {
|
||||
// replace & with encoded
|
||||
const title = t.replace(/&/g, '%26').replace(/\?/g, '%3F').replace(/#/g, '%23')
|
||||
titles.push(title)
|
||||
|
||||
// replace Season 2 with S2, else replace 2nd Season with S2, but keep the original title
|
||||
const match1 = title.match(/(\d)(?:nd|rd|th) Season/i)
|
||||
const match2 = title.match(/Season (\d)/i)
|
||||
|
||||
if (match2) {
|
||||
titles.push(title.replace(/Season \d/i, `S${match2[1]}`))
|
||||
} else if (match1) {
|
||||
titles.push(title.replace(/(\d)(?:nd|rd|th) Season/i, `S${match1[1]}`))
|
||||
}
|
||||
}
|
||||
for (const t of grouped) {
|
||||
appendTitle(t)
|
||||
if (t.includes('-')) appendTitle(t.replaceAll('-', ''))
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { fastPrettyBytes } from '../util.js'
|
||||
|
||||
export default async function (media) {
|
||||
const res = await fetch(`https://beta.releases.moe/api/collections/entries/records?page=1&perPage=1&filter=alID%3D%22${media.id}%22&skipTotal=1&expand=trs`)
|
||||
const { items } = await res.json()
|
||||
|
||||
if (!items[0]?.expand?.trs?.length) return []
|
||||
|
||||
const { trs } = items[0]?.expand
|
||||
|
||||
return trs.filter(({ infoHash }) => infoHash !== '<redacted>').map(torrent => {
|
||||
return {
|
||||
hash: torrent.infoHash,
|
||||
link: torrent.infoHash,
|
||||
title: `[${torrent.releaseGroup}] ${media.title.userPreferred}`,
|
||||
size: fastPrettyBytes(torrent.files.reduce((prev, curr) => prev + curr.length, 0)),
|
||||
type: torrent.isBest ? 'best' : 'alt',
|
||||
date: new Date(torrent.created),
|
||||
parseObject: {
|
||||
audio_term: [torrent.dualAudio ? 'DUALAUDIO' : '']
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { binarySearch } from '../util.js'
|
||||
|
||||
let seadex = []
|
||||
requestIdleCallback(async () => {
|
||||
const res = await fetch('https://sneedex.moe/api/public/nyaa')
|
||||
const json = await res.json()
|
||||
seadex = json.flatMap(({ nyaaIDs }) => nyaaIDs).sort((a, b) => a - b) // sort for binary search
|
||||
})
|
||||
|
||||
export default function (entries) {
|
||||
return entries.map(entry => {
|
||||
if (entry.id) {
|
||||
if (entry.id === '?') return entry
|
||||
if (binarySearch(seadex, entry.id)) entry.type = 'alt'
|
||||
return entry
|
||||
}
|
||||
const match = entry.link.match(/\d+/i)
|
||||
if (match && binarySearch(seadex, Number(match[0]))) entry.type = 'alt'
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
import { anitomyscript } from '../anime.js'
|
||||
import { fastPrettyBytes, sleep } from '../util.js'
|
||||
import { exclusions } from '../rss.js'
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import { anilistClient } from '../anilist.js'
|
||||
import { client } from '@/modules/torrent.js'
|
||||
import mapBestSneedexReleases from './sneedex.js'
|
||||
import getSeedexBests from './seadex.js'
|
||||
|
||||
export default async function ({ media, episode }) {
|
||||
const json = await getAniDBFromAL(media)
|
||||
if (typeof json !== 'object') {
|
||||
const bests = await getSeedexBests(media)
|
||||
if (!bests.length) throw new Error(json || 'No mapping found.')
|
||||
return bests
|
||||
}
|
||||
|
||||
const movie = isMovie(media) // don't query movies with qualities, to allow 4k
|
||||
|
||||
const aniDBEpisode = await getAniDBEpisodeFromAL({ media, episode }, json)
|
||||
let entries = await getToshoEntriesForMedia(media, aniDBEpisode, json, !movie && settings.value.rssQuality)
|
||||
if (!entries.length && !movie) entries = await getToshoEntriesForMedia(media, aniDBEpisode, json)
|
||||
if (!entries?.length) throw new Error('No entries found.')
|
||||
|
||||
const deduped = dedupeEntries(entries)
|
||||
const parseObjects = await anitomyscript(deduped.map(({ title }) => title))
|
||||
for (const i in parseObjects) deduped[i].parseObject = parseObjects[i]
|
||||
|
||||
const withBests = dedupeEntries([...await getSeedexBests(media), ...mapBestSneedexReleases(deduped)])
|
||||
|
||||
return updatePeerCounts(withBests)
|
||||
}
|
||||
|
||||
async function updatePeerCounts (entries) {
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
const updated = await Promise.race([
|
||||
new Promise(resolve => {
|
||||
function check ({ detail }) {
|
||||
if (detail.id !== id) return
|
||||
client.removeListener('scrape', check)
|
||||
resolve(detail.result)
|
||||
console.log(detail)
|
||||
}
|
||||
client.on('scrape', check)
|
||||
client.send('scrape', { id, infoHashes: entries.map(({ hash }) => hash) })
|
||||
}),
|
||||
sleep(5000)
|
||||
])
|
||||
|
||||
for (const { hash, complete, downloaded, incomplete } of updated || []) {
|
||||
const found = entries.find(mapped => mapped.hash === hash)
|
||||
found.downloads = downloaded
|
||||
found.leechers = incomplete
|
||||
found.seeders = complete
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
async function getAniDBFromAL (media) {
|
||||
console.log('getting AniDB ID from AL')
|
||||
const mappingsResponse = await fetch('https://api.ani.zip/mappings?anilist_id=' + media.id)
|
||||
const json = await mappingsResponse.json()
|
||||
if (json.mappings?.anidb_id) return json
|
||||
|
||||
console.log('failed getting AniDB ID, checking via parent')
|
||||
|
||||
const parentID = getParentForSpecial(media)
|
||||
if (!parentID) return
|
||||
|
||||
console.log('found via parent')
|
||||
|
||||
const parentResponse = await fetch('https://api.ani.zip/mappings?anilist_id=' + parentID)
|
||||
return parentResponse.json()
|
||||
}
|
||||
|
||||
function getParentForSpecial (media) {
|
||||
if (!['SPECIAL', 'OVA', 'ONA'].some(format => media.format === format)) return false
|
||||
const animeRelations = media.relations.edges.filter(({ node }) => node.type === 'ANIME')
|
||||
|
||||
return getRelation(animeRelations, 'PARENT') || getRelation(animeRelations, 'PREQUEL') || getRelation(animeRelations, 'SEQUEL')
|
||||
}
|
||||
|
||||
function getRelation (list, type) {
|
||||
return list.find(({ relationType }) => relationType === type)?.node.id
|
||||
}
|
||||
|
||||
// TODO: https://anilist.co/anime/13055/
|
||||
async function getAniDBEpisodeFromAL ({ media, episode }, { episodes, episodeCount, specialCount }) {
|
||||
console.log('getting AniDB EpID for Mal EP', { episode, episodes })
|
||||
if (!episode || !Object.values(episodes).length) return
|
||||
// if media has no specials or their episode counts don't match
|
||||
if (!specialCount || (media.episodes && media.episodes === episodeCount && episodes[Number(episode)])) return episodes[Number(episode)]
|
||||
console.log('EP count doesn\'t match, checking by air date')
|
||||
const res = await anilistClient.episodeDate({ id: media.id, ep: episode })
|
||||
// TODO: if media only has one episode, and airdate doesn't exist use start/release/end dates
|
||||
const alDate = new Date((res.data.AiringSchedule?.airingAt || 0) * 1000)
|
||||
|
||||
return getEpisodeNumberByAirDate(alDate, episodes, episode)
|
||||
}
|
||||
|
||||
export function getEpisodeNumberByAirDate (alDate, episodes, episode) {
|
||||
if (!+alDate) return episodes[Number(episode)] || episodes[1] // what the fuck, are you braindead anilist?, the source episode number to play is from an array created from AL ep count, so how come it's missing?
|
||||
// 1 is key for episod 1, not index
|
||||
|
||||
// find closest episodes by air date, multiple episodes can have the same air date distance
|
||||
// ineffcient but reliable
|
||||
const closestEpisodes = Object.values(episodes).reduce((prev, curr) => {
|
||||
if (!prev[0]) return [curr]
|
||||
const prevDate = Math.abs(+new Date(prev[0]?.airdate) - alDate)
|
||||
const currDate = Math.abs(+new Date(curr.airdate) - alDate)
|
||||
if (prevDate === currDate) {
|
||||
prev.push(curr)
|
||||
return prev
|
||||
}
|
||||
if (currDate < prevDate) return [curr]
|
||||
return prev
|
||||
}, [])
|
||||
|
||||
console.log({ closestEpisodes })
|
||||
|
||||
return closestEpisodes.reduce((prev, curr) => {
|
||||
return Math.abs(curr.episodeNumber - episode) < Math.abs(prev.episodeNumber - episode) ? curr : prev
|
||||
})
|
||||
}
|
||||
|
||||
async function getToshoEntriesForMedia (media, episode, { mappings }, quality) {
|
||||
const promises = []
|
||||
|
||||
if (episode) {
|
||||
const { anidbEid } = episode
|
||||
|
||||
console.log('fetching episode', anidbEid, quality)
|
||||
|
||||
promises.push(fetchSingleEpisodeForAnidb({ id: anidbEid, quality }))
|
||||
} else {
|
||||
// TODO: look for episodes via.... title?
|
||||
}
|
||||
|
||||
// look for batches and movies
|
||||
const movie = isMovie(media)
|
||||
if (mappings.anidb_id && media.status === 'FINISHED' && (movie || media.episodes !== 1)) {
|
||||
promises.push(fetchBatchesForAnidb({ episodeCount: media.episodes, id: mappings.anidb_id, quality, movie }))
|
||||
console.log('fetching batch', quality, movie)
|
||||
if (!movie) {
|
||||
const courRelation = getSplitCourRelation(media)
|
||||
if (courRelation) {
|
||||
console.log('found split cour!')
|
||||
const episodeCount = (media.episodes || 0) + (courRelation.episodes || 0)
|
||||
try {
|
||||
const mappingsResponse = await fetch('https://api.ani.zip/mappings?anilist_id=' + courRelation.id)
|
||||
const json = await mappingsResponse.json()
|
||||
console.log('found mappings for split cour', !!json.mappings.anidb_id)
|
||||
if (json.mappings.anidb_id) promises.push(fetchBatchesForAnidb({ episodeCount, id: json.mappings.anidb_id, quality }))
|
||||
} catch (e) {
|
||||
console.error('failed getting split-cour data', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapToshoEntries((await Promise.all(promises)).flat())
|
||||
}
|
||||
|
||||
function getSplitCourRelation (media) {
|
||||
// Part 2 / Cour 3 / 4th Cour
|
||||
if (isTitleSplitCour(media)) return getCourPrequel(media)
|
||||
|
||||
// Part 1 of split cour which usually doesn't get labeled as split cour
|
||||
// sequel can not exist
|
||||
return getCourSequel(media)
|
||||
}
|
||||
|
||||
const courRegex = /[2-9](?:nd|rd|th) Cour|Cour [2-9]|Part [2-9]/i
|
||||
|
||||
function isTitleSplitCour (media) {
|
||||
const titles = [...Object.values(media.title), ...media.synonyms]
|
||||
|
||||
console.log('checking cour titles', titles)
|
||||
|
||||
return titles.some(title => courRegex.test(title))
|
||||
}
|
||||
|
||||
const seasons = ['WINTER', 'SPRING', 'SUMMER', 'FALL']
|
||||
const getDate = ({ seasonYear, season }) => +new Date(`${seasonYear}-${seasons.indexOf(season) * 4 || 1}-01`)
|
||||
|
||||
function getMediaDate (media) {
|
||||
if (media.startDate) return +new Date(Object.values(media.startDate).join(' '))
|
||||
return getDate(media)
|
||||
}
|
||||
|
||||
function getCourSequel (media) {
|
||||
const mediaDate = getMediaDate(media)
|
||||
const animeRelations = media.relations.edges.filter(({ node, relationType }) => {
|
||||
if (node.type !== 'ANIME') return false
|
||||
if (node.status !== 'FINISHED') return false
|
||||
if (relationType !== 'SEQUEL') return false
|
||||
if (!['OVA', 'TV'].some(format => node.format === format)) return false // not movies or ona's
|
||||
if (mediaDate > getMediaDate(node)) return false // node needs to be released after media to be a sequel
|
||||
return isTitleSplitCour(node)
|
||||
}).map(({ node }) => node)
|
||||
|
||||
if (!animeRelations.length) return false
|
||||
|
||||
// get closest sequel
|
||||
return animeRelations.reduce((prev, curr) => {
|
||||
return getMediaDate(prev) - mediaDate > getMediaDate(curr) - mediaDate ? curr : prev
|
||||
})
|
||||
}
|
||||
|
||||
function getCourPrequel (media) {
|
||||
const mediaDate = getMediaDate(media)
|
||||
const animeRelations = media.relations.edges.filter(({ node, relationType }) => {
|
||||
if (node.type !== 'ANIME') return false
|
||||
if (node.status !== 'FINISHED') return false
|
||||
if (relationType !== 'PREQUEL') return false
|
||||
if (!['OVA', 'TV'].some(format => node.format === format)) return false
|
||||
if (mediaDate < getMediaDate(node)) return false // node needs to be released before media to be a prequel
|
||||
return true
|
||||
}).map(({ node }) => node)
|
||||
|
||||
if (!animeRelations.length) {
|
||||
console.error('Detected split count but couldn\'t find prequel', media)
|
||||
return false
|
||||
}
|
||||
|
||||
// get closest prequel
|
||||
return animeRelations.reduce((prev, curr) => {
|
||||
return mediaDate - getMediaDate(prev) > mediaDate - getMediaDate(curr) ? curr : prev
|
||||
})
|
||||
}
|
||||
|
||||
function isMovie (media) {
|
||||
if (media.format === 'MOVIE') return true
|
||||
if ([...Object.values(media.title), ...media.synonyms].some(title => title?.toLowerCase().includes('movie'))) return true
|
||||
// if (!getParentForSpecial(media)) return true // TODO: this is good for checking movies, but false positives with normal TV shows
|
||||
return media.duration > 80 && media.episodes === 1
|
||||
}
|
||||
|
||||
const QUALITIES = ['1080', '720', '540', '480']
|
||||
|
||||
const ANY = 'e*|a*|r*|i*|o*'
|
||||
|
||||
function buildToshoQuery (quality) {
|
||||
let query = `&qx=1&q=!("${exclusions.join('"|"')}")`
|
||||
if (quality) {
|
||||
query += `((${ANY}|"${quality}") !"${QUALITIES.filter(q => q !== quality).join('" !"')}")`
|
||||
} else {
|
||||
query += ANY // HACK: tosho NEEDS a search string, so we lazy search a single common vowel
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
async function fetchBatchesForAnidb ({ episodeCount, id, quality, movie = null }) {
|
||||
try {
|
||||
const queryString = buildToshoQuery(quality)
|
||||
const torrents = await fetch(settings.value.toshoURL + 'json?order=size-d&aid=' + id + queryString)
|
||||
|
||||
// safe both if AL includes EP 0 or doesn't
|
||||
const batches = (await torrents.json()).filter(entry => entry.num_files >= episodeCount)
|
||||
if (!movie) {
|
||||
for (const batch of batches) batch.type = 'batch'
|
||||
}
|
||||
console.log({ batches })
|
||||
return batches
|
||||
} catch (error) {
|
||||
console.log('failed fetching batch', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSingleEpisodeForAnidb ({ id, quality }) {
|
||||
try {
|
||||
const queryString = buildToshoQuery(quality)
|
||||
const torrents = await fetch(settings.value.toshoURL + 'json?eid=' + id + queryString)
|
||||
|
||||
const episodes = await torrents.json()
|
||||
console.log({ episodes })
|
||||
return episodes
|
||||
} catch (error) {
|
||||
console.log('failed fetching single episode', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function mapToshoEntries (entries) {
|
||||
return entries.map(entry => {
|
||||
return {
|
||||
title: entry.title || entry.torrent_name,
|
||||
link: entry.magnet_uri,
|
||||
id: entry.nyaa_id, // TODO: used for sneedex mappings, remove later
|
||||
seeders: entry.seeders >= 30000 ? 0 : entry.seeders,
|
||||
leechers: entry.leechers >= 30000 ? 0 : entry.leechers,
|
||||
downloads: entry.torrent_downloaded_count,
|
||||
hash: entry.info_hash,
|
||||
size: entry.total_size && fastPrettyBytes(entry.total_size),
|
||||
verified: !!entry.anidb_fid,
|
||||
type: entry.type,
|
||||
date: entry.timestamp && new Date(entry.timestamp * 1000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function dedupeEntries (entries) {
|
||||
const deduped = {}
|
||||
for (const entry of entries) {
|
||||
if (deduped[entry.hash]) {
|
||||
const dupe = deduped[entry.hash]
|
||||
dupe.title ??= entry.title
|
||||
dupe.link ??= entry.link
|
||||
dupe.id ||= entry.id
|
||||
dupe.seeders ||= entry.seeders >= 30000 ? 0 : entry.seeders
|
||||
dupe.leechers ||= entry.leechers >= 30000 ? 0 : entry.leechers
|
||||
dupe.downloads ||= entry.downloads
|
||||
dupe.size ||= entry.size
|
||||
dupe.verified ||= entry.verified
|
||||
dupe.date ||= entry.date
|
||||
dupe.type ??= entry.type
|
||||
} else {
|
||||
deduped[entry.hash] = entry
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(deduped)
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ try {
|
|||
}
|
||||
|
||||
/**
|
||||
* @type {import('svelte/store').Writable & { value: any }}
|
||||
* @type {import('simple-store-svelte').Writable<typeof defaults>}
|
||||
*/
|
||||
export const settings = writable({ ...defaults, ...scopedDefaults, ...storedSettings })
|
||||
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ export const SUPPORTS = {
|
|||
torrentPort: true,
|
||||
torrentPath: true,
|
||||
torrentPersist: true,
|
||||
keybinds: true
|
||||
keybinds: true,
|
||||
extensions: true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { SUPPORTS } from './support.js'
|
||||
|
||||
export function countdown (s) {
|
||||
const d = Math.floor(s / (3600 * 24))
|
||||
s -= d * 3600 * 24
|
||||
|
|
@ -166,14 +168,15 @@ export const defaults = {
|
|||
enableDoH: false,
|
||||
doHURL: 'https://cloudflare-dns.com/dns-query',
|
||||
disableSubtitleBlur: false,
|
||||
toshoURL: decodeURIComponent(atob('aHR0cHM6Ly9mZWVkLmFuaW1ldG9zaG8ub3JnLw==')),
|
||||
showDetailsInRPC: true,
|
||||
smoothScroll: true,
|
||||
cards: 'small',
|
||||
expandingSidebar: true,
|
||||
torrentPath: undefined,
|
||||
font: undefined,
|
||||
angle: 'default'
|
||||
angle: 'default',
|
||||
extensions: SUPPORTS.extensions ? ['@thaunknown/ani-resourced'] : [],
|
||||
sources: {}
|
||||
}
|
||||
|
||||
export const subtitleExtensions = ['srt', 'vtt', 'ass', 'ssa', 'sub', 'txt']
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@
|
|||
"@fontsource-variable/material-symbols-outlined": "^5.0.24",
|
||||
"@fontsource-variable/nunito": "^5.0.18",
|
||||
"@fontsource/roboto": "^5.0.12",
|
||||
"anitomyscript": "github:ThaUnknown/anitomyscript#42290c4b3f256893be08a4e89051f448ff5e9d00",
|
||||
"@thaunknown/ani-resourced": "^1.0.3",
|
||||
"anitomyscript": "github:ThaUnknown/anitomyscript#51abfeaa99114659fb4c0d30e32c61d50d6d1a8a",
|
||||
"bottleneck": "^2.19.5",
|
||||
"browser-event-target-emitter": "^1.0.1",
|
||||
"comlink": "^4.4.1",
|
||||
"jassub": "latest",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"p2pt": "github:ThaUnknown/p2pt#modernise",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import { getAnimeProgress, setAnimeProgress } from '@/modules/animeprogress.js'
|
||||
import { playAnime } from '../RSSView.svelte'
|
||||
import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte'
|
||||
import { client } from '@/modules/torrent.js'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
|
|
@ -1125,7 +1125,7 @@
|
|||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
<input type='number' step='0.1' bind:value={subDelay} on:click|stopPropagation class='form-control text-right form-control-sm' />
|
||||
<input type='text' inputmode='numeric' pattern='[0-9]*' step='0.1' bind:value={subDelay} on:click|stopPropagation class='form-control text-right form-control-sm' />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
<script context='module'>
|
||||
import { since } from '@/modules/util.js'
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { findInCurrent } from './Player/MediaHandler.svelte'
|
||||
import getRSSEntries from '@/modules/providers/tosho.js'
|
||||
import { click } from '@/modules/click.js'
|
||||
|
||||
import { writable } from 'simple-store-svelte'
|
||||
|
||||
const rss = writable({})
|
||||
|
||||
export function playAnime (media, episode = 1, force) {
|
||||
episode = Number(episode)
|
||||
episode = isNaN(episode) ? 1 : episode
|
||||
if (!force && findInCurrent({ media, episode })) return
|
||||
rss.set({ media, episode })
|
||||
}
|
||||
|
||||
const termMapping = {}
|
||||
termMapping['5.1'] = { text: '5.1', color: '#f67255' }
|
||||
termMapping['5.1CH'] = termMapping[5.1]
|
||||
termMapping['TRUEHD5.1'] = { text: 'TrueHD 5.1', color: '#f67255' }
|
||||
termMapping.AAC = { text: 'AAC', color: '#f67255' }
|
||||
termMapping.AACX2 = termMapping.AAC
|
||||
termMapping.AACX3 = termMapping.AAC
|
||||
termMapping.AACX4 = termMapping.AAC
|
||||
termMapping.AC3 = { text: 'AC3', color: '#f67255' }
|
||||
termMapping.EAC3 = { text: 'EAC3', color: '#f67255' }
|
||||
termMapping['E-AC-3'] = termMapping.EAC3
|
||||
termMapping.FLAC = { text: 'FLAC', color: '#f67255' }
|
||||
termMapping.FLACX2 = termMapping.FLAC
|
||||
termMapping.FLACX3 = termMapping.FLAC
|
||||
termMapping.FLACX4 = termMapping.FLAC
|
||||
termMapping.VORBIS = { text: 'Vorbis', color: '#f67255' }
|
||||
termMapping.DUALAUDIO = { text: 'Dual Audio', color: '#f67255' }
|
||||
termMapping['DUAL AUDIO'] = termMapping.DUALAUDIO
|
||||
termMapping['10BIT'] = { text: '10 Bit', color: '#0c8ce9' }
|
||||
termMapping['10BITS'] = termMapping['10BIT']
|
||||
termMapping['10-BIT'] = termMapping['10BIT']
|
||||
termMapping['10-BITS'] = termMapping['10BIT']
|
||||
termMapping.HI10 = termMapping['10BIT']
|
||||
termMapping.HI10P = termMapping['10BIT']
|
||||
termMapping.HI444 = { text: 'HI444', color: '#0c8ce9' }
|
||||
termMapping.HI444P = termMapping.HI444
|
||||
termMapping.HI444PP = termMapping.HI444
|
||||
termMapping.HEVC = { text: 'HEVC', color: '#0c8ce9' }
|
||||
termMapping.H265 = termMapping.HEVC
|
||||
termMapping['H.265'] = termMapping.HEVC
|
||||
termMapping.X265 = termMapping.HEVC
|
||||
termMapping.AV1 = { text: 'AV1', color: '#0c8ce9' }
|
||||
|
||||
function sanitiseTerms ({ video_term: video, audio_term: audio, video_resolution: resolution }) {
|
||||
if (!Array.isArray(video)) video = [video]
|
||||
if (!Array.isArray(audio)) audio = [audio]
|
||||
|
||||
const terms = [...new Set([...video, ...audio].map(term => termMapping[term?.toUpperCase()]).filter(t => t))]
|
||||
if (resolution) terms.unshift({ text: resolution, color: '#c6ec58' })
|
||||
|
||||
return terms
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { add } from '@/modules/torrent.js'
|
||||
import { media } from './Player/MediaHandler.svelte'
|
||||
|
||||
$: loadRss($rss)
|
||||
|
||||
let data = null
|
||||
let filtered = null
|
||||
|
||||
async function loadRss ({ media, episode }) {
|
||||
if (!media) return
|
||||
const promise = getRSSEntries({ media, episode })
|
||||
toast.promise(promise, {
|
||||
loading: `Looking for torrents for ${media.title.userPreferred} Episode ${parseInt(episode)}...`,
|
||||
success: `Found torrents for ${media.title.userPreferred} Episode ${parseInt(episode)}.`,
|
||||
error: err => {
|
||||
console.error(err)
|
||||
return `Couldn't find torrents for ${media.title.userPreferred} Episode ${parseInt(episode)}! Try specifying a torrent manually.\n${err.message}`
|
||||
}
|
||||
|
||||
})
|
||||
const entries = await promise
|
||||
|
||||
entries.sort((a, b) => b.seeders - a.seeders)
|
||||
if ($settings.rssAutoplay) {
|
||||
const best = entries.find(entry => entry.best)
|
||||
if (best?.seeders >= 15) { // only play best if it actually has a lot of seeders, 20 might be too little for those overkill blurays
|
||||
play(best)
|
||||
} else {
|
||||
play(entries[0])
|
||||
}
|
||||
} else {
|
||||
filtered = data = entries
|
||||
}
|
||||
}
|
||||
function close () {
|
||||
data = null
|
||||
}
|
||||
function checkClose ({ keyCode }) {
|
||||
if (keyCode === 27) close()
|
||||
}
|
||||
function play (entry) {
|
||||
$media = $rss
|
||||
$media.verified = entry.verified
|
||||
if (entry.seeders !== '?' && entry.seeders < 15) {
|
||||
toast('Availability Warning', {
|
||||
description: 'This release is poorly seeded and likely will have playback issues such as buffering!'
|
||||
})
|
||||
}
|
||||
add(entry.link)
|
||||
data = null
|
||||
}
|
||||
let modal
|
||||
$: data && modal?.focus()
|
||||
|
||||
function filter ({ target }) {
|
||||
const searchText = target.value
|
||||
|
||||
filtered = data.filter(({ title }) => title.toLowerCase().includes(searchText.toLowerCase()))
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class='modal z-40' class:show={data} id='viewAnime'>
|
||||
{#if data}
|
||||
<div class='modal-dialog' on:pointerup|self={close} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}>
|
||||
<div class='modal-content w-auto h-full mx-20 p-0 rounded overflow-x-hidden overflow-y-scroll'>
|
||||
<div class='w-full bg-dark-light d-flex px-15 py-10 position-sticky top-0 z-10'>
|
||||
<div class='material-symbols-outlined text-danger symbol-bold' title='Badges Are a Rough Guess of Information And Might Not Be Representative of Actual Data'>
|
||||
warning
|
||||
</div>
|
||||
<input type='text' class='form-control bg-dark w-300 ml-15' placeholder='Search...' on:input={filter} on:keydown|stopPropagation|stopImmediatePropagation|capture />
|
||||
<button class='btn btn-square bg-dark rounded-circle ml-auto pointer' type='button' use:click={close}> × </button>
|
||||
</div>
|
||||
<table class='table table-hover font-size-14 position-relative'>
|
||||
<thead>
|
||||
<tr class='border-0'>
|
||||
<td class='py-15 pl-20 pr-0' />
|
||||
<td class='py-15 px-20'>Name</td>
|
||||
<td class='py-15 px-20'>Size</td>
|
||||
<td class='py-15 px-20'>Seed</td>
|
||||
<td class='py-15 px-20'>Leech</td>
|
||||
<td class='py-15 px-20'>Downloads</td>
|
||||
<td class='py-15 px-20'>Released</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class='pointer'>
|
||||
{#each filtered as row}
|
||||
<tr class='border-0' class:text-secondary={row.type === 'best'} class:text-danger={row.type === 'alt'} use:click={() => play(row)}>
|
||||
<td class='py-10 pl-20 pr-0'>
|
||||
{#if row.type === 'best'}
|
||||
<div class='material-symbols-outlined font-size-24 symbol-bold' title='Best Release'>
|
||||
star
|
||||
</div>
|
||||
{:else if row.type === 'alt'}
|
||||
<div class='material-symbols-outlined font-size-24 symbol-bold' title='Alt Release'>
|
||||
star
|
||||
</div>
|
||||
{:else if row.verified}
|
||||
<div class='material-symbols-outlined font-size-24 symbol-bold' class:text-success={!row.type && row.verified} title='Verified'>
|
||||
verified
|
||||
</div>
|
||||
{:else if row.type === 'batch'}
|
||||
<div class='text-light material-symbols-outlined font-size-24 symbol-bold' title='Batch'>
|
||||
database
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class='py-10 px-20'>{row.title}
|
||||
<div class='d-flex flex-row text-dark font-weight-bold font-size-12'>
|
||||
{#each sanitiseTerms(row.parseObject) as { text, color }}
|
||||
<div style={'background:' + color} class='rounded px-15 mr-10 mt-5'>
|
||||
{text}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class='py-10 px-20 text-nowrap'>{row.size}</td>
|
||||
<td class='py-10 px-20'>{row.seeders ?? '?'}</td>
|
||||
<td class='py-10 px-20'>{row.leechers ?? '?'}</td>
|
||||
<td class='py-10 px-20'>{row.downloads ?? '?'}</td>
|
||||
<td class='py-10 px-20 text-nowrap'>{row.date ? since(row.date) : '?'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-content {
|
||||
margin: 8rem 6rem 0 6rem !important
|
||||
}
|
||||
.symbol-bold {
|
||||
font-variation-settings: 'wght' 500;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,3 +1,34 @@
|
|||
<script context='module'>
|
||||
import * as comlink from 'comlink'
|
||||
import { settings as set } from '@/modules/settings.js'
|
||||
|
||||
let worker = null
|
||||
|
||||
/** @param {string[]} urls */
|
||||
export async function createWorker (urls) {
|
||||
if (worker) worker.terminate()
|
||||
|
||||
worker = new Worker(new URL('@/modules/extensions/worker.js', import.meta.url), { type: 'module' })
|
||||
/** @type {comlink.Remote<import('@/modules/extensions/worker.js').loadExtensions>} */
|
||||
// @ts-expect-error NO clue why this errors
|
||||
const loadExtensions = await comlink.wrap(worker)
|
||||
|
||||
const extensions = await loadExtensions(urls)
|
||||
|
||||
const metadata = await extensions.metadata
|
||||
|
||||
for (const { name } of metadata) {
|
||||
if (set.value.sources[name] == null) {
|
||||
set.value.sources[name] = true
|
||||
}
|
||||
}
|
||||
return extensions
|
||||
}
|
||||
|
||||
/** @type {ReturnType<typeof createWorker>} */
|
||||
export let extensionsWorker = createWorker(set.value.extensions)
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { click } from '@/modules/click.js'
|
||||
import { defaults } from '@/modules/util.js'
|
||||
|
|
@ -9,6 +40,23 @@
|
|||
function handleFolder () {
|
||||
IPC.emit('dialog')
|
||||
}
|
||||
|
||||
let extensionUrl = ''
|
||||
|
||||
function addExtension () {
|
||||
if (!settings.extensions.includes(extensionUrl)) {
|
||||
settings.extensions.push(extensionUrl)
|
||||
extensionsWorker = createWorker(settings.extensions)
|
||||
settings.extensions = settings.extensions
|
||||
}
|
||||
extensionUrl = ''
|
||||
}
|
||||
|
||||
function removeExtension (i) {
|
||||
settings.extensions.splice(i, 1)
|
||||
extensionsWorker = createWorker(settings.extensions)
|
||||
settings.extensions = settings.extensions
|
||||
}
|
||||
</script>
|
||||
|
||||
<h4 class='mb-10 font-weight-bold'>Lookup Settings</h4>
|
||||
|
|
@ -18,7 +66,7 @@
|
|||
<option value='720'>720p</option>
|
||||
<option value='540'>540p</option>
|
||||
<option value='480'>480p</option>
|
||||
<option value="">None</option>
|
||||
<option value="">Any</option>
|
||||
</select>
|
||||
</SettingCard>
|
||||
<SettingCard title='Auto-Select Torrents' description='Automatically selects torrents based on quality and amount of seeders. Disable this to have more precise control over played torrents.'>
|
||||
|
|
@ -38,9 +86,6 @@
|
|||
<input type='url' class='form-control bg-dark w-300 mw-full' bind:value={settings.doHURL} placeholder={defaults.doHURL} />
|
||||
</SettingCard>
|
||||
{/if}
|
||||
<SettingCard title='Torrent API URL' description='URL of the API used to query data for torrents. Useful for proxies if your ISP blocks some domains. Needs to be CORS enabled.'>
|
||||
<input type='url' class='form-control bg-dark w-300 mw-full' bind:value={settings.toshoURL} placeholder={defaults.toshoURL} />
|
||||
</SettingCard>
|
||||
|
||||
<h4 class='mb-10 font-weight-bold'>Client Settings</h4>
|
||||
{#if SUPPORTS.torrentPath}
|
||||
|
|
@ -64,23 +109,23 @@
|
|||
{/if}
|
||||
<SettingCard title='Transfer Speed Limit' description='Download/Upload speed limit for torrents, higher values increase CPU usage, and values higher than your storage write speeds will quickly fill up RAM.'>
|
||||
<div class='input-group w-100 mw-full'>
|
||||
<input type='number' bind:value={settings.torrentSpeed} min='0' max='50' class='form-control text-right bg-dark' />
|
||||
<input type='number' inputmode='numeric' pattern='[0-9]*' bind:value={settings.torrentSpeed} min='0' max='50' class='form-control text-right bg-dark' />
|
||||
<div class='input-group-append'>
|
||||
<span class='input-group-text bg-dark'>MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
<SettingCard title='Max Number of Connections' description='Numer of peers per torrent. Higher values will increase download speeds but might quickly fill up available ports if your ISP limits the maximum allowed number of open connections.'>
|
||||
<input type='number' bind:value={settings.maxConns} min='1' max='512' class='form-control text-right bg-dark w-100 mw-full' />
|
||||
<input type='number' inputmode='numeric' pattern='[0-9]*' bind:value={settings.maxConns} min='1' max='512' class='form-control text-right bg-dark w-100 mw-full' />
|
||||
</SettingCard>
|
||||
{#if SUPPORTS.torrentPort}
|
||||
<SettingCard title='Torrent Port' description='Port used for Torrent connections. 0 is automatic.'>
|
||||
<input type='number' bind:value={settings.torrentPort} min='0' max='65536' class='form-control text-right bg-dark w-150 mw-full' />
|
||||
<input type='number' inputmode='numeric' pattern='[0-9]*' bind:value={settings.torrentPort} min='0' max='65536' class='form-control text-right bg-dark w-150 mw-full' />
|
||||
</SettingCard>
|
||||
{/if}
|
||||
{#if SUPPORTS.dht}
|
||||
<SettingCard title='DHT Port' description='Port used for DHT connections. 0 is automatic.'>
|
||||
<input type='number' bind:value={settings.dhtPort} min='0' max='65536' class='form-control text-right bg-dark w-150 mw-full' />
|
||||
<input type='number' inputmode='numeric' pattern='[0-9]*' bind:value={settings.dhtPort} min='0' max='65536' class='form-control text-right bg-dark w-150 mw-full' />
|
||||
</SettingCard>
|
||||
<SettingCard title='Disable DHT' description='Disables Distributed Hash Tables for use in private trackers to improve privacy. Might greatly reduce the amount of discovered peers.'>
|
||||
<div class='custom-switch'>
|
||||
|
|
@ -95,3 +140,53 @@
|
|||
<label for='torrent-pex'>{settings.torrentPeX ? 'On' : 'Off'}</label>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<h4 class='mb-10 font-weight-bold'>Extension Settings</h4>
|
||||
|
||||
<SettingCard title='Extensions' description='List of URLs to load sources from. While the extensions are sandboxed and should be safe from attacks, it is not recommended to add unknown or untrusted extensions.'>
|
||||
<div>
|
||||
<div class='input-group w-400 mw-full'>
|
||||
<input placeholder='Enter extension URL or NPM name' type='url' class='form-control w-400 bg-dark mw-full' bind:value={extensionUrl} />
|
||||
<div class='input-group-append'>
|
||||
<button class='btn btn-primary' type='button' use:click={addExtension}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class='w-full d-flex flex-column pt-10'>
|
||||
{#each settings.extensions as extension, i}
|
||||
<div class='btn-group mt-5 w-400 mw-full'>
|
||||
<div class='input-group-prepend overflow-hidden w-full'>
|
||||
<span class='input-group-text bg-dark w-full'>{extension}</span>
|
||||
</div>
|
||||
<button type='button' class='btn btn-danger btn-square px-5 material-symbols-outlined font-size-20' use:click={() => removeExtension(i)}>delete</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
<SettingCard title='Sources' description='List of sources to discover torrents from.'>
|
||||
<div class='w-400 mw-full'>
|
||||
<div class='w-full d-flex flex-column mb-10'>
|
||||
{#key settings.extensions}
|
||||
{#await extensionsWorker then worker}
|
||||
{#await worker.metadata then metadata}
|
||||
{#each metadata as { accuracy, name, description }}
|
||||
<div class='card m-0 p-15 mt-10'>
|
||||
<div class='mr-10 mb-5 mb-md-0'>
|
||||
<div class='font-size-16 font-weight-semi-bold mb-5'>{name}</div>
|
||||
<div class='text-muted pre-wrap'>{description}</div>
|
||||
</div>
|
||||
<div class='d-flex justify-content-between align-items-end'>
|
||||
<div>Accuracy: {accuracy}</div>
|
||||
<div class='custom-switch mt-10'>
|
||||
<input type='checkbox' id={`extension-${name}`} bind:checked={settings.sources[name]} />
|
||||
<label for={`extension-${name}`}>{settings.sources[name] ? 'On' : 'Off'}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/await}
|
||||
{/await}
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
|
|
|||
135
common/views/TorrentSearch/TorrentCard.svelte
Normal file
135
common/views/TorrentSearch/TorrentCard.svelte
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<script context='module'>
|
||||
import { click } from '@/modules/click.js'
|
||||
import { fastPrettyBytes, since } from '@/modules/util.js'
|
||||
|
||||
/** @typedef {import('@thaunknown/ani-resourced/sources/types.d.ts').Result} Result */
|
||||
/** @typedef {import('anitomyscript').AnitomyResult} AnitomyResult */
|
||||
|
||||
const termMapping = {}
|
||||
termMapping['5.1'] = { text: '5.1', color: '#f67255' }
|
||||
termMapping['5.1CH'] = termMapping[5.1]
|
||||
termMapping['TRUEHD5.1'] = { text: 'TrueHD 5.1', color: '#f67255' }
|
||||
termMapping.AAC = { text: 'AAC', color: '#f67255' }
|
||||
termMapping.AACX2 = termMapping.AAC
|
||||
termMapping.AACX3 = termMapping.AAC
|
||||
termMapping.AACX4 = termMapping.AAC
|
||||
termMapping.AC3 = { text: 'AC3', color: '#f67255' }
|
||||
termMapping.EAC3 = { text: 'EAC3', color: '#f67255' }
|
||||
termMapping['E-AC-3'] = termMapping.EAC3
|
||||
termMapping.FLAC = { text: 'FLAC', color: '#f67255' }
|
||||
termMapping.FLACX2 = termMapping.FLAC
|
||||
termMapping.FLACX3 = termMapping.FLAC
|
||||
termMapping.FLACX4 = termMapping.FLAC
|
||||
termMapping.VORBIS = { text: 'Vorbis', color: '#f67255' }
|
||||
termMapping.DUALAUDIO = { text: 'Dual Audio', color: '#f67255' }
|
||||
termMapping['DUAL AUDIO'] = termMapping.DUALAUDIO
|
||||
termMapping['10BIT'] = { text: '10 Bit', color: '#0c8ce9' }
|
||||
termMapping['10BITS'] = termMapping['10BIT']
|
||||
termMapping['10-BIT'] = termMapping['10BIT']
|
||||
termMapping['10-BITS'] = termMapping['10BIT']
|
||||
termMapping.HI10 = termMapping['10BIT']
|
||||
termMapping.HI10P = termMapping['10BIT']
|
||||
termMapping.HI444 = { text: 'HI444', color: '#0c8ce9' }
|
||||
termMapping.HI444P = termMapping.HI444
|
||||
termMapping.HI444PP = termMapping.HI444
|
||||
termMapping.HEVC = { text: 'HEVC', color: '#0c8ce9' }
|
||||
termMapping.H265 = termMapping.HEVC
|
||||
termMapping['H.265'] = termMapping.HEVC
|
||||
termMapping.X265 = termMapping.HEVC
|
||||
termMapping.AV1 = { text: 'AV1', color: '#0c8ce9' }
|
||||
|
||||
/** @param {AnitomyResult} param0 */
|
||||
function sanitiseTerms ({ video_term: vid, audio_term: aud, video_resolution: resolution }) {
|
||||
const video = !Array.isArray(vid) ? [vid] : vid
|
||||
const audio = !Array.isArray(aud) ? [aud] : aud
|
||||
|
||||
const terms = [...new Set([...video, ...audio].map(term => termMapping[term?.toUpperCase()]).filter(t => t))]
|
||||
if (resolution) terms.unshift({ text: resolution, color: '#c6ec58' })
|
||||
|
||||
return terms
|
||||
}
|
||||
|
||||
/** @param {AnitomyResult} param0 */
|
||||
function simplifyFilename ({ video_term: vid, audio_term: aud, video_resolution: resolution, file_name: name, release_group: group, file_checksum: checksum }) {
|
||||
const video = !Array.isArray(vid) ? [vid] : vid
|
||||
const audio = !Array.isArray(aud) ? [aud] : aud
|
||||
|
||||
let simpleName = name
|
||||
if (group) simpleName = simpleName.replace(group, '')
|
||||
if (resolution) simpleName = simpleName.replace(resolution, '')
|
||||
if (checksum) simpleName = simpleName.replace(checksum, '')
|
||||
for (const term of video) simpleName = simpleName.replace(term, '')
|
||||
for (const term of audio) simpleName = simpleName.replace(term, '')
|
||||
return simpleName.replace(/[[{(]\s*[\]})]/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/** @type {Result & { parseObject: AnitomyResult }} */
|
||||
export let result
|
||||
|
||||
/** @type {import('@/modules/al.d.ts').Media} */
|
||||
export let media
|
||||
|
||||
/** @type {Function} */
|
||||
export let play
|
||||
</script>
|
||||
|
||||
<div class='card bg-dark p-15 d-flex mx-0 overflow-hidden pointer mb-10 mt-0 position-relative scale' use:click={() => play(result)} title={result.parseObject.file_name}>
|
||||
{#if media.bannerImage}
|
||||
<div class='position-absolute top-0 left-0 w-full h-full'>
|
||||
<img src={media.bannerImage} alt='bannerImage' class='img-cover w-full h-full' style='border-radius: 5px;' />
|
||||
<div class='position-absolute top-0 left-0 w-full h-full' style='background: linear-gradient(90deg, #17191C 32%, rgba(23, 25, 28, 0.90) 100%);' />
|
||||
</div>
|
||||
{/if}
|
||||
<div class='d-flex pl-10 flex-column justify-content-between w-full h-100 position-relative' style='min-width: 0;'>
|
||||
<div class='d-flex w-full'>
|
||||
<div class='font-size-22 font-weight-bold text-nowrap'>{result.parseObject?.release_group && result.parseObject.release_group.length < 20 ? result.parseObject.release_group : 'No Group'}</div>
|
||||
{#if result.type === 'batch'}
|
||||
<div class='material-symbols-outlined card-title symbol-bold ml-auto' title='Batch'>
|
||||
database
|
||||
</div>
|
||||
{:else if result.verified}
|
||||
<div class='material-symbols-outlined card-title symbol-bold ml-auto' style='color: #53da33' title='Verified'>
|
||||
verified
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class='font-size-14 text-muted text-truncate overflow-hidden'>{simplifyFilename(result.parseObject)}</div>
|
||||
<div class='d-flex flex-row text-dark font-size-14' style='line-height:1'>
|
||||
<div class='text-light d-flex align-items-center text-nowrap'>{fastPrettyBytes(result.size)}</div>
|
||||
<div class='text-light d-flex align-items-center text-nowrap'> • </div>
|
||||
<div class='text-light d-flex align-items-center text-nowrap'>{result.seeders} Seeders</div>
|
||||
<div class='text-light d-flex align-items-center text-nowrap'> • </div>
|
||||
<div class='text-light d-flex align-items-center text-nowrap'>{since(new Date(result.date))}</div>
|
||||
<div class='d-flex ml-auto flex-row-reverse'>
|
||||
{#if result.type === 'best'}
|
||||
<div class='rounded px-15 py-5 ml-10 border text-nowrap d-flex align-items-center' style='background: #1d2d1e; border-color: #53da33 !important; color: #53da33'>
|
||||
Best Release
|
||||
</div>
|
||||
{:else if result.type === 'alt'}
|
||||
<div class='rounded px-15 py-5 ml-10 border text-nowrap d-flex align-items-center' style='background: #391d20; border-color: #c52d2d !important; color: #c52d2d'>
|
||||
Alt Release
|
||||
</div>
|
||||
{/if}
|
||||
{#each sanitiseTerms(result.parseObject) as { text }}
|
||||
<div class='rounded px-15 py-5 ml-10 bg-very-dark text-nowrap text-white d-flex align-items-center'>
|
||||
{text}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.symbol-bold {
|
||||
font-variation-settings: 'wght' 300;
|
||||
}
|
||||
.scale {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.scale:hover{
|
||||
transform: scale(1.04);
|
||||
}
|
||||
</style>
|
||||
178
common/views/TorrentSearch/TorrentMenu.svelte
Normal file
178
common/views/TorrentSearch/TorrentMenu.svelte
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<script context='module'>
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { settings } from '@/modules/settings.js'
|
||||
import { click } from '@/modules/click.js'
|
||||
import getResultsFromExtensions from '@/modules/extensions/index.js'
|
||||
|
||||
/** @typedef {import('@/modules/al.d.ts').Media} Media */
|
||||
|
||||
/** @param {Media} media */
|
||||
function isMovie (media) {
|
||||
if (media.format === 'MOVIE') return true
|
||||
if ([...Object.values(media.title), ...media.synonyms].some(title => title?.toLowerCase().includes('movie'))) return true
|
||||
// if (!getParentForSpecial(media)) return true // TODO: this is good for checking movies, but false positives with normal TV shows
|
||||
return media.duration > 80 && media.episodes === 1
|
||||
}
|
||||
|
||||
/** @param {ReturnType<typeof getResultsFromExtensions>} promise */
|
||||
async function getBest (promise) {
|
||||
const results = await promise
|
||||
|
||||
const best = results.find(result => result.type === 'best') || results.find(result => result.type === 'alt') || results[0]
|
||||
|
||||
if (best?.seeders < 10) return results[0]
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
function filterResults (results, searchText) {
|
||||
return results.filter(({ title }) => title.toLowerCase().includes(searchText.toLowerCase()))
|
||||
}
|
||||
|
||||
/** @param {ReturnType<typeof getResultsFromExtensions>} results */
|
||||
async function sortResults (results) {
|
||||
return (await results).sort((a, b) => b.seeders - a.seeders)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { media as currentMedia } from '../Player/MediaHandler.svelte'
|
||||
import TorrentCard from './TorrentCard.svelte'
|
||||
import { add } from '@/modules/torrent.js'
|
||||
import TorrentSkeletonCard from './TorrentSkeletonCard.svelte'
|
||||
|
||||
/** @type {{ media: Media, episode?: number }} */
|
||||
export let search
|
||||
|
||||
let countdown = 2
|
||||
let timeoutHandle
|
||||
|
||||
/** @param {ReturnType<typeof getBest>} promise */
|
||||
async function autoPlay (promise, autoPlay) {
|
||||
const best = await promise
|
||||
if ($settings.rssAutoplay) {
|
||||
clearTimeout(timeoutHandle)
|
||||
const decrement = () => {
|
||||
countdown--
|
||||
if (countdown === 0) {
|
||||
play(best)
|
||||
} else {
|
||||
timeoutHandle = setTimeout(decrement, 1000)
|
||||
}
|
||||
}
|
||||
timeoutHandle = setTimeout(decrement, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const movie = isMovie(search.media)
|
||||
let batch = search.media.status === 'FINISHED' && !movie
|
||||
|
||||
$: resolution = $settings.rssQuality
|
||||
|
||||
$: lookup = sortResults(getResultsFromExtensions({ ...search, batch, movie, resolution }))
|
||||
$: best = getBest(lookup)
|
||||
|
||||
$: if (!$settings.rssAutoplay) clearTimeout(timeoutHandle)
|
||||
$: autoPlay(best, $settings.rssAutoplay)
|
||||
|
||||
$: lookup.catch(err => {
|
||||
console.error(err)
|
||||
toast.error(`Couldn't find torrents for ${search.media.title.userPreferred} Episode ${search.episode}! Try specifying a torrent manually.\n${err.message}`)
|
||||
})
|
||||
|
||||
$: firstLoad = !firstLoad && lookup.catch(close)
|
||||
|
||||
let searchText = ''
|
||||
|
||||
/** @param {import('@thaunknown/ani-resourced/sources/types.d.ts').Result} result */
|
||||
function play (result) {
|
||||
$currentMedia = search.media
|
||||
$currentMedia.verified = result.verified
|
||||
if (!isNaN(result.seeders) && result.seeders < 10) {
|
||||
toast('Availability Warning', {
|
||||
description: 'This release is poorly seeded and likely will have playback issues such as buffering!'
|
||||
})
|
||||
}
|
||||
add(result.link)
|
||||
close()
|
||||
}
|
||||
|
||||
function episodeInput ({ target }) {
|
||||
const episode = Number(target.value)
|
||||
if (episode) search.episode = episode
|
||||
}
|
||||
|
||||
export let close
|
||||
</script>
|
||||
|
||||
<div class='w-full bg-very-dark position-sticky top-0 z-10 pt-20 px-30'>
|
||||
<div class='d-flex'>
|
||||
<h3 class='mb-10 font-weight-bold text-white'>Find Torrents</h3>
|
||||
<button class='btn btn-square rounded-circle ml-auto pointer' type='button' use:click={close}> × </button>
|
||||
</div>
|
||||
<h4 class='mb-10 text-light'>Auto-Selected Torrent</h4>
|
||||
{#await best}
|
||||
<TorrentSkeletonCard />
|
||||
{:then bestRelease}
|
||||
<TorrentCard result={bestRelease} {play} media={search.media} />
|
||||
{:catch error}
|
||||
<div class='p-15 mb-10'><div class='h-100' /></div>
|
||||
{/await}
|
||||
<div class='input-group mt-20'>
|
||||
<div class='input-group-prepend'>
|
||||
<span class='input-group-text d-flex material-symbols-outlined bg-dark pr-0 font-size-18'>search</span>
|
||||
</div>
|
||||
<input
|
||||
type='search'
|
||||
class='form-control bg-dark border-left-0'
|
||||
autocomplete='off'
|
||||
data-option='search'
|
||||
placeholder='Find a specific torrent...' bind:value={searchText} on:keydown|stopPropagation|stopImmediatePropagation|capture />
|
||||
</div>
|
||||
<div class='row mt-20 mb-10'>
|
||||
<div class='col-12 col-md-6 d-flex align-items-center justify-content-around'>
|
||||
<div class='custom-switch'>
|
||||
<input type='checkbox' id='rss-autoplay' bind:checked={$settings.rssAutoplay} />
|
||||
<label for='rss-autoplay'>Auto-Select Torrents [{countdown}]</label>
|
||||
</div>
|
||||
<div class='custom-switch'>
|
||||
<input type='checkbox' id='batches' bind:checked={batch} disabled={(search.media.status !== 'FINISHED') || movie} min='1' />
|
||||
<label for='batches'>Find Batches</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class='col-12 col-md-6 d-flex align-items-center justify-content-around mt-20 mt-md-0'>
|
||||
<div class='w-150 d-flex align-items-center'>
|
||||
<span>Episode</span>
|
||||
<input type='number' inputmode='numeric' pattern='[0-9]*' class='form-control bg-dark text-right ml-10' value={search.episode} on:input={episodeInput} disabled={!search.episode || movie} />
|
||||
</div>
|
||||
<div class='w-200 d-flex align-items-center'>
|
||||
<span>Resolution</span>
|
||||
<select class='form-control w-full bg-dark ml-10' bind:value={$settings.rssQuality}>
|
||||
<option value='1080' selected>1080p</option>
|
||||
<option value='720'>720p</option>
|
||||
<option value='540'>540p</option>
|
||||
<option value='480'>480p</option>
|
||||
<option value="">Any</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='mt-10 px-30'>
|
||||
{#await lookup}
|
||||
{#each Array.from({ length: 10 }) as _}
|
||||
<TorrentSkeletonCard />
|
||||
{/each}
|
||||
{:then results}
|
||||
{#each filterResults(results, searchText) as result}
|
||||
<TorrentCard {result} {play} media={search.media} />
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.px-30 {
|
||||
padding-left: 3rem;
|
||||
padding-right: 3rem;
|
||||
}
|
||||
</style>
|
||||
46
common/views/TorrentSearch/TorrentModal.svelte
Normal file
46
common/views/TorrentSearch/TorrentModal.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script context='module'>
|
||||
import { findInCurrent } from '../Player/MediaHandler.svelte'
|
||||
import { writable } from 'simple-store-svelte'
|
||||
|
||||
const rss = writable(null)
|
||||
|
||||
export function playAnime (media, episode = 1, force) {
|
||||
episode = Number(episode)
|
||||
episode = isNaN(episode) ? 1 : episode
|
||||
if (!force && findInCurrent({ media, episode })) return
|
||||
rss.set({ media, episode })
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import TorrentMenu from './TorrentMenu.svelte'
|
||||
|
||||
function close () {
|
||||
$rss = null
|
||||
}
|
||||
function checkClose ({ keyCode }) {
|
||||
if (keyCode === 27) close()
|
||||
}
|
||||
|
||||
let modal
|
||||
|
||||
$: search = $rss
|
||||
|
||||
$: search && modal?.focus()
|
||||
</script>
|
||||
|
||||
<div class='modal z-40' class:show={search} id='viewAnime'>
|
||||
{#if search}
|
||||
<div class='modal-dialog d-flex align-items-center px-md-15 pt-md-20' on:pointerup|self={close} on:keydown={checkClose} tabindex='-1' role='button' bind:this={modal}>
|
||||
<div class='modal-content m-0 mw-full h-full rounded overflow-hidden bg-very-dark d-flex flex-column overflow-y-scroll pt-0 px-0'>
|
||||
<TorrentMenu {search} {close} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-content {
|
||||
width: 115rem;
|
||||
}
|
||||
</style>
|
||||
14
common/views/TorrentSearch/TorrentSkeletonCard.svelte
Normal file
14
common/views/TorrentSearch/TorrentSkeletonCard.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<div class='card bg-dark p-15 d-flex mx-0 overflow-hidden mb-10 mt-0'>
|
||||
<div class='d-flex pl-10 flex-column justify-content-between w-full h-100' style='min-width: 0;'>
|
||||
<div class='d-flex w-full'>
|
||||
<div class='skeloader h-25 w-250 mw-full rounded bg-dark-light'><div class='skeleloader-swipe' /></div>
|
||||
</div>
|
||||
<div class='skeloader h-10 w-150 mw-full rounded bg-dark-light'><div class='skeleloader-swipe' /></div>
|
||||
<div class='d-flex flex-row'>
|
||||
<div class='skeloader h-10 w-300 mw-full rounded bg-dark-light'><div class='skeleloader-swipe' /></div>
|
||||
<div class='d-flex ml-auto flex-row-reverse'>
|
||||
<div class='skeloader h-10 w-150 mw-full rounded bg-dark-light ml-15'><div class='skeleloader-swipe' /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { since } from '@/modules/util'
|
||||
import { click } from '@/modules/click.js'
|
||||
import { getEpisodeNumberByAirDate } from '@/modules/providers/tosho.js'
|
||||
import { episodeByAirDate } from '@/modules/extensions/index.js'
|
||||
import { anilistClient } from '@/modules/anilist'
|
||||
import { liveAnimeProgress } from '@/modules/animeprogress.js'
|
||||
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
const alDate = new Date((airingAt || 0) * 1000)
|
||||
|
||||
const needsValidation = !(!specialCount || (media.episodes && media.episodes === episodeCount && episodes[Number(episode)]))
|
||||
const { image, summary, rating, title, length, airdate } = needsValidation ? getEpisodeNumberByAirDate(alDate, episodes, episode) : (episodes[Number(episode)] || {})
|
||||
const { image, summary, rating, title, length, airdate } = needsValidation ? episodeByAirDate(alDate, episodes, episode) : (episodes[Number(episode)] || {})
|
||||
|
||||
episodeList[episode - 1] = { episode, image, summary, rating, title, length: length || duration, airdate: +alDate || airdate, airingAt: +alDate || airdate }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { getContext } from 'svelte'
|
||||
import { getMediaMaxEp, formatMap, playMedia, setStatus } from '@/modules/anime.js'
|
||||
import { playAnime } from '../RSSView.svelte'
|
||||
import { playAnime } from '@/views/TorrentSearch/TorrentModal.svelte'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import { anilistClient } from '@/modules/anilist.js'
|
||||
import { click } from '@/modules/click.js'
|
||||
|
|
|
|||
|
|
@ -133,8 +133,11 @@ importers:
|
|||
'@fontsource/roboto':
|
||||
specifier: ^5.0.12
|
||||
version: 5.0.12
|
||||
'@thaunknown/ani-resourced':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
anitomyscript:
|
||||
specifier: github:ThaUnknown/anitomyscript#42290c4b3f256893be08a4e89051f448ff5e9d00
|
||||
specifier: github:ThaUnknown/anitomyscript#51abfeaa99114659fb4c0d30e32c61d50d6d1a8a
|
||||
version: github.com/ThaUnknown/anitomyscript/42290c4b3f256893be08a4e89051f448ff5e9d00
|
||||
bottleneck:
|
||||
specifier: ^2.19.5
|
||||
|
|
@ -142,6 +145,9 @@ importers:
|
|||
browser-event-target-emitter:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
comlink:
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1
|
||||
jassub:
|
||||
specifier: latest
|
||||
version: 1.7.15
|
||||
|
|
@ -1199,6 +1205,10 @@ packages:
|
|||
defer-to-connect: 2.0.1
|
||||
dev: true
|
||||
|
||||
/@thaunknown/ani-resourced@1.0.3:
|
||||
resolution: {integrity: sha512-flrfEMbSwwdhu+5wVsMgX0tGG+njCtAQ4jnA7ywjLNhSil/KirlzN34q3l0lKCnitFJgp1vOoHNCgkkkMWQ85Q==}
|
||||
dev: false
|
||||
|
||||
/@thaunknown/idb-chunk-store@1.0.2:
|
||||
resolution: {integrity: sha512-UdKshbKdHDP+p0XPdv55QiU/scdB9TzvovGFSgXThf+7Yd3noLeYp6KpkYyc1jzUXvI3/8+TemPeASOimrOXvw==}
|
||||
dependencies:
|
||||
|
|
@ -2878,6 +2888,10 @@ packages:
|
|||
delayed-stream: 1.0.0
|
||||
dev: true
|
||||
|
||||
/comlink@4.4.1:
|
||||
resolution: {integrity: sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q==}
|
||||
dev: false
|
||||
|
||||
/commander@10.0.1:
|
||||
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
|
||||
engines: {node: '>=14'}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
Loading…
Reference in a new issue