feat: implement season file management and UI updates for torrent downloads

This commit is contained in:
maxDorninger
2025-05-18 16:49:09 +02:00
parent bae450f7a4
commit 61adf166aa
15 changed files with 692 additions and 551 deletions

View File

@@ -4,12 +4,7 @@
import TvIcon from '@lucide/svelte/icons/tv';
import LayoutPanelLeft from '@lucide/svelte/icons/layout-panel-left';
import DownloadIcon from '@lucide/svelte/icons/download';
import Sun from "@lucide/svelte/icons/sun";
import Moon from "@lucide/svelte/icons/moon";
import {resetMode, setMode} from "mode-watcher";
import {buttonVariants} from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
const data = {
navMain: [
{

View File

@@ -0,0 +1,11 @@
<script>
import X from '@lucide/svelte/icons/x';
import Check from '@lucide/svelte/icons/check';
let {state} = $props();
</script>
{#if state}
<Check class="stroke-green-500"/>
{:else}
<X class="stroke-rose-600"/>
{/if}

View File

@@ -0,0 +1,308 @@
<script lang="ts">
import {env} from '$env/dynamic/public';
import {Button, buttonVariants} from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import {Input} from '$lib/components/ui/input';
import {Label} from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import type {PublicIndexerQueryResult} from '$lib/types.js';
import {getFullyQualifiedShowName} from '$lib/utils';
let {show} = $props();
let selectedSeasonNumber: number = $state(1);
let torrents: PublicIndexerQueryResult[] = $state([]);
let isLoadingTorrents: boolean = $state(false);
let torrentsError: string | null = $state(null);
let queryOverride: string = $state('');
let filePathSuffix: string = $state('');
async function downloadTorrent(result_id: string) {
let url = new URL(env.PUBLIC_API_URL + '/tv/torrents');
url.searchParams.append('public_indexer_result_id', result_id);
url.searchParams.append('show_id', show.id);
if (filePathSuffix !== '') {
url.searchParams.append('file_path_suffix', filePathSuffix);
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!response.ok) {
const errorMessage = `Failed to download torrent for show ${show.id} and season ${selectedSeasonNumber}: ${response.statusText}`;
console.error(errorMessage);
torrentsError = errorMessage;
return false;
}
const data: PublicIndexerQueryResult[] = await response.json();
console.log('Downloading torrent:', data);
return true;
} catch (err) {
const errorMessage = `Error downloading torrent: ${err instanceof Error ? err.message : 'An unknown error occurred'}`;
console.error(errorMessage);
return false;
}
}
async function getTorrents(
season_number: number,
override: boolean = false
): Promise<PublicIndexerQueryResult[]> {
isLoadingTorrents = true;
torrentsError = null;
torrents = [];
let url = new URL(env.PUBLIC_API_URL + '/tv/torrents');
url.searchParams.append('show_id', show.id);
if (override) {
url.searchParams.append('search_query_override', queryOverride);
} else {
url.searchParams.append('season_number', season_number.toString());
}
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!response.ok) {
const errorMessage = `Failed to fetch torrents for show ${show.id} and season ${selectedSeasonNumber}: ${response.statusText}`;
console.error(errorMessage);
torrentsError = errorMessage;
return [];
}
const data: PublicIndexerQueryResult[] = await response.json();
console.log('Fetched torrents:', data);
return data;
} catch (err) {
const errorMessage = `Error fetching torrents: ${err instanceof Error ? err.message : 'An unknown error occurred'}`;
console.error(errorMessage);
torrentsError = errorMessage;
return [];
} finally {
isLoadingTorrents = false;
}
}
$effect(() => {
if (show?.id) {
console.log('selectedSeasonNumber changed:', selectedSeasonNumber);
getTorrents(selectedSeasonNumber).then((fetchedTorrents) => {
if (!isLoadingTorrents) {
torrents = fetchedTorrents;
} else if (fetchedTorrents.length > 0 || torrentsError) {
torrents = fetchedTorrents;
}
});
}
});
</script>
{#snippet saveDirectoryPreview(show, filePathSuffix)}
/{getFullyQualifiedShowName(show)} [{show.metadata_provider}id-{show.external_id}]/
Season XX/{show.name} SXXEXX {filePathSuffix === ''
? ''
: ' - ' + filePathSuffix}.mkv
{/snippet}
<Dialog.Root>
<Dialog.Trigger class={buttonVariants({ variant: 'default' })}
>Download Seasons
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] w-fit min-w-[80vw] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Download a Season</Dialog.Title>
<Dialog.Description>
Search and download torrents for a specific season or season packs.
</Dialog.Description>
</Dialog.Header>
<Tabs.Root class="w-full" value="basic">
<Tabs.List>
<Tabs.Trigger value="basic">Standard Mode</Tabs.Trigger>
<Tabs.Trigger value="advanced">Advanced Mode</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="basic">
<div class="grid w-full items-center gap-1.5">
{#if show?.seasons?.length > 0}
<Label for="season-number"
>Enter a season number from 1 to {show.seasons.at(-1).number}</Label
>
<Input
type="number"
class="max-w-sm"
id="season-number"
bind:value={selectedSeasonNumber}
max={show.seasons.at(-1).number}
/>
<p class="text-sm text-muted-foreground">
Enter the season's number you want to search for. The first, usually 1, or the
last season number usually yield the most season packs. Note that only Seasons
which are listed in the "Seasons" cell will be imported!
</p>
<Label for="file-suffix">Filepath suffix</Label>
<Select.Root type="single" bind:value={filePathSuffix} id="file-suffix">
<Select.Trigger class="w-[180px]">{filePathSuffix}</Select.Trigger>
<Select.Content>
<Select.Item value="">None</Select.Item>
<Select.Item value="2160P">2160p</Select.Item>
<Select.Item value="1080P">1080p</Select.Item>
<Select.Item value="720P">720p</Select.Item>
<Select.Item value="480P">480p</Select.Item>
<Select.Item value="360P">360p</Select.Item>
</Select.Content>
</Select.Root>
<p class="text-sm text-muted-foreground">
This is necessary to differentiate between versions of the same season/show, for
example a 1080p and a 4K version of a season.
</p>
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-sm text-muted-foreground" id="file-suffix-display">
{@render saveDirectoryPreview(show, filePathSuffix)}
</p>
{:else}
<p class="text-sm text-muted-foreground">
No season information available for this show.
</p>
{/if}
</div>
</Tabs.Content>
<Tabs.Content value="advanced">
<div class="grid w-full items-center gap-1.5">
{#if show?.seasons?.length > 0}
<Label for="query-override">Enter a custom query</Label>
<div class="flex w-full max-w-sm items-center space-x-2">
<Input type="text" id="query-override" bind:value={queryOverride}/>
<Button
variant="secondary"
onclick={async () => {
isLoadingTorrents = true;
torrentsError = null;
torrents = [];
try {
const fetchedTorrents = await getTorrents(selectedSeasonNumber, true);
torrents = fetchedTorrents;
} catch (error) {
console.log(error);
} finally {
isLoadingTorrents = false;
}
}}
>
Search
</Button>
</div>
<p class="text-sm text-muted-foreground">
The custom query will override the default search string like "The Simpsons
Season 3". Note that only Seasons which are listed in the "Seasons" cell will be
imported!
</p>
<Label for="file-suffix">Filepath suffix</Label>
<Input
type="text"
class="max-w-sm"
id="file-suffix"
bind:value={filePathSuffix}
placeholder="1080P"
/>
<p class="text-sm text-muted-foreground">
This is necessary to differentiate between versions of the same season/show, for
example a 1080p and a 4K version of a season.
</p>
<Label for="file-suffix-display"
>The files will be saved in the following directory:</Label
>
<p class="text-sm text-muted-foreground" id="file-suffix-display">
{@render saveDirectoryPreview(show, filePathSuffix)}
</p>
{:else}
<p class="text-sm text-muted-foreground">
No season information available for this show.
</p>
{/if}
</div>
</Tabs.Content>
</Tabs.Root>
<div class="mt-4 items-center">
{#if isLoadingTorrents}
<div class="flex w-full max-w-sm items-center space-x-2">
<LoaderCircle class="animate-spin"/>
<p>Loading torrents...</p>
</div>
{:else if torrentsError}
<p class="text-red-500">Error: {torrentsError}</p>
{:else if torrents.length > 0}
<h3 class="mb-2 text-lg font-semibold">Found Torrents:</h3>
<div class="max-h-[200px] overflow-y-auto rounded-md border p-2">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Size</Table.Head>
<Table.Head>Seeders</Table.Head>
<Table.Head>Indexer Flags</Table.Head>
<Table.Head>Seasons</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each torrents as torrent (torrent.id)}
<Table.Row>
<Table.Cell
class="max-w-[300px] font-medium">{torrent.title}</Table.Cell>
<Table.Cell>{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB
</Table.Cell>
<Table.Cell>{torrent.seeders}</Table.Cell>
<Table.Cell>
{#each torrent.flags as flag}
{flag},&nbsp;
{/each}
</Table.Cell>
<Table.Cell>
{#if torrent.season.length === 1}
{torrent.season[0]}
{:else}
{torrent.season.at(0)}{torrent.season.at(-1)}
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<Button
size="sm"
variant="outline"
onclick={() => {
downloadTorrent(torrent.id);
}}
>
Download
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else if show?.seasons?.length > 0}
<p>No torrents found for season {selectedSeasonNumber}. Try a different season.</p>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,54 +1,55 @@
<script lang="ts">
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type {ComponentProps} from 'svelte';
import Sun from "@lucide/svelte/icons/sun";
import Moon from "@lucide/svelte/icons/moon";
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type {ComponentProps} from 'svelte';
import Sun from "@lucide/svelte/icons/sun";
import Moon from "@lucide/svelte/icons/moon";
import {toggleMode} from "mode-watcher";
import {Button} from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
items,
...restProps
}: {
items: {
title: string;
url: string;
// This should be `Component` after @lucide/svelte updates types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
}[];
} & ComponentProps<typeof Sidebar.Group> = $props();
import {toggleMode} from "mode-watcher";
let {
ref = $bindable(null),
items,
...restProps
}: {
items: {
title: string;
url: string;
// This should be `Component` after @lucide/svelte updates types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
}[];
} & ComponentProps<typeof Sidebar.Group> = $props();
</script>
<Sidebar.Group bind:ref {...restProps}>
<Sidebar.GroupContent>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton size="sm">
{#snippet child({props})}
<div on:click={()=>toggleMode()} {...props}>
<Sidebar.Group {...restProps} bind:ref>
<Sidebar.GroupContent>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton size="sm">
{#snippet child({props})}
<div onclick={()=>toggleMode()} {...props}>
<Sun class="dark:hidden "/>
<Moon class="hidden dark:inline"/>
<span>Toggle mode</span>
</div>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sun class="dark:hidden "/>
<span class="dark:hidden ">Switch to dark mode</span>
<Moon class="hidden dark:inline"/>
<span class="hidden dark:inline">Switch to light mode</span>
</div>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{#each items as item (item.title)}
<Sidebar.MenuItem>
<Sidebar.MenuButton size="sm">
{#snippet child({props})}
<a href={item.url} {...props}>
<item.icon/>
<span>{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
{#each items as item (item.title)}
<Sidebar.MenuItem>
<Sidebar.MenuButton size="sm">
{#snippet child({props})}
<a href={item.url} {...props}>
<item.icon/>
<span>{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>

View File

@@ -13,7 +13,7 @@
import {getContext} from 'svelte';
import UserDetails from './user-details.svelte';
import type {User} from '$lib/types';
import UserRound from '@lucide/svelte/icons/user-round';
const user: () => User = getContext('user');
const sidebar = useSidebar();
</script>
@@ -30,7 +30,9 @@
>
<Avatar.Root class="h-8 w-8 rounded-lg">
<!--<Avatar.Image src={user.avatar} alt={user.name} />-->
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
<Avatar.Fallback class="rounded-lg">
<UserRound/>
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<UserDetails/>