add retry and delete buttons to torrents table

This commit is contained in:
maxDorninger
2025-10-29 14:22:22 +01:00
parent 30b710e618
commit 39c88a0519
9 changed files with 399 additions and 72 deletions

View File

@@ -2,18 +2,13 @@ from fastapi import APIRouter
from fastapi import status
from fastapi.params import Depends
from media_manager.auth.users import current_active_user
from media_manager.auth.users import current_active_user, current_superuser
from media_manager.torrent.dependencies import torrent_service_dep, torrent_dep
from media_manager.torrent.schemas import Torrent
router = APIRouter()
@router.get("/{torrent_id}", status_code=status.HTTP_200_OK, response_model=Torrent)
def get_torrent(service: torrent_service_dep, torrent: torrent_dep):
return service.get_torrent_by_id(torrent_id=torrent.id)
@router.get(
"",
status_code=status.HTTP_200_OK,
@@ -22,3 +17,39 @@ def get_torrent(service: torrent_service_dep, torrent: torrent_dep):
)
def get_all_torrents(service: torrent_service_dep):
return service.get_all_torrents()
@router.get("/{torrent_id}", status_code=status.HTTP_200_OK, response_model=Torrent)
def get_torrent(service: torrent_service_dep, torrent: torrent_dep):
return service.get_torrent_by_id(torrent_id=torrent.id)
@router.delete(
"/{torrent_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(current_superuser)],
)
def delete_torrent(
service: torrent_service_dep,
torrent: torrent_dep,
delete_files: bool = False,
):
try:
service.cancel_download(torrent=torrent, delete_files=delete_files)
except RuntimeError:
pass
service.delete_torrent(torrent_id=torrent.id)
@router.post(
"/{torrent_id}/retry",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(current_superuser)],
)
def retry_torrent_download(
service: torrent_service_dep,
torrent: torrent_dep,
):
service.pause_download(torrent=torrent)
service.resume_download(torrent=torrent)

View File

@@ -105,13 +105,9 @@ class TorrentService:
self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
)
# TODO: extract deletion logic to tv module
# def delete_torrent(self, torrent_id: TorrentId):
# t = self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
# if not t.imported:
# from media_manager.tv.repository import remove_season_files_by_torrent_id
# remove_season_files_by_torrent_id(db=self.db, torrent_id=torrent_id)
# media_manager.torrent.repository.delete_torrent(db=self.db, torrent_id=t.id)
def delete_torrent(self, torrent_id: TorrentId):
t = self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
self.torrent_repository.delete_torrent(torrent_id=t.id)
def get_movie_files_of_torrent(self, torrent: Torrent):
return self.torrent_repository.get_movie_files_of_torrent(torrent_id=torrent.id)

View File

@@ -248,6 +248,43 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/auth/oauth/authorize': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Oauth:Oauth2.Cookie.Authorize */
get: operations['oauth_oauth2_cookie_authorize_api_v1_auth_oauth_authorize_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/auth/oauth/callback': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Oauth:Oauth2.Cookie.Callback
* @description The response varies based on the authentication backend used.
*/
get: operations['oauth_oauth2_cookie_callback_api_v1_auth_oauth_callback_get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/tv/shows': {
parameters: {
query?: never;
@@ -544,23 +581,6 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/torrent/{torrent_id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Torrent */
get: operations['get_torrent_api_v1_torrent__torrent_id__get'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/torrent': {
parameters: {
query?: never;
@@ -578,6 +598,41 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/torrent/{torrent_id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Torrent */
get: operations['get_torrent_api_v1_torrent__torrent_id__get'];
put?: never;
post?: never;
/** Delete Torrent */
delete: operations['delete_torrent_api_v1_torrent__torrent_id__delete'];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/torrent/{torrent_id}/retry': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Retry Torrent Download */
post: operations['retry_torrent_download_api_v1_torrent__torrent_id__retry_post'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/movies': {
parameters: {
query?: never;
@@ -1221,6 +1276,11 @@ export interface components {
*/
timestamp?: string;
};
/** OAuth2AuthorizeResponse */
OAuth2AuthorizeResponse: {
/** Authorization Url */
authorization_url: string;
};
/** PublicMovie */
PublicMovie: {
/**
@@ -2275,6 +2335,80 @@ export interface operations {
};
};
};
oauth_oauth2_cookie_authorize_api_v1_auth_oauth_authorize_get: {
parameters: {
query?: {
scopes?: string[];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['OAuth2AuthorizeResponse'];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
oauth_oauth2_cookie_callback_api_v1_auth_oauth_callback_get: {
parameters: {
query?: {
code?: string | null;
code_verifier?: string | null;
state?: string | null;
error?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': unknown;
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['ErrorModel'];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
get_all_shows_api_v1_tv_shows_get: {
parameters: {
query?: never;
@@ -2905,6 +3039,26 @@ export interface operations {
};
};
};
get_all_torrents_api_v1_torrent_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Torrent'][];
};
};
};
};
get_torrent_api_v1_torrent__torrent_id__get: {
parameters: {
query?: never;
@@ -2936,22 +3090,62 @@ export interface operations {
};
};
};
get_all_torrents_api_v1_torrent_get: {
delete_torrent_api_v1_torrent__torrent_id__delete: {
parameters: {
query?: never;
query?: {
delete_files?: boolean;
};
header?: never;
path?: never;
path: {
torrent_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Torrent'][];
'application/json': components['schemas']['HTTPValidationError'];
};
};
};
};
retry_torrent_download_api_v1_torrent__torrent_id__retry_post: {
parameters: {
query?: never;
header?: never;
path: {
torrent_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'];
};
};
};

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import { Label } from '$lib/components/ui/label';
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import client from '$lib/api';
import { Checkbox } from '$lib/components/ui/checkbox';
import { invalidateAll } from '$app/navigation';
let { torrentId, torrentName }: { torrentId: string; torrentName: string } = $props();
let dialogueState = $state(false);
let deleteFiles = $state(false);
async function deleteTorrent() {
const { error } = await client.DELETE(`/api/v1/torrent/{torrent_id}`, {
params: {
path: {
torrent_id: torrentId
},
query: {
delete_files: deleteFiles
}
}
});
if (error) {
toast.error(`Failed to delete torrent: ${error}`);
} else {
toast.success('Torrent deleted successfully!');
dialogueState = false;
}
await invalidateAll();
}
</script>
<Dialog.Root bind:open={dialogueState}>
<Dialog.Trigger class={buttonVariants({ variant: 'destructive' })}>Delete Torrent</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete a Torrent</Dialog.Title>
<Dialog.Description>
Delete Torrent "{torrentName}". This action cannot be undone!
</Dialog.Description>
</Dialog.Header>
<div class="flex w-full max-w-sm items-center space-x-2">
<Checkbox bind:checked={deleteFiles} id="delete-files" />
<div class="flex flex-col">
<Label for="delete-files">
Delete associated files as well.
<p class="text-muted-foreground text-sm font-normal">
(Only files in the download location will be deleted)
</p>
</Label>
</div>
</div>
<Dialog.Footer>
<Button onclick={() => (dialogueState = false)}>Cancel</Button>
<Button onclick={() => deleteTorrent()} variant="destructive">Delete Torrent</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -6,8 +6,43 @@
} from '$lib/utils.js';
import CheckmarkX from '$lib/components/checkmark-x.svelte';
import * as Table from '$lib/components/ui/table/index.js';
import type { components } from '$lib/api/api';
import { getContext } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import client from '$lib/api';
import { toast } from 'svelte-sonner';
import DeleteTorrentDialog from '$lib/components/delete-torrent-dialog.svelte';
let { torrents, isShow = true } = $props();
let {
torrents,
isShow = true
}: {
torrents:
| components['schemas']['MovieTorrent'][]
| components['schemas']['RichSeasonTorrent'][];
isShow: boolean;
} = $props();
let user: () => components['schemas']['UserRead'] = getContext('user');
async function retryTorrentDownload(
torrent: components['schemas']['MovieTorrent'] | components['schemas']['RichSeasonTorrent']
) {
console.log(`Retrying download for torrent ${torrent.torrent_title}`);
const { error } = await client.POST('/api/v1/torrent/{torrent_id}/retry', {
params: {
path: {
torrent_id: torrent.torrent_id!
}
}
});
if (error) {
toast.error(`Failed on retrying download: ${error}`);
} else {
console.log(`Successfully retried download for torrent ${torrent.torrent_title}`);
toast.success('Trying to download torrent...');
}
}
</script>
<Table.Root>
@@ -22,6 +57,9 @@
<Table.Head>Quality</Table.Head>
<Table.Head>File Path Suffix</Table.Head>
<Table.Head>Imported</Table.Head>
{#if user().is_superuser}
<Table.Head>Actions</Table.Head>
{/if}
</Table.Row>
</Table.Header>
<Table.Body>
@@ -32,7 +70,9 @@
</Table.Cell>
{#if isShow}
<Table.Cell>
{convertTorrentSeasonRangeToIntegerRange(torrent)}
{convertTorrentSeasonRangeToIntegerRange(
(torrent as components['schemas']['RichSeasonTorrent']).seasons!
)}
</Table.Cell>
{/if}
<Table.Cell>
@@ -47,6 +87,17 @@
<Table.Cell>
<CheckmarkX state={torrent.imported} />
</Table.Cell>
{#if user().is_superuser}
<Table.Cell class="justify flex flex-col gap-2 xl:flex-row">
{#if 'finished' !== getTorrentStatusString(torrent.status)}
<Button variant="secondary" onclick={() => retryTorrentDownload(torrent)}>
Retry Download
</Button>
{/if}
<DeleteTorrentDialog torrentName={torrent.torrent_title} torrentId={torrent.torrent_id!}
></DeleteTorrentDialog>
</Table.Cell>
{/if}
</Table.Row>
{/each}
</Table.Body>

View File

@@ -6,16 +6,8 @@
import * as Accordion from '$lib/components/ui/accordion/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import TorrentTable from '$lib/components/torrent-table.svelte';
import { onMount } from 'svelte';
import { base } from '$app/paths';
import client from '$lib/api';
import type { components } from '$lib/api/api';
let torrents: components['schemas']['RichMovieTorrent'][] = [];
onMount(async () => {
const { data } = await client.GET('/api/v1/movies/torrents');
torrents = data as components['schemas']['RichMovieTorrent'][];
});
import { page } from '$app/state';
</script>
<svelte:head>
@@ -54,7 +46,7 @@
Movie Torrents
</h1>
<Accordion.Root class="w-full" type="single">
{#each torrents as movie (movie.movie_id)}
{#each page.data.torrents as movie (movie.movie_id)}
<div class="p-6">
<Card.Root>
<Card.Header>

View File

@@ -0,0 +1,8 @@
import type { PageLoad } from './$types';
import client from '$lib/api';
export const load: PageLoad = async ({ fetch }) => {
const { data } = await client.GET('/api/v1/movies/torrents', { fetch: fetch });
return { torrents: data };
};

View File

@@ -8,8 +8,6 @@
import * as Card from '$lib/components/ui/card/index.js';
import TorrentTable from '$lib/components/torrent-table.svelte';
import { resolve } from '$app/paths';
import type { components } from '$lib/api/api';
let showsPromise: Promise<components['schemas']['RichShowTorrent'][]> = $state(page.data.shows);
</script>
<svelte:head>
@@ -47,26 +45,22 @@
<h1 class="scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
TV Torrents
</h1>
{#await showsPromise}
Loading...
{:then shows}
<Accordion.Root type="single" class="w-full">
{#each shows as show (show.show_id)}
<div class="p-6">
<Card.Root>
<Card.Header>
<Card.Title>
{getFullyQualifiedMediaName(show)}
</Card.Title>
</Card.Header>
<Card.Content>
<TorrentTable torrents={show.torrents} />
</Card.Content>
</Card.Root>
</div>
{:else}
<div class="col-span-full text-center text-muted-foreground">No Torrents added yet.</div>
{/each}
</Accordion.Root>
{/await}
<Accordion.Root type="single" class="w-full">
{#each page.data.torrents as show (show.show_id)}
<div class="p-6">
<Card.Root>
<Card.Header>
<Card.Title>
{getFullyQualifiedMediaName(show)}
</Card.Title>
</Card.Header>
<Card.Content>
<TorrentTable isShow={true} torrents={show.torrents} />
</Card.Content>
</Card.Root>
</div>
{:else}
<div class="col-span-full text-center text-muted-foreground">No Torrents added yet.</div>
{/each}
</Accordion.Root>
</div>

View File

@@ -3,5 +3,5 @@ import client from '$lib/api';
export const load: PageLoad = async ({ fetch }) => {
const { data } = await client.GET('/api/v1/tv/shows/torrents', { fetch: fetch });
return { shows: data };
return { torrents: data };
};