feat: offline library support
Some checks are pending
Check / check (push) Waiting to run

This commit is contained in:
ThaUnknown 2025-06-28 00:06:46 +02:00
parent f481bf7c09
commit 4969644374
No known key found for this signature in database
9 changed files with 95 additions and 80 deletions

View file

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "6.4.3",
"version": "6.4.4",
"license": "BUSL-1.1",
"private": true,
"packageManager": "pnpm@9.14.4",

14
src/app.d.ts vendored
View file

@ -100,6 +100,17 @@ export interface TorrentSettings {
torrentPeX: boolean
}
export interface LibraryEntry {
mediaID: number
episode: number
files: number
hash: string
progress: number
date: number
size: number
name: string
}
export interface Native {
authAL: (url: string) => Promise<AuthResponse>
restart: () => Promise<void>
@ -127,7 +138,8 @@ export interface Native {
checkAvailableSpace: (_?: unknown) => Promise<number>
checkIncomingConnections: (port: number) => Promise<boolean>
updatePeerCounts: (hashes: string[]) => Promise<Array<{ hash: string, complete: string, downloaded: string, incomplete: string }>>
playTorrent: (id: string) => Promise<TorrentFile[]>
playTorrent: (id: string, mediaID: number, episode: number) => Promise<TorrentFile[]>
library: () => Promise<LibraryEntry[]>
attachments: (hash: string, id: number) => Promise<Attachment[]>
tracks: (hash: string, id: number) => Promise<Array<{ number: string, language?: string, type: string, header?: string, name?: string }>>
subtitles: (hash: string, id: number, cb: (subtitle: { text: string, time: number, duration: number }, trackNumber: number) => void) => Promise<void>

View file

@ -63,8 +63,8 @@
return list.slice((page - 1) * perPage, page * perPage)
}
$: _progress = progress(media) ?? 0
$: completed = list(media) === 'COMPLETED'
$: _progress = completed ? 0 : progress(media) ?? 0
$: currentPage = Math.floor((!completed ? _progress : 0) / perPage) + 1

View file

@ -1,2 +1,3 @@
export { default as StatusCell } from './status.svelte'
export { default as NameCell } from './name.svelte'
export { default as MediaCell } from './mediatitle.svelte'

View file

@ -0,0 +1,11 @@
<script lang='ts'>
import { client } from '$lib/modules/anilist'
export let value: number
</script>
{#await client.single(value)}
?
{:then query}
{query.data?.Media?.title?.userPreferred ?? '?'}
{/await}

View file

@ -1,64 +1,76 @@
<script lang='ts'>
import { writable } from 'svelte/store'
import { Render, Subscribe, createRender, createTable } from 'svelte-headless-table'
import { DataBodyRow, Render, Subscribe, createRender, createTable } from 'svelte-headless-table'
import { addSortBy } from 'svelte-headless-table/plugins'
import Columnheader from '../columnheader.svelte'
import { NameCell, StatusCell } from './cells'
import { MediaCell, NameCell, StatusCell } from './cells'
import type { LibraryEntry } from '$lib/../app'
import * as Table from '$lib/components/ui/table'
import { client } from '$lib/modules/anilist'
import { server } from '$lib/modules/torrent'
import { cn, fastPrettyBytes } from '$lib/utils'
interface LibraryEntry {
series: string
episode: string
name: string
files: number
size: number
completed: boolean
downloaded: number
}
const lib = server.library
const data = writable<LibraryEntry[]>([])
const table = createTable(data, {
const table = createTable(lib, {
sort: addSortBy({ toggleOrder: ['asc', 'desc'] })
})
const columns = table.createColumns([
table.column({ accessor: 'series', header: 'Series', id: 'series' }),
table.column({ accessor: 'episode', header: 'Episode', id: 'episode' }),
table.column({
accessor: 'name',
header: 'Torrent Name',
id: 'name',
cell: ({ value }) => createRender(NameCell, { value })
accessor: 'mediaID',
header: 'Series',
id: 'series',
cell: ({ value }) => value ? createRender(MediaCell, { value }) : '?'
}),
table.column({
accessor: 'episode',
header: 'Episode',
id: 'episode',
cell: ({ value }) => value ?? '?'
}),
table.column({ accessor: 'files', header: 'Files', id: 'files' }),
table.column({
accessor: 'size',
header: 'Size',
id: 'size',
cell: ({ value }) => fastPrettyBytes(value)
cell: ({ value }) => value ? fastPrettyBytes(value) : '?'
}),
table.column({
accessor: 'completed',
accessor: 'progress',
header: 'Status',
id: 'completed',
cell: ({ value }) => createRender(StatusCell, { value })
cell: ({ value }) => value ? createRender(StatusCell, { value: value === 1 }) : '?'
}),
table.column({
accessor: 'downloaded',
header: 'Downloaded',
id: 'downloaded',
cell: ({ value }) => new Date(value).toLocaleDateString()
accessor: 'date',
header: 'Date',
id: 'date',
cell: ({ value }) => value ? new Date(value).toLocaleDateString() : '?'
}),
table.column({
accessor: e => e?.name ?? e.hash,
header: 'Torrent Name',
id: 'name',
cell: ({ value }) => createRender(NameCell, { value })
})
])
const tableModel = table.createViewModel(columns)
const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = tableModel
async function playEntry ({ mediaID, episode, hash }: LibraryEntry) {
if (!mediaID || !hash) return
const media = await client.single(mediaID)
server.play(hash, media.data!.Media!, episode)
}
// TODO
// $: allIDsPromise = client.multiple($lib.map(e => e.mediaID))
</script>
<div class='rounded-md border max-w-screen-xl h-full overflow-clip contain-strict'>
@ -94,7 +106,7 @@
{#if $pageRows.length}
{#each $pageRows as row (row.id)}
<Subscribe rowAttrs={row.attrs()} let:rowAttrs>
<Table.Row {...rowAttrs} class='h-12'>
<Table.Row {...rowAttrs} class={cn('h-12', (row instanceof DataBodyRow) && row.original.mediaID ? 'cursor-pointer' : 'cursor-not-allowed')} on:click={() => { if (row instanceof DataBodyRow) playEntry(row.original) }}>
{#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs>
<Table.Cell {...attrs} class={cn('px-4 h-14 first:pl-6 last:pr-6 text-nowrap', (cell.id === 'downloaded' || cell.id === 'episode') && 'text-muted-foreground')}>

View file

@ -95,6 +95,7 @@ export default Object.assign<Native, Partial<Native>>({
updatePeerCounts: async () => [],
isApp: false,
playTorrent: async () => dummyFiles,
library: async () => [],
attachments: async () => [],
tracks: async () => [],
subtitles: async () => undefined,

View file

@ -5,7 +5,7 @@ import { persisted } from 'svelte-persisted-store'
import native from '../native'
import { w2globby } from '../w2g/lobby'
import type { FileInfo, PeerInfo, TorrentFile, TorrentInfo } from '$lib/../app'
import type { TorrentFile, TorrentInfo } from '$lib/../app'
import type { Media } from '../anilist'
const defaultTorrentInfo: TorrentInfo = {
@ -22,60 +22,38 @@ const defaultTorrentInfo: TorrentInfo = {
const defaultProtocolStatus = { dht: false, lsd: false, pex: false, nat: false, forwarding: false, persisting: false, streaming: false }
export const server = new class ServerClient {
last = persisted<{media: Media, id: string, episode: number} | null>('last-torrent', null)
active = writable<Promise<{media: Media, id: string, episode: number, files: TorrentFile[]}| null>>()
last = persisted<{ media: Media, id: string, episode: number } | null>('last-torrent', null)
active = writable<Promise<{ media: Media, id: string, episode: number, files: TorrentFile[] } | null>>()
downloaded = writable(this.cachedSet())
stats = readable(defaultTorrentInfo, set => {
let listener = 0
stats = this._timedSafeReadable(defaultTorrentInfo, native.torrentInfo, 200)
const update = async () => {
const id = (await get(this.active))?.id
if (id) set(await native.torrentInfo(id))
listener = setTimeout(update, 200)
}
protocol = this._timedSafeReadable(defaultProtocolStatus, native.protocolStatus)
update()
return () => clearTimeout(listener)
})
peers = this._timedSafeReadable([], native.peerInfo)
protocol = readable(defaultProtocolStatus, set => {
let listener = 0
files = this._timedSafeReadable([], native.fileInfo)
const update = async () => {
const id = (await get(this.active))?.id
if (id) set(await native.protocolStatus(id))
listener = setTimeout(update, 5000)
}
library = this._timedSafeReadable([], native.library, 120_000)
update()
return () => clearTimeout(listener)
})
_timedSafeReadable<T> (defaultData: T, fn: (id: string) => Promise<T>, duration = 5000) {
return readable<T>(defaultData, set => {
let listener = 0
peers = readable<PeerInfo[]>([], set => {
let listener = 0
const update = async () => {
try {
const id = (await get(this.active))?.id
if (id) set(await fn(id))
} catch (error) {
console.error(error)
}
listener = setTimeout(update, duration)
}
const update = async () => {
const id = (await get(this.active))?.id
if (id) set(await native.peerInfo(id))
listener = setTimeout(update, 5000)
}
update()
return () => clearTimeout(listener)
})
files = readable<FileInfo[]>([], set => {
let listener = 0
const update = async () => {
const id = (await get(this.active))?.id
if (id) set(await native.fileInfo(id))
listener = setTimeout(update, 5000)
}
update()
return () => clearTimeout(listener)
})
update()
return () => clearTimeout(listener)
})
}
constructor () {
const last = get(this.last)
@ -98,7 +76,7 @@ export const server = new class ServerClient {
}
async _play (id: string, media: Media, episode: number) {
const result = { id, media, episode, files: await native.playTorrent(id) }
const result = { id, media, episode, files: await native.playTorrent(id, media.id, episode) }
this.downloaded.value = this.cachedSet()
return result
}

View file

@ -111,7 +111,7 @@
</div>
</div>
</div>
<div class='flex gap-2 items-center justify-center md:justify-start w-full lex-wrap'>
<div class='flex gap-2 items-center md:justify-start md:self-start'>
<div class='flex md:mr-3 w-full min-[380px]:w-[180px]'>
<PlayButton size='default' {media} class='rounded-r-none w-full' />
<EntryEditor {media} />