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

@@ -12,7 +12,8 @@ from indexer.schemas import PublicIndexerQueryResult, IndexerQueryResultId
from metadataProvider.schemas import MetaDataProviderShowSearchResult
from torrent.schemas import Torrent
from tv.exceptions import MediaAlreadyExists
from tv.schemas import Show, SeasonRequest, ShowId, RichShowTorrent, PublicShow
from tv.schemas import Show, SeasonRequest, ShowId, RichShowTorrent, PublicShow, SeasonId, SeasonFile, PublicSeasonFile, \
SeasonNumber
router = APIRouter()
@@ -65,11 +66,16 @@ def get_a_show(db: DbSessionDependency, show_id: ShowId):
return tv.service.get_show_by_id(db=db, show_id=show_id)
@router.get("/shows/{show_id}/{season_number}/files", status_code=status.HTTP_200_OK,
dependencies=[Depends(current_active_user)])
def get_season_files(db: DbSessionDependency, season_number: SeasonNumber, show_id: ShowId) -> list[PublicSeasonFile]:
return tv.service.get_public_season_files_by_season_number(db=db, season_number=season_number, show_id=show_id)
# --------------------------------
# MANAGE REQUESTS
# --------------------------------
@router.post("/season/request", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)])
@router.post("/seasons/requests", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)])
def request_a_season(db: DbSessionDependency, season_request: SeasonRequest):
"""
adds request flag to a season
@@ -77,16 +83,25 @@ def request_a_season(db: DbSessionDependency, season_request: SeasonRequest):
tv.service.request_season(db=db, season_request=season_request)
@router.get("/season/request", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)])
@router.get("/seasons/requests", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)])
def get_requested_seasons(db: DbSessionDependency) -> list[SeasonRequest]:
return tv.service.get_all_requested_seasons(db=db)
@router.delete("/season/request", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)])
@router.delete("/seasons/requests", status_code=status.HTTP_200_OK, dependencies=[Depends(current_active_user)])
def unrequest_season(db: DbSessionDependency, request: SeasonRequest):
tv.service.unrequest_season(db=db, season_request=request)
# --------------------------------
# MANAGE SEASON FILES
# --------------------------------
# --------------------------------
# MANAGE TORRENTS
# --------------------------------

View File

@@ -69,6 +69,10 @@ class SeasonFile(BaseModel):
torrent_id: TorrentId | None
file_path_suffix: str
class PublicSeasonFile(SeasonFile):
downloaded: bool = False
class RichSeasonTorrent(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -14,7 +14,7 @@ from tv import log
from tv.exceptions import MediaAlreadyExists
from tv.repository import add_season_file, get_season_files_by_season_id
from tv.schemas import Show, ShowId, SeasonRequest, SeasonFile, SeasonId, Season, RichShowTorrent, RichSeasonTorrent, \
PublicSeason, PublicShow
PublicSeason, PublicShow, PublicSeasonFile, SeasonNumber
def add_show(db: Session, external_id: int, metadata_provider: str) -> Show | None:
@@ -34,6 +34,22 @@ def unrequest_season(db: Session, season_request: SeasonRequest) -> None:
tv.repository.remove_season_from_requested_list(db=db, season_request=season_request)
def get_public_season_files_by_season_id(db: Session, season_id: SeasonId) -> list[PublicSeasonFile]:
season_files = get_season_files_by_season_id(db=db, season_id=season_id)
public_season_files = [PublicSeasonFile.model_validate(x) for x in season_files]
result = []
for season_file in public_season_files:
if season_file_exists_on_file(db=db, season_file=season_file):
season_file.downloaded = True
result.append(season_file)
return result
def get_public_season_files_by_season_number(db: Session, season_number: SeasonNumber, show_id: ShowId) -> list[
PublicSeasonFile]:
season = tv.repository.get_season_by_number(db=db, season_number=season_number, show_id=show_id)
return get_public_season_files_by_season_id(db=db, season_id=season.id)
def check_if_show_exists(db: Session,
external_id: int = None,
metadata_provider: str = None,
@@ -95,26 +111,25 @@ def get_show_by_id(db: Session, show_id: ShowId) -> PublicShow:
public_show.seasons = seasons
return public_show
def is_season_downloaded(db: Session, season_id: SeasonId) -> bool:
season_files = get_season_files_by_season_id(db=db, season_id=season_id)
for season_file in season_files:
if season_file.torrent_id is None:
if season_file_exists_on_file(db=db, season_file=season_file):
return True
else:
torrent_file = torrent.repository.get_torrent_by_id(db=db, torrent_id=season_file.torrent_id)
if torrent_file.imported:
return True
return False
def check_if_season_exists_on_file(db: Session, season_id: SeasonId) -> bool:
season = tv.repository.get_season(season_id=season_id, db=db)
if season:
def season_file_exists_on_file(db: Session, season_file: SeasonFile) -> bool:
if season_file.torrent_id is None:
return True
else:
raise ValueError(f"A season with this ID {season_id} does not exist")
torrent_file = torrent.repository.get_torrent_by_id(db=db, torrent_id=season_file.torrent_id)
if torrent_file.imported:
return True
return False
def get_show_by_external_id(db: Session, external_id: int, metadata_provider: str) -> Show | None:

21
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
cache/
node_modules
.output
.vercel
.netlify
.wrangler
.svelte-kit
build
.DS_Store
Thumbs.db
.env
.env.*
!.env.example
!.env.test
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

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/>

View File

@@ -76,6 +76,15 @@ export interface Season {
episodes: Episode[]; // items: { $ref: #/components/schemas/Episode }, type: array
id?: string; // type: string, format: uuid
}
export interface PublicSeasonFile {
season_id: string; // type: string, format: uuid
quality: Quality;
torrent_id?: string; // type: string, format: uuid
file_path_suffix?: string;
downloaded: boolean;
}
export interface PublicSeason {
number: number; // type: integer
name: string;

View File

@@ -5,10 +5,10 @@
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import {getTorrentQualityString, getTorrentStatusString} from '$lib/utils';
import type {RichShowTorrent, Torrent} from '$lib/types';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import type {RichShowTorrent} from '$lib/types';
import {getFullyQualifiedShowName} from '$lib/utils';
import * as Accordion from '$lib/components/ui/accordion/index.js';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
let showsPromise: Promise<RichShowTorrent[]> = $state(page.data.shows);
</script>
@@ -57,7 +57,7 @@
<Table.Head>Download Status</Table.Head>
<Table.Head>Quality</Table.Head>
<Table.Head>File Path Suffix</Table.Head>
<Table.Head>Import Status</Table.Head>
<Table.Head>Imported</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -90,7 +90,7 @@
</Table.Cell>
<Table.Cell>
<a href={'/dashboard/torrents/' + torrent.torrent_id}>
{torrent.imported ? 'Imported' : 'Not Imported'}
<CheckmarkX state={torrent.imported}/>
</a>
</Table.Cell>
</Table.Row>

View File

@@ -1,78 +1,77 @@
<script lang="ts">
import {page} from '$app/state';
import * as Card from '$lib/components/ui/card/index.js';
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import {toOptimizedURL} from 'sveltekit-image-optimize/components';
import {getFullyQualifiedShowName} from '$lib/utils';
import {base} from '$app/paths';
import logo from '$lib/images/svelte-logo.svg';
import {Button} from "$lib/components/ui/button";
import {goto} from "$app/navigation";
import {page} from '$app/state';
import * as Card from '$lib/components/ui/card/index.js';
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import {toOptimizedURL} from 'sveltekit-image-optimize/components';
import {getFullyQualifiedShowName} from '$lib/utils';
import logo from '$lib/images/svelte-logo.svg';
import {Button} from "$lib/components/ui/button";
import {goto} from "$app/navigation";
let tvShowsPromise = page.data.tvShows;
let tvShowsPromise = page.data.tvShows;
</script>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Shows</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Shows</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<div class="flex w-full flex-1 flex-col gap-4 p-4 pt-0">
<Button class="w-full max-w-[200px]" onclick={()=>{goto("/dashboard/tv/add-show")}} variant="outline">
Add a Show
</Button>
<Button class="w-full max-w-[200px]" onclick={()=>{goto("/dashboard/tv/add-show")}} variant="outline">
Add a Show
</Button>
<div
class="grid w-full auto-rows-min gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#await tvShowsPromise}
Loading...
{:then tvShowsJson}
{#await tvShowsJson.json()}
Loading...
{:then tvShows}
{#each tvShows as show}
<a href={'/dashboard/tv/' + show.id}>
<Card.Root class="h-full">
<Card.Header>
<Card.Title>{getFullyQualifiedShowName(show)}</Card.Title>
<Card.Description class="truncate">{show.overview}</Card.Description>
</Card.Header>
<Card.Content>
<img
class="aspect-9/16 center h-auto max-w-full rounded-lg object-cover"
src={toOptimizedURL(`${env.PUBLIC_API_URL}/static/image/${show.id}.jpg`)}
alt="{getFullyQualifiedShowName(show)}'s Poster Image"
on:error={(e) => {
e.target.src = logo;
}}
/>
</Card.Content>
</Card.Root>
</a>
{/each}
{/await}
{/await}
</div>
<div
class="grid w-full auto-rows-min gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#await tvShowsPromise}
Loading...
{:then tvShowsJson}
{#await tvShowsJson.json()}
Loading...
{:then tvShows}
{#each tvShows as show}
<a href={'/dashboard/tv/' + show.id}>
<Card.Root class="h-full">
<Card.Header>
<Card.Title>{getFullyQualifiedShowName(show)}</Card.Title>
<Card.Description class="truncate">{show.overview}</Card.Description>
</Card.Header>
<Card.Content>
<img
class="aspect-9/16 center h-auto max-w-full rounded-lg object-cover"
src={toOptimizedURL(`${env.PUBLIC_API_URL}/static/image/${show.id}.jpg`)}
alt="{getFullyQualifiedShowName(show)}'s Poster Image"
on:error={(e) => {
e.target.src = logo;
}}
/>
</Card.Content>
</Card.Root>
</a>
{/each}
{/await}
{/await}
</div>
</div>

View File

@@ -1,419 +1,110 @@
<script lang="ts">
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import {Input} from '$lib/components/ui/input';
import {Label} from '$lib/components/ui/label';
import {Button} from '$lib/components/ui/button';
import {ImageOff} from 'lucide-svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import * as Select from '$lib/components/ui/select/index.js';
import {env} from '$env/dynamic/public';
import {Separator} from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import {goto} from "$app/navigation";
import {ImageOff} from 'lucide-svelte';
import * as Table from '$lib/components/ui/table/index.js';
import {getContext} from 'svelte';
import type {Show} from '$lib/types.js';
import {getFullyQualifiedShowName} from '$lib/utils';
import {toOptimizedURL} from 'sveltekit-image-optimize/components';
import DownloadSeasonDialog from '$lib/components/download-season-dialog.svelte';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import {buttonVariants} from '$lib/components/ui/button/index.js';
import {getContext} from 'svelte';
import {goto} from '$app/navigation';
import type {PublicIndexerQueryResult, Show} from '$lib/types.js';
import {getFullyQualifiedShowName} from '$lib/utils';
import {toOptimizedURL} from 'sveltekit-image-optimize/components';
import X from '@lucide/svelte/icons/x';
import Check from '@lucide/svelte/icons/check';
let show: Show = getContext('show');
console.log('loaded show:', show);
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;
}
});
}
});
let show: Show = getContext('show');
console.log('loaded show:', show);
</script>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard/tv">Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>{getFullyQualifiedShowName(show)}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1"/>
<Separator class="mr-2 h-4" orientation="vertical"/>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="/dashboard">MediaManager</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Link href="/dashboard/tv">Shows</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>{getFullyQualifiedShowName(show)}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
{getFullyQualifiedShowName(show)}
{getFullyQualifiedShowName(show)}
</h1>
<div class="flex flex-1 flex-col gap-4 p-4">
<div class="flex items-center gap-2">
<div class="max-h-50% w-1/3 max-w-sm rounded-xl bg-muted/50">
{#if show?.id}
<img
class="aspect-9/16 h-auto w-full rounded-lg object-cover"
src={toOptimizedURL(`${env.PUBLIC_API_URL}/static/image/${show.id}.jpg`)}
alt="{show.name}'s Poster Image"
/>
{:else}
<div
class="aspect-9/16 flex h-auto w-full items-center justify-center rounded-lg bg-gray-200 text-gray-500"
>
<ImageOff size={48}/>
</div>
{/if}
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<p class="leading-7 [&:not(:first-child)]:mt-6">
{show.overview}
</p>
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<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">
/{getFullyQualifiedShowName(show)} [{show.metadata_provider}
id-{show.external_id}
]/Season XX/{show.name} SXXEXX {filePathSuffix === ''
? ''
: ' - ' + filePathSuffix}.mkv
</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">
/{getFullyQualifiedShowName(show)} [{show.metadata_provider}
id-{show.external_id}
]/Season XX/{show.name} SXXEXX {filePathSuffix === ''
? ''
: ' - ' + filePathSuffix}.mkv
</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>
</div>
</div>
<div class="min-h-[100vh] flex-1 rounded-xl bg-muted/50 p-4 md:min-h-min">
<div class="w-full overflow-x-auto">
<Table.Root>
<Table.Caption>A list of all seasons.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Number</Table.Head>
<Table.Head>Exists on file</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head>Overview</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if show?.seasons?.length > 0}
{#each show.seasons as season (season.id)}
<Table.Row
link={true}
onclick={() => goto('/dashboard/tv/' + show.id + '/' + season.number)}
>
<Table.Cell class="min-w-[10px] font-medium">{season.number}</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
{#if season.downloaded}
<Check class="stroke-green-500"/>
{:else}
<X class="stroke-rose-600"/>
{/if}
</Table.Cell>
<Table.Cell class="min-w-[50px]">{season.name}</Table.Cell>
<Table.Cell class="max-w-[300px] truncate">{season.overview}</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan="3" class="text-center">No season data available.</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center gap-2">
<div class="max-h-50% w-1/3 max-w-sm rounded-xl bg-muted/50">
{#if show?.id}
<img
class="aspect-9/16 h-auto w-full rounded-lg object-cover"
src={toOptimizedURL(`${env.PUBLIC_API_URL}/static/image/${show.id}.jpg`)}
alt="{show.name}'s Poster Image"
/>
{:else}
<div
class="aspect-9/16 flex h-auto w-full items-center justify-center rounded-lg bg-gray-200 text-gray-500"
>
<ImageOff size={48}/>
</div>
{/if}
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<p class="leading-7 [&:not(:first-child)]:mt-6">
{show.overview}
</p>
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<DownloadSeasonDialog {show}/>
</div>
</div>
<div class="min-h-[100vh] flex-1 rounded-xl bg-muted/50 p-4 md:min-h-min">
<div class="w-full overflow-x-auto">
<Table.Root>
<Table.Caption>A list of all seasons.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Number</Table.Head>
<Table.Head>Exists on file</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head>Overview</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if show?.seasons?.length > 0}
{#each show.seasons as season (season.id)}
<Table.Row
link={true}
onclick={() => goto('/dashboard/tv/' + show.id + '/' + season.number)}
>
<Table.Cell class="min-w-[10px] font-medium">{season.number}</Table.Cell>
<Table.Cell class="min-w-[10px] font-medium">
<CheckmarkX state={season.downloaded}/>
</Table.Cell>
<Table.Cell class="min-w-[50px]">{season.name}</Table.Cell>
<Table.Cell class="max-w-[300px] truncate">{season.overview}</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan="3" class="text-center">No season data available.</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
</div>

View File

@@ -6,9 +6,12 @@
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import {getContext} from 'svelte';
import type {Season, Show} from '$lib/types';
import type {PublicSeasonFile, RichShowTorrent, Season, Show} from '$lib/types';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import {getTorrentQualityString} from "$lib/utils";
const SeasonNumber = page.params.SeasonNumber;
let seasonFiles: PublicSeasonFile[] = $state(page.data.files);
let show: Show = getContext('show');
let season: Season;
show.seasons.forEach((item) => {
@@ -63,11 +66,40 @@
alt="{show.name}'s Poster Image"
/>
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4">
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4 w-1/4 ">
<p class="leading-7 [&:not(:first-child)]:mt-6">
{show.overview}
</p>
</div>
<div class="h-full flex-auto rounded-xl bg-muted/50 p-4 w-1/3">
<Table.Root>
<Table.Caption>A list of all downloaded/downloading versions of this season.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Quality</Table.Head>
<Table.Head>File Path Suffix</Table.Head>
<Table.Head>Imported</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each seasonFiles as file}
<Table.Row>
<Table.Cell class="w-[50px]">
{getTorrentQualityString(file.quality)}
</Table.Cell>
<Table.Cell class="w-[100px]">
{file.file_path_suffix}
</Table.Cell>
<Table.Cell class="w-[10px] font-medium">
<CheckmarkX state={file.imported}/>
</Table.Cell>
</Table.Row>
{:else }
<span class="font-semibold">You haven't downloaded this season yet.</span>
{/each}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="min-h-[100vh] flex-1 rounded-xl bg-muted/50 p-4 md:min-h-min">
<div class="w-full overflow-x-auto">

View File

@@ -0,0 +1,38 @@
import {env} from '$env/dynamic/public';
import type {PageLoad} from './$types';
const apiUrl = env.PUBLIC_API_URL;
export const load: PageLoad = async ({fetch, params}) => {
const url = `${apiUrl}/tv/shows/${params.showId}/${params.SeasonNumber}/files`;
try {
console.log(`Fetching data from: ${url}`);
const response = await fetch(url, {
method: 'GET',
credentials: 'include'
});
if (!response.ok) {
const errorText = await response.text();
console.error(`API request failed with status ${response.status}: ${errorText}`);
return {
error: `Failed to load TV show files. Status: ${response.status}`,
files: []
};
}
const filesData = await response.json();
console.log("received season_files data: ", filesData);
return {
files: filesData
};
} catch (error) {
console.error('An error occurred while fetching TV show files:', error);
return {
error: `An unexpected error occurred: ${error.message || 'Unknown error'}`,
files: []
};
}
};