add ability to import unknown tv shows

This commit is contained in:
maxid
2025-12-07 17:38:37 +01:00
parent aca60cd9b7
commit 13b32e7104
6 changed files with 326 additions and 12 deletions

View File

@@ -341,6 +341,46 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/tv/importable': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get All Importable Shows
* @description get a list of unknown shows that were detected in the tv directory and are importable
*/
get: operations['get_all_importable_shows_api_v1_tv_importable_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/tv/importable/{show_id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Import Detected Show
* @description get a list of unknown shows that were detected in the tv directory and are importable
*/
post: operations['import_detected_show_api_v1_tv_importable__show_id__post'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/tv/shows/torrents': {
parameters: {
query?: never;
@@ -1625,6 +1665,16 @@ export interface components {
* @enum {integer}
*/
TorrentStatus: 1 | 2 | 3 | 4;
/** TvShowImportSuggestion */
TvShowImportSuggestion: {
/**
* Directory
* Format: path
*/
directory: string;
/** Candidates */
candidates: components['schemas']['MetaDataProviderSearchResult'][];
};
/** UpdateSeasonRequest */
UpdateSeasonRequest: {
min_quality: components['schemas']['Quality'];
@@ -2489,15 +2539,6 @@ export interface operations {
'application/json': components['schemas']['Show'];
};
};
/** @description Show already exists */
409: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': string;
};
};
/** @description Validation Error */
422: {
headers: {
@@ -2591,6 +2632,69 @@ export interface operations {
};
};
};
get_all_importable_shows_api_v1_tv_importable_get: {
parameters: {
query?: {
metadata_provider?: 'tmdb' | 'tvdb';
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['TvShowImportSuggestion'][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
import_detected_show_api_v1_tv_importable__show_id__post: {
parameters: {
query: {
directory: string;
};
header?: never;
path: {
/** @description The ID of the show */
show_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
get_shows_with_torrents_api_v1_tv_shows_torrents_get: {
parameters: {
query?: never;

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
let {
directory,
isTv,
children
}: {
directory: string;
isTv: boolean;
children: any;
} = $props();
</script>
<Card.Root class="col-span-full flex h-full flex-col overflow-x-hidden sm:col-span-1">
<Card.Header>
<Card.Title class="flex h-12 items-center leading-tight">
An importable {isTv ? 'TV show' : 'movie'} was detected!
</Card.Title>
<Card.Description>
The detected {isTv ? 'TV show' : 'movie'} is in this directory:
<code
class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold"
>
{directory}
</code>
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-1 items-center justify-center">
{@render children?.()}
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { toast } from 'svelte-sonner';
import client from '$lib/api';
import type { components } from '$lib/api/api';
import { Spinner } from '$lib/components/ui/spinner';
import SuggestedMediaCard from '$lib/components/import-media/suggested-media-card.svelte';
let {
isTv,
name,
candidates,
children
}: {
isTv: boolean;
name: string;
candidates: components['schemas']['MetaDataProviderSearchResult'][];
children: any;
} = $props();
let dialogOpen = $state(false);
let submitRequestError = $state<string | null>(null);
let isImporting = $state<boolean>(false);
async function handleImportMedia(media: components['schemas']['MetaDataProviderSearchResult']) {
isImporting = true;
submitRequestError = null;
let { data } = await client.POST('/api/v1/tv/shows', {
params: {
query: {
metadata_provider: media.metadata_provider as 'tmdb' | 'tvdb',
show_id: media.external_id
}
}
});
console.log('oida:', data);
let showId = data?.id ?? 'no_id';
const { error } = await client.POST('/api/v1/tv/importable/{show_id}', {
params: {
path: {
show_id: showId
},
query: {
directory: name
}
}
});
isImporting = false;
if (error) {
toast.error('Failed to import');
} else {
dialogOpen = false;
toast.success('Imported successfully!');
}
}
</script>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Trigger
class={buttonVariants({ variant: 'default' })}
onclick={() => {
dialogOpen = true;
}}
>
{@render children?.()}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] w-fit min-w-[80vw] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Import unknown {isTv ? 'show' : 'movie'} "{name}"</Dialog.Title>
<Dialog.Description
>Select the {isTv ? 'show' : 'movie'} that is in this directory to import it!
</Dialog.Description>
</Dialog.Header>
<div
class="grid w-full auto-rows-min gap-4 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-4"
>
{#if !isImporting}
{#each candidates as candidate (candidate.external_id)}
<SuggestedMediaCard result={candidate} action={() => handleImportMedia(candidate)}
></SuggestedMediaCard>
{:else}
No {isTv ? 'shows' : 'movies'} were found, change the directory's name for better search results!
{/each}
{:else}
<Spinner class="size-8"></Spinner>
{/if}
{#if submitRequestError}
<p class="col-span-full text-center text-sm text-red-500">{submitRequestError}</p>
{/if}
</div>
<Dialog.Footer>
<Button disabled={isImporting} onclick={() => (dialogOpen = false)} variant="outline"
>Cancel
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { ImageOff } from 'lucide-svelte';
import type { components } from '$lib/api/api';
let {
result,
action
}: {
result: components['schemas']['MetaDataProviderSearchResult'];
action: () => void;
} = $props();
</script>
<Card.Root class="col-span-full flex h-full flex-col overflow-x-hidden sm:col-span-1">
<Card.Header>
<Card.Title class="flex h-12 items-center leading-tight">
{result.name}
{#if result.year != null}
({result.year})
{/if}
</Card.Title>
<Card.Description class="truncate">
{result.overview !== '' ? result.overview : 'No overview available'}
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-1 items-center justify-center">
{#if result.poster_path != null}
<img
class="h-full w-full rounded-lg object-contain"
src={result.poster_path}
alt="{result.name}'s Poster Image"
/>
{:else}
<div class="flex h-full w-full items-center justify-center">
<ImageOff class="h-12 w-12 text-gray-400" />
</div>
{/if}
</Card.Content>
<Card.Footer class="flex flex-col items-start gap-2 rounded-b-lg border-t bg-card p-4">
<Button class="w-full font-semibold" onclick={() => action()}>
Import using this metadata
</Button>
<div class="flex w-full items-center gap-2">
{#if result.vote_average != null}
<span class="flex items-center text-sm font-medium text-yellow-600">
<svg class="mr-1 h-4 w-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"
><path
d="M10 15l-5.878 3.09 1.122-6.545L.488 6.91l6.561-.955L10 0l2.951 5.955 6.561.955-4.756 4.635 1.122 6.545z"
/></svg
>
Rating: {Math.round(result.vote_average)}/10
</span>
{/if}
</div>
</Card.Footer>
</Card.Root>