mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-04-20 00:32:04 +00:00
wip: media resolver [mostly done]
This commit is contained in:
parent
86492ed76f
commit
0fb7535cee
23 changed files with 679 additions and 129 deletions
|
|
@ -13,7 +13,12 @@ export default tseslint.config(
|
|||
}
|
||||
},
|
||||
rules: {
|
||||
'svelte/html-self-closing': 'off',
|
||||
'svelte/html-self-closing': [
|
||||
'error',
|
||||
'all'
|
||||
],
|
||||
'svelte/no-reactive-reassign': 'off',
|
||||
'no-undef-init': 'off',
|
||||
'import/order': ['error', {
|
||||
'newlines-between': 'always',
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type']
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ importers:
|
|||
version: 0.0.18(svelte@4.2.19)
|
||||
eslint-config-standard-universal:
|
||||
specifier: github:thaunknown/eslint-config-standard-universal
|
||||
version: https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/c0cd0946f376fa99433109da8553c7c2013f2934(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(jiti@1.21.6)
|
||||
version: https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/117de7995034e8df00c8cffe8f28025ded2833f7(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(jiti@1.21.6)
|
||||
globals:
|
||||
specifier: ^15.11.0
|
||||
version: 15.11.0
|
||||
|
|
@ -1176,8 +1176,8 @@ packages:
|
|||
peerDependencies:
|
||||
eslint: '>=6.0.0'
|
||||
|
||||
eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/c0cd0946f376fa99433109da8553c7c2013f2934:
|
||||
resolution: {tarball: https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/c0cd0946f376fa99433109da8553c7c2013f2934}
|
||||
eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/117de7995034e8df00c8cffe8f28025ded2833f7:
|
||||
resolution: {tarball: https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/117de7995034e8df00c8cffe8f28025ded2833f7}
|
||||
version: 1.0.4
|
||||
|
||||
eslint-import-resolver-node@0.3.9:
|
||||
|
|
@ -2230,8 +2230,8 @@ packages:
|
|||
resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
svelte@5.23.1:
|
||||
resolution: {integrity: sha512-DUu3e5tQDO+PtKffjqJ548YfeKtw2Rqc9/+nlP26DZ0AopWTJNylkNnTOP/wcgIt1JSnovyISxEZ/lDR1OhbOw==}
|
||||
svelte@5.25.5:
|
||||
resolution: {integrity: sha512-ULi9rkVWQJyJYZSpy6SIgSTchWadyWG1QYAUx3JAXL2gXrnhdXtoB20KmXGSNdtNyquq3eYd/gkwAkLcL5PGWw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tabbable@6.2.0:
|
||||
|
|
@ -3564,7 +3564,7 @@ snapshots:
|
|||
eslint: 9.17.0(jiti@1.21.6)
|
||||
semver: 7.6.3
|
||||
|
||||
eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/c0cd0946f376fa99433109da8553c7c2013f2934(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(jiti@1.21.6):
|
||||
eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/117de7995034e8df00c8cffe8f28025ded2833f7(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(jiti@1.21.6):
|
||||
dependencies:
|
||||
'@stylistic/eslint-plugin': 4.2.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
eslint: 9.17.0(jiti@1.21.6)
|
||||
|
|
@ -3572,9 +3572,9 @@ snapshots:
|
|||
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.17.0(jiti@1.21.6))
|
||||
eslint-plugin-n: 17.15.0(eslint@9.17.0(jiti@1.21.6))
|
||||
eslint-plugin-promise: 7.2.1(eslint@9.17.0(jiti@1.21.6))
|
||||
eslint-plugin-svelte: 3.1.0(eslint@9.17.0(jiti@1.21.6))(svelte@5.23.1)
|
||||
eslint-plugin-svelte: 3.1.0(eslint@9.17.0(jiti@1.21.6))(svelte@5.25.5)
|
||||
globals: 16.0.0
|
||||
svelte: 5.23.1
|
||||
svelte: 5.25.5
|
||||
typescript: 5.7.2
|
||||
typescript-eslint: 8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -3656,7 +3656,7 @@ snapshots:
|
|||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@1.21.6))
|
||||
eslint: 9.17.0(jiti@1.21.6)
|
||||
|
||||
eslint-plugin-svelte@3.1.0(eslint@9.17.0(jiti@1.21.6))(svelte@5.23.1):
|
||||
eslint-plugin-svelte@3.1.0(eslint@9.17.0(jiti@1.21.6))(svelte@5.25.5):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@1.21.6))
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
|
@ -3668,9 +3668,9 @@ snapshots:
|
|||
postcss-load-config: 3.1.4(postcss@8.4.49)
|
||||
postcss-safe-parser: 7.0.1(postcss@8.4.49)
|
||||
semver: 7.6.3
|
||||
svelte-eslint-parser: 1.0.1(svelte@5.23.1)
|
||||
svelte-eslint-parser: 1.0.1(svelte@5.25.5)
|
||||
optionalDependencies:
|
||||
svelte: 5.23.1
|
||||
svelte: 5.25.5
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
|
|
@ -4640,7 +4640,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- picomatch
|
||||
|
||||
svelte-eslint-parser@1.0.1(svelte@5.23.1):
|
||||
svelte-eslint-parser@1.0.1(svelte@5.25.5):
|
||||
dependencies:
|
||||
eslint-scope: 8.2.0
|
||||
eslint-visitor-keys: 4.2.0
|
||||
|
|
@ -4649,7 +4649,7 @@ snapshots:
|
|||
postcss-scss: 4.0.9(postcss@8.4.49)
|
||||
postcss-selector-parser: 7.1.0
|
||||
optionalDependencies:
|
||||
svelte: 5.23.1
|
||||
svelte: 5.25.5
|
||||
|
||||
svelte-hmr@0.16.0(svelte@4.2.19):
|
||||
dependencies:
|
||||
|
|
@ -4693,7 +4693,7 @@ snapshots:
|
|||
magic-string: 0.30.12
|
||||
periscopic: 3.1.0
|
||||
|
||||
svelte@5.23.1:
|
||||
svelte@5.25.5:
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
|
|
|||
|
|
@ -171,13 +171,6 @@ details:active,
|
|||
background-image: var(--bg);
|
||||
}
|
||||
|
||||
.border-gradient-to-t {
|
||||
border-image: fill 0 linear-gradient(#0008, #000);
|
||||
}
|
||||
|
||||
.border-gradient-to-l {
|
||||
border-image: fill 0 linear-gradient(90deg, hsl(var(--background) / 1) 32%, hsl(var(--background) / 0.9) 100%);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
display: none !important;
|
||||
|
|
|
|||
13
src/app.d.ts
vendored
13
src/app.d.ts
vendored
|
|
@ -20,6 +20,16 @@ export interface Track {
|
|||
language: string
|
||||
}
|
||||
|
||||
export interface TorrentFile {
|
||||
name: string
|
||||
hash: string
|
||||
type: string
|
||||
size: number
|
||||
path: string
|
||||
url: string
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface Native {
|
||||
authAL: (url: string) => Promise<AuthResponse>
|
||||
restart: () => Promise<void>
|
||||
|
|
@ -44,6 +54,8 @@ export interface Native {
|
|||
checkAvailableSpace: (_?: unknown) => Promise<number>
|
||||
checkIncomingConnections: (_?: unknown) => Promise<boolean>
|
||||
updatePeerCounts: (hashes: string[]) => Promise<Array<{ hash, complete, downloaded, incomplete }>>
|
||||
playTorrent: (id: string) => Promise<TorrentFile[]>
|
||||
getAttachmentsURL: () => Promise<string>
|
||||
isApp: boolean
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +69,6 @@ declare global {
|
|||
}
|
||||
// interface Platform {}
|
||||
}
|
||||
var native: Native
|
||||
|
||||
interface HTMLMediaElement {
|
||||
videoTracks?: Track[]
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@
|
|||
await sleep(790)
|
||||
isAnimating = isSpinning = isFlying = false
|
||||
}
|
||||
// TODO: finish :^)
|
||||
// @ts-expect-error non-standard API
|
||||
const idleDetector = new IdleDetector()
|
||||
idleDetector.addEventListener('change', () => {
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@
|
|||
export let eps: EpisodesResponse | null
|
||||
export let media: Media
|
||||
|
||||
// TODO: add watch progress from local sync
|
||||
|
||||
const episodeCount = Math.max(_episodes(media) ?? 0, eps?.episodeCount ?? 0)
|
||||
|
||||
const { episodes, specialCount } = eps ?? {}
|
||||
|
|
@ -78,7 +76,7 @@
|
|||
{@const target = _progress + 1 === episode}
|
||||
<div use:click={() => play(episode)}
|
||||
class={cn(
|
||||
'select:scale-[1.05] select:shadow-lg scale-100 transition-all duration-200 shrink-0 ease-out focus-visible:ring-ring focus-visible:ring-1 rounded-md bg-neutral-950 text-secondary-foreground select:bg-neutral-900 flex w-full max-h-28 pointer relative overflow-hidden group',
|
||||
'select:scale-[1.05] select:shadow-lg scale-100 transition-[transform,box-shadow] duration-200 shrink-0 ease-out focus-visible:ring-ring focus-visible:ring-1 rounded-md bg-neutral-950 text-secondary-foreground select:bg-neutral-900 flex w-full max-h-28 pointer relative overflow-hidden group',
|
||||
target && 'ring-ring ring-1',
|
||||
filler && '!ring-yellow-400 ring-1'
|
||||
)}>
|
||||
|
|
@ -90,8 +88,8 @@
|
|||
{length ?? media.duration}m
|
||||
</div>
|
||||
{/if}
|
||||
<div class='absolute flex items-center justify-center w-full h-full bg-black group-select:bg-opacity-50 bg-opacity-0 duration-300 text-white transition-all ease-out top-0'>
|
||||
<Play class='size-6 scale-75 opacity-0 group-select:opacity-100 group-select:scale-100 duration-300 transition-all ease-out' fill='currentColor' />
|
||||
<div class='absolute flex items-center justify-center w-full h-full bg-black group-select:bg-opacity-50 bg-opacity-0 duration-200 text-white transition-[background] ease-out top-0'>
|
||||
<Play class='size-6 scale-75 opacity-0 group-select:opacity-100 group-select:scale-100 duration-200 transition-[transform,opacity] ease-out' fill='currentColor' />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
return simpleName.replace(/[[{(]\s*[\]})]/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// episode is optional here, but is actually always defined
|
||||
export const searchStore = writable<{episode?: number, media?: Media}>({})
|
||||
</script>
|
||||
|
||||
|
|
@ -66,6 +67,8 @@
|
|||
import { Banner } from './ui/img'
|
||||
|
||||
import { saved } from '$lib/modules/extensions'
|
||||
import { server } from '$lib/modules/torrent'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
$: open = !!$searchStore.media
|
||||
|
||||
|
|
@ -78,8 +81,9 @@
|
|||
let inputText = ''
|
||||
|
||||
function play (result: TorrentResult & { parseObject: AnitomyResult, extension: Set<string> }) {
|
||||
server.play(result.hash, $searchStore.media!, $searchStore.episode!)
|
||||
goto('/app/player/')
|
||||
close(false)
|
||||
// TODO
|
||||
}
|
||||
|
||||
async function playBest () {
|
||||
|
|
@ -140,7 +144,9 @@
|
|||
<Dialog.Root bind:open onOpenChange={close} portal='#root'>
|
||||
<Dialog.Content class='bg-black h-full lg:border-x-4 border-b-0 max-w-5xl w-full max-h-[calc(100%-1rem)] mt-2 p-0 items-center flex lg:rounded-t-xl overflow-hidden'>
|
||||
<div class='absolute top-0 left-0 w-full h-full max-h-28 overflow-hidden'>
|
||||
<Banner media={$searchStore.media} class='object-cover w-full h-full absolute bottom-[0.5px] left-0 -z-10' />
|
||||
{#if $searchStore.media}
|
||||
<Banner media={$searchStore.media} class='object-cover w-full h-full absolute bottom-[0.5px] left-0 -z-10' />
|
||||
{/if}
|
||||
<div class='w-full h-full banner-2' />
|
||||
</div>
|
||||
<div class='gap-4 w-full relative h-full flex flex-col pt-6'>
|
||||
|
|
@ -209,7 +215,9 @@
|
|||
<div class='text-xl font-bold text-nowrap'>{result.parseObject.release_group && result.parseObject.release_group.length < 20 ? result.parseObject.release_group : 'No Group'}</div>
|
||||
<div class='ml-auto flex gap-2 self-start'>
|
||||
{#each result.extension as id (id)}
|
||||
<img src={$saved[id].icon} alt={id} class='size-4' title='Provided by {id}' decoding='async' loading='lazy' />
|
||||
{#if $saved[id]}
|
||||
<img src={$saved[id].icon} alt={id} class='size-4' title='Provided by {id}' decoding='async' loading='lazy' />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang='ts' context='module'>
|
||||
import { writable } from 'simple-store-svelte'
|
||||
|
||||
import { safeBanner, type Media } from '$lib/modules/anilist'
|
||||
import type { Media } from '$lib/modules/anilist'
|
||||
|
||||
import { cn } from '$lib/utils'
|
||||
|
||||
export const bannerSrc = writable<Media | null>(null)
|
||||
|
|
@ -10,19 +11,31 @@
|
|||
</script>
|
||||
|
||||
<script lang='ts'>
|
||||
import { Banner } from '../img'
|
||||
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLImageElement>
|
||||
|
||||
$: src = $bannerSrc && safeBanner($bannerSrc)
|
||||
let className: $$Props['class'] = ''
|
||||
export { className as class } // TODO: needs nice animations, should update to coverimage on mobile width
|
||||
</script>
|
||||
|
||||
{#await src then src}
|
||||
<div class={cn('object-cover w-screen absolute top-0 left-0 h-full overflow-hidden pointer-events-none bg-black', className)}>
|
||||
{#if src}
|
||||
<div class='min-w-[100vw] w-screen h-[30rem] bg-url bg-center bg-cover opacity-100 transition-opacity duration-500 border-gradient-to-t' style:--bg='url({src})' class:!opacity-45={$hideBanner} />
|
||||
{/if}
|
||||
{#if $bannerSrc}
|
||||
<div class={cn('object-cover w-screen absolute top-0 left-0 h-full overflow-hidden pointer-events-none bg-black banner', className)}>
|
||||
{#key $bannerSrc}
|
||||
<Banner media={$bannerSrc} class='min-w-[100vw] w-screen h-[30rem] object-cover opacity-100 transition-opacity duration-500 banner-gr relative' />
|
||||
{/key}
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.banner-gr::after) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0 ; bottom: 0;
|
||||
/* when clicking, translate fucks up the position, and video might leak down 1 or 2 pixels, stickig under the gradient, look bad */
|
||||
width: 100%; height: 100% ;
|
||||
background: linear-gradient(#0008, #000);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
|
||||
type $$Props = HTMLAttributes<HTMLImageElement> & { media: Media }
|
||||
|
||||
const src = banner(media)
|
||||
const isYoutube = src?.startsWith('https://i.ytimg.com/')
|
||||
$: src = banner(media)
|
||||
$: isYoutube = src?.startsWith('https://i.ytimg.com/')
|
||||
let className: $$Props['class'] = ''
|
||||
export { className as class }
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@
|
|||
import EpisodesList from '$lib/components/EpisodesList.svelte'
|
||||
import { episodes } from '$lib/modules/anizip'
|
||||
|
||||
export let mediaInfo: MediaInfo
|
||||
export let prev: null | (() => void)
|
||||
export let next: null | (() => void)
|
||||
export let mediaInfo: MediaInfo | undefined = undefined
|
||||
export let prev: (() => void) | undefined = undefined
|
||||
export let next: (() => void) | undefined = undefined
|
||||
// bindings
|
||||
// values
|
||||
let videoHeight = 9
|
||||
|
|
@ -83,16 +83,16 @@
|
|||
$: fullscreenElement ? screen.orientation.lock('landscape') : screen.orientation.unlock()
|
||||
|
||||
function checkAudio () {
|
||||
if ('audioTracks' in HTMLVideoElement.prototype) {
|
||||
if (!video.audioTracks!.length) {
|
||||
if (video.audioTracks) {
|
||||
if (!video.audioTracks.length) {
|
||||
toast.error('Audio Codec Unsupported', {
|
||||
description: "This torrent's audio codec is not supported, try a different release by disabling Autoplay Torrents in RSS settings."
|
||||
})
|
||||
} else if (video.audioTracks!.length > 1) {
|
||||
const preferredTrack = [...video.audioTracks!].find(({ language }) => language === $settings.audioLanguage)
|
||||
} else if (video.audioTracks.length > 1) {
|
||||
const preferredTrack = [...video.audioTracks].find(({ language }) => language === $settings.audioLanguage)
|
||||
if (preferredTrack) return selectAudio(preferredTrack.id)
|
||||
|
||||
const japaneseTrack = [...video.audioTracks!].find(({ language }) => language === 'jpn')
|
||||
const japaneseTrack = [...video.audioTracks].find(({ language }) => language === 'jpn')
|
||||
if (japaneseTrack) return selectAudio(japaneseTrack.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -154,9 +154,9 @@
|
|||
}
|
||||
let animations: Animation[] = []
|
||||
|
||||
const thumbnailer = new Thumbnailer(mediaInfo.url)
|
||||
const thumbnailer = new Thumbnailer(mediaInfo?.url)
|
||||
|
||||
$: thumbnailer.updateSource(mediaInfo.url)
|
||||
$: thumbnailer.updateSource(mediaInfo?.url)
|
||||
|
||||
onMount(() => {
|
||||
thumbnailer.setVideo(video)
|
||||
|
|
@ -267,7 +267,7 @@
|
|||
|
||||
$: seekIndex = Math.max(0, Math.floor(seekPercent * safeduration / 100 / thumbnailer.interval))
|
||||
|
||||
$: native.setMediaSession(mediaInfo.session)
|
||||
$: if (mediaInfo?.session) native.setMediaSession(mediaInfo.session)
|
||||
$: native.setPositionState({ duration: safeduration, position: Math.max(0, currentTime), playbackRate })
|
||||
$: native.setPlayBackState(readyState === 0 ? 'none' : paused ? 'paused' : 'playing')
|
||||
native.setActionHandler('play', playPause)
|
||||
|
|
@ -275,13 +275,17 @@
|
|||
native.setActionHandler('seekto', ({ seekTime }) => seekTo(seekTime ?? 0))
|
||||
native.setActionHandler('seekbackward', () => seek(-2))
|
||||
native.setActionHandler('seekforward', () => seek(2))
|
||||
native.setActionHandler('previoustrack', prev)
|
||||
native.setActionHandler('nexttrack', next)
|
||||
native.setActionHandler('previoustrack', () => prev?.())
|
||||
native.setActionHandler('nexttrack', () => next?.())
|
||||
// about://flags/#auto-picture-in-picture-for-video-playback
|
||||
native.setActionHandler('enterpictureinpicture', () => pip(true))
|
||||
|
||||
let openSubs: () => Promise<void>
|
||||
|
||||
function cycleSubtitles () {
|
||||
// TODO
|
||||
}
|
||||
|
||||
function seekBarKey (event: KeyboardEvent) {
|
||||
// left right up down return preventdefault
|
||||
if (['ArrowLeft', 'ArrowRight'].includes(event.key)) event.stopPropagation()
|
||||
|
|
@ -452,9 +456,9 @@
|
|||
|
||||
<svelte:document bind:fullscreenElement use:bindPiP={pictureInPictureElement} />
|
||||
|
||||
<div style:aspect-ratio='{videoWidth} / {videoHeight}' class='max-w-full max-h-full min-w-[clamp(0%,700px,100%)] relative content-center fullscreen:bg-black fullscreen:rounded-none rounded-xl overflow-clip text-left' bind:this={wrapper}>
|
||||
<video class='w-full max-h-full grow bg-black' preload='auto' class:cursor-none={immersed} class:object-cover={fitWidth}
|
||||
src={mediaInfo.url}
|
||||
<div class='w-full h-full relative content-center fullscreen:bg-black overflow-clip text-left' bind:this={wrapper}>
|
||||
<video class='w-full h-full grow bg-black' preload='auto' class:cursor-none={immersed} class:object-cover={fitWidth}
|
||||
src={mediaInfo?.url}
|
||||
bind:videoHeight
|
||||
bind:videoWidth
|
||||
bind:currentTime
|
||||
|
|
@ -527,16 +531,18 @@
|
|||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class='absolute w-full bottom-0 flex flex-col gradient px-10 py-4 transition-opacity select:opacity-100 cursor-default' class:opacity-0={immersed}>
|
||||
<div class='absolute w-full bottom-0 flex flex-col gradient px-4 py-3 transition-opacity select:opacity-100 cursor-default' class:opacity-0={immersed}>
|
||||
<div class='flex justify-between gap-12 items-end'>
|
||||
<div class='flex flex-col gap-2 text-left cursor-pointer'>
|
||||
<div class='text-white text-lg font-normal leading-none line-clamp-1 hover:text-neutral-300' use:click={() => goto(`/app/anime/${mediaInfo.media.id}`)}>{mediaInfo.session.title}</div>
|
||||
<div class='text-white text-lg font-normal leading-none line-clamp-1 hover:text-neutral-300' use:click={() => goto(`/app/anime/${mediaInfo?.media.id}`)}>{mediaInfo?.session.title ?? 'Unknown Anime'}</div>
|
||||
<Sheet.Root portal={wrapper}>
|
||||
<Sheet.Trigger id='episode-list-button' class='text-[rgba(217,217,217,0.6)] hover:text-neutral-500 text-sm leading-none font-light line-clamp-1 text-left'>{mediaInfo.session.description}</Sheet.Trigger>
|
||||
<Sheet.Trigger id='episode-list-button' class='text-[rgba(217,217,217,0.6)] hover:text-neutral-500 text-sm leading-none font-light line-clamp-1 text-left'>{mediaInfo?.session.description ?? 'Unknown Episode'}</Sheet.Trigger>
|
||||
<Sheet.Content class='w-[550px] sm:max-w-full h-full overflow-y-scroll flex flex-col pb-0 shrink-0 gap-0 bg-black'>
|
||||
{#await episodes(Number(mediaInfo.media.id)) then eps}
|
||||
<EpisodesList {eps} media={mediaInfo.media} />
|
||||
{/await}
|
||||
{#if mediaInfo?.media}
|
||||
{#await episodes(mediaInfo.media.id) then eps}
|
||||
<EpisodesList {eps} media={mediaInfo.media} />
|
||||
{/await}
|
||||
{/if}
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@
|
|||
</script>
|
||||
|
||||
<script lang='ts'>
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
import { getChapterTitle } from './util'
|
||||
|
|
@ -150,7 +149,7 @@
|
|||
</div>
|
||||
{/each}
|
||||
{#if !seeking && seek}
|
||||
<div class='absolute w-full transform-gpu flex pointer-events-none group-hover/seekbar:opacity-100 opacity-0 bottom-9' style:--tw-translate-x='clamp(64px, {clamp(seek)}%, calc(100% - 64px))'>
|
||||
<div class='absolute w-full transform-gpu flex pointer-events-none group-hover/seekbar:opacity-100 opacity-0 bottom-9' style:--tw-translate-x='clamp(70px, {clamp(seek)}%, calc(100% - 70px))'>
|
||||
<div class='-translate-x-1/2 text-sm leading-none text-nowrap flex flex-col justify-center items-center gap-1 rounded-lg bg-neutral-200 border-white border py-2 px-3 has-[img]:p-0 text-zinc-900 shadow-lg'>
|
||||
{#await thumbnailer.getThumbnail(seekIndex)}
|
||||
{#if title}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ export default class Thumbnailer {
|
|||
nextTask: RenderItem | undefined
|
||||
src
|
||||
|
||||
constructor (src: string) {
|
||||
constructor (src?: string) {
|
||||
this.video.preload = 'none'
|
||||
this.video.src = this.src = src
|
||||
this.video.load()
|
||||
this.video.playbackRate = 0
|
||||
this.video.muted = true
|
||||
if (src) {
|
||||
this.video.src = this.src = src
|
||||
this.video.load()
|
||||
}
|
||||
}
|
||||
|
||||
setVideo (currentVideo: HTMLVideoElement) {
|
||||
|
|
@ -89,8 +91,8 @@ export default class Thumbnailer {
|
|||
return await this._createThumbnail(index)
|
||||
}
|
||||
|
||||
updateSource (src: string) {
|
||||
if (src === this.src) return
|
||||
updateSource (src?: string) {
|
||||
if (src === this.src || !src) return
|
||||
for (const thumbnail of this.thumbnails) URL.revokeObjectURL(thumbnail)
|
||||
this.thumbnails = []
|
||||
this.currentTask = undefined
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export interface MediaInfo {
|
|||
url: string
|
||||
media: Media
|
||||
episode: number
|
||||
forced: boolean
|
||||
session: SessionMetadata
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Client, fetchExchange, queryStore, type OperationResultState } from '@urql/svelte'
|
||||
import { Client, fetchExchange, queryStore, type OperationResultState, gql as _gql } from '@urql/svelte'
|
||||
import { offlineExchange } from '@urql/exchange-graphcache'
|
||||
import { makeDefaultStorage } from '@urql/exchange-graphcache/default-storage'
|
||||
import { authExchange } from '@urql/exchange-auth'
|
||||
|
|
@ -6,17 +6,20 @@ import { refocusExchange } from '@urql/exchange-refocus'
|
|||
import Bottleneck from 'bottleneck'
|
||||
import { readable, writable, type Writable } from 'simple-store-svelte'
|
||||
import { derived } from 'svelte/store'
|
||||
import lavenshtein from 'js-levenshtein'
|
||||
|
||||
import schema from './schema.json' with { type: 'json' }
|
||||
import { CustomLists, DeleteEntry, Entry, Following, FullMedia, FullMediaList, Schedule, Search, ToggleFavourite, UserLists, Viewer } from './queries'
|
||||
import { CustomLists, DeleteEntry, Entry, Following, FullMedia, FullMediaList, IDMedia, Schedule, Search, ToggleFavourite, UserLists, Viewer } from './queries'
|
||||
import { currentSeason, currentYear, lastSeason, lastYear, nextSeason, nextYear } from './util'
|
||||
import gql from './gql'
|
||||
|
||||
import type { ResultOf, VariablesOf } from 'gql.tada'
|
||||
import type { AnyVariables, TypedDocumentNode } from 'urql'
|
||||
import type { AnyVariables, RequestPolicy, TypedDocumentNode } from 'urql'
|
||||
import type { Media } from './types'
|
||||
|
||||
import { safeLocalStorage, sleep } from '$lib/utils'
|
||||
import native from '$lib/modules/native'
|
||||
import { dev } from '$app/environment'
|
||||
|
||||
function arrayEqual <T> (a: T[], b: T[]) {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i])
|
||||
|
|
@ -40,6 +43,15 @@ function deferred () {
|
|||
return { resolve, promise }
|
||||
}
|
||||
|
||||
function getDistanceFromTitle (media: Media & {lavenshtein?: number}, name: string) {
|
||||
const titles = Object.values(media.title ?? {}).filter(v => v).map(title => lavenshtein(title!.toLowerCase(), name.toLowerCase()))
|
||||
const synonyms = (media.synonyms ?? []).filter(v => v).map(title => lavenshtein(title!.toLowerCase(), name.toLowerCase()) + 2)
|
||||
const distances = [...titles, ...synonyms]
|
||||
const min = distances.reduce((prev, curr) => prev < curr ? prev : curr)
|
||||
media.lavenshtein = min
|
||||
return media as Media & {lavenshtein: number}
|
||||
}
|
||||
|
||||
class AnilistClient {
|
||||
storagePromise = deferred()
|
||||
storage = makeDefaultStorage({
|
||||
|
|
@ -49,13 +61,12 @@ class AnilistClient {
|
|||
})
|
||||
|
||||
client = new Client({
|
||||
url: 'https://graphql.anilist.co', // TODO: uncoment fetch, its annoying for debugging stack tracess
|
||||
// fetch: (req: RequestInfo | URL, opts?: RequestInit) => this.handleRequest(req, opts),
|
||||
url: 'https://graphql.anilist.co',
|
||||
fetch: dev ? fetch : (req: RequestInfo | URL, opts?: RequestInit) => this.handleRequest(req, opts),
|
||||
exchanges: [
|
||||
refocusExchange(),
|
||||
offlineExchange({
|
||||
schema: schema as Parameters<typeof offlineExchange>[0]['schema'],
|
||||
logger: (...args) => console.log(...args),
|
||||
storage: this.storage,
|
||||
updates: {
|
||||
Mutation: {
|
||||
|
|
@ -124,6 +135,11 @@ class AnilistClient {
|
|||
}
|
||||
}
|
||||
},
|
||||
resolvers: {
|
||||
Query: {
|
||||
Media: (parent, { id }) => ({ __typename: 'Media', id })
|
||||
}
|
||||
},
|
||||
optimistic: {
|
||||
ToggleFavourite ({ animeId }, cache, info) {
|
||||
const id = animeId as number
|
||||
|
|
@ -318,6 +334,58 @@ class AnilistClient {
|
|||
return queryStore({ client: this.client, query: Search, variables, pause })
|
||||
}
|
||||
|
||||
async searchCompound (flattenedTitles: Array<{key: string, title: string, year?: string, isAdult: boolean}>) {
|
||||
if (!flattenedTitles.length) return []
|
||||
// isAdult doesn't need an extra variable, as the title is the same regardless of type, so we re-use the same variable for adult and non-adult requests
|
||||
|
||||
const requestVariables = flattenedTitles.reduce<Record<`v${number}`, string>>((obj, { title, isAdult }, i) => {
|
||||
if (isAdult && i !== 0) return obj
|
||||
obj[`v${i}`] = title
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
const queryVariables = flattenedTitles.reduce<string[]>((arr, { isAdult }, i) => {
|
||||
if (isAdult && i !== 0) return arr
|
||||
arr.push(`$v${i}: String`)
|
||||
return arr
|
||||
}, []).join(', ')
|
||||
const fragmentQueries = flattenedTitles.map(({ year, isAdult }, i) => /* js */`
|
||||
v${i}: Page(perPage: 10) {
|
||||
media(type: ANIME, search: $v${(isAdult && i !== 0) ? i - 1 : i}, status_in: [RELEASING, FINISHED], isAdult: ${!!isAdult} ${year ? `, seasonYear: ${year}` : ''}) {
|
||||
...med
|
||||
}
|
||||
}`).join(',')
|
||||
|
||||
const query = _gql/* gql */`
|
||||
query(${queryVariables}) {
|
||||
${fragmentQueries}
|
||||
}
|
||||
|
||||
fragment med on Media {
|
||||
id,
|
||||
title {
|
||||
romaji,
|
||||
english,
|
||||
native
|
||||
},
|
||||
synonyms
|
||||
}`
|
||||
|
||||
const res = await this.client.query<Record<string, {media: Media[]}>>(query, requestVariables)
|
||||
|
||||
const searchResults: Record<string, number> = {}
|
||||
for (const [variableName, { media }] of Object.entries(res.data!)) {
|
||||
if (!media.length) continue
|
||||
const titleObject = flattenedTitles[Number(variableName.slice(1))]!
|
||||
if (searchResults[titleObject.key]) continue
|
||||
searchResults[titleObject.key] = media.map(media => getDistanceFromTitle(media, titleObject.title)).reduce((prev, curr) => prev.lavenshtein <= curr.lavenshtein ? prev : curr).id
|
||||
}
|
||||
|
||||
const ids = Object.values(searchResults)
|
||||
const search = await this.client.query(Search, { ids, perPage: 50 })
|
||||
return Object.entries(searchResults).map(([filename, id]) => [filename, search.data!.Page!.media!.find(media => media!.id === id)]) as Array<[string, Media | undefined]>
|
||||
}
|
||||
|
||||
schedule () {
|
||||
return queryStore({ client: this.client, query: Schedule, variables: { seasonCurrent: currentSeason, seasonYearCurrent: currentYear, seasonLast: lastSeason, seasonYearLast: lastYear, seasonNext: nextSeason, seasonYearNext: nextYear }, pause: true })
|
||||
}
|
||||
|
|
@ -334,6 +402,10 @@ class AnilistClient {
|
|||
return await this.client.mutation(Entry, variables)
|
||||
}
|
||||
|
||||
async single (id: number, requestPolicy: RequestPolicy = 'cache-first') {
|
||||
return await this.client.query(IDMedia, { id }, { requestPolicy })
|
||||
}
|
||||
|
||||
following (id: number) {
|
||||
return queryStore({ client: this.client, query: Following, variables: { id } })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,28 +9,6 @@ export function banner (media: Pick<Media, 'trailer' | 'bannerImage' | 'coverIma
|
|||
return media.coverImage?.extraLarge as string | undefined
|
||||
}
|
||||
|
||||
const sizes = ['hq720', 'sddefault', 'hqdefault', 'mqdefault', 'default']
|
||||
|
||||
export async function safeBanner (media: Pick<Media, 'trailer' | 'bannerImage' | 'coverImage'>): Promise<string | undefined> { // TODO: this needs to be a component
|
||||
const src = banner(media)
|
||||
if (!src?.startsWith('https://i.ytimg.com/')) return src
|
||||
|
||||
return await new Promise(resolve => {
|
||||
const img = new Image()
|
||||
let sizeAttempt = 0
|
||||
|
||||
img.onload = () => {
|
||||
if (img.naturalWidth === 120 && img.naturalHeight === 90) {
|
||||
img.src = `https://i.ytimg.com/vi/${media.trailer?.id}/${sizes[sizeAttempt++]}.jpg`
|
||||
} else {
|
||||
resolve(img.src)
|
||||
img.remove()
|
||||
}
|
||||
}
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
export const STATUS_LABELS = {
|
||||
CURRENT: 'Watching',
|
||||
PLANNING: 'Plan to Watch',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,26 @@
|
|||
import type { AuthResponse, Native } from '../../app'
|
||||
|
||||
const dummyFiles = [
|
||||
{
|
||||
name: 'My Happy Marriage Season 2.webm',
|
||||
hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
type: 'video/webm',
|
||||
size: 1234567890,
|
||||
path: '/Amebku.webm',
|
||||
url: '/Ameku.webm',
|
||||
id: 0
|
||||
}
|
||||
// {
|
||||
// name: 'My Happy Marriage Season 2.mkv',
|
||||
// hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
// type: 'video/mkv',
|
||||
// size: 1234567890,
|
||||
// path: '/video.mkv',
|
||||
// url: '/video.mkv',
|
||||
// id: 1
|
||||
// }
|
||||
]
|
||||
|
||||
export default Object.assign<Native, Partial<Native>>({
|
||||
authAL: (url: string) => {
|
||||
return new Promise<AuthResponse>((resolve, reject) => {
|
||||
|
|
@ -37,5 +58,8 @@ export default Object.assign<Native, Partial<Native>>({
|
|||
checkAvailableSpace: () => new Promise(resolve => setTimeout(() => resolve(Math.floor(Math.random() * (1e10 - 1e8 + 1) + 1e8)), 1000)),
|
||||
checkIncomingConnections: () => new Promise(resolve => setTimeout(() => resolve(Math.random() > 0.5), 5000)),
|
||||
updatePeerCounts: async () => [],
|
||||
isApp: false
|
||||
}, globalThis.native)
|
||||
isApp: false,
|
||||
playTorrent: async () => dummyFiles,
|
||||
getAttachmentsURL: async () => location.origin
|
||||
// @ts-expect-error idk
|
||||
}, globalThis.native as Partial<Native>)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { writable } from 'simple-store-svelte'
|
||||
import { persisted } from 'svelte-persisted-store'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
import native from '../native'
|
||||
|
||||
import type { Media } from '../anilist'
|
||||
import type { TorrentFile } from '../../../app'
|
||||
|
||||
export const server = new class ServerClient {
|
||||
attachmentsURL = native.getAttachmentsURL()
|
||||
last = persisted<{media: Media, id: string, episode: number} | null>('last-torrent', null)
|
||||
active = writable<Promise<{media: Media, id: string, episode: number, files: TorrentFile[]}| null>>()
|
||||
|
||||
constructor () {
|
||||
const last = get(this.last)
|
||||
if (last) this.play(last.id, last.media, last.episode)
|
||||
}
|
||||
|
||||
play (id: string, media: Media, episode: number) {
|
||||
this.last.set({ id, media, episode })
|
||||
this.active.value = this._play(id, media, episode)
|
||||
return this.active.value
|
||||
}
|
||||
|
||||
async _play (id: string, media: Media, episode: number) {
|
||||
return { id, media, episode, files: await native.playTorrent(id) }
|
||||
}
|
||||
}()
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './client'
|
||||
|
|
@ -1,43 +1,37 @@
|
|||
<script lang='ts'>
|
||||
import { onMount, tick } from 'svelte'
|
||||
|
||||
import type { MediaInfo } from '$lib/components/ui/player/util'
|
||||
import { resolveFilesPoorly } from './resolver'
|
||||
import Mediahandler from './mediahandler.svelte'
|
||||
|
||||
import { bannerSrc, hideBanner } from '$lib/components/ui/banner'
|
||||
import { Player } from '$lib/components/ui/player'
|
||||
import { banner, client, title } from '$lib/modules/anilist'
|
||||
import { IDMedia } from '$lib/modules/anilist/queries'
|
||||
import { hideBanner } from '$lib/components/ui/banner'
|
||||
import { server } from '$lib/modules/torrent'
|
||||
|
||||
onMount(async () => {
|
||||
await tick()
|
||||
hideBanner.value = true
|
||||
})
|
||||
|
||||
const mediaInfo: PromiseLike<MediaInfo> = client.client.query(IDMedia, { id: 176642 }, { requestPolicy: 'cache-first' }).then(v => {
|
||||
const media = v.data!.Media!
|
||||
bannerSrc.value = media
|
||||
|
||||
return {
|
||||
url: '/Ameku.webm',
|
||||
episode: 6,
|
||||
media,
|
||||
forced: false,
|
||||
session: {
|
||||
title: title(media),
|
||||
description: 'Episode 6 - Fierce Blazing Finale',
|
||||
image: banner(media) ?? ''
|
||||
}
|
||||
}
|
||||
})
|
||||
const act = server.active
|
||||
|
||||
$: active = resolveFilesPoorly($act)
|
||||
</script>
|
||||
|
||||
<div class='px-3 w-full h-full py-10 gap-4 flex justify-center items-center'>
|
||||
{#await mediaInfo then mediaInfo}
|
||||
<div class='w-full h-full content-center text-webkit-center'>
|
||||
<Player {mediaInfo} />
|
||||
</div>
|
||||
{/await}
|
||||
<div class='w-full h-full'>
|
||||
{#if active}
|
||||
{#await active}
|
||||
<Player />
|
||||
{:then mediaInfo}
|
||||
{#if mediaInfo}
|
||||
<Mediahandler {mediaInfo} />
|
||||
{:else}
|
||||
<Player />
|
||||
{/if}
|
||||
{/await}
|
||||
{:else}
|
||||
<Player />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
56
src/routes/app/player/mediahandler.svelte
Normal file
56
src/routes/app/player/mediahandler.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang='ts'>
|
||||
import type { resolveFilesPoorly, ResolvedFile } from './resolver'
|
||||
|
||||
import { Player } from '$lib/components/ui/player'
|
||||
import { banner, episodes, title } from '$lib/modules/anilist'
|
||||
|
||||
export let mediaInfo: NonNullable<Awaited<ReturnType<typeof resolveFilesPoorly>>>
|
||||
|
||||
function fileToMedaInfo (file: ResolvedFile) {
|
||||
return {
|
||||
url: file.url,
|
||||
episode: file.metadata.episode,
|
||||
media: file.metadata.media,
|
||||
session: {
|
||||
title: title(file.metadata.media),
|
||||
description: 'N/A', // TODO
|
||||
image: banner(file.metadata.media) ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let current = fileToMedaInfo(mediaInfo.target)
|
||||
|
||||
function findEpisode (episode: number) {
|
||||
return mediaInfo.targetAnimeFiles.find(file => file.metadata.episode === episode) ?? mediaInfo.targetAnimeFiles.find(file => file.metadata.episode === 1)
|
||||
}
|
||||
|
||||
function hasNext (file: ResolvedFile) {
|
||||
return Number(file.metadata.episode) < (episodes(file.metadata.media) ?? 1)
|
||||
}
|
||||
function hasPrev (file: ResolvedFile) {
|
||||
return Number(file.metadata.episode) > 1
|
||||
}
|
||||
function playNext () {
|
||||
const nextFile = findEpisode(parseInt('' + mediaInfo.target.metadata.episode) + 1)
|
||||
if (nextFile) {
|
||||
current = fileToMedaInfo(nextFile)
|
||||
}
|
||||
}
|
||||
function playPrev () {
|
||||
const prevFile = findEpisode(parseInt('' + mediaInfo.target.metadata.episode) - 1)
|
||||
if (prevFile) {
|
||||
current = fileToMedaInfo(prevFile)
|
||||
}
|
||||
}
|
||||
|
||||
$: next = hasNext(mediaInfo.target)
|
||||
? playNext
|
||||
: undefined
|
||||
|
||||
$: prev = hasPrev(mediaInfo.target)
|
||||
? playPrev
|
||||
: undefined
|
||||
</script>
|
||||
|
||||
<Player mediaInfo={current} {prev} {next} />
|
||||
358
src/routes/app/player/resolver.ts
Normal file
358
src/routes/app/player/resolver.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import anitomyscript from 'anitomyscript'
|
||||
|
||||
import type { AnitomyResult } from 'anitomyscript'
|
||||
import type { TorrentFile } from '../../../app'
|
||||
import type { ResultOf } from 'gql.tada'
|
||||
import type { MediaEdgeFrag } from '$lib/modules/anilist/queries'
|
||||
|
||||
import { client, episodes, type Media } from '$lib/modules/anilist'
|
||||
|
||||
export const subtitleExtensions = ['srt', 'vtt', 'ass', 'ssa', 'sub', 'txt']
|
||||
export const subRx = new RegExp(`.(${subtitleExtensions.join('|')})$`, 'i')
|
||||
|
||||
export const videoExtensions = ['3g2', '3gp', 'asf', 'avi', 'dv', 'flv', 'gxf', 'm2ts', 'm4a', 'm4b', 'm4p', 'm4r', 'm4v', 'mkv', 'mov', 'mp4', 'mpd', 'mpeg', 'mpg', 'mxf', 'nut', 'ogm', 'ogv', 'swf', 'ts', 'vob', 'webm', 'wmv', 'wtv']
|
||||
export const videoRx = new RegExp(`.(${videoExtensions.join('|')})$`, 'i')
|
||||
|
||||
// freetype supported
|
||||
export const fontExtensions = ['ttf', 'ttc', 'woff', 'woff2', 'otf', 'cff', 'otc', 'pfa', 'pfb', 'pcf', 'fnt', 'bdf', 'pfr', 'eot']
|
||||
export const fontRx = new RegExp(`.(${fontExtensions.join('|')})$`, 'i')
|
||||
|
||||
export type ResolvedFile = TorrentFile & {metadata: { episode: string | number | undefined, parseObject: AnitomyResult, media: Media, failed: boolean }}
|
||||
|
||||
export async function resolveFilesPoorly (promise: Promise<{media: Media, id: string, episode: number, files: TorrentFile[]}| null>) {
|
||||
const list = await promise
|
||||
|
||||
if (!list) return
|
||||
|
||||
const videoFiles: TorrentFile[] = []
|
||||
const otherFiles: TorrentFile[] = []
|
||||
for (const file of list.files) {
|
||||
if (videoRx.test(file.name)) {
|
||||
videoFiles.push(file)
|
||||
} else {
|
||||
otherFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = videoFiles.length === 1 ? [{ episode: list.episode, parseObject: (await anitomyscript([videoFiles[0]!.name]))[0]!, media: list.media, failed: false }] : await AnimeResolver.resolveFileAnime(videoFiles.map(file => file.name))
|
||||
|
||||
const resolvedFiles: ResolvedFile[] = videoFiles.map(file => {
|
||||
return {
|
||||
...file,
|
||||
metadata: resolved.find(({ parseObject }) => file.name.includes(parseObject.file_name))!
|
||||
}
|
||||
}).filter(file => !TYPE_EXCLUSIONS.includes(file.metadata.parseObject.anime_type?.toUpperCase() ?? ''))
|
||||
|
||||
let targetAnimeFiles = resolvedFiles.filter(file => file.metadata.media.id && file.metadata.media.id === list.media.id)
|
||||
|
||||
if (!targetAnimeFiles.length) {
|
||||
const max = highestOccurence(resolvedFiles, file => file.metadata.parseObject.anime_title!).metadata.parseObject.anime_title
|
||||
targetAnimeFiles = resolvedFiles.filter(file => file.metadata.parseObject.anime_title === max)
|
||||
}
|
||||
|
||||
targetAnimeFiles.sort((a, b) => Number(a.metadata.episode) - Number(b.metadata.episode))
|
||||
targetAnimeFiles.sort((a, b) => Number(b.metadata.parseObject.anime_season ?? 1) - Number(a.metadata.parseObject.anime_season ?? 1))
|
||||
|
||||
const targetEpisode = targetAnimeFiles.find(file => file.metadata.episode === list.episode) ?? targetAnimeFiles.find(file => file.metadata.episode === 1) ?? targetAnimeFiles[0]!
|
||||
|
||||
return {
|
||||
target: targetEpisode,
|
||||
targetAnimeFiles,
|
||||
otherFiles,
|
||||
resolvedFiles
|
||||
}
|
||||
}
|
||||
|
||||
// export function findInCurrent (obj) {
|
||||
// const oldNowPlaying = nowPlaying.value
|
||||
|
||||
// if (oldNowPlaying.media?.id === obj.media.id && oldNowPlaying.episode === obj.episode) return false
|
||||
|
||||
// const fileList = files.value
|
||||
|
||||
// const targetFile = fileList.find(file => file.media?.media?.id === obj.media.id &&
|
||||
// (file.media?.episode === obj.episode || obj.media.episodes === 1 || (!obj.media.episodes && (obj.episode === 1 || !obj.episode) && (oldNowPlaying.episode === 1 || !oldNowPlaying.episode))) // movie check
|
||||
// )
|
||||
// if (!targetFile) return false
|
||||
// if (oldNowPlaying.media?.id !== obj.media.id) {
|
||||
// // mediachange, filelist change
|
||||
// media.set({ media: obj.media, episode: obj.episode })
|
||||
// handleFiles(fileList)
|
||||
// } else {
|
||||
// playFile(targetFile)
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
|
||||
const TYPE_EXCLUSIONS = ['ED', 'ENDING', 'NCED', 'NCOP', 'OP', 'OPENING', 'PREVIEW', 'PV']
|
||||
|
||||
// find best media in batch to play
|
||||
// currently in progress or unwatched
|
||||
// tv, movie, ona, ova
|
||||
// TODO: load magnets manually
|
||||
// function findPreferredPlaybackMedia (videoFiles) {
|
||||
// for (const { media } of videoFiles) {
|
||||
// if (media.media?.mediaListEntry?.status === 'CURRENT') return { media: media.media, episode: (media.media.mediaListEntry.progress || 0) + 1 }
|
||||
// }
|
||||
|
||||
// for (const { media } of videoFiles) {
|
||||
// if (media.media?.mediaListEntry?.status === 'REPEATING') return { media: media.media, episode: (media.media.mediaListEntry.progress || 0) + 1 }
|
||||
// }
|
||||
|
||||
// let lowestPlanning
|
||||
// for (const { media, episode } of videoFiles) {
|
||||
// if (media.media?.mediaListEntry?.status === 'PLANNING' && (!lowestPlanning || episode > lowestPlanning.episode)) lowestPlanning = { media: media.media, episode }
|
||||
// }
|
||||
// if (lowestPlanning) return lowestPlanning
|
||||
|
||||
// // unwatched
|
||||
// for (const format of ['TV', 'MOVIE', 'ONA', 'OVA']) {
|
||||
// let lowestUnwatched
|
||||
// for (const { media, episode } of videoFiles) {
|
||||
// if (media.media?.format === format && !media.media.mediaListEntry && (!lowestUnwatched || episode > lowestUnwatched.episode)) lowestUnwatched = { media: media.media, episode }
|
||||
// }
|
||||
// if (lowestUnwatched) return lowestUnwatched
|
||||
// }
|
||||
|
||||
// // highest occurence if all else fails - unlikely
|
||||
|
||||
// const max = highestOccurence(videoFiles, file => file.media.media?.id).media
|
||||
// if (max?.media) {
|
||||
// return { media: max.media, episode: (max.media.mediaListEntry?.progress + 1 || 1) }
|
||||
// }
|
||||
// }
|
||||
|
||||
// function fileListToDebug (files) {
|
||||
// return files.map(({ name, media, url }) => `\n${name} ${media?.parseObject.anime_title} ${media?.parseObject.episode_number} ${media?.media?.title.userPreferred} ${media?.episode}`).join('')
|
||||
// }
|
||||
|
||||
// find element with most occurences in array according to map function
|
||||
function highestOccurence <T> (arr: T[] = [], mapfn = (a: T) => ''): T {
|
||||
return arr.reduce<{sums: Record<string, number>, max?: T}>((acc, el) => {
|
||||
const mapped = mapfn(el)
|
||||
acc.sums[mapped] = (acc.sums[mapped] ?? 0) + 1
|
||||
acc.max = (acc.max !== undefined ? acc.sums[mapfn(acc.max)]! : -1) > acc.sums[mapped] ? acc.max : el
|
||||
return acc
|
||||
}, { sums: {}, max: undefined }).max as T
|
||||
}
|
||||
|
||||
const postfix: Record<number, string> = {
|
||||
1: 'st', 2: 'nd', 3: 'rd'
|
||||
}
|
||||
|
||||
function * chunks <T> (arr: T[], size: number): Generator<T[]> {
|
||||
for (let i = 0; i < arr.length; i += size) {
|
||||
yield arr.slice(i, i + size)
|
||||
}
|
||||
}
|
||||
|
||||
const AnimeResolver = new class AnimeResolver {
|
||||
// name: media cache from title resolving
|
||||
animeNameCache: Record<string, number> = {}
|
||||
|
||||
getCacheKeyForTitle (obj: AnitomyResult): string {
|
||||
let key = obj.anime_title
|
||||
if (obj.anime_year) key += obj.anime_year
|
||||
return key!
|
||||
}
|
||||
|
||||
alternativeTitles (title: string): string[] {
|
||||
const titles = new Set<string>()
|
||||
|
||||
let modified = title
|
||||
// preemptively change S2 into Season 2 or 2nd Season, otherwise this will have accuracy issues
|
||||
const seasonMatch = title.match(/ S(\d+)/)
|
||||
if (seasonMatch) {
|
||||
if (Number(seasonMatch[1]) === 1) { // if this is S1, remove the " S1" or " S01"
|
||||
modified = title.replace(/ S(\d+)/, '')
|
||||
titles.add(modified)
|
||||
} else {
|
||||
modified = title.replace(/ S(\d+)/, ` ${Number(seasonMatch[1])}${postfix[Number(seasonMatch[1])] ?? 'th'} Season`)
|
||||
titles.add(modified)
|
||||
titles.add(title.replace(/ S(\d+)/, ` Season ${Number(seasonMatch[1])}`))
|
||||
}
|
||||
} else {
|
||||
titles.add(title)
|
||||
}
|
||||
|
||||
// remove - :
|
||||
const specialMatch = modified.match(/[-:]/g)
|
||||
if (specialMatch) {
|
||||
modified = modified.replace(/[-:]/g, '').replace(/[ ]{2,}/, ' ')
|
||||
titles.add(modified)
|
||||
}
|
||||
|
||||
// remove (TV)
|
||||
const tvMatch = modified.match(/\(TV\)/)
|
||||
if (tvMatch) {
|
||||
modified = modified.replace('(TV)', '')
|
||||
titles.add(modified)
|
||||
}
|
||||
|
||||
return [...titles]
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve anime name based on file name and store it
|
||||
*/
|
||||
async findAnimesByTitle (parseObjects: AnitomyResult[]): Promise<void> {
|
||||
if (!parseObjects.length) return
|
||||
const titleObjects = parseObjects.map(obj => {
|
||||
const key = this.getCacheKeyForTitle(obj)
|
||||
const titleObjects: Array<{key: string, title: string, year?: string, isAdult: boolean}> = this.alternativeTitles(obj.anime_title!).map(title => ({ title, year: obj.anime_year, key, isAdult: false }))
|
||||
// @ts-expect-error cba fixing this for now, but this is correct
|
||||
titleObjects.push({ ...titleObjects.at(-1), isAdult: true })
|
||||
return titleObjects
|
||||
}).flat()
|
||||
|
||||
for (const chunk of chunks(titleObjects, 60)) {
|
||||
// single title has a complexity of 8.1, al limits complexity to 500, so this can be at most 62, undercut it to 60, al pagination is 50, but at most we'll do 30 titles since isAduld duplicates each title
|
||||
for (const [key, media] of await client.searchCompound(chunk)) {
|
||||
if (media?.id) this.animeNameCache[key] = media.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getAnimeById (id: number) {
|
||||
return (await client.single(id)).data?.Media as Media
|
||||
}
|
||||
|
||||
// TODO: anidb aka true episodes need to be mapped to anilist episodes a bit better, shit like mushoku offsets caused by episode 0's in between seasons
|
||||
async resolveFileAnime (fileName: string[]) {
|
||||
if (!fileName.length) return []
|
||||
const parseObjs = await anitomyscript(fileName)
|
||||
|
||||
const TYPE_EXCLUSIONS = ['ED', 'ENDING', 'NCED', 'NCOP', 'OP', 'OPENING', 'PREVIEW', 'PV']
|
||||
|
||||
const uniq: Record<string, AnitomyResult> = {}
|
||||
for (const obj of parseObjs) {
|
||||
const key = this.getCacheKeyForTitle(obj)
|
||||
if (key in this.animeNameCache) continue // skip already resolved
|
||||
if (obj.anime_type && TYPE_EXCLUSIONS.includes(obj.anime_type.toUpperCase())) continue // skip non-episode media
|
||||
uniq[key] = obj
|
||||
}
|
||||
await this.findAnimesByTitle(Object.values(uniq))
|
||||
|
||||
const fileAnimes = []
|
||||
for (const parseObj of parseObjs) {
|
||||
let failed = false
|
||||
let episode
|
||||
const id = this.animeNameCache[this.getCacheKeyForTitle(parseObj)]
|
||||
if (!id) continue
|
||||
let media = await this.getAnimeById(id)
|
||||
// resolve episode, if movie, dont.
|
||||
const maxep = episodes(media)
|
||||
if ((media.format !== 'MOVIE' || maxep) && parseObj.episode_number) {
|
||||
if (Array.isArray(parseObj.episode_number)) {
|
||||
// is an episode range
|
||||
if (parseInt(parseObj.episode_number[0]) === 1) {
|
||||
// if it starts with #1 and overflows then it includes more than 1 season in a batch, cant fix this cleanly, name is parsed per file basis so this shouldnt be an issue
|
||||
episode = `${parseObj.episode_number[0]} ~ ${parseObj.episode_number[1]}`
|
||||
} else {
|
||||
if (maxep && parseInt(parseObj.episode_number[1]) > maxep) {
|
||||
// get root media to start at S1, instead of S2 or some OVA due to parsing errors
|
||||
// this is most likely safe, if it was relative episodes then it would likely use an accurate title for the season
|
||||
// if they didnt use an accurate title then its likely an absolute numbering scheme
|
||||
// parent check is to break out of those incorrectly resolved OVA's
|
||||
// if we used anime season to resolve anime name, then there's no need to march into prequel!
|
||||
const prequel = !parseObj.anime_season && (this.findEdge(media, 'PREQUEL').node ?? ((media.format === 'OVA' || media.format === 'ONA') && this.findEdge(media, 'PARENT').node))
|
||||
// debug(`Prequel ${prequel?.id}:${prequel?.title.userPreferred}`)
|
||||
const root = prequel && (await this.resolveSeason({ media: await this.getAnimeById(prequel.id), force: true })).media
|
||||
// debug(`Root ${root?.id}:${root?.title.userPreferred}`)
|
||||
|
||||
// if highest value is bigger than episode count or latest streamed episode +1 for safety, parseint to math.floor a number like 12.5 - specials - in 1 go
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const result = await this.resolveSeason({ media: root || media, episode: parseObj.episode_number[1], increment: !parseObj.anime_season ? null : true })
|
||||
// debug(`Found rootMedia for ${parseObj.anime_title}: ${result.rootMedia.id}:${result.rootMedia.title.userPreferred} from ${media.id}:${media.title.userPreferred}`)
|
||||
media = result.rootMedia
|
||||
const diff = parseObj.episode_number[1] - result.episode
|
||||
episode = `${parseObj.episode_number[0] - diff} ~ ${result.episode}`
|
||||
failed = !!result.failed
|
||||
// if (failed) debug(`Failed to resolve ${parseObj.anime_title} ${parseObj.episode_number} ${media.title.userPreferred}`)
|
||||
} else {
|
||||
// cant find ep count or range seems fine
|
||||
episode = `${Number(parseObj.episode_number[0])} ~ ${Number(parseObj.episode_number[1])}`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (maxep && parseInt(parseObj.episode_number) > maxep) {
|
||||
// see big comment above
|
||||
const prequel = !parseObj.anime_season && (this.findEdge(media, 'PREQUEL').node ?? ((media.format === 'OVA' || media.format === 'ONA') && this.findEdge(media, 'PARENT').node))
|
||||
// debug(`Prequel ${prequel?.id}:${prequel?.title.userPreferred}`)
|
||||
const root = prequel && (await this.resolveSeason({ media: await this.getAnimeById(prequel.id), force: true })).media
|
||||
// debug(`Root ${root?.id}:${root?.title.userPreferred}`)
|
||||
|
||||
// value bigger than episode count
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const result = await this.resolveSeason({ media: root || media, episode: parseInt(parseObj.episode_number), increment: !parseObj.anime_season ? null : true })
|
||||
// debug(`Found rootMedia for ${parseObj.anime_title}: ${result.rootMedia.id}:${result.rootMedia.title.userPreferred} from ${media.id}:${media.title.userPreferred}`)
|
||||
media = result.rootMedia
|
||||
episode = result.episode
|
||||
failed = !!result.failed
|
||||
// if (failed) debug(`Failed to resolve ${parseObj.anime_title} ${parseObj.episode_number} ${media.title.userPreferred}`)
|
||||
} else {
|
||||
// cant find ep count or episode seems fine
|
||||
episode = Number(parseObj.episode_number)
|
||||
}
|
||||
}
|
||||
}
|
||||
// debug(`Resolved ${parseObj.anime_title} ${parseObj.episode_number} ${episode} ${media.id}:${media.title.userPreferred}`)
|
||||
fileAnimes.push({
|
||||
episode: episode ?? parseObj.episode_number,
|
||||
parseObject: parseObj,
|
||||
media,
|
||||
failed
|
||||
})
|
||||
}
|
||||
return fileAnimes
|
||||
}
|
||||
|
||||
findEdge (media: Media, type: string, formats = ['TV', 'TV_SHORT'], skip?: boolean): ResultOf<typeof MediaEdgeFrag> {
|
||||
let res = media.relations?.edges?.find(edge => {
|
||||
if (edge?.relationType === type) {
|
||||
return formats.includes(edge.node?.format ?? '')
|
||||
}
|
||||
return false
|
||||
})
|
||||
// this is hit-miss
|
||||
if (!res && !skip && type === 'SEQUEL') res = this.findEdge(media, type, formats = ['TV', 'TV_SHORT', 'OVA'], true)
|
||||
return res as ResultOf<typeof MediaEdgeFrag>
|
||||
}
|
||||
|
||||
// note: this doesnt cover anime which uses partially relative and partially absolute episode number, BUT IT COULD!
|
||||
async resolveSeason (opts: {media?: Media, episode?: number, increment?: boolean | null, offset?: number, rootMedia?: Media, force?: boolean}): Promise<{ media: Media, episode: number, offset: number, increment: boolean, rootMedia: Media, failed?: boolean }> {
|
||||
// media, episode, increment, offset, force
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
if (!opts.media || !(opts.episode || opts.force)) throw new Error('No episode or media for season resolve!')
|
||||
|
||||
let { media, episode = 1, increment, offset = 0, rootMedia = opts.media, force } = opts
|
||||
|
||||
const rootHighest = episodes(rootMedia) ?? 1
|
||||
|
||||
const prequel = !increment && this.findEdge(media, 'PREQUEL').node
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const sequel = !prequel && (increment || increment == null) && this.findEdge(media, 'SEQUEL').node
|
||||
const edge = prequel ?? sequel
|
||||
increment = increment ?? !prequel
|
||||
|
||||
if (!edge) {
|
||||
const obj = { media, episode: episode - offset, offset, increment, rootMedia, failed: true }
|
||||
return obj
|
||||
}
|
||||
media = await this.getAnimeById(edge.id)
|
||||
|
||||
const highest = episodes(media) ?? 1
|
||||
|
||||
const diff = episode - (highest + offset)
|
||||
offset += increment ? rootHighest : highest
|
||||
if (increment) rootMedia = media
|
||||
|
||||
// force marches till end of tree, no need for checks
|
||||
if (!force && diff <= rootHighest) {
|
||||
episode -= offset
|
||||
return { media, episode, offset, increment, rootMedia }
|
||||
}
|
||||
|
||||
return await this.resolveSeason({ media, episode, increment, offset, rootMedia, force })
|
||||
}
|
||||
}()
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import process from 'node:process'
|
||||
|
||||
import adapter from '@sveltejs/adapter-static'
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
|
|
@ -13,6 +15,9 @@ const config = {
|
|||
adapter: adapter({ fallback: 'index.html' }),
|
||||
alias: {
|
||||
'@/*': './path/to/lib/*'
|
||||
},
|
||||
version: {
|
||||
name: process.env.npm_package_version
|
||||
}
|
||||
},
|
||||
runtime: ''
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
"tadaTurboLocation": "./src/lib/modules/anilist/graphql-turbo.d.ts"
|
||||
}
|
||||
],
|
||||
"maxNodeModuleJsDepth": 1
|
||||
"maxNodeModuleJsDepth": 2
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
|
|
|
|||
Loading…
Reference in a new issue