mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-21 05:15:13 +02:00
Support for handling Single Episode Torrents (#331)
**Description** As explained on #322, MediaManager currently only matches torrents that represent full seasons or season packs. As a result, valid episode-based releases — commonly returned by indexers such as EZTV — are filtered out during scoring and never considered for download. Initial changes to the season parsing logic allow these torrents to be discovered. However, additional changes are required beyond season parsing to properly support single-episode imports. This PR is intended as a work-in-progress / RFC to discuss the required changes and align on the correct approach before completing the implementation. **Things planned to do** [X] Update Web UI to better display episode-level details [ ] Update TV show import logic to handle single episode files, instead of assuming full season files (to avoid integrity errors when episodes are missing) [ ] Create episode file tables to store episode-level data, similar to season files [ ] Implement fetching and downloading logic for single-episode torrents **Notes / current limitations** At the moment, the database and import logic assume one file per season per quality, which works for season packs but not for episode-based releases. These changes are intentionally not completed yet and are part of the discussion this PR aims to start. **Request for feedback** This represents a significant change in how TV content is handled in MediaManager. Before proceeding further, feedback from @maxdorninger on the overall direction and next steps would be greatly appreciated. Once aligned, the remaining tasks can be implemented incrementally. --------- Co-authored-by: Maximilian Dorninger <97409287+maxdorninger@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
75
web/src/lib/api/api.d.ts
vendored
75
web/src/lib/api/api.d.ts
vendored
@@ -626,10 +626,10 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Season Files
|
||||
* @description Get files associated with a specific season.
|
||||
* Get Episode Files
|
||||
* @description Get episode files associated with a specific season.
|
||||
*/
|
||||
get: operations['get_season_files_api_v1_tv_seasons__season_id__files_get'];
|
||||
get: operations['get_episode_files_api_v1_tv_seasons__season_id__files_get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -1316,6 +1316,8 @@ export interface components {
|
||||
external_id: number;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Overview */
|
||||
overview?: string | null;
|
||||
};
|
||||
/** ErrorModel */
|
||||
ErrorModel: {
|
||||
@@ -1360,6 +1362,8 @@ export interface components {
|
||||
readonly quality: components['schemas']['Quality'];
|
||||
/** Season */
|
||||
readonly season: number[];
|
||||
/** Episode */
|
||||
readonly episode: number[];
|
||||
};
|
||||
/** LibraryItem */
|
||||
LibraryItem: {
|
||||
@@ -1504,6 +1508,45 @@ export interface components {
|
||||
/** Authorization Url */
|
||||
authorization_url: string;
|
||||
};
|
||||
/** PublicEpisode */
|
||||
PublicEpisode: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Number */
|
||||
number: number;
|
||||
/**
|
||||
* Downloaded
|
||||
* @default false
|
||||
*/
|
||||
downloaded: boolean;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Overview */
|
||||
overview?: string | null;
|
||||
/** External Id */
|
||||
external_id: number;
|
||||
};
|
||||
/** PublicEpisodeFile */
|
||||
PublicEpisodeFile: {
|
||||
/**
|
||||
* Episode Id
|
||||
* Format: uuid
|
||||
*/
|
||||
episode_id: string;
|
||||
quality: components['schemas']['Quality'];
|
||||
/** Torrent Id */
|
||||
torrent_id: string | null;
|
||||
/** File Path Suffix */
|
||||
file_path_suffix: string;
|
||||
/**
|
||||
* Downloaded
|
||||
* @default false
|
||||
*/
|
||||
downloaded: boolean;
|
||||
};
|
||||
/** PublicMovie */
|
||||
PublicMovie: {
|
||||
/**
|
||||
@@ -1580,25 +1623,7 @@ export interface components {
|
||||
/** External Id */
|
||||
external_id: number;
|
||||
/** Episodes */
|
||||
episodes: components['schemas']['Episode'][];
|
||||
};
|
||||
/** PublicSeasonFile */
|
||||
PublicSeasonFile: {
|
||||
/**
|
||||
* Season Id
|
||||
* Format: uuid
|
||||
*/
|
||||
season_id: string;
|
||||
quality: components['schemas']['Quality'];
|
||||
/** Torrent Id */
|
||||
torrent_id: string | null;
|
||||
/** File Path Suffix */
|
||||
file_path_suffix: string;
|
||||
/**
|
||||
* Downloaded
|
||||
* @default false
|
||||
*/
|
||||
downloaded: boolean;
|
||||
episodes: components['schemas']['PublicEpisode'][];
|
||||
};
|
||||
/** PublicShow */
|
||||
PublicShow: {
|
||||
@@ -1719,6 +1744,8 @@ export interface components {
|
||||
file_path_suffix: string;
|
||||
/** Seasons */
|
||||
seasons: number[];
|
||||
/** Episodes */
|
||||
episodes: number[];
|
||||
};
|
||||
/** RichShowTorrent */
|
||||
RichShowTorrent: {
|
||||
@@ -3232,7 +3259,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_season_files_api_v1_tv_seasons__season_id__files_get: {
|
||||
get_episode_files_api_v1_tv_seasons__season_id__files_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@@ -3250,7 +3277,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PublicSeasonFile'][];
|
||||
'application/json': components['schemas']['PublicEpisodeFile'][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatSecondsToOptimalUnit } from '$lib/utils.ts';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import client from '$lib/api';
|
||||
import type { components } from '$lib/api/api';
|
||||
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
|
||||
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
|
||||
import { getFullyQualifiedMediaName } from '$lib/utils';
|
||||
|
||||
let { show }: { show: components['schemas']['Show'] } = $props();
|
||||
|
||||
let dialogueState = $state(false);
|
||||
let torrentsError: string | null = $state(null);
|
||||
let queryOverride: string = $state('');
|
||||
let filePathSuffix: string = $state('');
|
||||
|
||||
let torrentsPromise: any = $state();
|
||||
let torrentsData: any[] | null = $state(null);
|
||||
let isLoading: boolean = $state(false);
|
||||
|
||||
const tableColumnHeadings = [
|
||||
{ name: 'Size', id: 'size' },
|
||||
{ name: 'Usenet', id: 'usenet' },
|
||||
{ name: 'Seeders', id: 'seeders' },
|
||||
{ name: 'Age', id: 'age' },
|
||||
{ name: 'Score', id: 'score' },
|
||||
{ name: 'Indexer', id: 'indexer' },
|
||||
{ name: 'Indexer Flags', id: 'flags' },
|
||||
{ name: 'Seasons', id: 'season' }
|
||||
];
|
||||
|
||||
async function downloadTorrent(result_id: string) {
|
||||
torrentsError = null;
|
||||
|
||||
const { response } = await client.POST('/api/v1/tv/torrents', {
|
||||
params: {
|
||||
query: {
|
||||
show_id: show.id!,
|
||||
public_indexer_result_id: result_id,
|
||||
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 409) {
|
||||
const errorMessage = `There already is a File using the Filepath Suffix '${filePathSuffix}'. Try again with a different Filepath Suffix.`;
|
||||
console.warn(errorMessage);
|
||||
torrentsError = errorMessage;
|
||||
if (dialogueState) toast.info(errorMessage);
|
||||
} else if (!response.ok) {
|
||||
const errorMessage = `Failed to download torrent for show ${show.id}: ${response.statusText}`;
|
||||
console.error(errorMessage);
|
||||
torrentsError = errorMessage;
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
toast.success('Torrent download started successfully!');
|
||||
}
|
||||
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!queryOverride || queryOverride.trim() === '') {
|
||||
toast.error('Please enter a custom query.');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
torrentsError = null;
|
||||
torrentsData = null;
|
||||
|
||||
torrentsPromise = client
|
||||
.GET('/api/v1/tv/torrents', {
|
||||
params: {
|
||||
query: {
|
||||
show_id: show.id!,
|
||||
search_query_override: queryOverride
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((data) => data?.data)
|
||||
.finally(() => (isLoading = false));
|
||||
|
||||
toast.info('Searching for torrents...');
|
||||
|
||||
torrentsData = await torrentsPromise;
|
||||
|
||||
if (!torrentsData || torrentsData.length === 0) {
|
||||
toast.info('No torrents found.');
|
||||
} else {
|
||||
toast.success(`Found ${torrentsData.length} torrents.`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DownloadDialogWrapper
|
||||
bind:open={dialogueState}
|
||||
triggerText="Custom Download"
|
||||
title="Custom Torrent Download"
|
||||
description="Search and download torrents using a fully custom query string."
|
||||
>
|
||||
<div class="grid w-full items-center gap-1.5">
|
||||
<Label for="query-override">Enter a custom query</Label>
|
||||
|
||||
<div class="flex w-full max-w-sm items-center space-x-2">
|
||||
<Input
|
||||
bind:value={queryOverride}
|
||||
id="query-override"
|
||||
type="text"
|
||||
placeholder={`e.g. ${getFullyQualifiedMediaName(show)} S01 1080p BluRay`}
|
||||
/>
|
||||
<Button disabled={isLoading} class="w-fit" onclick={search}>Search</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground">
|
||||
The custom query completely overrides the default search logic. Make sure the torrent title
|
||||
matches the episodes you want imported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if torrentsError}
|
||||
<div class="my-2 w-full text-center text-red-500">
|
||||
An error occurred: {torrentsError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
|
||||
{#snippet rowSnippet(torrent)}
|
||||
<Table.Cell class="font-medium">{torrent.title}</Table.Cell>
|
||||
<Table.Cell>{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB</Table.Cell>
|
||||
<Table.Cell>{torrent.usenet}</Table.Cell>
|
||||
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{torrent.score}</Table.Cell>
|
||||
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if torrent.flags}
|
||||
{#each torrent.flags as flag (flag)}
|
||||
<Badge variant="outline">{flag}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{torrent.season ?? '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<SelectFilePathSuffixDialog
|
||||
bind:filePathSuffix
|
||||
media={show}
|
||||
callback={() => downloadTorrent(torrent.id)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</TorrentTable>
|
||||
</DownloadDialogWrapper>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatSecondsToOptimalUnit } from '$lib/utils';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import client from '$lib/api';
|
||||
import type { components } from '$lib/api/api';
|
||||
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
|
||||
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
|
||||
|
||||
let {
|
||||
show,
|
||||
selectedEpisodeNumbers,
|
||||
triggerText = 'Download Episodes'
|
||||
}: {
|
||||
show: components['schemas']['Show'];
|
||||
selectedEpisodeNumbers: { seasonNumber: number; episodeNumber: number }[];
|
||||
triggerText?: string;
|
||||
} = $props();
|
||||
|
||||
let dialogueState = $state(false);
|
||||
let torrentsPromise: any = $state();
|
||||
let torrentsError: string | null = $state(null);
|
||||
let isLoading: boolean = $state(false);
|
||||
let filePathSuffix: string = $state('');
|
||||
|
||||
const tableColumnHeadings = [
|
||||
{ name: 'Size', id: 'size' },
|
||||
{ name: 'Usenet', id: 'usenet' },
|
||||
{ name: 'Seeders', id: 'seeders' },
|
||||
{ name: 'Age', id: 'age' },
|
||||
{ name: 'Score', id: 'score' },
|
||||
{ name: 'Indexer', id: 'indexer' },
|
||||
{ name: 'Indexer Flags', id: 'flags' }
|
||||
];
|
||||
|
||||
function torrentMatchesSelectedEpisodes(
|
||||
torrentTitle: string,
|
||||
selectedEpisodes: { seasonNumber: number; episodeNumber: number }[]
|
||||
) {
|
||||
const normalizedTitle = torrentTitle.toLowerCase();
|
||||
|
||||
return selectedEpisodes.some((ep) => {
|
||||
const s = String(ep.seasonNumber).padStart(2, '0');
|
||||
const e = String(ep.episodeNumber).padStart(2, '0');
|
||||
|
||||
const patterns = [
|
||||
`s${s}e${e}`,
|
||||
`${s}x${e}`,
|
||||
`season ${ep.seasonNumber} episode ${ep.episodeNumber}`
|
||||
];
|
||||
|
||||
return patterns.some((pattern) => normalizedTitle.includes(pattern));
|
||||
});
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!selectedEpisodeNumbers || selectedEpisodeNumbers.length === 0) {
|
||||
toast.error('No episodes selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
torrentsError = null;
|
||||
|
||||
torrentsPromise = Promise.all(
|
||||
selectedEpisodeNumbers.map((ep) =>
|
||||
client
|
||||
.GET('/api/v1/tv/torrents', {
|
||||
params: {
|
||||
query: {
|
||||
show_id: show.id!,
|
||||
season_number: ep.seasonNumber,
|
||||
episode_number: ep.episodeNumber
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r?.data ?? [])
|
||||
)
|
||||
)
|
||||
.then((results) => results.flat())
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.then((allTorrents: any[]) =>
|
||||
allTorrents.filter((torrent) =>
|
||||
torrentMatchesSelectedEpisodes(torrent.title, selectedEpisodeNumbers)
|
||||
)
|
||||
)
|
||||
.finally(() => (isLoading = false));
|
||||
|
||||
try {
|
||||
await torrentsPromise;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
torrentsError = error.message || 'An error occurred while searching for torrents.';
|
||||
toast.error(torrentsError);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTorrent(result_id: string) {
|
||||
const { response } = await client.POST('/api/v1/tv/torrents', {
|
||||
params: {
|
||||
query: {
|
||||
show_id: show.id!,
|
||||
public_indexer_result_id: result_id,
|
||||
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error('Download failed.');
|
||||
} else {
|
||||
toast.success('Download started.');
|
||||
}
|
||||
|
||||
await invalidateAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DownloadDialogWrapper
|
||||
bind:open={dialogueState}
|
||||
{triggerText}
|
||||
title="Download Selected Episodes"
|
||||
description="Search and download torrents for selected episodes."
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Selected episodes:
|
||||
<strong>
|
||||
{selectedEpisodeNumbers.length > 0
|
||||
? selectedEpisodeNumbers
|
||||
.map(
|
||||
(e) =>
|
||||
`S${String(e.seasonNumber).padStart(2, '0')}E${String(e.episodeNumber).padStart(2, '0')}`
|
||||
)
|
||||
.join(', ')
|
||||
: 'None'}
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
class="w-fit"
|
||||
disabled={isLoading || selectedEpisodeNumbers.length === 0}
|
||||
onclick={search}
|
||||
>
|
||||
Search Torrents
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if torrentsError}
|
||||
<div class="my-2 w-full text-center text-red-500">
|
||||
An error occurred: {torrentsError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
|
||||
{#snippet rowSnippet(torrent)}
|
||||
<Table.Cell>{torrent.title}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB
|
||||
</Table.Cell>
|
||||
<Table.Cell>{torrent.usenet}</Table.Cell>
|
||||
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{torrent.score}</Table.Cell>
|
||||
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if torrent.flags}
|
||||
{#each torrent.flags as flag (flag)}
|
||||
<Badge variant="outline">{flag}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<SelectFilePathSuffixDialog
|
||||
bind:filePathSuffix
|
||||
media={show}
|
||||
callback={() => downloadTorrent(torrent.id)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</TorrentTable>
|
||||
</DownloadDialogWrapper>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatSecondsToOptimalUnit } from '$lib/utils.ts';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import client from '$lib/api';
|
||||
import type { components } from '$lib/api/api';
|
||||
import SelectFilePathSuffixDialog from '$lib/components/download-dialogs/select-file-path-suffix-dialog.svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import TorrentTable from '$lib/components/download-dialogs/torrent-table.svelte';
|
||||
import DownloadDialogWrapper from '$lib/components/download-dialogs/download-dialog-wrapper.svelte';
|
||||
|
||||
let {
|
||||
show,
|
||||
selectedSeasonNumbers,
|
||||
triggerText = 'Download Selected Seasons'
|
||||
}: {
|
||||
show: components['schemas']['Show'];
|
||||
selectedSeasonNumbers: number[];
|
||||
triggerText?: string;
|
||||
} = $props();
|
||||
|
||||
let dialogueState = $state(false);
|
||||
let torrentsError: string | null = $state(null);
|
||||
let filePathSuffix: string = $state('');
|
||||
let torrentsPromise: any = $state();
|
||||
let isLoading: boolean = $state(false);
|
||||
|
||||
const tableColumnHeadings = [
|
||||
{ name: 'Size', id: 'size' },
|
||||
{ name: 'Usenet', id: 'usenet' },
|
||||
{ name: 'Seeders', id: 'seeders' },
|
||||
{ name: 'Age', id: 'age' },
|
||||
{ name: 'Score', id: 'score' },
|
||||
{ name: 'Indexer', id: 'indexer' },
|
||||
{ name: 'Indexer Flags', id: 'flags' },
|
||||
{ name: 'Seasons', id: 'season' }
|
||||
];
|
||||
|
||||
async function downloadTorrent(result_id: string) {
|
||||
torrentsError = null;
|
||||
|
||||
const { response } = await client.POST('/api/v1/tv/torrents', {
|
||||
params: {
|
||||
query: {
|
||||
show_id: show.id!,
|
||||
public_indexer_result_id: result_id,
|
||||
override_file_path_suffix: filePathSuffix === '' ? undefined : filePathSuffix
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 409) {
|
||||
const errorMessage = `Filepath Suffix '${filePathSuffix}' already exists.`;
|
||||
torrentsError = errorMessage;
|
||||
toast.error(errorMessage);
|
||||
} else if (!response.ok) {
|
||||
const errorMessage = `Failed to download torrent: ${response.statusText}`;
|
||||
torrentsError = errorMessage;
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
toast.success('Torrent download started successfully!');
|
||||
}
|
||||
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
function isEpisodeRelease(title: string) {
|
||||
const lower = title.toLowerCase();
|
||||
|
||||
const episodePatterns = [
|
||||
/s\d{1,2}e\d{1,2}/i,
|
||||
/\d{1,2}x\d{1,2}/i,
|
||||
/\be\d{1,2}\b/i,
|
||||
/e\d{1,2}-e?\d{1,2}/i,
|
||||
/vol\.?\s?\d+/i
|
||||
];
|
||||
|
||||
return episodePatterns.some((regex) => regex.test(lower));
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!selectedSeasonNumbers || selectedSeasonNumbers.length === 0) {
|
||||
toast.error('No seasons selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
torrentsError = null;
|
||||
|
||||
toast.info(`Searching torrents for seasons: ${selectedSeasonNumbers.join(', ')}`);
|
||||
|
||||
torrentsPromise = Promise.all(
|
||||
selectedSeasonNumbers.map((seasonNumber) =>
|
||||
client
|
||||
.GET('/api/v1/tv/torrents', {
|
||||
params: {
|
||||
query: {
|
||||
show_id: show.id!,
|
||||
season_number: seasonNumber
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((data) => data?.data ?? [])
|
||||
)
|
||||
)
|
||||
.then((results) => results.flat())
|
||||
.then((allTorrents) => allTorrents.filter((torrent) => !isEpisodeRelease(torrent.title)))
|
||||
.finally(() => (isLoading = false));
|
||||
|
||||
try {
|
||||
await torrentsPromise;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
torrentsError = error.message || 'An error occurred while searching for torrents.';
|
||||
toast.error(torrentsError);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DownloadDialogWrapper
|
||||
bind:open={dialogueState}
|
||||
{triggerText}
|
||||
title="Download Selected Seasons"
|
||||
description="Search and download torrents for the selected seasons."
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Selected seasons:
|
||||
<strong>
|
||||
{selectedSeasonNumbers.length > 0
|
||||
? selectedSeasonNumbers
|
||||
.slice()
|
||||
.sort((a, b) => a - b)
|
||||
.map((n) => `S${String(n).padStart(2, '0')}`)
|
||||
.join(', ')
|
||||
: 'None'}
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
class="w-fit"
|
||||
disabled={isLoading || selectedSeasonNumbers.length === 0}
|
||||
onclick={search}
|
||||
>
|
||||
Search Torrents
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if torrentsError}
|
||||
<div class="my-2 w-full text-center text-red-500">
|
||||
An error occurred: {torrentsError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<TorrentTable {torrentsPromise} columns={tableColumnHeadings}>
|
||||
{#snippet rowSnippet(torrent)}
|
||||
<Table.Cell class="font-medium">{torrent.title}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{(torrent.size / 1024 / 1024 / 1024).toFixed(2)}GB
|
||||
</Table.Cell>
|
||||
<Table.Cell>{torrent.usenet}</Table.Cell>
|
||||
<Table.Cell>{torrent.usenet ? 'N/A' : torrent.seeders}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{torrent.age ? formatSecondsToOptimalUnit(torrent.age) : torrent.usenet ? 'N/A' : ''}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{torrent.score}</Table.Cell>
|
||||
<Table.Cell>{torrent.indexer ?? 'unknown'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if torrent.flags}
|
||||
{#each torrent.flags as flag (flag)}
|
||||
<Badge variant="outline">{flag}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{torrent.season ?? '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<SelectFilePathSuffixDialog
|
||||
bind:filePathSuffix
|
||||
media={show}
|
||||
callback={() => downloadTorrent(torrent.id)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</TorrentTable>
|
||||
</DownloadDialogWrapper>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
convertTorrentSeasonRangeToIntegerRange,
|
||||
convertTorrentEpisodeRangeToIntegerRange,
|
||||
getTorrentQualityString,
|
||||
getTorrentStatusString
|
||||
} from '$lib/utils.js';
|
||||
@@ -59,6 +60,7 @@
|
||||
<Table.Head>Name</Table.Head>
|
||||
{#if isShow}
|
||||
<Table.Head>Seasons</Table.Head>
|
||||
<Table.Head>Episodes</Table.Head>
|
||||
{/if}
|
||||
<Table.Head>Download Status</Table.Head>
|
||||
<Table.Head>Quality</Table.Head>
|
||||
@@ -97,6 +99,11 @@
|
||||
(torrent as components['schemas']['RichSeasonTorrent']).seasons!
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{convertTorrentEpisodeRangeToIntegerRange(
|
||||
(torrent as components['schemas']['RichSeasonTorrent']).episodes!
|
||||
)}
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell>
|
||||
{getTorrentStatusString(torrent.status)}
|
||||
|
||||
@@ -51,6 +51,17 @@ export function convertTorrentSeasonRangeToIntegerRange(seasons: number[]): stri
|
||||
}
|
||||
}
|
||||
|
||||
export function convertTorrentEpisodeRangeToIntegerRange(episodes: number[]): string {
|
||||
if (episodes.length === 1) return episodes[0]?.toString() || 'unknown';
|
||||
else if (episodes.length > 1) {
|
||||
const lastEpisode = episodes.at(-1);
|
||||
return episodes[0]?.toString() + '-' + (lastEpisode?.toString() || 'unknown');
|
||||
} else {
|
||||
console.log('Error parsing episode range: ' + episodes);
|
||||
return 'Error parsing episode range: ' + episodes;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLogout() {
|
||||
await client.POST('/api/v1/auth/cookie/logout');
|
||||
await goto(resolve('/login', {}));
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<Card.Title>Overview</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="leading-7 not-first:mt-6">
|
||||
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
|
||||
{movie.overview}
|
||||
</p>
|
||||
</Card.Content>
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ImageOff } from 'lucide-svelte';
|
||||
import { Ellipsis } from 'lucide-svelte';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { getContext } from 'svelte';
|
||||
import type { components } from '$lib/api/api';
|
||||
import { getFullyQualifiedMediaName } from '$lib/utils';
|
||||
import DownloadSeasonDialog from '$lib/components/download-dialogs/download-season-dialog.svelte';
|
||||
import DownloadSelectedSeasonsDialog from '$lib/components/download-dialogs/download-selected-seasons-dialog.svelte';
|
||||
import DownloadSelectedEpisodesDialog from '$lib/components/download-dialogs/download-selected-episodes-dialog.svelte';
|
||||
import DownloadCustomDialog from '$lib/components/download-dialogs/download-custom-dialog.svelte';
|
||||
import CheckmarkX from '$lib/components/checkmark-x.svelte';
|
||||
import { page } from '$app/state';
|
||||
import TorrentTable from '$lib/components/torrents/torrent-table.svelte';
|
||||
@@ -22,11 +25,85 @@
|
||||
import DeleteMediaDialog from '$lib/components/delete-media-dialog.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import client from '$lib/api';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let show: components['schemas']['PublicShow'] = $derived(page.data.showData);
|
||||
let torrents: components['schemas']['RichShowTorrent'] = $derived(page.data.torrentsData);
|
||||
let user: () => components['schemas']['UserRead'] = getContext('user');
|
||||
|
||||
let expandedSeasons = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleSeason(seasonId: string) {
|
||||
if (expandedSeasons.has(seasonId)) {
|
||||
expandedSeasons.delete(seasonId);
|
||||
} else {
|
||||
expandedSeasons.add(seasonId);
|
||||
}
|
||||
expandedSeasons = new SvelteSet(expandedSeasons);
|
||||
}
|
||||
|
||||
let selectedSeasons = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleSeasonSelection(seasonId: string) {
|
||||
if (selectedSeasons.has(seasonId)) {
|
||||
selectedSeasons.delete(seasonId);
|
||||
} else {
|
||||
selectedSeasons.add(seasonId);
|
||||
}
|
||||
selectedSeasons = new SvelteSet(selectedSeasons);
|
||||
}
|
||||
|
||||
let selectedSeasonNumbers = $derived(
|
||||
show.seasons.filter((s) => selectedSeasons.has(s.id)).map((s) => s.number)
|
||||
);
|
||||
|
||||
let downloadButtonLabel = $derived(
|
||||
selectedSeasonNumbers.length === 0
|
||||
? 'Download Seasons'
|
||||
: `Download Season(s) ${selectedSeasonNumbers
|
||||
.slice()
|
||||
.sort((a, b) => a - b)
|
||||
.map((n) => `S${String(n).padStart(2, '0')}`)
|
||||
.join(', ')}`
|
||||
);
|
||||
|
||||
let selectedEpisodes = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleEpisodeSelection(episodeId: string) {
|
||||
if (selectedEpisodes.has(episodeId)) {
|
||||
selectedEpisodes.delete(episodeId);
|
||||
} else {
|
||||
selectedEpisodes.add(episodeId);
|
||||
}
|
||||
selectedEpisodes = new SvelteSet(selectedEpisodes);
|
||||
}
|
||||
|
||||
let selectedEpisodeNumbers = $derived(
|
||||
show.seasons.flatMap((season) =>
|
||||
season.episodes
|
||||
.filter((ep) => selectedEpisodes.has(ep.id))
|
||||
.map((ep) => ({
|
||||
seasonNumber: season.number,
|
||||
episodeNumber: ep.number
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
let episodeDownloadLabel = $derived(
|
||||
selectedEpisodeNumbers.length === 0
|
||||
? 'Download Episodes'
|
||||
: `Download Episode(s) ${selectedEpisodeNumbers
|
||||
.map(
|
||||
(e) =>
|
||||
`S${String(e.seasonNumber).padStart(2, '0')}E${String(e.episodeNumber).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}`
|
||||
)
|
||||
.join(', ')}`
|
||||
);
|
||||
|
||||
let continuousDownloadEnabled = $derived(show.continuous_download);
|
||||
|
||||
async function toggle_continuous_download() {
|
||||
@@ -109,7 +186,7 @@
|
||||
<Card.Title>Overview</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="leading-7 not-first:mt-6">
|
||||
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
|
||||
{show.overview}
|
||||
</p>
|
||||
</Card.Content>
|
||||
@@ -146,7 +223,23 @@
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col items-center gap-4">
|
||||
{#if user().is_superuser}
|
||||
<DownloadSeasonDialog {show} />
|
||||
{#if selectedSeasonNumbers.length > 0}
|
||||
<DownloadSelectedSeasonsDialog
|
||||
{show}
|
||||
{selectedSeasonNumbers}
|
||||
triggerText={downloadButtonLabel}
|
||||
/>
|
||||
{/if}
|
||||
{#if selectedEpisodeNumbers.length > 0}
|
||||
<DownloadSelectedEpisodesDialog
|
||||
{show}
|
||||
{selectedEpisodeNumbers}
|
||||
triggerText={episodeDownloadLabel}
|
||||
/>
|
||||
{/if}
|
||||
{#if selectedSeasonNumbers.length === 0 && selectedEpisodeNumbers.length === 0}
|
||||
<DownloadCustomDialog {show} />
|
||||
{/if}
|
||||
{/if}
|
||||
<RequestSeasonDialog {show} />
|
||||
</Card.Content>
|
||||
@@ -162,35 +255,87 @@
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="w-full overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Root class="w-full table-fixed">
|
||||
<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 class="w-[40px]"></Table.Head>
|
||||
<Table.Head class="w-[80px]">Number</Table.Head>
|
||||
<Table.Head class="w-[100px]">Exists on file</Table.Head>
|
||||
<Table.Head class="w-[240px]">Title</Table.Head>
|
||||
<Table.Head>Overview</Table.Head>
|
||||
<Table.Head class="w-[64px] text-center">Details</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if show.seasons.length > 0}
|
||||
{#each show.seasons as season (season.id)}
|
||||
<Table.Row
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve('/dashboard/tv/[showId]/[seasonId]', {
|
||||
showId: show.id,
|
||||
seasonId: season.id
|
||||
})
|
||||
)}
|
||||
class={`group cursor-pointer transition-colors hover:bg-muted/60 ${
|
||||
expandedSeasons.has(season.id) ? 'bg-muted/50' : 'bg-muted/10'
|
||||
}`}
|
||||
onclick={() => toggleSeason(season.id)}
|
||||
>
|
||||
<Table.Cell class="min-w-[10px] font-medium">{season.number}</Table.Cell>
|
||||
<Table.Cell class="w-[40px]">
|
||||
<Checkbox
|
||||
checked={selectedSeasons.has(season.id)}
|
||||
onCheckedChange={() => toggleSeasonSelection(season.id)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[10px] font-medium">
|
||||
S{String(season.number).padStart(2, '0')}
|
||||
</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.Cell class="w-[64px] text-center">
|
||||
<button
|
||||
class="inline-flex cursor-pointer items-center
|
||||
justify-center
|
||||
rounded-md p-1
|
||||
transition-colors
|
||||
hover:bg-muted/95
|
||||
focus-visible:ring-2
|
||||
focus-visible:ring-ring focus-visible:outline-none"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
goto(
|
||||
resolve('/dashboard/tv/[showId]/[seasonId]', {
|
||||
showId: show.id,
|
||||
seasonId: season.id
|
||||
})
|
||||
);
|
||||
}}
|
||||
aria-label="Season details"
|
||||
>
|
||||
<Ellipsis size={16} class="text-muted-foreground" />
|
||||
</button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{#if expandedSeasons.has(season.id)}
|
||||
{#each season.episodes as episode (episode.id)}
|
||||
<Table.Row class="bg-muted/20">
|
||||
<Table.Cell class="w-[40px]">
|
||||
<Checkbox
|
||||
checked={selectedEpisodes.has(episode.id)}
|
||||
onCheckedChange={() => toggleEpisodeSelection(episode.id)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[10px] font-medium">
|
||||
E{String(episode.number).padStart(2, '0')}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[10px] font-medium">
|
||||
<CheckmarkX state={episode.downloaded} />
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[50px]">{episode.title}</Table.Cell>
|
||||
<Table.Cell colspan={2} class="truncate">{episode.overview}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
|
||||
@@ -11,9 +11,15 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let seasonFiles: components['schemas']['PublicSeasonFile'][] = $derived(page.data.files);
|
||||
let episodeFiles: components['schemas']['PublicEpisodeFile'][] = $derived(page.data.files);
|
||||
let season: components['schemas']['Season'] = $derived(page.data.season);
|
||||
let show: components['schemas']['Show'] = $derived(page.data.showData);
|
||||
|
||||
let episodeById = $derived(
|
||||
Object.fromEntries(
|
||||
season.episodes.map((ep) => [ep.id, `E${String(ep.number).padStart(2, '0')}`])
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -59,7 +65,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
{getFullyQualifiedMediaName(show)} Season {season.number}
|
||||
{getFullyQualifiedMediaName(show)} - Season {season.number}
|
||||
</h1>
|
||||
<main class="mx-auto flex w-full flex-1 flex-col gap-4 p-4 md:max-w-[80em]">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-stretch">
|
||||
@@ -68,13 +74,20 @@
|
||||
</div>
|
||||
<div class="h-full w-full flex-auto rounded-xl md:w-1/4">
|
||||
<Card.Root class="h-full w-full">
|
||||
<Card.Header>
|
||||
<Card.Title>Overview</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="leading-7 not-first:mt-6">
|
||||
{show.overview}
|
||||
</p>
|
||||
<Card.Content class="flex flex-col gap-6">
|
||||
<div>
|
||||
<Card.Title class="mb-2 text-base">Series Overview</Card.Title>
|
||||
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
|
||||
{show.overview}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-border"></div>
|
||||
<div>
|
||||
<Card.Title class="mb-2 text-base">Season Overview</Card.Title>
|
||||
<p class="text-justify text-sm leading-6 hyphens-auto text-muted-foreground">
|
||||
{season.overview}
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -95,14 +108,18 @@
|
||||
>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Episode</Table.Head>
|
||||
<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 (file)}
|
||||
{#each episodeFiles as file (file)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-[50px]">
|
||||
{episodeById[file.episode_id] ?? 'E??'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-[50px]">
|
||||
{getTorrentQualityString(file.quality)}
|
||||
</Table.Cell>
|
||||
@@ -114,7 +131,11 @@
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<span class="font-semibold">You haven't downloaded this season yet.</span>
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="text-center py-6 font-semibold">
|
||||
You haven't downloaded episodes of this season yet.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
@@ -132,19 +153,23 @@
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="w-full overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Root class="w-full table-fixed">
|
||||
<Table.Caption>A list of all episodes.</Table.Caption>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[100px]">Number</Table.Head>
|
||||
<Table.Head class="min-w-[50px]">Title</Table.Head>
|
||||
<Table.Head class="w-[80px]">Number</Table.Head>
|
||||
<Table.Head class="w-[240px]">Title</Table.Head>
|
||||
<Table.Head>Overview</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each season.episodes as episode (episode.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-[100px] font-medium">{episode.number}</Table.Cell>
|
||||
<Table.Cell class="w-[100px] font-medium"
|
||||
>E{String(episode.number).padStart(2, '0')}</Table.Cell
|
||||
>
|
||||
<Table.Cell class="min-w-[50px]">{episode.title}</Table.Cell>
|
||||
<Table.Cell class="truncate">{episode.overview}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
const seasonFiles = client.GET('/api/v1/tv/seasons/{season_id}/files', {
|
||||
const episodeFiles = client.GET('/api/v1/tv/seasons/{season_id}/files', {
|
||||
fetch: fetch,
|
||||
params: {
|
||||
path: {
|
||||
@@ -19,7 +19,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
}
|
||||
});
|
||||
return {
|
||||
files: await seasonFiles.then((x) => x.data),
|
||||
files: await episodeFiles.then((x) => x.data),
|
||||
season: await season.then((x) => x.data)
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user