diff --git a/backend/src/tv/repository.py b/backend/src/tv/repository.py index 952d0c1..fc409be 100644 --- a/backend/src/tv/repository.py +++ b/backend/src/tv/repository.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session, joinedload from torrent.models import Torrent from torrent.schemas import TorrentId, Torrent as TorrentSchema +from tv import log from tv.models import Season, Show, Episode, SeasonRequest, SeasonFile from tv.schemas import Season as SeasonSchema, SeasonId, Show as ShowSchema, ShowId, \ SeasonRequest as SeasonRequestSchema, SeasonFile as SeasonFileSchema, SeasonNumber, SeasonRequestId, \ @@ -110,12 +111,22 @@ def get_season(season_id: SeasonId, db: Session) -> SeasonSchema: return SeasonSchema.model_validate(db.get(Season, season_id)) -def add_season_to_requested_list(season_request: SeasonRequestSchema, db: Session) -> None: +def add_season_request(season_request: SeasonRequestSchema, db: Session) -> None: """ Adds a Season to the SeasonRequest table, which marks it as requested. """ - db.add(SeasonRequest(**season_request.model_dump())) + log.debug(f"Adding season request {season_request.model_dump()}") + db_model = SeasonRequest( + id=season_request.id, + season_id=season_request.season_id, + wanted_quality=season_request.wanted_quality, + min_quality=season_request.min_quality, + requested_by_id=season_request.requested_by.id if season_request.requested_by else None, + authorized=season_request.authorized, + authorized_by_id=season_request.authorized_by.id if season_request.authorized_by else None + ) + db.add(db_model) db.commit() @@ -144,7 +155,7 @@ def get_season_requests(db: Session) -> list[RichSeasonRequestSchema]: return [RichSeasonRequestSchema(min_quality=x.min_quality, wanted_quality=x.wanted_quality, show=x.season.show, season=x.season, requested_by=x.requested_by, authorized_by=x.authorized_by, authorized=x.authorized, - id=x.id) + id=x.id, season_id=x.season.id) for x in result] diff --git a/backend/src/tv/router.py b/backend/src/tv/router.py index 1e65abe..fdf96f5 100644 --- a/backend/src/tv/router.py +++ b/backend/src/tv/router.py @@ -1,3 +1,4 @@ +import logging from typing import Annotated from fastapi import APIRouter, Depends, status @@ -12,6 +13,7 @@ from backend.src.database import DbSessionDependency from indexer.schemas import PublicIndexerQueryResult, IndexerQueryResultId from metadataProvider.schemas import MetaDataProviderShowSearchResult from torrent.schemas import Torrent +from tv import log from tv.exceptions import MediaAlreadyExists from tv.schemas import Show, SeasonRequest, ShowId, RichShowTorrent, PublicShow, PublicSeasonFile, SeasonNumber, \ CreateSeasonRequest, SeasonRequestId, UpdateSeasonRequest, RichSeasonRequest @@ -91,8 +93,7 @@ def request_a_season(db: DbSessionDependency, user: Annotated[User, Depends(curr """ request: SeasonRequest = SeasonRequest.model_validate(season_request) request.requested_by = UserRead.model_validate(user) - - tv.service.request_season(db=db, season_request=request) + tv.service.add_season_request(db=db, season_request=request) return @@ -102,6 +103,14 @@ def get_season_requests(db: DbSessionDependency) -> list[RichSeasonRequest]: return tv.service.get_all_season_requests(db=db) +@router.delete("/seasons/requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(current_active_user)]) +def delete_season_request(db: DbSessionDependency, request_id: SeasonRequestId): + tv.service.delete_season_request(db=db, season_request_id=request_id) + return + + + @router.patch("/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT) def authorize_request(db: DbSessionDependency, user: Annotated[User, Depends(current_superuser)], season_request_id: SeasonRequestId, authorized_status: bool = False): @@ -115,7 +124,7 @@ def authorize_request(db: DbSessionDependency, user: Annotated[User, Depends(cur return -@router.put("/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.put("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT) def update_request(db: DbSessionDependency, user: Annotated[User, Depends(current_superuser)], season_request: UpdateSeasonRequest): season_request: SeasonRequest = SeasonRequest.model_validate(season_request) @@ -123,13 +132,6 @@ def update_request(db: DbSessionDependency, user: Annotated[User, Depends(curren tv.service.update_season_request(db=db, season_request=season_request) return - -@router.delete("/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(current_active_user)]) -def delete_season_request(db: DbSessionDependency, request_id: SeasonRequestId): - tv.service.delete_season_request(db=db, season_request_id=request_id) - - # -------------------------------- # MANAGE TORRENTS # -------------------------------- diff --git a/backend/src/tv/schemas.py b/backend/src/tv/schemas.py index 868a95d..1f6ced8 100644 --- a/backend/src/tv/schemas.py +++ b/backend/src/tv/schemas.py @@ -2,7 +2,7 @@ import typing import uuid from uuid import UUID -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, Field, ConfigDict, model_validator from tvdb_v4_official import Request from auth.schemas import UserRead @@ -59,6 +59,12 @@ class SeasonRequestBase(BaseModel): min_quality: Quality wanted_quality: Quality + @model_validator(mode="after") + def ensure_wanted_quality_is_eq_or_gt_min_quality(self) -> "SeasonRequestBase": + if self.min_quality.value < self.wanted_quality.value: + raise ValueError("Error text") + return self + class CreateSeasonRequest(SeasonRequestBase): season_id: SeasonId @@ -73,8 +79,8 @@ class SeasonRequest(SeasonRequestBase): model_config = ConfigDict(from_attributes=True) id: SeasonRequestId = Field(default_factory=uuid.uuid4) - season: Season + season_id: SeasonId requested_by: UserRead | None = None authorized: bool = False authorized_by: UserRead | None = None @@ -82,6 +88,8 @@ class SeasonRequest(SeasonRequestBase): class RichSeasonRequest(SeasonRequest): show: Show + season: Season + class SeasonFile(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/tv/service.py b/backend/src/tv/service.py index 97d704c..1f2f4bf 100644 --- a/backend/src/tv/service.py +++ b/backend/src/tv/service.py @@ -27,8 +27,8 @@ def add_show(db: Session, external_id: int, metadata_provider: str) -> Show | No return saved_show -def request_season(db: Session, season_request: SeasonRequest) -> None: - tv.repository.add_season_to_requested_list(db=db, season_request=season_request) +def add_season_request(db: Session, season_request: SeasonRequest) -> None: + tv.repository.add_season_request(db=db, season_request=season_request) def update_season_request(db: Session, season_request: SeasonRequest) -> None: diff --git a/web/components.json b/web/components.json index a429661..1b00ff9 100644 --- a/web/components.json +++ b/web/components.json @@ -1,8 +1,6 @@ { "$schema": "https://next.shadcn-svelte.com/schema.json", - "style": "new-york", "tailwind": { - "config": "tailwind.config.ts", "css": "src\\app.css", "baseColor": "zinc" }, @@ -10,8 +8,9 @@ "components": "$lib/components", "utils": "$lib/utils", "ui": "$lib/components/ui", - "hooks": "$lib/hooks" + "hooks": "$lib/hooks", + "lib": "$lib" }, "typescript": true, - "registry": "https://next.shadcn-svelte.com/registry" + "registry": "https://tw3.shadcn-svelte.com/registry/new-york" } diff --git a/web/package-lock.json b/web/package-lock.json index a4dc302..daa1d44 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,7 +12,6 @@ "@sveltejs/adapter-node": "^5.2.12", "eslint-plugin-unused-imports": "^4.1.4", "lucide-svelte": "^0.507.0", - "mode-watcher": "^0.5.1", "sharp": "^0.34.1", "sveltekit-image-optimize": "^0.0.7", "uuid": "^11.1.0" @@ -36,11 +35,13 @@ "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^2.46.1", "globals": "^15.14.0", + "mode-watcher": "^1.0.7", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.10", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "svelte-sonner": "^0.3.28", "tailwind-merge": "^3.2.0", "tailwind-variants": "^1.0.0", "tailwindcss": "^3.4.17", @@ -4794,11 +4795,32 @@ } }, "node_modules/mode-watcher": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-0.5.1.tgz", - "integrity": "sha512-adEC6T7TMX/kzQlaO/MtiQOSFekZfQu4MC+lXyoceQG+U5sKpJWZ4yKXqw846ExIuWJgedkOIPqAYYRk/xHm+w==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.0.7.tgz", + "integrity": "sha512-ZGA7ZGdOvBJeTQkzdBOnXSgTkO6U6iIFWJoyGCTt6oHNg9XP9NBvS26De+V4W2aqI+B0yYXUskFG2VnEo3zyMQ==", + "dev": true, + "dependencies": { + "runed": "^0.25.0", + "svelte-toolbelt": "^0.7.1" + }, "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.1" + "svelte": "^5.27.0" + } + }, + "node_modules/mode-watcher/node_modules/runed": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz", + "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" } }, "node_modules/mri": { @@ -5920,6 +5942,15 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/svelte-sonner": { + "version": "0.3.28", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-0.3.28.tgz", + "integrity": "sha512-K3AmlySeFifF/cKgsYNv5uXqMVNln0NBAacOYgmkQStLa/UoU0LhfAACU6Gr+YYC8bOCHdVmFNoKuDbMEsppJg==", + "dev": true, + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" + } + }, "node_modules/svelte-toolbelt": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", diff --git a/web/package.json b/web/package.json index 35d1345..19b78ff 100644 --- a/web/package.json +++ b/web/package.json @@ -32,11 +32,13 @@ "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^2.46.1", "globals": "^15.14.0", + "mode-watcher": "^1.0.7", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.10", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "svelte-sonner": "^0.3.28", "tailwind-merge": "^3.2.0", "tailwind-variants": "^1.0.0", "tailwindcss": "^3.4.17", @@ -50,7 +52,6 @@ "@sveltejs/adapter-node": "^5.2.12", "eslint-plugin-unused-imports": "^4.1.4", "lucide-svelte": "^0.507.0", - "mode-watcher": "^0.5.1", "sharp": "^0.34.1", "sveltekit-image-optimize": "^0.0.7", "uuid": "^11.1.0" diff --git a/web/src/lib/components/app-sidebar.svelte b/web/src/lib/components/app-sidebar.svelte index 900004d..789f4b5 100644 --- a/web/src/lib/components/app-sidebar.svelte +++ b/web/src/lib/components/app-sidebar.svelte @@ -22,8 +22,8 @@ url: '/dashboard/tv/torrents' }, { - title: 'Settings', - url: '#' + title: 'Requests', + url: '/dashboard/tv/requests' } ] diff --git a/web/src/lib/components/request-season-dialog.svelte b/web/src/lib/components/request-season-dialog.svelte new file mode 100644 index 0000000..35dd6bc --- /dev/null +++ b/web/src/lib/components/request-season-dialog.svelte @@ -0,0 +1,162 @@ + + + + { + dialogOpen = true; + }} + > + Request Season + + + + Request a Season for {getFullyQualifiedShowName(show)} + + Select a season and desired qualities to submit a request. + + +
+ +
+ + + + {#each selectedSeasonsIds as seasonId (seasonId)} + {#if show.seasons.find(season => season.id === seasonId)} + {show.seasons.find(season => season.id === seasonId).number},  + {/if} + {:else} + Select one or more seasons + {/each} + + + {#each show.seasons as season (season.id)} + + Season {season.number}{season.name ? `: ${season.name}` : ''} + + {/each} + + +
+ + +
+ + + + {minQuality ? getTorrentQualityString(minQuality) : "Select Minimum Quality"} + + + {#each qualityOptions as option (option.value)} + {option.label} + {/each} + + +
+ + +
+ + + + {wantedQuality ? getTorrentQualityString(wantedQuality) : "Select Wanted Quality"} + + + {#each qualityOptions as option (option.value)} + {option.label} + {/each} + + +
+ + {#if submitRequestError} +

{submitRequestError}

+ {/if} +
+ + + + +
+
\ No newline at end of file diff --git a/web/src/lib/components/season-requests-table.svelte b/web/src/lib/components/season-requests-table.svelte index d7070fc..4d62dab 100644 --- a/web/src/lib/components/season-requests-table.svelte +++ b/web/src/lib/components/season-requests-table.svelte @@ -1,18 +1,75 @@ @@ -34,26 +91,38 @@ {#each requests as request (request.id)} {#if filter(request)} - + {getFullyQualifiedShowName(request.show)} {request.season.number} - + {getTorrentQualityString(request.min_quality)} - + {getTorrentQualityString(request.wanted_quality)} - {request.requested_by?.email} + {request.requested_by?.email ?? 'N/A'} - {request.authorized_by?.email} + {request.authorized_by?.email ?? 'N/A'} + + + {#if user().is_superuser} + + {/if} + {#if user().is_superuser || user().id === request.requested_by?.id} + + {/if} {/if} diff --git a/web/src/lib/components/ui/sonner/index.ts b/web/src/lib/components/ui/sonner/index.ts new file mode 100644 index 0000000..2668d92 --- /dev/null +++ b/web/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export {default as Toaster} from "./sonner.svelte"; diff --git a/web/src/lib/components/ui/sonner/sonner.svelte b/web/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..4ca4bf8 --- /dev/null +++ b/web/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,20 @@ + + + diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 1603cab..21dd933 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -10,7 +10,7 @@ export const qualityMap: { [key: number]: string } = { 1: 'high', 2: 'medium', 3: 'low', - 4: 'very_low', + 4: 'very low', 5: 'unknown' }; export const torrentStatusMap: { [key: number]: string } = { diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index c661601..29ccf98 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,9 +1,11 @@ + {@render children()} diff --git a/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte b/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte index 7b974ed..071d9af 100644 --- a/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte +++ b/web/src/routes/dashboard/tv/[showId=uuid]/+page.svelte @@ -14,6 +14,7 @@ import CheckmarkX from '$lib/components/checkmark-x.svelte'; import {page} from '$app/state'; import TorrentTable from '$lib/components/torrent-table.svelte'; + import RequestSeasonDialog from '$lib/components/request-season-dialog.svelte'; let show: Show = getContext('show'); let user: User = getContext('user'); @@ -74,6 +75,7 @@ {#if user().is_superuser} {/if} +
diff --git a/web/src/routes/dashboard/tv/requests/+page.svelte b/web/src/routes/dashboard/tv/requests/+page.svelte index 249163e..410e97a 100644 --- a/web/src/routes/dashboard/tv/requests/+page.svelte +++ b/web/src/routes/dashboard/tv/requests/+page.svelte @@ -14,7 +14,7 @@ let requests: SeasonRequest[] = $state(page.data.requestsData); - +
@@ -45,5 +45,5 @@

Season Requests

- +