diff --git a/backend/src/auth/config.py b/backend/src/auth/config.py index 597e732..0be560e 100644 --- a/backend/src/auth/config.py +++ b/backend/src/auth/config.py @@ -1,3 +1,4 @@ +from pydantic import EmailStr from pydantic_settings import BaseSettings, SettingsConfigDict @@ -7,6 +8,7 @@ class AuthConfig(BaseSettings): model_config = SettingsConfigDict(env_prefix='AUTH_') token_secret: str session_lifetime: int = 60 * 60 * 24 + admin_email: str | list[str] @property def jwt_signing_key(self): return self._jwt_signing_key diff --git a/backend/src/auth/users.py b/backend/src/auth/users.py index cc133ea..1d9e639 100644 --- a/backend/src/auth/users.py +++ b/backend/src/auth/users.py @@ -14,6 +14,7 @@ from httpx_oauth.oauth2 import OAuth2 import auth.config from auth.db import User, get_user_db +from auth.schemas import UserUpdate config = auth.config.AuthConfig() SECRET = config.token_secret @@ -40,6 +41,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): async def on_after_register(self, user: User, request: Optional[Request] = None): print(f"User {user.id} has registered.") + if user.email in config.admin_email: + updated_user = UserUpdate(is_superuser=True, is_verified=True) + await self.update(user=user, user_update=updated_user) async def on_after_forgot_password( self, user: User, token: str, request: Optional[Request] = None @@ -84,5 +88,5 @@ cookie_auth_backend = AuthenticationBackend( fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [bearer_auth_backend, cookie_auth_backend]) -current_active_user = fastapi_users.current_user(active=True) -current_superuser = fastapi_users.current_user(active=True, superuser=True) +current_active_user = fastapi_users.current_user(active=True, verified=True) +current_superuser = fastapi_users.current_user(active=True, verified=True, superuser=True) diff --git a/backend/src/main.py b/backend/src/main.py index 6f731b3..440a34b 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -4,6 +4,8 @@ from logging.config import dictConfig from pythonjsonlogger.json import JsonFormatter +import router + LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, @@ -113,6 +115,11 @@ app.include_router( prefix="/auth", tags=["auth"], ) +# Misc Router +app.include_router( + router.router, + tags=["users"] +) # User Router app.include_router( fastapi_users.get_users_router(UserRead, UserUpdate), diff --git a/backend/src/metadataProvider/__init__.py b/backend/src/metadataProvider/__init__.py index 8e29f95..859cb92 100644 --- a/backend/src/metadataProvider/__init__.py +++ b/backend/src/metadataProvider/__init__.py @@ -1,6 +1,7 @@ import logging import metadataProvider.tmdb +import metadataProvider.tvdb from metadataProvider.abstractMetaDataProvider import metadata_providers from metadataProvider.schemas import MetaDataProviderShowSearchResult from tv.schemas import Show diff --git a/backend/src/metadataProvider/tmdb.py b/backend/src/metadataProvider/tmdb.py index baefdc2..79b8098 100644 --- a/backend/src/metadataProvider/tmdb.py +++ b/backend/src/metadataProvider/tmdb.py @@ -69,17 +69,15 @@ class TmdbMetadataProvider(AbstractMetadataProvider): metadata_provider=self.name, ) - # TODO: convert images automatically to .jpg # downloading the poster + # NOTE: all pictures from TMDB should already be jpeg, so no need to convert if show_metadata["poster_path"] is not None: poster_url = "https://image.tmdb.org/t/p/original" + show_metadata["poster_path"] - res = requests.get(poster_url, stream=True) - content_type = res.headers["content-type"] - file_extension = mimetypes.guess_extension(content_type) - if res.status_code == 200: - with open(self.storage_path.joinpath(str(show.id) + file_extension), 'wb') as f: - f.write(res.content) - log.info(f"image for show {show.name} successfully downloaded") + if metadataProvider.utils.download_poster_image(storage_path=self.storage_path, poster_url=poster_url, + show=show): + log.info("Successfully downloaded poster image for show " + show.name) + else: + log.warning(f"download for image of show {show.name} failed") else: log.warning(f"image for show {show.name} could not be downloaded") diff --git a/backend/src/metadataProvider/tvdb.py b/backend/src/metadataProvider/tvdb.py new file mode 100644 index 0000000..79e0c64 --- /dev/null +++ b/backend/src/metadataProvider/tvdb.py @@ -0,0 +1,98 @@ +import pprint + +import tvdb_v4_official +import logging +import mimetypes + +import requests +import tmdbsimple +from pydantic_settings import BaseSettings +from tmdbsimple import TV, TV_Seasons + +import metadataProvider.utils +from metadataProvider.abstractMetaDataProvider import AbstractMetadataProvider, register_metadata_provider +from metadataProvider.schemas import MetaDataProviderShowSearchResult +from tv.schemas import Episode, Season, Show, SeasonNumber, EpisodeNumber + + +class TvdbConfig(BaseSettings): + TVDB_API_KEY: str | None = None + + +config = TvdbConfig() +log = logging.getLogger(__name__) + + +class TvdbMetadataProvider(AbstractMetadataProvider): + name = "tvdb" + tvdb_client: tvdb_v4_official.TVDB + + def __init__(self, api_key: str = None): + self.tvdb_client = tvdb_v4_official.TVDB(api_key) + + def get_show_metadata(self, id: int = None) -> Show: + """ + + :param id: the external id of the show + :type id: int + :return: returns a ShowMetadata object + :rtype: ShowMetadata + """ + series = self.tvdb_client.get_series_extended(id) + seasons = [] + for season in series["seasons"]: + s = self.tvdb_client.get_season_extended(season["id"]) + episodes = [Episode(number=episode['number'], external_id=episode['id'], title=episode['name']) for episode + in s["episodes"]] + seasons.append(Season(number=s['number'], name="TVDB doesn't provide Season Names", + overview="TVDB doesn't provide Season Overviews", external_id=s['id'], + episodes=episodes)) + try: + year = series['year'] + except KeyError: + year = None + show = Show(name=series['name'], overview=series['overview'], year=year, + external_id=series['id'], metadata_provider=self.name, seasons=seasons) + + if series["image"] is not None: + metadataProvider.utils.download_poster_image(storage_path=self.storage_path, + poster_url=series['image'], show=show) + else: + log.warning(f"image for show {show.name} could not be downloaded") + + return show + + def search_show(self, query: str) -> list[MetaDataProviderShowSearchResult]: + results = self.tvdb_client.search(query) + formatted_results = [] + for result in results: + if result['type'] == 'series': + try: + year = result['year'] + except KeyError: + year = None + + formatted_results.append( + MetaDataProviderShowSearchResult( + poster_path=result["image_url"], + overview=result["overview"], + name=result["name"], + external_id=result["tvdb_id"], + year=year, + metadata_provider=self.name, + added=False, + ) + ) + return formatted_results + + +if config.TVDB_API_KEY is not None: + log.info("Registering TVDB as metadata provider") + register_metadata_provider(metadata_provider=TvdbMetadataProvider(config.TVDB_API_KEY)) + +if __name__ == "__main__": + tvdb = TvdbMetadataProvider(config.TVDB_API_KEY) + # show_metadata = tvdb.get_show_metadata(id=328724) # Replace with a valid TVDB ID + # pprint.pprint(dict(show_metadata)) + search_results = tvdb.search_show("Simpsons Declassified") + pprint.pprint(search_results) diff --git a/backend/src/metadataProvider/utils.py b/backend/src/metadataProvider/utils.py index 6940706..e031307 100644 --- a/backend/src/metadataProvider/utils.py +++ b/backend/src/metadataProvider/utils.py @@ -1,5 +1,23 @@ +import mimetypes + +import requests + + + def get_year_from_first_air_date(first_air_date: str | None) -> int | None: if first_air_date: return int(first_air_date.split('-')[0]) else: return None + + +def download_poster_image(storage_path=None, poster_url=None, show=None) -> bool: + res = requests.get(poster_url, stream=True) + content_type = res.headers["content-type"] + file_extension = mimetypes.guess_extension(content_type) + if res.status_code == 200: + with open(storage_path.joinpath(str(show.id) + file_extension), 'wb') as f: + f.write(res.content) + return True + else: + return False diff --git a/backend/src/router.py b/backend/src/router.py new file mode 100644 index 0000000..59c1d48 --- /dev/null +++ b/backend/src/router.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends +from fastapi import status +from sqlalchemy import select + +from auth.db import User +from auth.schemas import UserRead +from auth.users import current_superuser +from database import DbSessionDependency + +router = APIRouter() + + +@router.get("/users/all", status_code=status.HTTP_200_OK, dependencies=[Depends(current_superuser)]) +def get_all_users(db: DbSessionDependency) -> list[UserRead]: + stmt = select(User) + result = db.execute(stmt).scalars().unique() + return [UserRead.model_validate(user) for user in result] diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 6d5b65e..cc86da6 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -1,6 +1,7 @@ import {createImageOptimizer} from 'sveltekit-image-optimize'; import type {Handle} from '@sveltejs/kit'; import {createFileSystemCache} from 'sveltekit-image-optimize/cache-adapters'; +import type {HandleServerError} from '@sveltejs/kit'; const cache = createFileSystemCache('./cache'); const imageHandler = createImageOptimizer({ diff --git a/web/src/lib/components/app-sidebar.svelte b/web/src/lib/components/app-sidebar.svelte index eeee2b5..1aea417 100644 --- a/web/src/lib/components/app-sidebar.svelte +++ b/web/src/lib/components/app-sidebar.svelte @@ -22,23 +22,16 @@ url: '/dashboard/tv/add-show' }, { + title: 'Torrents', + url: '/dashboard/tv/torrents' + }, + { title: 'Settings', url: '#' } - ] + + ] }, - { - title: 'Torrents', - url: '#', - icon: DownloadIcon, - isActive: true, - items: [ - { - title: 'Show Torrents', - url: '/dashboard/torrents' - } - ] - } ], navSecondary: [ { diff --git a/web/src/lib/components/login-form.svelte b/web/src/lib/components/login-form.svelte index 1beda79..2011474 100644 --- a/web/src/lib/components/login-form.svelte +++ b/web/src/lib/components/login-form.svelte @@ -1,99 +1,199 @@ +{#snippet tabSwitcher()} + + + + +{/snippet} + + + + + {@render tabSwitcher()} - - - Login - Enter your email below to login to your account - - -
-
- - -
-
- - -
+ Login + Enter your email below to login to your account + + + +
+ + +
+
+
+ + + Forgot your password? +
+ +
- {#if errorMessage} -

{errorMessage}

- {/if} + {#if errorMessage} +

{errorMessage}

+ {/if} - - + + - + -
- Don't have an account? - Sign up -
-
-
+
+ Don't have an account? + Sign up +
+ +
+
+ + + + + {@render tabSwitcher()} + Sign up + Enter your email and password below to sign up. + + + +
+
+ + +
+
+
+ +
+ +
+ + {#if errorMessage} +

{errorMessage}

+ {/if} + + +
+ + + +
+ Already have an account? + Login +
+
+
+
+
diff --git a/web/src/routes/dashboard/+layout.svelte b/web/src/routes/dashboard/+layout.svelte index d8bd03c..2bf7e5b 100644 --- a/web/src/routes/dashboard/+layout.svelte +++ b/web/src/routes/dashboard/+layout.svelte @@ -3,9 +3,13 @@ import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import type {LayoutProps} from './$types'; import {setContext} from 'svelte'; + import {goto} from '$app/navigation'; let {data, children}: LayoutProps = $props(); console.log('Received User Data: ', data.user); + if (!data.user.is_verified) { + goto('/login/verify') + } setContext('user', () => data.user); diff --git a/web/src/routes/dashboard/settings/+page.svelte b/web/src/routes/dashboard/settings/+page.svelte new file mode 100644 index 0000000..e69de29 diff --git a/web/src/routes/dashboard/tv/+page.svelte b/web/src/routes/dashboard/tv/+page.svelte index 41a46e1..3026b01 100644 --- a/web/src/routes/dashboard/tv/+page.svelte +++ b/web/src/routes/dashboard/tv/+page.svelte @@ -37,11 +37,9 @@
- - - +

+ TV Shows +

@@ -53,10 +51,10 @@ {:then tvShows} {#each tvShows as show} - + - {getFullyQualifiedShowName(show)} - {show.overview} + {getFullyQualifiedShowName(show)} + {show.overview}
@@ -54,8 +54,7 @@

- {show.name} - {show.year == null ? '' : '(' + show.year + ')'} Season {SeasonNumber} + {getFullyQualifiedShowName(show)} Season {SeasonNumber}

@@ -91,7 +90,7 @@ {file.file_path_suffix} - + {:else } diff --git a/web/src/routes/dashboard/tv/add-show/+page.svelte b/web/src/routes/dashboard/tv/add-show/+page.svelte index 1058189..375f34c 100644 --- a/web/src/routes/dashboard/tv/add-show/+page.svelte +++ b/web/src/routes/dashboard/tv/add-show/+page.svelte @@ -88,7 +88,6 @@

- Add a show

diff --git a/web/src/routes/dashboard/torrents/+page.svelte b/web/src/routes/dashboard/tv/torrents/+page.svelte similarity index 92% rename from web/src/routes/dashboard/torrents/+page.svelte rename to web/src/routes/dashboard/tv/torrents/+page.svelte index 0be0fea..f32f477 100644 --- a/web/src/routes/dashboard/torrents/+page.svelte +++ b/web/src/routes/dashboard/tv/torrents/+page.svelte @@ -28,7 +28,11 @@