diff --git a/backend/src/tv/router.py b/backend/src/tv/router.py index b1a1015..ce54b9c 100644 --- a/backend/src/tv/router.py +++ b/backend/src/tv/router.py @@ -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 # -------------------------------- diff --git a/backend/src/tv/schemas.py b/backend/src/tv/schemas.py index 38c0229..420ec61 100644 --- a/backend/src/tv/schemas.py +++ b/backend/src/tv/schemas.py @@ -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) diff --git a/backend/src/tv/service.py b/backend/src/tv/service.py index 98cdfca..3251180 100644 --- a/backend/src/tv/service.py +++ b/backend/src/tv/service.py @@ -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: diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..1f716b2 --- /dev/null +++ b/web/.gitignore @@ -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-* diff --git a/web/src/lib/components/app-sidebar.svelte b/web/src/lib/components/app-sidebar.svelte index be00a3b..eeee2b5 100644 --- a/web/src/lib/components/app-sidebar.svelte +++ b/web/src/lib/components/app-sidebar.svelte @@ -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: [ { diff --git a/web/src/lib/components/checkmark-x.svelte b/web/src/lib/components/checkmark-x.svelte new file mode 100644 index 0000000..4d2f0a1 --- /dev/null +++ b/web/src/lib/components/checkmark-x.svelte @@ -0,0 +1,11 @@ + +{#if state} + +{:else} + +{/if} \ No newline at end of file diff --git a/web/src/lib/components/download-season-dialog.svelte b/web/src/lib/components/download-season-dialog.svelte new file mode 100644 index 0000000..f658ed4 --- /dev/null +++ b/web/src/lib/components/download-season-dialog.svelte @@ -0,0 +1,308 @@ + + +{#snippet saveDirectoryPreview(show, filePathSuffix)} + /{getFullyQualifiedShowName(show)} [{show.metadata_provider}id-{show.external_id}]/ + Season XX/{show.name} SXXEXX {filePathSuffix === '' + ? '' + : ' - ' + filePathSuffix}.mkv +{/snippet} + + + Download Seasons + + + + Download a Season + + Search and download torrents for a specific season or season packs. + + + + + Standard Mode + Advanced Mode + + +
+ {#if show?.seasons?.length > 0} + + +

+ 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! +

+ + + {filePathSuffix} + + None + 2160p + 1080p + 720p + 480p + 360p + + +

+ This is necessary to differentiate between versions of the same season/show, for + example a 1080p and a 4K version of a season. +

+ +

+ {@render saveDirectoryPreview(show, filePathSuffix)} +

+ {:else} +

+ No season information available for this show. +

+ {/if} +
+
+ +
+ {#if show?.seasons?.length > 0} + +
+ + +
+

+ 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! +

+ + +

+ This is necessary to differentiate between versions of the same season/show, for + example a 1080p and a 4K version of a season. +

+ + +

+ {@render saveDirectoryPreview(show, filePathSuffix)} +

+ {:else} +

+ No season information available for this show. +

+ {/if} +
+
+
+
+ {#if isLoadingTorrents} +
+ +

Loading torrents...

+
+ {:else if torrentsError} +

Error: {torrentsError}

+ {:else if torrents.length > 0} +

Found Torrents:

+
+ + + + Title + Size + Seeders + Indexer Flags + Seasons + Actions + + + + {#each torrents as torrent (torrent.id)} + + {torrent.title} + {(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB + + {torrent.seeders} + + {#each torrent.flags as flag} + {flag},  + {/each} + + + {#if torrent.season.length === 1} + {torrent.season[0]} + {:else} + {torrent.season.at(0)}−{torrent.season.at(-1)} + {/if} + + + + + + {/each} + + +
+ {:else if show?.seasons?.length > 0} +

No torrents found for season {selectedSeasonNumber}. Try a different season.

+ {/if} +
+
+
\ No newline at end of file diff --git a/web/src/lib/components/nav-secondary.svelte b/web/src/lib/components/nav-secondary.svelte index 3b8d882..5e144fa 100644 --- a/web/src/lib/components/nav-secondary.svelte +++ b/web/src/lib/components/nav-secondary.svelte @@ -1,54 +1,55 @@ - - - - - - {#snippet child({props})} -
toggleMode()} {...props}> + + + + + + {#snippet child({props})} +
toggleMode()} {...props}> - -
- {/snippet} -
-
+ + Switch to dark mode +
+ {/snippet} +
+
- {#each items as item (item.title)} - - - {#snippet child({props})} - - - {item.title} - - {/snippet} - - - {/each} -
-
+ {#each items as item (item.title)} + + + {#snippet child({props})} + + + {item.title} + + {/snippet} + + + {/each} + +
diff --git a/web/src/lib/components/nav-user.svelte b/web/src/lib/components/nav-user.svelte index 6141177..647f87d 100644 --- a/web/src/lib/components/nav-user.svelte +++ b/web/src/lib/components/nav-user.svelte @@ -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(); @@ -30,7 +30,9 @@ > - CN + + +
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e9a6fde..60c93fd 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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; diff --git a/web/src/routes/dashboard/torrents/+page.svelte b/web/src/routes/dashboard/torrents/+page.svelte index 8bdf4c8..0be0fea 100644 --- a/web/src/routes/dashboard/torrents/+page.svelte +++ b/web/src/routes/dashboard/torrents/+page.svelte @@ -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 = $state(page.data.shows); @@ -57,7 +57,7 @@ Download Status Quality File Path Suffix - Import Status + Imported @@ -90,7 +90,7 @@ - {torrent.imported ? 'Imported' : 'Not Imported'} + diff --git a/web/src/routes/dashboard/tv/+page.svelte b/web/src/routes/dashboard/tv/+page.svelte index fcc9322..41a46e1 100644 --- a/web/src/routes/dashboard/tv/+page.svelte +++ b/web/src/routes/dashboard/tv/+page.svelte @@ -1,78 +1,77 @@
-
- - - - - - - -
+
+ + + + + + + +
- + -
- {#await tvShowsPromise} - Loading... - {:then tvShowsJson} - {#await tvShowsJson.json()} - Loading... - {:then tvShows} - {#each tvShows as show} - - - - {getFullyQualifiedShowName(show)} - {show.overview} - - - {getFullyQualifiedShowName(show)}'s Poster Image { - e.target.src = logo; - }} - /> - - - - {/each} - {/await} - {/await} -
+
+ {#await tvShowsPromise} + Loading... + {:then tvShowsJson} + {#await tvShowsJson.json()} + Loading... + {:then tvShows} + {#each tvShows as show} + + + + {getFullyQualifiedShowName(show)} + {show.overview} + + + {getFullyQualifiedShowName(show)}'s Poster Image { + e.target.src = logo; + }} + /> + + + + {/each} + {/await} + {/await} +
diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte b/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte index b24dfc4..50fbf5c 100644 --- a/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte +++ b/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte @@ -1,419 +1,110 @@
-
- - - - - - - -
+
+ + + + + + + +

- {getFullyQualifiedShowName(show)} + {getFullyQualifiedShowName(show)}

-
-
- {#if show?.id} - {show.name}'s Poster Image - {:else} -
- -
- {/if} -
-
-

- {show.overview} -

-
-
- - Download Seasons - - - - Download a Season - - Search and download torrents for a specific season or season packs. - - - - - Standard Mode - Advanced Mode - - -
- {#if show?.seasons?.length > 0} - - -

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

- - - {filePathSuffix} - - None - 2160p - 1080p - 720p - 480p - 360p - - -

- This is necessary to differentiate between versions of the same season/show, for - example a 1080p and a 4K version of a season. -

- -

- /{getFullyQualifiedShowName(show)} [{show.metadata_provider} - id-{show.external_id} - ]/Season XX/{show.name} SXXEXX {filePathSuffix === '' - ? '' - : ' - ' + filePathSuffix}.mkv -

- {:else} -

- No season information available for this show. -

- {/if} -
-
- -
- {#if show?.seasons?.length > 0} - -
- - -
-

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

- - -

- This is necessary to differentiate between versions of the same season/show, for - example a 1080p and a 4K version of a season. -

- - -

- /{getFullyQualifiedShowName(show)} [{show.metadata_provider} - id-{show.external_id} - ]/Season XX/{show.name} SXXEXX {filePathSuffix === '' - ? '' - : ' - ' + filePathSuffix}.mkv -

- {:else} -

- No season information available for this show. -

- {/if} -
-
-
-
- {#if isLoadingTorrents} -
- -

Loading torrents...

-
- {:else if torrentsError} -

Error: {torrentsError}

- {:else if torrents.length > 0} -

Found Torrents:

-
- - - - Title - Size - Seeders - Indexer Flags - Seasons - Actions - - - - {#each torrents as torrent (torrent.id)} - - {torrent.title} - {(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB - - {torrent.seeders} - - {#each torrent.flags as flag} - {flag},  - {/each} - - - {#if torrent.season.length === 1} - {torrent.season[0]} - {:else} - {torrent.season.at(0)}−{torrent.season.at(-1)} - {/if} - - - - - - {/each} - - -
- {:else if show?.seasons?.length > 0} -

No torrents found for season {selectedSeasonNumber}. Try a different season.

- {/if} -
-
-
-
-
-
-
- - A list of all seasons. - - - Number - Exists on file - Title - Overview - - - - {#if show?.seasons?.length > 0} - {#each show.seasons as season (season.id)} - goto('/dashboard/tv/' + show.id + '/' + season.number)} - > - {season.number} - - {#if season.downloaded} - - {:else} - - {/if} - - - - {season.name} - {season.overview} - - {/each} - {:else} - - No season data available. - - {/if} - - -
-
+
+
+ {#if show?.id} + {show.name}'s Poster Image + {:else} +
+ +
+ {/if} +
+
+

+ {show.overview} +

+
+
+ +
+
+
+
+ + A list of all seasons. + + + Number + Exists on file + Title + Overview + + + + {#if show?.seasons?.length > 0} + {#each show.seasons as season (season.id)} + goto('/dashboard/tv/' + show.id + '/' + season.number)} + > + {season.number} + + + + {season.name} + {season.overview} + + {/each} + {:else} + + No season data available. + + {/if} + + +
+
diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonNumber=integer]/+page.svelte b/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonNumber=integer]/+page.svelte index 4a2b9b2..7743c3a 100644 --- a/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonNumber=integer]/+page.svelte +++ b/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonNumber=integer]/+page.svelte @@ -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" />
-
+

{show.overview}

+
+ + A list of all downloaded/downloading versions of this season. + + + Quality + File Path Suffix + Imported + + + + {#each seasonFiles as file} + + + {getTorrentQualityString(file.quality)} + + + {file.file_path_suffix} + + + + + + {:else } + You haven't downloaded this season yet. + {/each} + + +
diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonNumber=integer]/+page.ts b/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonNumber=integer]/+page.ts new file mode 100644 index 0000000..d1b1586 --- /dev/null +++ b/web/src/routes/dashboard/tv/[showId=uuid]/[SeasonNumber=integer]/+page.ts @@ -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: [] + }; + } +}; \ No newline at end of file