work on the frontend for the movies

This commit is contained in:
maxDorninger
2025-06-28 18:24:00 +02:00
parent 75c156e66d
commit bcc259dff2
19 changed files with 505 additions and 6 deletions

View File

@@ -1,5 +1,6 @@
import logging
import requests
import tmdbsimple
from pydantic_settings import BaseSettings
from tmdbsimple import TV, TV_Seasons
@@ -15,6 +16,7 @@ from media_manager.movies.schemas import Movie
class TmdbConfig(BaseSettings):
TMDB_RELAY_URL: str = "https://metadata-relay.maxid.me"
TMDB_API_KEY: str | None = None
@@ -31,6 +33,15 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
if config.TMDB_API_KEY is None:
raise InvalidConfigError("TMDB_API_KEY is not set")
tmdbsimple.API_KEY = config.TMDB_API_KEY
self.url = config.TMDB_RELAY_URL
def __get_tv_metadata(self, id: int) -> dict:
"""
Helper function to get TV metadata from TMDB.
:param id: The external ID of the TV show.
:return: A dictionary containing the TV show metadata.
"""
return requests.get(url=f"{self.url}/tv/shows/{id}").json()
def download_show_poster_image(self, show: Show) -> bool:
show_metadata = TV(show.external_id).info()

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, ConfigDict, model_validator
from media_manager.auth.schemas import UserRead
from media_manager.torrent.models import Quality
from media_manager.torrent.schemas import TorrentId, Torrent
from media_manager.torrent.schemas import TorrentId, Torrent, TorrentStatus
MovieId = typing.NewType("MovieId", UUID)
MovieRequestId = typing.NewType("MovieRequestId", UUID)
@@ -74,9 +74,19 @@ class RichMovieRequest(MovieRequest):
movie: Movie
class MovieTorrent(BaseModel):
model_config = ConfigDict(from_attributes=True)
torrent_id: TorrentId
torrent_title: str
status: TorrentStatus
quality: Quality
imported: bool
file_path_suffix: str
class RichMovieTorrent(BaseModel):
movie_id: MovieId
name: str
year: int | None
metadata_provider: str
torrents: list[Torrent]
torrents: list[MovieTorrent]

View File

@@ -29,7 +29,7 @@
<Table.Row>
<Table.Cell class="font-medium">
<a href={'/dashboard/torrents/' + torrent.torrent_id}>
{torrent.torrent_title}
{isShow ? torrent.torrent_title : torrent.title}
</a>
</Table.Cell>
{#if isShow}

View File

@@ -0,0 +1,51 @@
import {Menubar as MenubarPrimitive} from "bits-ui";
import Root from "./menubar.svelte";
import CheckboxItem from "./menubar-checkbox-item.svelte";
import Content from "./menubar-content.svelte";
import Item from "./menubar-item.svelte";
import GroupHeading from "./menubar-group-heading.svelte";
import RadioItem from "./menubar-radio-item.svelte";
import Separator from "./menubar-separator.svelte";
import Shortcut from "./menubar-shortcut.svelte";
import SubContent from "./menubar-sub-content.svelte";
import SubTrigger from "./menubar-sub-trigger.svelte";
import Trigger from "./menubar-trigger.svelte";
const Menu = MenubarPrimitive.Menu;
const Group = MenubarPrimitive.Group;
const Sub = MenubarPrimitive.Sub;
const RadioGroup = MenubarPrimitive.RadioGroup;
export {
Root,
CheckboxItem,
Content,
Item,
GroupHeading,
RadioItem,
Separator,
Shortcut,
SubContent,
SubTrigger,
Trigger,
Menu,
Group,
Sub,
RadioGroup,
//
Root as Menubar,
CheckboxItem as MenubarCheckboxItem,
Content as MenubarContent,
Item as MenubarItem,
GroupHeading as MenubarGroupHeading,
RadioItem as MenubarRadioItem,
Separator as MenubarSeparator,
Shortcut as MenubarShortcut,
SubContent as MenubarSubContent,
SubTrigger as MenubarSubTrigger,
Trigger as MenubarTrigger,
Menu as MenubarMenu,
Group as MenubarGroup,
Sub as MenubarSub,
RadioGroup as MenubarRadioGroup,
};

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import {Menubar as MenubarPrimitive, type WithoutChildrenOrChild} from "bits-ui";
import Check from "@lucide/svelte/icons/check";
import Minus from "@lucide/svelte/icons/minus";
import {cn} from "$lib/utils.js";
import type {Snippet} from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<MenubarPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<MenubarPrimitive.CheckboxItem
{...restProps}
bind:checked
bind:indeterminate
bind:ref
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
>
{#snippet children({checked, indeterminate})}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<Minus class="size-4"/>
{:else}
<Check class={cn("size-4", !checked && "text-transparent")}/>
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</MenubarPrimitive.CheckboxItem>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import {Menubar as MenubarPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 8,
alignOffset = -4,
align = "start",
side = "bottom",
portalProps,
...restProps
}: MenubarPrimitive.ContentProps & {
portalProps?: MenubarPrimitive.PortalProps;
} = $props();
</script>
<MenubarPrimitive.Portal {...portalProps}>
<MenubarPrimitive.Content
{...restProps}
{align}
{alignOffset}
bind:ref
class={cn(
"bg-popover text-popover-foreground z-50 min-w-[12rem] rounded-md border p-1 shadow-md focus:outline-none",
className
)}
{side}
{sideOffset}
/>
</MenubarPrimitive.Portal>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import {Menubar as MenubarPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: MenubarPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<MenubarPrimitive.GroupHeading
{...restProps}
bind:ref
class={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
/>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import {Menubar as MenubarPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: MenubarPrimitive.ItemProps & {
inset?: boolean;
} = $props();
</script>
<MenubarPrimitive.Item
{...restProps}
bind:ref
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
/>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import {Menubar as MenubarPrimitive, type WithoutChild} from "bits-ui";
import Circle from "@lucide/svelte/icons/circle";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<MenubarPrimitive.RadioItemProps> = $props();
</script>
<MenubarPrimitive.RadioItem
{...restProps}
bind:ref
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
>
{#snippet children({checked})}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<Circle class="size-2 fill-current"/>
{/if}
</span>
{@render childrenProp?.({checked})}
{/snippet}
</MenubarPrimitive.RadioItem>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import {Menubar as MenubarPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: MenubarPrimitive.SeparatorProps = $props();
</script>
<MenubarPrimitive.Separator
{...restProps}
bind:ref
class={cn("bg-muted -mx-1 my-1 h-px", className)}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type {HTMLAttributes} from "svelte/elements";
import type {WithElementRef} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
{...restProps}
bind:this={ref}
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import {Menubar as MenubarPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: MenubarPrimitive.SubContentProps = $props();
</script>
<MenubarPrimitive.SubContent
{...restProps}
bind:ref
class={cn(
"bg-popover text-popover-foreground z-50 min-w-max rounded-md border p-1 shadow-lg focus:outline-none",
className
)}
/>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import {Menubar as MenubarPrimitive} from "bits-ui";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: MenubarPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<MenubarPrimitive.SubTrigger
{...restProps}
bind:ref
class={cn(
"data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
>
{@render children?.()}
<ChevronRight class="ml-auto size-4"/>
</MenubarPrimitive.SubTrigger>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import {Menubar as MenubarPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: MenubarPrimitive.TriggerProps = $props();
</script>
<MenubarPrimitive.Trigger
{...restProps}
bind:ref
class={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none",
className
)}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import {Menubar as MenubarPrimitive} from "bits-ui";
import {cn} from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: MenubarPrimitive.RootProps = $props();
</script>
<MenubarPrimitive.Root
{...restProps}
bind:ref
class={cn(
"bg-background flex h-9 items-center space-x-1 rounded-md border p-1 shadow-sm",
className
)}
/>

View File

@@ -192,6 +192,14 @@ export interface RichShowTorrent {
torrents: RichSeasonTorrent[];
}
export interface RichMovieTorrent {
show_id: string;
name: string;
year: number | null;
metadata_provider: string;
torrents: Torrent[];
}
interface SeasonRequestBase {
min_quality: Quality;
wanted_quality: Quality;

View File

@@ -0,0 +1,145 @@
<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 {ChevronDown} from 'lucide-svelte';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import type {MetaDataProviderShowSearchResult} from '$lib/types.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import AddMediaCard from '$lib/components/add-media-card.svelte';
import {toast} from 'svelte-sonner';
import {onMount} from "svelte";
import * as Menubar from "$lib/components/ui/menubar/index.js";
const apiUrl = env.PUBLIC_API_URL;
let searchTerm: string = $state('');
let metadataProvider: string = $state('tmdb');
let results: MetaDataProviderShowSearchResult[] | null = $state(null);
onMount(search)
async function search() {
let url = new URL(apiUrl + '/movies/recommended');
if (searchTerm.length > 0) {
let url = new URL(apiUrl + '/movies/search');
url.searchParams.append('query', searchTerm);
url.searchParams.append('metadata_provider', metadataProvider);
toast.info(`Searching for "${searchTerm}" using ${metadataProvider.toUpperCase()}...`);
}
try {
const response = await fetch(url, {
method: 'GET',
credentials: 'include'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Search failed: ${response.status} ${errorText || response.statusText}`);
}
results = await response.json();
if (searchTerm.length === 0) {
return
}
if (results && results.length > 0) {
toast.success(`Found ${results.length} result(s) for "${searchTerm}".`);
} else {
toast.info(`No results found for "${searchTerm}".`);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'An unknown error occurred during search.';
console.error('Search error:', error);
toast.error(errorMessage);
results = null; // Clear previous results on error
}
}
</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/movies">Movies</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block"/>
<Breadcrumb.Item>
<Breadcrumb.Page>Add a Movie</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<div class="flex w-full max-w-[90vw] flex-1 flex-col items-center gap-4 p-4 pt-0">
<div class="grid w-full max-w-sm items-center gap-12">
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
Add a Movie
</h1>
<section>
<Label for="search-box">Movie Name</Label>
<Input bind:value={searchTerm} id="search-box" placeholder="Show Name" type="text"/>
<p class="text-sm text-muted-foreground">Search for a Movie to add.</p>
</section>
<section>
<Collapsible.Root class="w-full space-y-1">
<Collapsible.Trigger>
<div class="flex items-center justify-between space-x-4 px-4">
<h4 class="text-sm font-semibold">Advanced Settings</h4>
<Button class="w-9 p-0" size="sm" variant="ghost">
<ChevronDown/>
<span class="sr-only">Toggle</span>
</Button>
</div>
</Collapsible.Trigger>
<Collapsible.Content class="space-y-1">
<Label for="metadata-provider-selector">Choose which Metadata Provider to query.</Label>
<RadioGroup.Root bind:value={metadataProvider} id="metadata-provider-selector">
<div class="flex items-center space-x-2">
<RadioGroup.Item id="option-one" value="tmdb"/>
<Label for="option-one">TMDB (Recommended)</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item id="option-two" value="tvdb"/>
<Label for="option-two">TVDB</Label>
</div>
</RadioGroup.Root>
</Collapsible.Content>
</Collapsible.Root>
</section>
<section>
<Button onclick={search} type="submit">Search</Button>
</section>
</div>
<Separator class="my-8"/>
{#if results != null}
{#if results.length === 0}
<h3 class="mx-auto">No Shows found.</h3>
{:else}
<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"
>
{#each results as result}
<AddMediaCard result={result} isShow={false}/>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -13,6 +13,7 @@
import AddMediaCard from '$lib/components/add-media-card.svelte';
import {toast} from 'svelte-sonner';
import {onMount} from "svelte";
import * as Menubar from "$lib/components/ui/menubar/index.js";
const apiUrl = env.PUBLIC_API_URL;
let searchTerm: string = $state('');
@@ -86,7 +87,7 @@
<div class="flex w-full max-w-[90vw] flex-1 flex-col items-center gap-4 p-4 pt-0">
<div class="grid w-full max-w-sm items-center gap-12">
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
Add a show
Add a Show
</h1>
<section>
<Label for="search-box">Show Name</Label>
@@ -94,7 +95,7 @@
<p class="text-sm text-muted-foreground">Search for a Show to add.</p>
</section>
<section>
<Collapsible.Root class="w-[350px] space-y-2">
<Collapsible.Root class="w-full space-y-1">
<Collapsible.Trigger>
<div class="flex items-center justify-between space-x-4 px-4">
<h4 class="text-sm font-semibold">Advanced Settings</h4>
@@ -104,7 +105,7 @@
</Button>
</div>
</Collapsible.Trigger>
<Collapsible.Content class="space-y-2">
<Collapsible.Content class="space-y-1">
<Label for="metadata-provider-selector">Choose which Metadata Provider to query.</Label>
<RadioGroup.Root bind:value={metadataProvider} id="metadata-provider-selector">
<div class="flex items-center space-x-2">

8
web/web.iml Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>