Skip to content

Commit

Permalink
feat: extension system
Browse files Browse the repository at this point in the history
feat: new torrent modal
  • Loading branch information
ThaUnknown committed Mar 19, 2024
1 parent 96609ba commit 6fc4851
Show file tree
Hide file tree
Showing 25 changed files with 761 additions and 742 deletions.
4 changes: 2 additions & 2 deletions common/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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} />
Expand Down
2 changes: 1 addition & 1 deletion common/components/Search.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'>
Expand Down
2 changes: 1 addition & 1 deletion common/modules/anime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
197 changes: 197 additions & 0 deletions common/modules/extensions/index.js
Original file line number Diff line number Diff line change
@@ -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 changes: 53 additions & 0 deletions common/modules/extensions/worker.js
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 6fc4851

Please sign in to comment.