mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-08-01 01:26:28 -04:00
Merge pull request #541 from k4black/feat-async-migration
refactor: convert backend to async (engine, repos, services, routers, httpx)
This commit is contained in:
@@ -7,11 +7,10 @@ from fastapi_users.db import (
|
|||||||
SQLAlchemyUserDatabase,
|
SQLAlchemyUserDatabase,
|
||||||
)
|
)
|
||||||
from sqlalchemy import String
|
from sqlalchemy import String
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from media_manager.config import MediaManagerConfig
|
from media_manager.database import Base, get_async_session
|
||||||
from media_manager.database import Base, build_db_url
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
|
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
|
||||||
@@ -27,17 +26,6 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
engine = create_async_engine(
|
|
||||||
build_db_url(**MediaManagerConfig().database.model_dump()), echo=False
|
|
||||||
)
|
|
||||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_async_session() -> AsyncGenerator[AsyncSession]:
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
yield session
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_db(
|
async def get_user_db(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> AsyncGenerator[SQLAlchemyUserDatabase]:
|
) -> AsyncGenerator[SQLAlchemyUserDatabase]:
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ openid_config = MediaManagerConfig().auth.openid_connect
|
|||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def get_all_users(db: DbSessionDependency) -> list[UserRead]:
|
async def get_all_users(db: DbSessionDependency) -> list[UserRead]:
|
||||||
stmt = select(User)
|
stmt = select(User)
|
||||||
result = db.execute(stmt).scalars().unique()
|
result = (await db.execute(stmt)).scalars().unique()
|
||||||
return [UserRead.model_validate(user) for user in result]
|
return [UserRead.model_validate(user) for user in result]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from media_manager.exceptions import ConflictError, NotFoundError
|
from media_manager.exceptions import ConflictError, NotFoundError
|
||||||
|
|
||||||
@@ -20,51 +20,51 @@ class BaseRepository[T, S]:
|
|||||||
Base repository providing common CRUD operations for media models.
|
Base repository providing common CRUD operations for media models.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db: Session, model: type[T], schema: type[S]) -> None:
|
def __init__(self, db: AsyncSession, model: type[T], schema: type[S]) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
self.model = model
|
self.model = model
|
||||||
self.schema = schema
|
self.schema = schema
|
||||||
|
|
||||||
def get_by_id(self, entity_id: EntityId) -> S:
|
async def get_by_id(self, entity_id: EntityId) -> S:
|
||||||
result = self.db.get(self.model, entity_id)
|
result = await self.db.get(self.model, entity_id)
|
||||||
if not result:
|
if not result:
|
||||||
msg = f"{self.model.__name__} with id {entity_id} not found."
|
msg = f"{self.model.__name__} with id {entity_id} not found."
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
return self.schema.model_validate(result)
|
return self.schema.model_validate(result)
|
||||||
|
|
||||||
def get_by_external_id(self, external_id: int, metadata_provider: str) -> S:
|
async def get_by_external_id(self, external_id: int, metadata_provider: str) -> S:
|
||||||
stmt = select(self.model).where(
|
stmt = select(self.model).where(
|
||||||
self.model.external_id == external_id,
|
self.model.external_id == external_id,
|
||||||
self.model.metadata_provider == metadata_provider,
|
self.model.metadata_provider == metadata_provider,
|
||||||
)
|
)
|
||||||
result = self.db.execute(stmt).scalar_one_or_none()
|
result = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||||
if not result:
|
if not result:
|
||||||
msg = f"{self.model.__name__} with external_id {external_id} and provider {metadata_provider} not found."
|
msg = f"{self.model.__name__} with external_id {external_id} and provider {metadata_provider} not found."
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
return self.schema.model_validate(result)
|
return self.schema.model_validate(result)
|
||||||
|
|
||||||
def get_all(self) -> list[S]:
|
async def get_all(self) -> list[S]:
|
||||||
stmt = select(self.model)
|
stmt = select(self.model)
|
||||||
results = self.db.execute(stmt).scalars().unique().all()
|
results = (await self.db.execute(stmt)).scalars().unique().all()
|
||||||
return [self.schema.model_validate(r) for r in results]
|
return [self.schema.model_validate(r) for r in results]
|
||||||
|
|
||||||
def delete(self, entity_id: EntityId) -> None:
|
async def delete(self, entity_id: EntityId) -> None:
|
||||||
obj = self.db.get(self.model, entity_id)
|
obj = await self.db.get(self.model, entity_id)
|
||||||
if not obj:
|
if not obj:
|
||||||
msg = f"{self.model.__name__} with id {entity_id} not found."
|
msg = f"{self.model.__name__} with id {entity_id} not found."
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
self.db.delete(obj)
|
await self.db.delete(obj)
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
def set_library(self, entity_id: EntityId, library: str) -> None:
|
async def set_library(self, entity_id: EntityId, library: str) -> None:
|
||||||
obj = self.db.get(self.model, entity_id)
|
obj = await self.db.get(self.model, entity_id)
|
||||||
if not obj:
|
if not obj:
|
||||||
msg = f"{self.model.__name__} with id {entity_id} not found."
|
msg = f"{self.model.__name__} with id {entity_id} not found."
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
obj.library = library
|
obj.library = library
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
def save_media_base(
|
async def save_media_base(
|
||||||
self,
|
self,
|
||||||
media_schema: S,
|
media_schema: S,
|
||||||
model_class: type[T],
|
model_class: type[T],
|
||||||
@@ -76,35 +76,34 @@ class BaseRepository[T, S]:
|
|||||||
if exclude is None:
|
if exclude is None:
|
||||||
exclude = set()
|
exclude = set()
|
||||||
|
|
||||||
db_obj = self.db.get(model_class, media_schema.id) if media_schema.id else None
|
db_obj = (
|
||||||
|
await self.db.get(model_class, media_schema.id) if media_schema.id else None
|
||||||
|
)
|
||||||
|
|
||||||
if db_obj:
|
if db_obj:
|
||||||
# Update existing
|
|
||||||
# Always exclude "id" from updates
|
|
||||||
update_exclude = exclude | {"id"}
|
update_exclude = exclude | {"id"}
|
||||||
for key, value in media_schema.model_dump(exclude=update_exclude).items():
|
for key, value in media_schema.model_dump(exclude=update_exclude).items():
|
||||||
if hasattr(db_obj, key):
|
if hasattr(db_obj, key):
|
||||||
setattr(db_obj, key, value)
|
setattr(db_obj, key, value)
|
||||||
else:
|
else:
|
||||||
# Insert new
|
|
||||||
db_obj = model_class(**media_schema.model_dump(exclude=exclude))
|
db_obj = model_class(**media_schema.model_dump(exclude=exclude))
|
||||||
self.db.add(db_obj)
|
self.db.add(db_obj)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
self.db.refresh(db_obj)
|
await self.db.refresh(db_obj)
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
msg = f"Integrity error while saving {model_class.__name__}: {e.orig}"
|
msg = f"Integrity error while saving {model_class.__name__}: {e.orig}"
|
||||||
raise ConflictError(msg) from e
|
raise ConflictError(msg) from e
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
log.exception(f"Database error while saving {model_class.__name__}")
|
log.exception(f"Database error while saving {model_class.__name__}")
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
return self.schema.model_validate(db_obj)
|
return self.schema.model_validate(db_obj)
|
||||||
|
|
||||||
def update_media_attributes_base(
|
async def update_media_attributes_base(
|
||||||
self,
|
self,
|
||||||
media_id: EntityId,
|
media_id: EntityId,
|
||||||
model_class: type[T],
|
model_class: type[T],
|
||||||
@@ -113,7 +112,7 @@ class BaseRepository[T, S]:
|
|||||||
"""
|
"""
|
||||||
Generic update method for media attributes.
|
Generic update method for media attributes.
|
||||||
"""
|
"""
|
||||||
db_obj = self.db.get(model_class, media_id)
|
db_obj = await self.db.get(model_class, media_id)
|
||||||
if not db_obj:
|
if not db_obj:
|
||||||
msg = f"{model_class.__name__} with id {media_id} not found."
|
msg = f"{model_class.__name__} with id {media_id} not found."
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
@@ -130,15 +129,15 @@ class BaseRepository[T, S]:
|
|||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
try:
|
try:
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
self.db.refresh(db_obj)
|
await self.db.refresh(db_obj)
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return self.schema.model_validate(db_obj)
|
return self.schema.model_validate(db_obj)
|
||||||
|
|
||||||
def add_media_file_base(
|
async def add_media_file_base(
|
||||||
self, file_schema: S, model_class: type[T], schema_class: type[S]
|
self, file_schema: S, model_class: type[T], schema_class: type[S]
|
||||||
) -> S:
|
) -> S:
|
||||||
"""
|
"""
|
||||||
@@ -147,18 +146,18 @@ class BaseRepository[T, S]:
|
|||||||
db_model = model_class(**file_schema.model_dump())
|
db_model = model_class(**file_schema.model_dump())
|
||||||
try:
|
try:
|
||||||
self.db.add(db_model)
|
self.db.add(db_model)
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
self.db.refresh(db_model)
|
await self.db.refresh(db_model)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
raise
|
raise
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
return schema_class.model_validate(db_model)
|
return schema_class.model_validate(db_model)
|
||||||
|
|
||||||
def remove_files_by_torrent_id_base(
|
async def remove_files_by_torrent_id_base(
|
||||||
self, torrent_id: EntityId, model_class: type[T]
|
self, torrent_id: EntityId, model_class: type[T]
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -166,10 +165,10 @@ class BaseRepository[T, S]:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
stmt = delete(model_class).where(model_class.torrent_id == torrent_id)
|
stmt = delete(model_class).where(model_class.torrent_id == torrent_id)
|
||||||
result = self.db.execute(stmt)
|
result = await self.db.execute(stmt)
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ class BaseMediaService[T, S]:
|
|||||||
self.indexer_service = indexer_service
|
self.indexer_service = indexer_service
|
||||||
self.notification_service = notification_service
|
self.notification_service = notification_service
|
||||||
|
|
||||||
def get_all_media(self) -> list[S]:
|
async def get_all_media(self) -> list[S]:
|
||||||
return self.repository.get_all()
|
return await self.repository.get_all()
|
||||||
|
|
||||||
def get_root_directory(
|
def get_root_directory(
|
||||||
self, media: S, default_dir: Path, libraries: list[Any]
|
self, media: S, default_dir: Path, libraries: list[Any]
|
||||||
@@ -64,36 +64,36 @@ class BaseMediaService[T, S]:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def notify_import_success(self, media_name: str, media_type: str) -> None:
|
async def notify_import_success(self, media_name: str, media_type: str) -> None:
|
||||||
if self.notification_service:
|
if self.notification_service:
|
||||||
self.notification_service.send_notification_to_all_providers(
|
await self.notification_service.send_notification_to_all_providers(
|
||||||
title=f"{media_type.capitalize()} Downloaded",
|
title=f"{media_type.capitalize()} Downloaded",
|
||||||
message=f"{media_type.capitalize()} {media_name} has been successfully downloaded and imported.",
|
message=f"{media_type.capitalize()} {media_name} has been successfully downloaded and imported.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def notify_import_failure(
|
async def notify_import_failure(
|
||||||
self, media_name: str, media_type: str, error_msg: str = ""
|
self, media_name: str, media_type: str, error_msg: str = ""
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.notification_service:
|
if self.notification_service:
|
||||||
msg = f"Failed to import files for {media_type} {media_name}."
|
msg = f"Failed to import files for {media_type} {media_name}."
|
||||||
if error_msg:
|
if error_msg:
|
||||||
msg += f" Error: {error_msg}"
|
msg += f" Error: {error_msg}"
|
||||||
self.notification_service.send_notification_to_all_providers(
|
await self.notification_service.send_notification_to_all_providers(
|
||||||
title="Import Failed",
|
title="Import Failed",
|
||||||
message=msg,
|
message=msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_import_candidates(
|
async def get_import_candidates(
|
||||||
self,
|
self,
|
||||||
directory: Path,
|
directory: Path,
|
||||||
metadata_provider: AbstractMetadataProvider,
|
metadata_provider: AbstractMetadataProvider,
|
||||||
search_func: Callable[
|
search_func: Callable[
|
||||||
[str, AbstractMetadataProvider], list[MetaDataProviderSearchResult]
|
[str, AbstractMetadataProvider],
|
||||||
|
Awaitable[list[MetaDataProviderSearchResult]],
|
||||||
],
|
],
|
||||||
) -> MediaImportSuggestion:
|
) -> MediaImportSuggestion:
|
||||||
# Implementation from previous turn
|
|
||||||
name, _ = self._extract_name_and_year(directory.name)
|
name, _ = self._extract_name_and_year(directory.name)
|
||||||
candidates = search_func(name, metadata_provider)
|
candidates = await search_func(name, metadata_provider)
|
||||||
return MediaImportSuggestion(
|
return MediaImportSuggestion(
|
||||||
directory=str(directory),
|
directory=str(directory),
|
||||||
candidates=candidates,
|
candidates=candidates,
|
||||||
@@ -107,47 +107,47 @@ class BaseMediaService[T, S]:
|
|||||||
return match.group(1), int(match.group(2))
|
return match.group(1), int(match.group(2))
|
||||||
return directory_name, None
|
return directory_name, None
|
||||||
|
|
||||||
def get_importable_media(
|
async def get_importable_media(
|
||||||
self,
|
self,
|
||||||
root_path: Path,
|
root_path: Path,
|
||||||
metadata_provider: AbstractMetadataProvider,
|
metadata_provider: AbstractMetadataProvider,
|
||||||
get_candidates_func: Callable[
|
get_candidates_func: Callable[
|
||||||
[Path, AbstractMetadataProvider], MediaImportSuggestion
|
[Path, AbstractMetadataProvider], Awaitable[MediaImportSuggestion]
|
||||||
],
|
],
|
||||||
) -> list[MediaImportSuggestion]:
|
) -> list[MediaImportSuggestion]:
|
||||||
importable_dirs = get_importable_media_directories(root_path)
|
importable_dirs = get_importable_media_directories(root_path)
|
||||||
return [
|
return [
|
||||||
get_candidates_func(directory, metadata_provider)
|
await get_candidates_func(directory, metadata_provider)
|
||||||
for directory in importable_dirs
|
for directory in importable_dirs
|
||||||
]
|
]
|
||||||
|
|
||||||
def import_existing_media(
|
async def import_existing_media(
|
||||||
self,
|
self,
|
||||||
media: S,
|
media: S,
|
||||||
source_directory: Path,
|
source_directory: Path,
|
||||||
import_func: Callable[[S, Path, Callable[[Any], None]], bool],
|
import_func: Callable[[S, Path, Callable[[Any], Any]], Awaitable[bool]],
|
||||||
add_file_record_func: Callable[[Any], Any],
|
add_file_record_func: Callable[[Any], Awaitable[Any]],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
success = import_func(media, source_directory, add_file_record_func)
|
success = await import_func(media, source_directory, add_file_record_func)
|
||||||
if success:
|
if success:
|
||||||
log.info(f"Successfully imported {media.name} from {source_directory}")
|
log.info(f"Successfully imported {media.name} from {source_directory}")
|
||||||
return success
|
return success
|
||||||
|
|
||||||
def import_all_torrents_base(
|
async def import_all_torrents_base(
|
||||||
self,
|
self,
|
||||||
get_media_func: Callable[[Any], S],
|
get_media_func: Callable[[Any], Awaitable[S | None]],
|
||||||
import_torrent_func: Callable[[Any, S], None],
|
import_torrent_func: Callable[[Any, S], Awaitable[None]],
|
||||||
media_type_name: str,
|
media_type_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
log.info(f"Importing all torrents for {media_type_name}")
|
log.info(f"Importing all torrents for {media_type_name}")
|
||||||
torrents = self.torrent_service.get_completed_torrents()
|
torrents = await self.torrent_service.get_completed_torrents()
|
||||||
for t in torrents:
|
for t in torrents:
|
||||||
if t.imported:
|
if t.imported:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
media = get_media_func(t)
|
media = await get_media_func(t)
|
||||||
if media:
|
if media:
|
||||||
import_torrent_func(t, media)
|
await import_torrent_func(t, media)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception(f"Error importing torrent {t.title}")
|
log.exception(f"Error importing torrent {t.title}")
|
||||||
log.info(f"Finished importing all torrents for {media_type_name}")
|
log.info(f"Finished importing all torrents for {media_type_name}")
|
||||||
@@ -161,9 +161,9 @@ class BaseMetadataService[T, S]:
|
|||||||
def __init__(self, repository: BaseRepository[T, S]) -> None:
|
def __init__(self, repository: BaseRepository[T, S]) -> None:
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
|
|
||||||
def check_if_exists(self, external_id: int, metadata_provider: str) -> bool:
|
async def check_if_exists(self, external_id: int, metadata_provider: str) -> bool:
|
||||||
try:
|
try:
|
||||||
self.repository.get_by_external_id(
|
await self.repository.get_by_external_id(
|
||||||
external_id=external_id, metadata_provider=metadata_provider
|
external_id=external_id, metadata_provider=metadata_provider
|
||||||
)
|
)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
@@ -171,38 +171,40 @@ class BaseMetadataService[T, S]:
|
|||||||
else:
|
else:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def add_media_base(
|
async def add_media_base(
|
||||||
self,
|
self,
|
||||||
external_id: int,
|
external_id: int,
|
||||||
metadata_provider: AbstractMetadataProvider, # noqa: ARG002
|
metadata_provider: AbstractMetadataProvider, # noqa: ARG002
|
||||||
get_metadata_func: Callable[..., S],
|
get_metadata_func: Callable[..., Awaitable[S]],
|
||||||
save_func: Callable[[S], S],
|
save_func: Callable[[S], Awaitable[S]],
|
||||||
download_poster_func: Callable[[S], bool],
|
download_poster_func: Callable[[S], Awaitable[bool]],
|
||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
) -> S:
|
) -> S:
|
||||||
media_with_metadata = get_metadata_func(external_id, language=language)
|
media_with_metadata = await get_metadata_func(external_id, language=language)
|
||||||
if not media_with_metadata:
|
if not media_with_metadata:
|
||||||
raise NotFoundError
|
raise NotFoundError
|
||||||
|
|
||||||
saved_media = save_func(media_with_metadata)
|
saved_media = await save_func(media_with_metadata)
|
||||||
download_poster_func(saved_media)
|
await download_poster_func(saved_media)
|
||||||
return saved_media
|
return saved_media
|
||||||
|
|
||||||
def search_for_media_base(
|
async def search_for_media_base(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
metadata_provider: AbstractMetadataProvider,
|
metadata_provider: AbstractMetadataProvider,
|
||||||
search_func: Callable[[str | None], list[MetaDataProviderSearchResult]],
|
search_func: Callable[
|
||||||
get_by_external_id_func: Callable[..., S],
|
[str | None], Awaitable[list[MetaDataProviderSearchResult]]
|
||||||
|
],
|
||||||
|
get_by_external_id_func: Callable[..., Awaitable[S]],
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
results = search_func(query)
|
results = await search_func(query)
|
||||||
for result in results:
|
for result in results:
|
||||||
if self.check_if_exists(
|
if await self.check_if_exists(
|
||||||
external_id=result.external_id, metadata_provider=metadata_provider.name
|
external_id=result.external_id, metadata_provider=metadata_provider.name
|
||||||
):
|
):
|
||||||
result.added = True
|
result.added = True
|
||||||
try:
|
try:
|
||||||
media = get_by_external_id_func(
|
media = await get_by_external_id_func(
|
||||||
external_id=result.external_id,
|
external_id=result.external_id,
|
||||||
metadata_provider=metadata_provider.name,
|
metadata_provider=metadata_provider.name,
|
||||||
)
|
)
|
||||||
@@ -213,30 +215,35 @@ class BaseMetadataService[T, S]:
|
|||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def get_popular_media_base(
|
async def get_popular_media_base(
|
||||||
self,
|
self,
|
||||||
metadata_provider: AbstractMetadataProvider,
|
metadata_provider: AbstractMetadataProvider,
|
||||||
search_func: Callable[[str | None], list[MetaDataProviderSearchResult]],
|
search_func: Callable[
|
||||||
|
[str | None], Awaitable[list[MetaDataProviderSearchResult]]
|
||||||
|
],
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
results = search_func(None)
|
results = await search_func(None)
|
||||||
return [
|
return [
|
||||||
result
|
r
|
||||||
for result in results
|
for r in results
|
||||||
if not self.check_if_exists(
|
if not await self.check_if_exists(
|
||||||
external_id=result.external_id, metadata_provider=metadata_provider.name
|
external_id=r.external_id,
|
||||||
|
metadata_provider=metadata_provider.name,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def update_all_metadata_base(
|
async def update_all_metadata_base(
|
||||||
self,
|
self,
|
||||||
get_all_to_update_func: Callable[[], list[S]],
|
get_all_to_update_func: Callable[[], Awaitable[list[S]]],
|
||||||
update_single_func: Callable[[S, AbstractMetadataProvider], S | None],
|
update_single_func: Callable[
|
||||||
|
[S, AbstractMetadataProvider], Awaitable[S | None]
|
||||||
|
],
|
||||||
tmdb_provider_class: Callable[[], AbstractMetadataProvider],
|
tmdb_provider_class: Callable[[], AbstractMetadataProvider],
|
||||||
tvdb_provider_class: Callable[[], AbstractMetadataProvider],
|
tvdb_provider_class: Callable[[], AbstractMetadataProvider],
|
||||||
media_type_name: str,
|
media_type_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
log.info(f"Updating metadata for all {media_type_name}")
|
log.info(f"Updating metadata for all {media_type_name}")
|
||||||
media_list = get_all_to_update_func()
|
media_list = await get_all_to_update_func()
|
||||||
log.info(f"Found {len(media_list)} {media_type_name} to update")
|
log.info(f"Found {len(media_list)} {media_type_name} to update")
|
||||||
for item in media_list:
|
for item in media_list:
|
||||||
try:
|
try:
|
||||||
@@ -249,7 +256,7 @@ class BaseMetadataService[T, S]:
|
|||||||
f"Unsupported provider {item.metadata_provider} for {item.name}"
|
f"Unsupported provider {item.metadata_provider} for {item.name}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
update_single_func(item, provider)
|
await update_single_func(item, provider)
|
||||||
except InvalidConfigError:
|
except InvalidConfigError:
|
||||||
log.exception(f"Config error for {item.name}")
|
log.exception(f"Config error for {item.name}")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections.abc import Generator
|
from collections.abc import AsyncIterator
|
||||||
from contextvars import ContextVar
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.engine import Engine
|
|
||||||
from sqlalchemy.engine.url import URL
|
from sqlalchemy.engine.url import URL
|
||||||
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
from media_manager.database.config import DbConfig
|
from media_manager.database.config import DbConfig
|
||||||
|
|
||||||
@@ -16,8 +19,8 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
engine: Engine | None = None
|
engine: AsyncEngine | None = None
|
||||||
SessionLocal: sessionmaker | None = None
|
SessionLocal: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
|
||||||
def build_db_url(
|
def build_db_url(
|
||||||
@@ -40,7 +43,7 @@ def build_db_url(
|
|||||||
def init_engine(
|
def init_engine(
|
||||||
db_config: DbConfig | None = None,
|
db_config: DbConfig | None = None,
|
||||||
url: str | URL | None = None,
|
url: str | URL | None = None,
|
||||||
) -> Engine:
|
) -> AsyncEngine:
|
||||||
"""
|
"""
|
||||||
Initialize the global SQLAlchemy engine and session factory.
|
Initialize the global SQLAlchemy engine and session factory.
|
||||||
Pass either a DbConfig-like object or a full URL. Only initializes once.
|
Pass either a DbConfig-like object or a full URL. Only initializes once.
|
||||||
@@ -64,7 +67,7 @@ def init_engine(
|
|||||||
db_config.dbname,
|
db_config.dbname,
|
||||||
)
|
)
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_async_engine(
|
||||||
url,
|
url,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_size=10,
|
pool_size=10,
|
||||||
@@ -72,33 +75,23 @@ def init_engine(
|
|||||||
pool_timeout=30,
|
pool_timeout=30,
|
||||||
pool_recycle=1800,
|
pool_recycle=1800,
|
||||||
)
|
)
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
log.debug("SQLAlchemy engine initialized")
|
log.debug("SQLAlchemy engine initialized")
|
||||||
return engine
|
return engine
|
||||||
|
|
||||||
|
|
||||||
def get_engine() -> Engine:
|
async def get_async_session() -> AsyncIterator[AsyncSession]:
|
||||||
if engine is None:
|
|
||||||
msg = "Engine not initialized. Call init_engine(...) first."
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
return engine
|
|
||||||
|
|
||||||
|
|
||||||
def get_session() -> Generator[Session]:
|
|
||||||
if SessionLocal is None:
|
if SessionLocal is None:
|
||||||
msg = "Session factory not initialized. Call init_engine(...) first."
|
msg = "Session factory not initialized. Call init_engine(...) first."
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
db = SessionLocal()
|
async with SessionLocal() as db:
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
db.commit()
|
await db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
await db.rollback()
|
||||||
log.critical("", exc_info=True)
|
log.critical("", exc_info=True)
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
db_session: ContextVar[Session] = ContextVar("db_session")
|
DbSessionDependency = Annotated[AsyncSession, Depends(get_async_session)]
|
||||||
DbSessionDependency = Annotated[Session, Depends(get_session)]
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from media_manager.indexer.models import IndexerQueryResult
|
from media_manager.indexer.models import IndexerQueryResult
|
||||||
from media_manager.indexer.schemas import (
|
from media_manager.indexer.schemas import (
|
||||||
@@ -14,20 +14,24 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class IndexerRepository:
|
class IndexerRepository:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def get_result(self, result_id: IndexerQueryResultId) -> IndexerQueryResultSchema:
|
async def get_result(
|
||||||
|
self, result_id: IndexerQueryResultId
|
||||||
|
) -> IndexerQueryResultSchema:
|
||||||
return IndexerQueryResultSchema.model_validate(
|
return IndexerQueryResultSchema.model_validate(
|
||||||
self.db.get(IndexerQueryResult, result_id)
|
await self.db.get(IndexerQueryResult, result_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_result(self, result: IndexerQueryResultSchema) -> IndexerQueryResultSchema:
|
async def save_result(
|
||||||
|
self, result: IndexerQueryResultSchema
|
||||||
|
) -> IndexerQueryResultSchema:
|
||||||
result_data = result.model_dump()
|
result_data = result.model_dump()
|
||||||
result_data["download_url"] = str(
|
result_data["download_url"] = str(
|
||||||
result.download_url
|
result.download_url
|
||||||
) # this is the needful, because sqlalchemy is too dumb to handle the HttpUrl type
|
) # this is the needful, because sqlalchemy is too dumb to handle the HttpUrl type
|
||||||
|
|
||||||
self.db.add(IndexerQueryResult(**result_data))
|
self.db.add(IndexerQueryResult(**result_data))
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from media_manager.config import MediaManagerConfig
|
from media_manager.config import MediaManagerConfig
|
||||||
@@ -24,10 +25,10 @@ class IndexerService:
|
|||||||
if config.indexers.jackett.enabled:
|
if config.indexers.jackett.enabled:
|
||||||
self.indexers.append(Jackett())
|
self.indexers.append(Jackett())
|
||||||
|
|
||||||
def get_result(self, result_id: IndexerQueryResultId) -> IndexerQueryResult:
|
async def get_result(self, result_id: IndexerQueryResultId) -> IndexerQueryResult:
|
||||||
return self.repository.get_result(result_id=result_id)
|
return await self.repository.get_result(result_id=result_id)
|
||||||
|
|
||||||
def search(self, query: str, is_tv: bool) -> list[IndexerQueryResult]:
|
async def search(self, query: str, is_tv: bool) -> list[IndexerQueryResult]:
|
||||||
"""
|
"""
|
||||||
Search for results using the indexers based on a query.
|
Search for results using the indexers based on a query.
|
||||||
|
|
||||||
@@ -40,7 +41,9 @@ class IndexerService:
|
|||||||
|
|
||||||
for indexer in self.indexers:
|
for indexer in self.indexers:
|
||||||
try:
|
try:
|
||||||
indexer_results = indexer.search(query, is_tv=is_tv)
|
indexer_results = await asyncio.to_thread(
|
||||||
|
indexer.search, query, is_tv=is_tv
|
||||||
|
)
|
||||||
results.extend(indexer_results)
|
results.extend(indexer_results)
|
||||||
log.debug(
|
log.debug(
|
||||||
f"Indexer {indexer.__class__.__name__} returned {len(indexer_results)} results for query: {query}"
|
f"Indexer {indexer.__class__.__name__} returned {len(indexer_results)} results for query: {query}"
|
||||||
@@ -51,18 +54,20 @@ class IndexerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
self.repository.save_result(result=result)
|
await self.repository.save_result(result=result)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def search_movie(self, movie: Movie) -> list[IndexerQueryResult]:
|
async def search_movie(self, movie: Movie) -> list[IndexerQueryResult]:
|
||||||
query = f"{movie.name} {movie.year}"
|
query = f"{movie.name} {movie.year}"
|
||||||
query = remove_special_chars_and_parentheses(query)
|
query = remove_special_chars_and_parentheses(query)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for indexer in self.indexers:
|
for indexer in self.indexers:
|
||||||
try:
|
try:
|
||||||
indexer_results = indexer.search_movie(query=query, movie=movie)
|
indexer_results = await asyncio.to_thread(
|
||||||
|
indexer.search_movie, query=query, movie=movie
|
||||||
|
)
|
||||||
if indexer_results:
|
if indexer_results:
|
||||||
results.extend(indexer_results)
|
results.extend(indexer_results)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -71,19 +76,24 @@ class IndexerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
self.repository.save_result(result=result)
|
await self.repository.save_result(result=result)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def search_season(self, show: Show, season_number: int) -> list[IndexerQueryResult]:
|
async def search_season(
|
||||||
|
self, show: Show, season_number: int
|
||||||
|
) -> list[IndexerQueryResult]:
|
||||||
query = f"{show.name} {show.year} S{season_number:02d}"
|
query = f"{show.name} {show.year} S{season_number:02d}"
|
||||||
query = remove_special_chars_and_parentheses(query)
|
query = remove_special_chars_and_parentheses(query)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for indexer in self.indexers:
|
for indexer in self.indexers:
|
||||||
try:
|
try:
|
||||||
indexer_results = indexer.search_season(
|
indexer_results = await asyncio.to_thread(
|
||||||
query=query, show=show, season_number=season_number
|
indexer.search_season,
|
||||||
|
query=query,
|
||||||
|
show=show,
|
||||||
|
season_number=season_number,
|
||||||
)
|
)
|
||||||
if indexer_results:
|
if indexer_results:
|
||||||
results.extend(indexer_results)
|
results.extend(indexer_results)
|
||||||
@@ -93,6 +103,6 @@ class IndexerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
self.repository.save_result(result=result)
|
await self.repository.save_result(result=result)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -18,27 +18,31 @@ class AbstractMetadataProvider(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_show_metadata(self, show_id: int, language: str | None = None) -> Show:
|
async def get_show_metadata(
|
||||||
|
self, show_id: int, language: str | None = None
|
||||||
|
) -> Show:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_movie_metadata(self, movie_id: int, language: str | None = None) -> Movie:
|
async def get_movie_metadata(
|
||||||
|
self, movie_id: int, language: str | None = None
|
||||||
|
) -> Movie:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def search_show(
|
async def search_show(
|
||||||
self, query: str | None = None
|
self, query: str | None = None
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def search_movie(
|
async def search_movie(
|
||||||
self, query: str | None = None
|
self, query: str | None = None
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def download_show_poster_image(self, show: Show) -> bool:
|
async def download_show_poster_image(self, show: Show) -> bool:
|
||||||
"""
|
"""
|
||||||
Downloads the poster image for a show.
|
Downloads the poster image for a show.
|
||||||
:param show: The show to download the poster image for.
|
:param show: The show to download the poster image for.
|
||||||
@@ -47,7 +51,7 @@ class AbstractMetadataProvider(ABC):
|
|||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def download_movie_poster_image(self, movie: Movie) -> bool:
|
async def download_movie_poster_image(self, movie: Movie) -> bool:
|
||||||
"""
|
"""
|
||||||
Downloads the poster image for a show.
|
Downloads the poster image for a show.
|
||||||
:param movie: The show to download the poster image for.
|
:param movie: The show to download the poster image for.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import override
|
from typing import override
|
||||||
|
|
||||||
import requests
|
import httpx
|
||||||
|
|
||||||
import media_manager.metadataProvider.utils
|
import media_manager.metadataProvider.utils
|
||||||
from media_manager.config import MediaManagerConfig
|
from media_manager.config import MediaManagerConfig
|
||||||
@@ -17,6 +17,8 @@ ENDED_STATUS = {"Ended", "Canceled"}
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
|
||||||
class TmdbMetadataProvider(AbstractMetadataProvider):
|
class TmdbMetadataProvider(AbstractMetadataProvider):
|
||||||
name = "tmdb"
|
name = "tmdb"
|
||||||
@@ -39,70 +41,72 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
return original_language
|
return original_language
|
||||||
return self.default_language
|
return self.default_language
|
||||||
|
|
||||||
def __get_show_metadata(self, show_id: int, language: str | None = None) -> dict:
|
async def __get_show_metadata(
|
||||||
|
self, show_id: int, language: str | None = None
|
||||||
|
) -> dict:
|
||||||
if language is None:
|
if language is None:
|
||||||
language = self.default_language
|
language = self.default_language
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/tv/shows/{show_id}",
|
url=f"{self.url}/tv/shows/{show_id}",
|
||||||
params={"language": language},
|
params={"language": language},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.RequestException as e:
|
except httpx.HTTPError as e:
|
||||||
log.exception(f"TMDB API error getting show metadata for ID {show_id}")
|
log.exception(f"TMDB API error getting show metadata for ID {show_id}")
|
||||||
if notification_manager.is_configured():
|
if notification_manager.is_configured():
|
||||||
notification_manager.send_notification(
|
await notification_manager.send_notification(
|
||||||
title="TMDB API Error",
|
title="TMDB API Error",
|
||||||
message=f"Failed to fetch show metadata for ID {show_id} from TMDB. Error: {e}",
|
message=f"Failed to fetch show metadata for ID {show_id} from TMDB. Error: {e}",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __get_show_external_ids(self, show_id: int) -> dict:
|
async def __get_show_external_ids(self, show_id: int) -> dict:
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/tv/shows/{show_id}/external_ids",
|
url=f"{self.url}/tv/shows/{show_id}/external_ids",
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.RequestException as e:
|
except httpx.HTTPError as e:
|
||||||
log.exception(f"TMDB API error getting show external IDs for ID {show_id}")
|
log.exception(f"TMDB API error getting show external IDs for ID {show_id}")
|
||||||
if notification_manager.is_configured():
|
if notification_manager.is_configured():
|
||||||
notification_manager.send_notification(
|
await notification_manager.send_notification(
|
||||||
title="TMDB API Error",
|
title="TMDB API Error",
|
||||||
message=f"Failed to fetch show external IDs for ID {show_id} from TMDB. Error: {e}",
|
message=f"Failed to fetch show external IDs for ID {show_id} from TMDB. Error: {e}",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __get_season_metadata(
|
async def __get_season_metadata(
|
||||||
self, show_id: int, season_number: int, language: str | None = None
|
self, show_id: int, season_number: int, language: str | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if language is None:
|
if language is None:
|
||||||
language = self.default_language
|
language = self.default_language
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/tv/shows/{show_id}/{season_number}",
|
url=f"{self.url}/tv/shows/{show_id}/{season_number}",
|
||||||
params={"language": language},
|
params={"language": language},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.RequestException as e:
|
except httpx.HTTPError as e:
|
||||||
log.exception(
|
log.exception(
|
||||||
f"TMDB API error getting season {season_number} metadata for show ID {show_id}"
|
f"TMDB API error getting season {season_number} metadata for show ID {show_id}"
|
||||||
)
|
)
|
||||||
if notification_manager.is_configured():
|
if notification_manager.is_configured():
|
||||||
notification_manager.send_notification(
|
await notification_manager.send_notification(
|
||||||
title="TMDB API Error",
|
title="TMDB API Error",
|
||||||
message=f"Failed to fetch season {season_number} metadata for show ID {show_id} from TMDB. Error: {e}",
|
message=f"Failed to fetch season {season_number} metadata for show ID {show_id} from TMDB. Error: {e}",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __search_tv(self, query: str, page: int) -> dict:
|
async def __search_tv(self, query: str, page: int) -> dict:
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/tv/search",
|
url=f"{self.url}/tv/search",
|
||||||
params={
|
params={
|
||||||
"query": query,
|
"query": query,
|
||||||
@@ -112,74 +116,76 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.RequestException as e:
|
except httpx.HTTPError as e:
|
||||||
log.exception(f"TMDB API error searching TV shows with query '{query}'")
|
log.exception(f"TMDB API error searching TV shows with query '{query}'")
|
||||||
if notification_manager.is_configured():
|
if notification_manager.is_configured():
|
||||||
notification_manager.send_notification(
|
await notification_manager.send_notification(
|
||||||
title="TMDB API Error",
|
title="TMDB API Error",
|
||||||
message=f"Failed to search TV shows with query '{query}' on TMDB. Error: {e}",
|
message=f"Failed to search TV shows with query '{query}' on TMDB. Error: {e}",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __get_trending_tv(self) -> dict:
|
async def __get_trending_tv(self) -> dict:
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/tv/trending",
|
url=f"{self.url}/tv/trending",
|
||||||
params={"language": self.default_language},
|
params={"language": self.default_language},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.RequestException as e:
|
except httpx.HTTPError as e:
|
||||||
log.exception("TMDB API error getting trending TV")
|
log.exception("TMDB API error getting trending TV")
|
||||||
if notification_manager.is_configured():
|
if notification_manager.is_configured():
|
||||||
notification_manager.send_notification(
|
await notification_manager.send_notification(
|
||||||
title="TMDB API Error",
|
title="TMDB API Error",
|
||||||
message=f"Failed to fetch trending TV shows from TMDB. Error: {e}",
|
message=f"Failed to fetch trending TV shows from TMDB. Error: {e}",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __get_movie_metadata(self, movie_id: int, language: str | None = None) -> dict:
|
async def __get_movie_metadata(
|
||||||
|
self, movie_id: int, language: str | None = None
|
||||||
|
) -> dict:
|
||||||
if language is None:
|
if language is None:
|
||||||
language = self.default_language
|
language = self.default_language
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/movies/{movie_id}",
|
url=f"{self.url}/movies/{movie_id}",
|
||||||
params={"language": language},
|
params={"language": language},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.RequestException as e:
|
except httpx.HTTPError as e:
|
||||||
log.exception(f"TMDB API error getting movie metadata for ID {movie_id}")
|
log.exception(f"TMDB API error getting movie metadata for ID {movie_id}")
|
||||||
if notification_manager.is_configured():
|
if notification_manager.is_configured():
|
||||||
notification_manager.send_notification(
|
await notification_manager.send_notification(
|
||||||
title="TMDB API Error",
|
title="TMDB API Error",
|
||||||
message=f"Failed to fetch movie metadata for ID {movie_id} from TMDB. Error: {e}",
|
message=f"Failed to fetch movie metadata for ID {movie_id} from TMDB. Error: {e}",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __get_movie_external_ids(self, movie_id: int) -> dict:
|
async def __get_movie_external_ids(self, movie_id: int) -> dict:
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/movies/{movie_id}/external_ids", timeout=60
|
url=f"{self.url}/movies/{movie_id}/external_ids", timeout=60
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.RequestException as e:
|
except httpx.HTTPError as e:
|
||||||
log.exception(
|
log.exception(
|
||||||
f"TMDB API error getting movie external IDs for ID {movie_id}"
|
f"TMDB API error getting movie external IDs for ID {movie_id}"
|
||||||
)
|
)
|
||||||
if notification_manager.is_configured():
|
if notification_manager.is_configured():
|
||||||
notification_manager.send_notification(
|
await notification_manager.send_notification(
|
||||||
title="TMDB API Error",
|
title="TMDB API Error",
|
||||||
message=f"Failed to fetch movie external IDs for ID {movie_id} from TMDB. Error: {e}",
|
message=f"Failed to fetch movie external IDs for ID {movie_id} from TMDB. Error: {e}",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __search_movie(self, query: str, page: int) -> dict:
|
async def __search_movie(self, query: str, page: int) -> dict:
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/movies/search",
|
url=f"{self.url}/movies/search",
|
||||||
params={
|
params={
|
||||||
"query": query,
|
"query": query,
|
||||||
@@ -189,40 +195,42 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.RequestException as e:
|
except httpx.HTTPError as e:
|
||||||
log.exception(f"TMDB API error searching movies with query '{query}'")
|
log.exception(f"TMDB API error searching movies with query '{query}'")
|
||||||
if notification_manager.is_configured():
|
if notification_manager.is_configured():
|
||||||
notification_manager.send_notification(
|
await notification_manager.send_notification(
|
||||||
title="TMDB API Error",
|
title="TMDB API Error",
|
||||||
message=f"Failed to search movies with query '{query}' on TMDB. Error: {e}",
|
message=f"Failed to search movies with query '{query}' on TMDB. Error: {e}",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __get_trending_movies(self) -> dict:
|
async def __get_trending_movies(self) -> dict:
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/movies/trending",
|
url=f"{self.url}/movies/trending",
|
||||||
params={"language": self.default_language},
|
params={"language": self.default_language},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.RequestException as e:
|
except httpx.HTTPError as e:
|
||||||
log.exception("TMDB API error getting trending movies")
|
log.exception("TMDB API error getting trending movies")
|
||||||
if notification_manager.is_configured():
|
if notification_manager.is_configured():
|
||||||
notification_manager.send_notification(
|
await notification_manager.send_notification(
|
||||||
title="TMDB API Error",
|
title="TMDB API Error",
|
||||||
message=f"Failed to fetch trending movies from TMDB. Error: {e}",
|
message=f"Failed to fetch trending movies from TMDB. Error: {e}",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def download_show_poster_image(self, show: Show) -> bool:
|
async def download_show_poster_image(self, show: Show) -> bool:
|
||||||
# Determine which language to use based on show's original_language
|
# Determine which language to use based on show's original_language
|
||||||
language = self.__get_language_param(show.original_language)
|
language = self.__get_language_param(show.original_language)
|
||||||
|
|
||||||
# Fetch metadata in the appropriate language to get localized poster
|
# Fetch metadata in the appropriate language to get localized poster
|
||||||
show_metadata = self.__get_show_metadata(show.external_id, language=language)
|
show_metadata = await self.__get_show_metadata(
|
||||||
|
show.external_id, language=language
|
||||||
|
)
|
||||||
|
|
||||||
# downloading the poster
|
# downloading the poster
|
||||||
# all pictures from TMDB should already be jpeg, so no need to convert
|
# all pictures from TMDB should already be jpeg, so no need to convert
|
||||||
@@ -230,7 +238,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
poster_url = (
|
poster_url = (
|
||||||
"https://image.tmdb.org/t/p/original" + show_metadata["poster_path"]
|
"https://image.tmdb.org/t/p/original" + show_metadata["poster_path"]
|
||||||
)
|
)
|
||||||
if media_manager.metadataProvider.utils.download_poster_image(
|
if await media_manager.metadataProvider.utils.download_poster_image(
|
||||||
storage_path=self.storage_path, poster_url=poster_url, uuid=show.id
|
storage_path=self.storage_path, poster_url=poster_url, uuid=show.id
|
||||||
):
|
):
|
||||||
log.info("Successfully downloaded poster image for show " + show.name)
|
log.info("Successfully downloaded poster image for show " + show.name)
|
||||||
@@ -243,7 +251,9 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def get_show_metadata(self, show_id: int, language: str | None = None) -> Show:
|
async def get_show_metadata(
|
||||||
|
self, show_id: int, language: str | None = None
|
||||||
|
) -> Show:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
:param show_id: the external id of the show
|
:param show_id: the external id of the show
|
||||||
@@ -255,45 +265,44 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
"""
|
"""
|
||||||
# If language not provided, fetch once to determine original language
|
# If language not provided, fetch once to determine original language
|
||||||
if language is None:
|
if language is None:
|
||||||
show_metadata = self.__get_show_metadata(show_id)
|
show_metadata = await self.__get_show_metadata(show_id)
|
||||||
language = show_metadata.get("original_language")
|
language = show_metadata.get("original_language")
|
||||||
|
|
||||||
# Determine which language to use for metadata
|
# Determine which language to use for metadata
|
||||||
language = self.__get_language_param(language)
|
language = self.__get_language_param(language)
|
||||||
|
|
||||||
# Fetch show metadata in the appropriate language
|
# Fetch show metadata in the appropriate language
|
||||||
show_metadata = self.__get_show_metadata(show_id, language=language)
|
show_metadata = await self.__get_show_metadata(show_id, language=language)
|
||||||
|
|
||||||
# get imdb id
|
# get imdb id
|
||||||
external_ids = self.__get_show_external_ids(show_id=show_id)
|
external_ids = await self.__get_show_external_ids(show_id=show_id)
|
||||||
imdb_id = external_ids.get("imdb_id")
|
imdb_id = external_ids.get("imdb_id")
|
||||||
|
|
||||||
season_list = []
|
season_metadata_list = [
|
||||||
# inserting all the metadata into the objects
|
await self.__get_season_metadata(
|
||||||
for season in show_metadata["seasons"]:
|
|
||||||
season_metadata = self.__get_season_metadata(
|
|
||||||
show_id=show_metadata["id"],
|
show_id=show_metadata["id"],
|
||||||
season_number=season["season_number"],
|
season_number=season["season_number"],
|
||||||
language=language,
|
language=language,
|
||||||
)
|
)
|
||||||
episode_list = [
|
for season in show_metadata["seasons"]
|
||||||
Episode(
|
]
|
||||||
external_id=int(episode["id"]),
|
season_list = [
|
||||||
title=episode["name"],
|
Season(
|
||||||
number=EpisodeNumber(episode["episode_number"]),
|
external_id=int(season_metadata["id"]),
|
||||||
)
|
name=season_metadata["name"],
|
||||||
for episode in season_metadata["episodes"]
|
overview=season_metadata["overview"],
|
||||||
]
|
number=SeasonNumber(season_metadata["season_number"]),
|
||||||
|
episodes=[
|
||||||
season_list.append(
|
Episode(
|
||||||
Season(
|
external_id=int(episode["id"]),
|
||||||
external_id=int(season_metadata["id"]),
|
title=episode["name"],
|
||||||
name=season_metadata["name"],
|
number=EpisodeNumber(episode["episode_number"]),
|
||||||
overview=season_metadata["overview"],
|
)
|
||||||
number=SeasonNumber(season_metadata["season_number"]),
|
for episode in season_metadata["episodes"]
|
||||||
episodes=episode_list,
|
],
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
for season_metadata in season_metadata_list
|
||||||
|
]
|
||||||
|
|
||||||
year = media_manager.metadataProvider.utils.get_year_from_date(
|
year = media_manager.metadataProvider.utils.get_year_from_date(
|
||||||
show_metadata["first_air_date"]
|
show_metadata["first_air_date"]
|
||||||
@@ -312,7 +321,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def search_show(
|
async def search_show(
|
||||||
self, query: str | None = None, max_pages: int = 5
|
self, query: str | None = None, max_pages: int = 5
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
"""
|
"""
|
||||||
@@ -321,10 +330,10 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
if query is None:
|
if query is None:
|
||||||
results = self.__get_trending_tv()["results"]
|
results = (await self.__get_trending_tv())["results"]
|
||||||
else:
|
else:
|
||||||
for page_number in range(1, max_pages + 1):
|
for page_number in range(1, max_pages + 1):
|
||||||
result_page = self.__search_tv(query=query, page=page_number)
|
result_page = await self.__search_tv(query=query, page=page_number)
|
||||||
|
|
||||||
if not result_page["results"]:
|
if not result_page["results"]:
|
||||||
break
|
break
|
||||||
@@ -371,7 +380,9 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
return formatted_results
|
return formatted_results
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def get_movie_metadata(self, movie_id: int, language: str | None = None) -> Movie:
|
async def get_movie_metadata(
|
||||||
|
self, movie_id: int, language: str | None = None
|
||||||
|
) -> Movie:
|
||||||
"""
|
"""
|
||||||
Get movie metadata with language-aware fetching.
|
Get movie metadata with language-aware fetching.
|
||||||
|
|
||||||
@@ -384,17 +395,19 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
"""
|
"""
|
||||||
# If language not provided, fetch once to determine original language
|
# If language not provided, fetch once to determine original language
|
||||||
if language is None:
|
if language is None:
|
||||||
movie_metadata = self.__get_movie_metadata(movie_id=movie_id)
|
movie_metadata = await self.__get_movie_metadata(movie_id=movie_id)
|
||||||
language = movie_metadata.get("original_language")
|
language = movie_metadata.get("original_language")
|
||||||
|
|
||||||
# Determine which language to use for metadata
|
# Determine which language to use for metadata
|
||||||
language = self.__get_language_param(language)
|
language = self.__get_language_param(language)
|
||||||
|
|
||||||
# Fetch movie metadata in the appropriate language
|
# Fetch movie metadata in the appropriate language
|
||||||
movie_metadata = self.__get_movie_metadata(movie_id=movie_id, language=language)
|
movie_metadata = await self.__get_movie_metadata(
|
||||||
|
movie_id=movie_id, language=language
|
||||||
|
)
|
||||||
|
|
||||||
# get imdb id
|
# get imdb id
|
||||||
external_ids = self.__get_movie_external_ids(movie_id=movie_id)
|
external_ids = await self.__get_movie_external_ids(movie_id=movie_id)
|
||||||
imdb_id = external_ids.get("imdb_id")
|
imdb_id = external_ids.get("imdb_id")
|
||||||
|
|
||||||
year = media_manager.metadataProvider.utils.get_year_from_date(
|
year = media_manager.metadataProvider.utils.get_year_from_date(
|
||||||
@@ -412,7 +425,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def search_movie(
|
async def search_movie(
|
||||||
self, query: str | None = None, max_pages: int = 5
|
self, query: str | None = None, max_pages: int = 5
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
"""
|
"""
|
||||||
@@ -421,10 +434,10 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
if query is None:
|
if query is None:
|
||||||
results = self.__get_trending_movies()["results"]
|
results = (await self.__get_trending_movies())["results"]
|
||||||
else:
|
else:
|
||||||
for page_number in range(1, max_pages + 1):
|
for page_number in range(1, max_pages + 1):
|
||||||
result_page = self.__search_movie(query=query, page=page_number)
|
result_page = await self.__search_movie(query=query, page=page_number)
|
||||||
|
|
||||||
if not result_page["results"]:
|
if not result_page["results"]:
|
||||||
break
|
break
|
||||||
@@ -471,12 +484,12 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
return formatted_results
|
return formatted_results
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def download_movie_poster_image(self, movie: Movie) -> bool:
|
async def download_movie_poster_image(self, movie: Movie) -> bool:
|
||||||
# Determine which language to use based on movie's original_language
|
# Determine which language to use based on movie's original_language
|
||||||
language = self.__get_language_param(movie.original_language)
|
language = self.__get_language_param(movie.original_language)
|
||||||
|
|
||||||
# Fetch metadata in the appropriate language to get localized poster
|
# Fetch metadata in the appropriate language to get localized poster
|
||||||
movie_metadata = self.__get_movie_metadata(
|
movie_metadata = await self.__get_movie_metadata(
|
||||||
movie_id=movie.external_id, language=language
|
movie_id=movie.external_id, language=language
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -486,7 +499,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
poster_url = (
|
poster_url = (
|
||||||
"https://image.tmdb.org/t/p/original" + movie_metadata["poster_path"]
|
"https://image.tmdb.org/t/p/original" + movie_metadata["poster_path"]
|
||||||
)
|
)
|
||||||
if media_manager.metadataProvider.utils.download_poster_image(
|
if await media_manager.metadataProvider.utils.download_poster_image(
|
||||||
storage_path=self.storage_path, poster_url=poster_url, uuid=movie.id
|
storage_path=self.storage_path, poster_url=poster_url, uuid=movie.id
|
||||||
):
|
):
|
||||||
log.info("Successfully downloaded poster image for movie " + movie.name)
|
log.info("Successfully downloaded poster image for movie " + movie.name)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import override
|
from typing import override
|
||||||
|
|
||||||
import requests
|
import httpx
|
||||||
|
|
||||||
import media_manager.metadataProvider.utils
|
import media_manager.metadataProvider.utils
|
||||||
from media_manager.config import MediaManagerConfig
|
from media_manager.config import MediaManagerConfig
|
||||||
@@ -14,6 +14,8 @@ from media_manager.tv.schemas import Episode, Season, SeasonNumber, Show
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
|
||||||
class TvdbMetadataProvider(AbstractMetadataProvider):
|
class TvdbMetadataProvider(AbstractMetadataProvider):
|
||||||
name = "tvdb"
|
name = "tvdb"
|
||||||
@@ -22,37 +24,46 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
config = MediaManagerConfig().metadata.tvdb
|
config = MediaManagerConfig().metadata.tvdb
|
||||||
self.url = config.tvdb_relay_url
|
self.url = config.tvdb_relay_url
|
||||||
|
|
||||||
def __get_show(self, show_id: int) -> dict:
|
async def __get_show(self, show_id: int) -> dict:
|
||||||
return requests.get(url=f"{self.url}/tv/shows/{show_id}", timeout=60).json()
|
response = await _client.get(url=f"{self.url}/tv/shows/{show_id}", timeout=60)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
def __get_season(self, show_id: int) -> dict:
|
async def __get_season(self, show_id: int) -> dict:
|
||||||
return requests.get(url=f"{self.url}/tv/seasons/{show_id}", timeout=60).json()
|
response = await _client.get(
|
||||||
|
url=f"{self.url}/tv/seasons/{show_id}", timeout=60
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
def __search_tv(self, query: str) -> dict:
|
async def __search_tv(self, query: str) -> dict:
|
||||||
return requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/tv/search", params={"query": query}, timeout=60
|
url=f"{self.url}/tv/search", params={"query": query}, timeout=60
|
||||||
).json()
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
def __get_trending_tv(self) -> dict:
|
async def __get_trending_tv(self) -> dict:
|
||||||
return requests.get(url=f"{self.url}/tv/trending", timeout=60).json()
|
response = await _client.get(url=f"{self.url}/tv/trending", timeout=60)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
def __get_movie(self, movie_id: int) -> dict:
|
async def __get_movie(self, movie_id: int) -> dict:
|
||||||
return requests.get(url=f"{self.url}/movies/{movie_id}", timeout=60).json()
|
response = await _client.get(url=f"{self.url}/movies/{movie_id}", timeout=60)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
def __search_movie(self, query: str) -> dict:
|
async def __search_movie(self, query: str) -> dict:
|
||||||
return requests.get(
|
response = await _client.get(
|
||||||
url=f"{self.url}/movies/search", params={"query": query}, timeout=60
|
url=f"{self.url}/movies/search", params={"query": query}, timeout=60
|
||||||
).json()
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
def __get_trending_movies(self) -> dict:
|
async def __get_trending_movies(self) -> dict:
|
||||||
return requests.get(url=f"{self.url}/movies/trending", timeout=60).json()
|
response = await _client.get(url=f"{self.url}/movies/trending", timeout=60)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def download_show_poster_image(self, show: Show) -> bool:
|
async def download_show_poster_image(self, show: Show) -> bool:
|
||||||
show_metadata = self.__get_show(show_id=show.external_id)
|
show_metadata = await self.__get_show(show_id=show.external_id)
|
||||||
|
|
||||||
if show_metadata["image"] is not None:
|
if show_metadata["image"] is not None:
|
||||||
media_manager.metadataProvider.utils.download_poster_image(
|
await media_manager.metadataProvider.utils.download_poster_image(
|
||||||
storage_path=self.storage_path,
|
storage_path=self.storage_path,
|
||||||
poster_url=show_metadata["image"],
|
poster_url=show_metadata["image"],
|
||||||
uuid=show.id,
|
uuid=show.id,
|
||||||
@@ -63,13 +74,15 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def get_show_metadata(self, show_id: int, language: str | None = None) -> Show:
|
async def get_show_metadata(
|
||||||
|
self, show_id: int, language: str | None = None
|
||||||
|
) -> Show:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
:param show_id: The external id of the show
|
:param show_id: The external id of the show
|
||||||
:param language: does nothing, TVDB does not support multiple languages
|
:param language: does nothing, TVDB does not support multiple languages
|
||||||
"""
|
"""
|
||||||
series = self.__get_show(show_id)
|
series = await self.__get_show(show_id)
|
||||||
seasons = []
|
seasons = []
|
||||||
seasons_ids = [season["id"] for season in series["seasons"]]
|
seasons_ids = [season["id"] for season in series["seasons"]]
|
||||||
|
|
||||||
@@ -81,11 +94,10 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
if remote_id.get("type") == 2:
|
if remote_id.get("type") == 2:
|
||||||
imdb_id = remote_id.get("id")
|
imdb_id = remote_id.get("id")
|
||||||
|
|
||||||
for season in seasons_ids:
|
season_payloads = [await self.__get_season(show_id=sid) for sid in seasons_ids]
|
||||||
s = self.__get_season(show_id=season)
|
for s in season_payloads:
|
||||||
# the seasons need to be filtered to a certain type,
|
# Filter to "aired order" only; mixing aired/dvd orders duplicates
|
||||||
# otherwise the same season will be imported in aired and dvd order,
|
# (show_id, season_number) and violates the seasons unique constraint.
|
||||||
# which causes duplicate season number + show ids which in turn violates a unique constraint of the season table
|
|
||||||
if s["type"]["id"] != 1:
|
if s["type"]["id"] != 1:
|
||||||
log.info(
|
log.info(
|
||||||
f"Season {s['type']['id']} will not be downloaded because it is not a 'aired order' season"
|
f"Season {s['type']['id']} will not be downloaded because it is not a 'aired order' season"
|
||||||
@@ -122,11 +134,11 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def search_show(
|
async def search_show(
|
||||||
self, query: str | None = None
|
self, query: str | None = None
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
if query:
|
if query:
|
||||||
results = self.__search_tv(query=query)
|
results = await self.__search_tv(query=query)
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
for result in results:
|
for result in results:
|
||||||
try:
|
try:
|
||||||
@@ -151,7 +163,7 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.warning("Error processing search result", exc_info=True)
|
log.warning("Error processing search result", exc_info=True)
|
||||||
return formatted_results
|
return formatted_results
|
||||||
results = self.__get_trending_tv()
|
results = await self.__get_trending_tv()
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
for result in results:
|
for result in results:
|
||||||
try:
|
try:
|
||||||
@@ -181,20 +193,16 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
return formatted_results
|
return formatted_results
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def search_movie(
|
async def search_movie(
|
||||||
self, query: str | None = None
|
self, query: str | None = None
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
if query:
|
if query:
|
||||||
results = self.__search_movie(query=query)
|
results = await self.__search_movie(query=query)
|
||||||
results = results[0:20]
|
results = [r for r in results[0:20] if r["type"] == "movie"]
|
||||||
log.debug(f"got {len(results)} results from TVDB search")
|
log.debug(f"got {len(results)} results from TVDB search")
|
||||||
|
movie_payloads = [await self.__get_movie(r["tvdb_id"]) for r in results]
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
for result in results:
|
for result in movie_payloads:
|
||||||
if result["type"] != "movie":
|
|
||||||
continue
|
|
||||||
|
|
||||||
result = self.__get_movie(result["tvdb_id"])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
year = result["year"]
|
year = result["year"]
|
||||||
@@ -216,12 +224,12 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.warning("Error processing search result", exc_info=True)
|
log.warning("Error processing search result", exc_info=True)
|
||||||
return formatted_results
|
return formatted_results
|
||||||
results = self.__get_trending_movies()
|
results = await self.__get_trending_movies()
|
||||||
results = results[0:20]
|
results = results[0:20]
|
||||||
log.debug(f"got {len(results)} results from TVDB search")
|
log.debug(f"got {len(results)} results from TVDB search")
|
||||||
|
movie_payloads = [await self.__get_movie(r["id"]) for r in results]
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
for result in results:
|
for result in movie_payloads:
|
||||||
result = self.__get_movie(result["id"])
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
year = result["year"]
|
year = result["year"]
|
||||||
@@ -252,11 +260,11 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
return formatted_results
|
return formatted_results
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def download_movie_poster_image(self, movie: Movie) -> bool:
|
async def download_movie_poster_image(self, movie: Movie) -> bool:
|
||||||
movie_metadata = self.__get_movie(movie.external_id)
|
movie_metadata = await self.__get_movie(movie.external_id)
|
||||||
|
|
||||||
if movie_metadata["image"] is not None:
|
if movie_metadata["image"] is not None:
|
||||||
media_manager.metadataProvider.utils.download_poster_image(
|
await media_manager.metadataProvider.utils.download_poster_image(
|
||||||
storage_path=self.storage_path,
|
storage_path=self.storage_path,
|
||||||
poster_url=movie_metadata["image"],
|
poster_url=movie_metadata["image"],
|
||||||
uuid=movie.id,
|
uuid=movie.id,
|
||||||
@@ -267,14 +275,16 @@ class TvdbMetadataProvider(AbstractMetadataProvider):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def get_movie_metadata(self, movie_id: int, language: str | None = None) -> Movie:
|
async def get_movie_metadata(
|
||||||
|
self, movie_id: int, language: str | None = None
|
||||||
|
) -> Movie:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
:param movie_id: the external id of the movie
|
:param movie_id: the external id of the movie
|
||||||
:param language: does nothing, TVDB does not support multiple languages
|
:param language: does nothing, TVDB does not support multiple languages
|
||||||
:return: returns a Movie object
|
:return: returns a Movie object
|
||||||
"""
|
"""
|
||||||
movie = self.__get_movie(movie_id=movie_id)
|
movie = await self.__get_movie(movie_id=movie_id)
|
||||||
|
|
||||||
# get imdb id from remote ids
|
# get imdb id from remote ids
|
||||||
imdb_id = None
|
imdb_id = None
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import requests
|
import httpx
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
@@ -11,15 +12,22 @@ def get_year_from_date(first_air_date: str | None) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def download_poster_image(storage_path: Path, poster_url: str, uuid: UUID) -> bool:
|
def _process_image(image_file_path: Path, content: bytes) -> None:
|
||||||
res = requests.get(poster_url, stream=True, timeout=60)
|
image_file_path.write_bytes(content)
|
||||||
|
|
||||||
|
original_image = Image.open(image_file_path)
|
||||||
|
original_image.save(image_file_path.with_suffix(".avif"), quality=50)
|
||||||
|
original_image.save(image_file_path.with_suffix(".webp"), quality=50)
|
||||||
|
|
||||||
|
|
||||||
|
async def download_poster_image(
|
||||||
|
storage_path: Path, poster_url: str, uuid: UUID
|
||||||
|
) -> bool:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
res = await client.get(poster_url)
|
||||||
|
|
||||||
if res.status_code == 200:
|
if res.status_code == 200:
|
||||||
image_file_path = storage_path.joinpath(str(uuid)).with_suffix(".jpg")
|
image_file_path = storage_path.joinpath(str(uuid)).with_suffix(".jpg")
|
||||||
image_file_path.write_bytes(res.content)
|
await asyncio.to_thread(_process_image, image_file_path, res.content)
|
||||||
|
|
||||||
original_image = Image.open(image_file_path)
|
|
||||||
original_image.save(image_file_path.with_suffix(".avif"), quality=50)
|
|
||||||
original_image.save(image_file_path.with_suffix(".webp"), quality=50)
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -72,12 +72,12 @@ def get_movie_service(
|
|||||||
movie_service_dep = Annotated[MovieService, Depends(get_movie_service)]
|
movie_service_dep = Annotated[MovieService, Depends(get_movie_service)]
|
||||||
|
|
||||||
|
|
||||||
def get_movie_by_id(
|
async def get_movie_by_id(
|
||||||
movie_service: movie_service_dep,
|
movie_service: movie_service_dep,
|
||||||
movie_id: MovieId = Path(..., description="The ID of the movie"),
|
movie_id: MovieId = Path(..., description="The ID of the movie"),
|
||||||
) -> Movie:
|
) -> Movie:
|
||||||
try:
|
try:
|
||||||
movie = movie_service.get_movie_by_id(movie_id)
|
movie = await movie_service.get_movie_by_id(movie_id)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from media_manager.common.service import BaseMediaService
|
from media_manager.common.service import BaseMediaService
|
||||||
@@ -49,7 +50,7 @@ class MovieImportService(BaseMediaService[Movie, Movie]):
|
|||||||
libraries=misc_config.movie_libraries,
|
libraries=misc_config.movie_libraries,
|
||||||
)
|
)
|
||||||
|
|
||||||
def import_movie(
|
async def import_movie(
|
||||||
self,
|
self,
|
||||||
movie: Movie,
|
movie: Movie,
|
||||||
video_files: list[Path],
|
video_files: list[Path],
|
||||||
@@ -72,7 +73,9 @@ class MovieImportService(BaseMediaService[Movie, Movie]):
|
|||||||
target_video_file = (
|
target_video_file = (
|
||||||
movie_root_path / f"{movie_file_name}{video_files[0].suffix}"
|
movie_root_path / f"{movie_file_name}{video_files[0].suffix}"
|
||||||
)
|
)
|
||||||
import_file(target_file=target_video_file, source_file=video_files[0])
|
await asyncio.to_thread(
|
||||||
|
import_file, target_file=target_video_file, source_file=video_files[0]
|
||||||
|
)
|
||||||
imported_any = True
|
imported_any = True
|
||||||
|
|
||||||
for subtitle_file in subtitle_files:
|
for subtitle_file in subtitle_files:
|
||||||
@@ -82,7 +85,9 @@ class MovieImportService(BaseMediaService[Movie, Movie]):
|
|||||||
if match:
|
if match:
|
||||||
lang = match.group(1)
|
lang = match.group(1)
|
||||||
target = movie_root_path / f"{movie_file_name}.{lang}.srt"
|
target = movie_root_path / f"{movie_file_name}.{lang}.srt"
|
||||||
import_file(target_file=target, source_file=subtitle_file)
|
await asyncio.to_thread(
|
||||||
|
import_file, target_file=target, source_file=subtitle_file
|
||||||
|
)
|
||||||
imported_any = True
|
imported_any = True
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception(f"Failed to import movie {movie.name}")
|
log.exception(f"Failed to import movie {movie.name}")
|
||||||
@@ -90,50 +95,55 @@ class MovieImportService(BaseMediaService[Movie, Movie]):
|
|||||||
else:
|
else:
|
||||||
return imported_any
|
return imported_any
|
||||||
|
|
||||||
def import_torrent_files(self, torrent: Torrent, movie: Movie) -> None:
|
async def import_torrent_files(self, torrent: Torrent, movie: Movie) -> None:
|
||||||
video_files, subtitle_files, _ = get_files_for_import(torrent=torrent)
|
# Filesystem scan + archive extraction; offload off the event loop.
|
||||||
|
video_files, subtitle_files, _ = await asyncio.to_thread(
|
||||||
|
get_files_for_import, torrent=torrent
|
||||||
|
)
|
||||||
if len(video_files) != 1:
|
if len(video_files) != 1:
|
||||||
self.notify_import_failure(
|
await self.notify_import_failure(
|
||||||
movie.name,
|
movie.name,
|
||||||
"movie",
|
"movie",
|
||||||
"Multiple video files found. Manual import required.",
|
"Multiple video files found. Manual import required.",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
movie_files = self.torrent_service.get_movie_files_of_torrent(torrent=torrent)
|
movie_files = await self.torrent_service.get_movie_files_of_torrent(torrent=torrent)
|
||||||
if not movie_files:
|
if not movie_files:
|
||||||
torrent.imported = False
|
torrent.imported = False
|
||||||
self.torrent_service.torrent_repository.save_torrent(torrent=torrent)
|
await self.torrent_service.torrent_repository.save_torrent(torrent=torrent)
|
||||||
self.notify_import_failure(movie.name, "movie")
|
await self.notify_import_failure(movie.name, "movie")
|
||||||
return
|
return
|
||||||
|
|
||||||
success = [
|
success = [
|
||||||
self.import_movie(movie, video_files, subtitle_files, mf.file_path_suffix)
|
await self.import_movie(movie, video_files, subtitle_files, mf.file_path_suffix)
|
||||||
for mf in movie_files
|
for mf in movie_files
|
||||||
]
|
]
|
||||||
|
|
||||||
if all(success):
|
if all(success):
|
||||||
torrent.imported = True
|
torrent.imported = True
|
||||||
self.torrent_service.torrent_repository.save_torrent(torrent=torrent)
|
await self.torrent_service.torrent_repository.save_torrent(torrent=torrent)
|
||||||
self.notify_import_success(movie.name, "movie")
|
await self.notify_import_success(movie.name, "movie")
|
||||||
else:
|
else:
|
||||||
self.notify_import_failure(movie.name, "movie")
|
await self.notify_import_failure(movie.name, "movie")
|
||||||
|
|
||||||
def get_import_candidates(
|
async def get_import_candidates(
|
||||||
self, movie_path: Path, metadata_provider: AbstractMetadataProvider
|
self, movie_path: Path, metadata_provider: AbstractMetadataProvider
|
||||||
) -> MediaImportSuggestion:
|
) -> MediaImportSuggestion:
|
||||||
return super().get_import_candidates(
|
return await super().get_import_candidates(
|
||||||
directory=movie_path,
|
directory=movie_path,
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
search_func=self.movie_metadata_service.search_for_movie,
|
search_func=self.movie_metadata_service.search_for_movie,
|
||||||
)
|
)
|
||||||
|
|
||||||
def import_existing_movie(self, movie: Movie, source_directory: Path) -> bool:
|
async def import_existing_movie(self, movie: Movie, source_directory: Path) -> bool:
|
||||||
def _logic(m: Movie, path: Path, add_cb: Callable[[MovieFile], None]) -> bool:
|
async def _logic(
|
||||||
v, s, _ = get_files_for_import(directory=path)
|
m: Movie, path: Path, add_cb: Callable[[MovieFile], Awaitable[None]]
|
||||||
res = self.import_movie(m, v, s, "IMPORTED")
|
) -> bool:
|
||||||
|
v, s, _ = await asyncio.to_thread(get_files_for_import, directory=path)
|
||||||
|
res = await self.import_movie(m, v, s, "IMPORTED")
|
||||||
if res:
|
if res:
|
||||||
add_cb(
|
await add_cb(
|
||||||
MovieFile(
|
MovieFile(
|
||||||
movie_id=m.id,
|
movie_id=m.id,
|
||||||
file_path_suffix="IMPORTED",
|
file_path_suffix="IMPORTED",
|
||||||
@@ -143,24 +153,24 @@ class MovieImportService(BaseMediaService[Movie, Movie]):
|
|||||||
)
|
)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
return self.import_existing_media(
|
return await self.import_existing_media(
|
||||||
media=movie,
|
media=movie,
|
||||||
source_directory=source_directory,
|
source_directory=source_directory,
|
||||||
import_func=_logic,
|
import_func=_logic,
|
||||||
add_file_record_func=self.movie_repository.add_movie_file,
|
add_file_record_func=self.movie_repository.add_movie_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_importable_movies(
|
async def get_importable_movies(
|
||||||
self, metadata_provider: AbstractMetadataProvider
|
self, metadata_provider: AbstractMetadataProvider
|
||||||
) -> list[MediaImportSuggestion]:
|
) -> list[MediaImportSuggestion]:
|
||||||
return self.get_importable_media(
|
return await self.get_importable_media(
|
||||||
root_path=MediaManagerConfig().misc.movie_directory,
|
root_path=MediaManagerConfig().misc.movie_directory,
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
get_candidates_func=self.get_import_candidates,
|
get_candidates_func=self.get_import_candidates,
|
||||||
)
|
)
|
||||||
|
|
||||||
def import_all_torrents(self) -> None:
|
async def import_all_torrents(self) -> None:
|
||||||
self.import_all_torrents_base(
|
await self.import_all_torrents_base(
|
||||||
get_media_func=self.torrent_service.get_movie_of_torrent,
|
get_media_func=self.torrent_service.get_movie_of_torrent,
|
||||||
import_torrent_func=self.import_torrent_files,
|
import_torrent_func=self.import_torrent_files,
|
||||||
media_type_name="movie",
|
media_type_name="movie",
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ class MovieMetadataService(BaseMetadataService[Movie, Movie]):
|
|||||||
super().__init__(repository=movie_repository)
|
super().__init__(repository=movie_repository)
|
||||||
self.movie_repository = movie_repository
|
self.movie_repository = movie_repository
|
||||||
|
|
||||||
def add_movie(
|
async def add_movie(
|
||||||
self,
|
self,
|
||||||
external_id: int,
|
external_id: int,
|
||||||
metadata_provider: AbstractMetadataProvider,
|
metadata_provider: AbstractMetadataProvider,
|
||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
) -> Movie:
|
) -> Movie:
|
||||||
return self.add_media_base(
|
return await self.add_media_base(
|
||||||
external_id=external_id,
|
external_id=external_id,
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
get_metadata_func=metadata_provider.get_movie_metadata,
|
get_metadata_func=metadata_provider.get_movie_metadata,
|
||||||
@@ -33,51 +33,52 @@ class MovieMetadataService(BaseMetadataService[Movie, Movie]):
|
|||||||
language=language,
|
language=language,
|
||||||
)
|
)
|
||||||
|
|
||||||
def search_for_movie(
|
async def search_for_movie(
|
||||||
self, query: str, metadata_provider: AbstractMetadataProvider
|
self, query: str, metadata_provider: AbstractMetadataProvider
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
return self.search_for_media_base(
|
return await self.search_for_media_base(
|
||||||
query=query,
|
query=query,
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
search_func=metadata_provider.search_movie,
|
search_func=metadata_provider.search_movie,
|
||||||
get_by_external_id_func=self.movie_repository.get_movie_by_external_id,
|
get_by_external_id_func=self.movie_repository.get_movie_by_external_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_popular_movies(
|
async def get_popular_movies(
|
||||||
self, metadata_provider: AbstractMetadataProvider
|
self, metadata_provider: AbstractMetadataProvider
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
return self.get_popular_media_base(
|
return await self.get_popular_media_base(
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
search_func=metadata_provider.search_movie,
|
search_func=metadata_provider.search_movie,
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_movie_metadata(
|
async def update_movie_metadata(
|
||||||
self, db_movie: Movie, metadata_provider: AbstractMetadataProvider
|
self, db_movie: Movie, metadata_provider: AbstractMetadataProvider
|
||||||
) -> Movie | None:
|
) -> Movie | None:
|
||||||
"""
|
"""
|
||||||
Updates the metadata of a movie.
|
Updates the metadata of a movie.
|
||||||
"""
|
"""
|
||||||
log.debug(f"Found movie: {db_movie.name} for metadata update.")
|
log.debug(f"Found movie: {db_movie.name} for metadata update.")
|
||||||
fresh_movie_data = metadata_provider.get_movie_metadata(
|
fresh_movie_data = await metadata_provider.get_movie_metadata(
|
||||||
movie_id=db_movie.external_id, language=db_movie.original_language
|
movie_id=db_movie.external_id,
|
||||||
|
language=db_movie.original_language,
|
||||||
)
|
)
|
||||||
if not fresh_movie_data:
|
if not fresh_movie_data:
|
||||||
log.warning(f"Could not fetch fresh metadata for movie: {db_movie.name}")
|
log.warning(f"Could not fetch fresh metadata for movie: {db_movie.name}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self.movie_repository.update_movie_attributes(
|
await self.movie_repository.update_movie_attributes(
|
||||||
movie_id=db_movie.id,
|
movie_id=db_movie.id,
|
||||||
name=fresh_movie_data.name,
|
name=fresh_movie_data.name,
|
||||||
overview=fresh_movie_data.overview,
|
overview=fresh_movie_data.overview,
|
||||||
year=fresh_movie_data.year,
|
year=fresh_movie_data.year,
|
||||||
imdb_id=fresh_movie_data.imdb_id,
|
imdb_id=fresh_movie_data.imdb_id,
|
||||||
)
|
)
|
||||||
updated_movie = self.movie_repository.get_movie_by_id(db_movie.id)
|
updated_movie = await self.movie_repository.get_movie_by_id(db_movie.id)
|
||||||
metadata_provider.download_movie_poster_image(movie=updated_movie)
|
await metadata_provider.download_movie_poster_image(movie=updated_movie)
|
||||||
return updated_movie
|
return updated_movie
|
||||||
|
|
||||||
def update_all_metadata(self) -> None:
|
async def update_all_metadata(self) -> None:
|
||||||
self.update_all_metadata_base(
|
await self.update_all_metadata_base(
|
||||||
get_all_to_update_func=self.movie_repository.get_movies,
|
get_all_to_update_func=self.movie_repository.get_movies,
|
||||||
update_single_func=self.update_movie_metadata,
|
update_single_func=self.update_movie_metadata,
|
||||||
tmdb_provider_class=TmdbMetadataProvider,
|
tmdb_provider_class=TmdbMetadataProvider,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from media_manager.common.repository import BaseRepository
|
from media_manager.common.repository import BaseRepository
|
||||||
from media_manager.exceptions import NotFoundError
|
from media_manager.exceptions import NotFoundError
|
||||||
@@ -31,45 +31,47 @@ class MovieRepository(BaseRepository[Movie, MovieSchema]):
|
|||||||
Provides methods to retrieve, save, and delete movies.
|
Provides methods to retrieve, save, and delete movies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
super().__init__(db, Movie, MovieSchema)
|
super().__init__(db, Movie, MovieSchema)
|
||||||
|
|
||||||
def get_movie_by_id(self, movie_id: MovieId) -> MovieSchema:
|
async def get_movie_by_id(self, movie_id: MovieId) -> MovieSchema:
|
||||||
return self.get_by_id(entity_id=movie_id)
|
return await self.get_by_id(entity_id=movie_id)
|
||||||
|
|
||||||
def get_movie_by_external_id(
|
async def get_movie_by_external_id(
|
||||||
self, external_id: int, metadata_provider: str
|
self, external_id: int, metadata_provider: str
|
||||||
) -> MovieSchema:
|
) -> MovieSchema:
|
||||||
return self.get_by_external_id(
|
return await self.get_by_external_id(
|
||||||
external_id=external_id, metadata_provider=metadata_provider
|
external_id=external_id, metadata_provider=metadata_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_movies(self) -> list[MovieSchema]:
|
async def get_movies(self) -> list[MovieSchema]:
|
||||||
return self.get_all()
|
return await self.get_all()
|
||||||
|
|
||||||
def delete_movie(self, movie_id: MovieId) -> None:
|
async def delete_movie(self, movie_id: MovieId) -> None:
|
||||||
self.delete(entity_id=movie_id)
|
await self.delete(entity_id=movie_id)
|
||||||
|
|
||||||
def set_movie_library(self, movie_id: MovieId, library: str) -> None:
|
async def set_movie_library(self, movie_id: MovieId, library: str) -> None:
|
||||||
self.set_library(entity_id=movie_id, library=library)
|
await self.set_library(entity_id=movie_id, library=library)
|
||||||
|
|
||||||
def save_movie(self, movie: MovieSchema) -> MovieSchema:
|
async def save_movie(self, movie: MovieSchema) -> MovieSchema:
|
||||||
return self.save_media_base(media_schema=movie, model_class=Movie)
|
return await self.save_media_base(media_schema=movie, model_class=Movie)
|
||||||
|
|
||||||
def add_movie_file(self, movie_file: MovieFileSchema) -> MovieFileSchema:
|
async def add_movie_file(self, movie_file: MovieFileSchema) -> MovieFileSchema:
|
||||||
return self.add_media_file_base(
|
return await self.add_media_file_base(
|
||||||
file_schema=movie_file, model_class=MovieFile, schema_class=MovieFileSchema
|
file_schema=movie_file, model_class=MovieFile, schema_class=MovieFileSchema
|
||||||
)
|
)
|
||||||
|
|
||||||
def remove_movie_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
|
async def remove_movie_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
|
||||||
return self.remove_files_by_torrent_id_base(
|
return await self.remove_files_by_torrent_id_base(
|
||||||
torrent_id=torrent_id, model_class=MovieFile
|
torrent_id=torrent_id, model_class=MovieFile
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_movie_files_by_movie_id(self, movie_id: MovieId) -> list[MovieFileSchema]:
|
async def get_movie_files_by_movie_id(
|
||||||
|
self, movie_id: MovieId
|
||||||
|
) -> list[MovieFileSchema]:
|
||||||
try:
|
try:
|
||||||
stmt = select(MovieFile).where(MovieFile.movie_id == movie_id)
|
stmt = select(MovieFile).where(MovieFile.movie_id == movie_id)
|
||||||
results = self.db.execute(stmt).scalars().all()
|
results = (await self.db.execute(stmt)).scalars().all()
|
||||||
return [MovieFileSchema.model_validate(sf) for sf in results]
|
return [MovieFileSchema.model_validate(sf) for sf in results]
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
log.exception(
|
log.exception(
|
||||||
@@ -77,7 +79,9 @@ class MovieRepository(BaseRepository[Movie, MovieSchema]):
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def get_torrents_by_movie_id(self, movie_id: MovieId) -> list[MovieTorrentSchema]:
|
async def get_torrents_by_movie_id(
|
||||||
|
self, movie_id: MovieId
|
||||||
|
) -> list[MovieTorrentSchema]:
|
||||||
try:
|
try:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Torrent, MovieFile.file_path_suffix)
|
select(Torrent, MovieFile.file_path_suffix)
|
||||||
@@ -85,7 +89,7 @@ class MovieRepository(BaseRepository[Movie, MovieSchema]):
|
|||||||
.join(MovieFile, MovieFile.torrent_id == Torrent.id)
|
.join(MovieFile, MovieFile.torrent_id == Torrent.id)
|
||||||
.where(MovieFile.movie_id == movie_id)
|
.where(MovieFile.movie_id == movie_id)
|
||||||
)
|
)
|
||||||
results = self.db.execute(stmt).all()
|
results = (await self.db.execute(stmt)).all()
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
for torrent, file_path_suffix in results:
|
for torrent, file_path_suffix in results:
|
||||||
movie_torrent = MovieTorrentSchema(
|
movie_torrent = MovieTorrentSchema(
|
||||||
@@ -104,7 +108,7 @@ class MovieRepository(BaseRepository[Movie, MovieSchema]):
|
|||||||
else:
|
else:
|
||||||
return formatted_results
|
return formatted_results
|
||||||
|
|
||||||
def get_all_movies_with_torrents(self) -> list[MovieSchema]:
|
async def get_all_movies_with_torrents(self) -> list[MovieSchema]:
|
||||||
try:
|
try:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Movie)
|
select(Movie)
|
||||||
@@ -113,20 +117,20 @@ class MovieRepository(BaseRepository[Movie, MovieSchema]):
|
|||||||
.join(Torrent, MovieFile.torrent_id == Torrent.id)
|
.join(Torrent, MovieFile.torrent_id == Torrent.id)
|
||||||
.order_by(Movie.name)
|
.order_by(Movie.name)
|
||||||
)
|
)
|
||||||
results = self.db.execute(stmt).scalars().unique().all()
|
results = (await self.db.execute(stmt)).scalars().unique().all()
|
||||||
return [MovieSchema.model_validate(movie) for movie in results]
|
return [MovieSchema.model_validate(movie) for movie in results]
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
log.exception("Database error retrieving all movies with torrents")
|
log.exception("Database error retrieving all movies with torrents")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def get_movie_by_torrent_id(self, torrent_id: TorrentId) -> MovieSchema:
|
async def get_movie_by_torrent_id(self, torrent_id: TorrentId) -> MovieSchema:
|
||||||
try:
|
try:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Movie)
|
select(Movie)
|
||||||
.join(MovieFile, Movie.id == MovieFile.movie_id)
|
.join(MovieFile, Movie.id == MovieFile.movie_id)
|
||||||
.where(MovieFile.torrent_id == torrent_id)
|
.where(MovieFile.torrent_id == torrent_id)
|
||||||
)
|
)
|
||||||
result = self.db.execute(stmt).unique().scalar_one_or_none()
|
result = (await self.db.execute(stmt)).unique().scalar_one_or_none()
|
||||||
if not result:
|
if not result:
|
||||||
msg = f"Movie for torrent_id {torrent_id} not found."
|
msg = f"Movie for torrent_id {torrent_id} not found."
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
@@ -136,7 +140,7 @@ class MovieRepository(BaseRepository[Movie, MovieSchema]):
|
|||||||
else:
|
else:
|
||||||
return MovieSchema.model_validate(result)
|
return MovieSchema.model_validate(result)
|
||||||
|
|
||||||
def update_movie_attributes(
|
async def update_movie_attributes(
|
||||||
self,
|
self,
|
||||||
movie_id: MovieId,
|
movie_id: MovieId,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
@@ -144,7 +148,7 @@ class MovieRepository(BaseRepository[Movie, MovieSchema]):
|
|||||||
year: int | None = None,
|
year: int | None = None,
|
||||||
imdb_id: str | None = None,
|
imdb_id: str | None = None,
|
||||||
) -> MovieSchema:
|
) -> MovieSchema:
|
||||||
return self.update_media_attributes_base(
|
return await self.update_media_attributes_base(
|
||||||
media_id=movie_id,
|
media_id=movie_id,
|
||||||
model_class=Movie,
|
model_class=Movie,
|
||||||
name=name,
|
name=name,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ router = APIRouter()
|
|||||||
"/search",
|
"/search",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def search_for_movie(
|
async def search_for_movie(
|
||||||
query: str,
|
query: str,
|
||||||
movie_metadata_service: movie_metadata_service_dep,
|
movie_metadata_service: movie_metadata_service_dep,
|
||||||
metadata_provider: metadata_provider_dep,
|
metadata_provider: metadata_provider_dep,
|
||||||
@@ -46,7 +46,7 @@ def search_for_movie(
|
|||||||
"""
|
"""
|
||||||
Search for a movie on the configured metadata provider.
|
Search for a movie on the configured metadata provider.
|
||||||
"""
|
"""
|
||||||
return movie_metadata_service.search_for_movie(
|
return await movie_metadata_service.search_for_movie(
|
||||||
query=query, metadata_provider=metadata_provider
|
query=query, metadata_provider=metadata_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,14 +55,14 @@ def search_for_movie(
|
|||||||
"/recommended",
|
"/recommended",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_popular_movies(
|
async def get_popular_movies(
|
||||||
movie_metadata_service: movie_metadata_service_dep,
|
movie_metadata_service: movie_metadata_service_dep,
|
||||||
metadata_provider: metadata_provider_dep,
|
metadata_provider: metadata_provider_dep,
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
"""
|
"""
|
||||||
Get a list of recommended/popular movies from the metadata provider.
|
Get a list of recommended/popular movies from the metadata provider.
|
||||||
"""
|
"""
|
||||||
return movie_metadata_service.get_popular_movies(
|
return await movie_metadata_service.get_popular_movies(
|
||||||
metadata_provider=metadata_provider
|
metadata_provider=metadata_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,14 +77,14 @@ def get_popular_movies(
|
|||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def get_all_importable_movies(
|
async def get_all_importable_movies(
|
||||||
movie_import_service: movie_import_service_dep,
|
movie_import_service: movie_import_service_dep,
|
||||||
metadata_provider: metadata_provider_dep,
|
metadata_provider: metadata_provider_dep,
|
||||||
) -> list[MediaImportSuggestion]:
|
) -> list[MediaImportSuggestion]:
|
||||||
"""
|
"""
|
||||||
Get a list of unknown movies that were detected in the movie directory and are importable.
|
Get a list of unknown movies that were detected in the movie directory and are importable.
|
||||||
"""
|
"""
|
||||||
return movie_import_service.get_importable_movies(
|
return await movie_import_service.get_importable_movies(
|
||||||
metadata_provider=metadata_provider
|
metadata_provider=metadata_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ def get_all_importable_movies(
|
|||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
)
|
)
|
||||||
def import_detected_movie(
|
async def import_detected_movie(
|
||||||
movie_import_service: movie_import_service_dep, movie: movie_dep, directory: str
|
movie_import_service: movie_import_service_dep, movie: movie_dep, directory: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -105,7 +105,7 @@ def import_detected_movie(
|
|||||||
MediaManagerConfig().misc.movie_directory
|
MediaManagerConfig().misc.movie_directory
|
||||||
):
|
):
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")
|
||||||
success = movie_import_service.import_existing_movie(
|
success = await movie_import_service.import_existing_movie(
|
||||||
movie=movie, source_directory=source_directory
|
movie=movie, source_directory=source_directory
|
||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
@@ -121,11 +121,11 @@ def import_detected_movie(
|
|||||||
"",
|
"",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_all_movies(movie_service: movie_service_dep) -> list[Movie]:
|
async def get_all_movies(movie_service: movie_service_dep) -> list[Movie]:
|
||||||
"""
|
"""
|
||||||
Get all movies in the library.
|
Get all movies in the library.
|
||||||
"""
|
"""
|
||||||
return movie_service.get_all_movies()
|
return await movie_service.get_all_movies()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -139,7 +139,7 @@ def get_all_movies(movie_service: movie_service_dep) -> list[Movie]:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def add_a_movie(
|
async def add_a_movie(
|
||||||
movie_metadata_service: movie_metadata_service_dep,
|
movie_metadata_service: movie_metadata_service_dep,
|
||||||
metadata_provider: metadata_provider_dep,
|
metadata_provider: metadata_provider_dep,
|
||||||
movie_id: int,
|
movie_id: int,
|
||||||
@@ -149,13 +149,13 @@ def add_a_movie(
|
|||||||
Add a new movie to the library.
|
Add a new movie to the library.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
movie = movie_metadata_service.add_movie(
|
movie = await movie_metadata_service.add_movie(
|
||||||
external_id=movie_id,
|
external_id=movie_id,
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
language=language,
|
language=language,
|
||||||
)
|
)
|
||||||
except ConflictError:
|
except ConflictError:
|
||||||
movie = movie_metadata_service.movie_repository.get_movie_by_external_id(
|
movie = await movie_metadata_service.movie_repository.get_movie_by_external_id(
|
||||||
external_id=movie_id, metadata_provider=metadata_provider.name
|
external_id=movie_id, metadata_provider=metadata_provider.name
|
||||||
)
|
)
|
||||||
if not movie:
|
if not movie:
|
||||||
@@ -167,13 +167,13 @@ def add_a_movie(
|
|||||||
"/torrents",
|
"/torrents",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_all_movies_with_torrents(
|
async def get_all_movies_with_torrents(
|
||||||
movie_service: movie_service_dep,
|
movie_service: movie_service_dep,
|
||||||
) -> list[RichMovieTorrent]:
|
) -> list[RichMovieTorrent]:
|
||||||
"""
|
"""
|
||||||
Get all movies that are associated with torrents.
|
Get all movies that are associated with torrents.
|
||||||
"""
|
"""
|
||||||
return movie_service.get_all_movies_with_torrents()
|
return await movie_service.get_all_movies_with_torrents()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -196,11 +196,13 @@ def get_available_libraries() -> list[LibraryItem]:
|
|||||||
"/{movie_id}",
|
"/{movie_id}",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_movie_by_id(movie_service: movie_service_dep, movie: movie_dep) -> PublicMovie:
|
async def get_movie_by_id(
|
||||||
|
movie_service: movie_service_dep, movie: movie_dep
|
||||||
|
) -> PublicMovie:
|
||||||
"""
|
"""
|
||||||
Get details for a specific movie.
|
Get details for a specific movie.
|
||||||
"""
|
"""
|
||||||
return movie_service.get_public_movie_by_id(movie=movie)
|
return await movie_service.get_public_movie_by_id(movie=movie)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -208,7 +210,7 @@ def get_movie_by_id(movie_service: movie_service_dep, movie: movie_dep) -> Publi
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def delete_a_movie(
|
async def delete_a_movie(
|
||||||
movie_service: movie_service_dep,
|
movie_service: movie_service_dep,
|
||||||
movie: movie_dep,
|
movie: movie_dep,
|
||||||
delete_files_on_disk: bool = False,
|
delete_files_on_disk: bool = False,
|
||||||
@@ -217,7 +219,7 @@ def delete_a_movie(
|
|||||||
"""
|
"""
|
||||||
Delete a movie from the library.
|
Delete a movie from the library.
|
||||||
"""
|
"""
|
||||||
movie_service.delete_movie(
|
await movie_service.delete_movie(
|
||||||
movie=movie,
|
movie=movie,
|
||||||
delete_files_on_disk=delete_files_on_disk,
|
delete_files_on_disk=delete_files_on_disk,
|
||||||
delete_torrents=delete_torrents,
|
delete_torrents=delete_torrents,
|
||||||
@@ -229,7 +231,7 @@ def delete_a_movie(
|
|||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
)
|
)
|
||||||
def set_library(
|
async def set_library(
|
||||||
movie: movie_dep,
|
movie: movie_dep,
|
||||||
movie_service: movie_service_dep,
|
movie_service: movie_service_dep,
|
||||||
library: str,
|
library: str,
|
||||||
@@ -237,7 +239,7 @@ def set_library(
|
|||||||
"""
|
"""
|
||||||
Set the library path for a Movie.
|
Set the library path for a Movie.
|
||||||
"""
|
"""
|
||||||
movie_service.set_movie_library(movie=movie, library=library)
|
await movie_service.set_movie_library(movie=movie, library=library)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@@ -245,20 +247,20 @@ def set_library(
|
|||||||
"/{movie_id}/files",
|
"/{movie_id}/files",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_movie_files_by_movie_id(
|
async def get_movie_files_by_movie_id(
|
||||||
movie_service: movie_service_dep, movie: movie_dep
|
movie_service: movie_service_dep, movie: movie_dep
|
||||||
) -> list[PublicMovieFile]:
|
) -> list[PublicMovieFile]:
|
||||||
"""
|
"""
|
||||||
Get files associated with a specific movie.
|
Get files associated with a specific movie.
|
||||||
"""
|
"""
|
||||||
return movie_service.get_public_movie_files(movie=movie)
|
return await movie_service.get_public_movie_files(movie=movie)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{movie_id}/torrents",
|
"/{movie_id}/torrents",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def search_for_torrents_for_movie(
|
async def search_for_torrents_for_movie(
|
||||||
movie_service: movie_service_dep,
|
movie_service: movie_service_dep,
|
||||||
movie: movie_dep,
|
movie: movie_dep,
|
||||||
search_query_override: str | None = None,
|
search_query_override: str | None = None,
|
||||||
@@ -266,7 +268,7 @@ def search_for_torrents_for_movie(
|
|||||||
"""
|
"""
|
||||||
Search for torrents for a specific movie.
|
Search for torrents for a specific movie.
|
||||||
"""
|
"""
|
||||||
return movie_service.get_all_available_torrents_for_movie(
|
return await movie_service.get_all_available_torrents_for_movie(
|
||||||
movie=movie, search_query_override=search_query_override
|
movie=movie, search_query_override=search_query_override
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -276,7 +278,7 @@ def search_for_torrents_for_movie(
|
|||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def download_torrent_for_movie(
|
async def download_torrent_for_movie(
|
||||||
movie_service: movie_service_dep,
|
movie_service: movie_service_dep,
|
||||||
movie: movie_dep,
|
movie: movie_dep,
|
||||||
public_indexer_result_id: IndexerQueryResultId,
|
public_indexer_result_id: IndexerQueryResultId,
|
||||||
@@ -285,7 +287,7 @@ def download_torrent_for_movie(
|
|||||||
"""
|
"""
|
||||||
Trigger a download for a specific torrent for a movie.
|
Trigger a download for a specific torrent for a movie.
|
||||||
"""
|
"""
|
||||||
return movie_service.download_torrent(
|
return await movie_service.download_torrent(
|
||||||
public_indexer_result_id=public_indexer_result_id,
|
public_indexer_result_id=public_indexer_result_id,
|
||||||
movie=movie,
|
movie=movie,
|
||||||
override_movie_file_path_suffix=override_file_path_suffix,
|
override_movie_file_path_suffix=override_file_path_suffix,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -49,7 +50,7 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
self.movie_import_service = movie_import_service
|
self.movie_import_service = movie_import_service
|
||||||
self.movie_metadata_service = movie_metadata_service
|
self.movie_metadata_service = movie_metadata_service
|
||||||
|
|
||||||
def delete_movie(
|
async def delete_movie(
|
||||||
self,
|
self,
|
||||||
movie: Movie,
|
movie: Movie,
|
||||||
delete_files_on_disk: bool = False,
|
delete_files_on_disk: bool = False,
|
||||||
@@ -69,23 +70,23 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
|
|
||||||
if movie_dir.exists() and movie_dir.is_dir():
|
if movie_dir.exists() and movie_dir.is_dir():
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(movie_dir)
|
await asyncio.to_thread(shutil.rmtree, movie_dir)
|
||||||
log.info(f"Deleted movie directory: {movie_dir}")
|
log.info(f"Deleted movie directory: {movie_dir}")
|
||||||
except OSError:
|
except OSError:
|
||||||
log.exception(f"Deleting movie directory: {movie_dir}")
|
log.exception(f"Deleting movie directory: {movie_dir}")
|
||||||
|
|
||||||
if delete_torrents:
|
if delete_torrents:
|
||||||
# Get all torrents associated with this movie
|
# Get all torrents associated with this movie
|
||||||
movie_torrents = self.movie_repository.get_torrents_by_movie_id(
|
movie_torrents = await self.movie_repository.get_torrents_by_movie_id(
|
||||||
movie_id=movie.id
|
movie_id=movie.id
|
||||||
)
|
)
|
||||||
|
|
||||||
for movie_torrent in movie_torrents:
|
for movie_torrent in movie_torrents:
|
||||||
torrent = self.torrent_service.get_torrent_by_id(
|
torrent = await self.torrent_service.get_torrent_by_id(
|
||||||
torrent_id=movie_torrent.torrent_id
|
torrent_id=movie_torrent.torrent_id
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.torrent_service.cancel_download(
|
await self.torrent_service.cancel_download(
|
||||||
torrent=torrent, delete_files=True
|
torrent=torrent, delete_files=True
|
||||||
)
|
)
|
||||||
log.info(f"Deleted torrent: {torrent.torrent_title}")
|
log.info(f"Deleted torrent: {torrent.torrent_title}")
|
||||||
@@ -93,26 +94,26 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
log.exception(f"Failed to delete torrent {torrent.hash}")
|
log.exception(f"Failed to delete torrent {torrent.hash}")
|
||||||
|
|
||||||
# Delete from database
|
# Delete from database
|
||||||
self.movie_repository.delete_movie(movie.id)
|
await self.movie_repository.delete_movie(movie.id)
|
||||||
|
|
||||||
def get_public_movie_files(self, movie: Movie) -> list[PublicMovieFile]:
|
async def get_public_movie_files(self, movie: Movie) -> list[PublicMovieFile]:
|
||||||
"""
|
"""
|
||||||
Get all public movie files for a given movie.
|
Get all public movie files for a given movie.
|
||||||
|
|
||||||
:param movie: The movie object.
|
:param movie: The movie object.
|
||||||
:return: A list of public movie files.
|
:return: A list of public movie files.
|
||||||
"""
|
"""
|
||||||
movie_files = self.movie_repository.get_movie_files_by_movie_id(
|
movie_files = await self.movie_repository.get_movie_files_by_movie_id(
|
||||||
movie_id=movie.id
|
movie_id=movie.id
|
||||||
)
|
)
|
||||||
public_movie_files = [PublicMovieFile.model_validate(x) for x in movie_files]
|
public_movie_files = [PublicMovieFile.model_validate(x) for x in movie_files]
|
||||||
result = []
|
result = []
|
||||||
for movie_file in public_movie_files:
|
for movie_file in public_movie_files:
|
||||||
movie_file.imported = self.movie_file_exists_on_file(movie_file=movie_file)
|
movie_file.imported = await self.movie_file_exists_on_file(movie_file=movie_file)
|
||||||
result.append(movie_file)
|
result.append(movie_file)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_all_available_torrents_for_movie(
|
async def get_all_available_torrents_for_movie(
|
||||||
self, movie: Movie, search_query_override: str | None = None
|
self, movie: Movie, search_query_override: str | None = None
|
||||||
) -> list[IndexerQueryResult]:
|
) -> list[IndexerQueryResult]:
|
||||||
"""
|
"""
|
||||||
@@ -123,52 +124,52 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
:return: A list of indexer query results.
|
:return: A list of indexer query results.
|
||||||
"""
|
"""
|
||||||
if search_query_override:
|
if search_query_override:
|
||||||
return self.indexer_service.search(query=search_query_override, is_tv=False)
|
return await self.indexer_service.search(query=search_query_override, is_tv=False)
|
||||||
|
|
||||||
torrents = self.indexer_service.search_movie(movie=movie)
|
torrents = await self.indexer_service.search_movie(movie=movie)
|
||||||
|
|
||||||
return evaluate_indexer_query_results(
|
return evaluate_indexer_query_results(
|
||||||
is_tv=False, query_results=torrents, media=movie
|
is_tv=False, query_results=torrents, media=movie
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_public_movie_by_id(self, movie: Movie) -> PublicMovie:
|
async def get_public_movie_by_id(self, movie: Movie) -> PublicMovie:
|
||||||
"""
|
"""
|
||||||
Get a public movie from a Movie object.
|
Get a public movie from a Movie object.
|
||||||
|
|
||||||
:param movie: The movie object.
|
:param movie: The movie object.
|
||||||
:return: A public movie.
|
:return: A public movie.
|
||||||
"""
|
"""
|
||||||
torrents = self.get_torrents_for_movie(movie=movie).torrents
|
torrents = (await self.get_torrents_for_movie(movie=movie)).torrents
|
||||||
public_movie = PublicMovie.model_validate(movie)
|
public_movie = PublicMovie.model_validate(movie)
|
||||||
public_movie.downloaded = self.is_movie_downloaded(movie=movie)
|
public_movie.downloaded = await self.is_movie_downloaded(movie=movie)
|
||||||
public_movie.torrents = torrents
|
public_movie.torrents = torrents
|
||||||
return public_movie
|
return public_movie
|
||||||
|
|
||||||
def get_movie_by_id(self, movie_id: MovieId) -> Movie:
|
async def get_movie_by_id(self, movie_id: MovieId) -> Movie:
|
||||||
"""
|
"""
|
||||||
Get a movie by its ID.
|
Get a movie by its ID.
|
||||||
|
|
||||||
:param movie_id: The ID of the movie.
|
:param movie_id: The ID of the movie.
|
||||||
:return: The movie.
|
:return: The movie.
|
||||||
"""
|
"""
|
||||||
return self.movie_repository.get_movie_by_id(movie_id)
|
return await self.movie_repository.get_movie_by_id(movie_id)
|
||||||
|
|
||||||
def is_movie_downloaded(self, movie: Movie) -> bool:
|
async def is_movie_downloaded(self, movie: Movie) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a movie is downloaded.
|
Check if a movie is downloaded.
|
||||||
|
|
||||||
:param movie: The movie object.
|
:param movie: The movie object.
|
||||||
:return: True if the movie is downloaded, False otherwise.
|
:return: True if the movie is downloaded, False otherwise.
|
||||||
"""
|
"""
|
||||||
movie_files = self.movie_repository.get_movie_files_by_movie_id(
|
movie_files = await self.movie_repository.get_movie_files_by_movie_id(
|
||||||
movie_id=movie.id
|
movie_id=movie.id
|
||||||
)
|
)
|
||||||
for movie_file in movie_files:
|
for movie_file in movie_files:
|
||||||
if self.movie_file_exists_on_file(movie_file=movie_file):
|
if await self.movie_file_exists_on_file(movie_file=movie_file):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def movie_file_exists_on_file(self, movie_file: MovieFile) -> bool:
|
async def movie_file_exists_on_file(self, movie_file: MovieFile) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a movie file exists on the filesystem.
|
Check if a movie file exists on the filesystem.
|
||||||
|
|
||||||
@@ -177,12 +178,12 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
"""
|
"""
|
||||||
if movie_file.torrent_id is None:
|
if movie_file.torrent_id is None:
|
||||||
return True
|
return True
|
||||||
torrent_file = self.torrent_service.get_torrent_by_id(
|
torrent_file = await self.torrent_service.get_torrent_by_id(
|
||||||
torrent_id=movie_file.torrent_id
|
torrent_id=movie_file.torrent_id
|
||||||
)
|
)
|
||||||
return bool(torrent_file.imported)
|
return bool(torrent_file.imported)
|
||||||
|
|
||||||
def get_movie_by_external_id(
|
async def get_movie_by_external_id(
|
||||||
self, external_id: int, metadata_provider: str
|
self, external_id: int, metadata_provider: str
|
||||||
) -> Movie | None:
|
) -> Movie | None:
|
||||||
"""
|
"""
|
||||||
@@ -192,27 +193,27 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
:param metadata_provider: The metadata provider.
|
:param metadata_provider: The metadata provider.
|
||||||
:return: The movie or None if not found.
|
:return: The movie or None if not found.
|
||||||
"""
|
"""
|
||||||
return self.movie_repository.get_movie_by_external_id(
|
return await self.movie_repository.get_movie_by_external_id(
|
||||||
external_id=external_id, metadata_provider=metadata_provider
|
external_id=external_id, metadata_provider=metadata_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_movie_library(self, movie: Movie, library: str) -> None:
|
async def set_movie_library(self, movie: Movie, library: str) -> None:
|
||||||
self.movie_repository.set_movie_library(movie.id, library)
|
await self.movie_repository.set_movie_library(movie.id, library)
|
||||||
|
|
||||||
def get_all_movies(self) -> list[Movie]:
|
async def get_all_movies(self) -> list[Movie]:
|
||||||
"""
|
"""
|
||||||
Get all movies in the library.
|
Get all movies in the library.
|
||||||
"""
|
"""
|
||||||
return self.get_all_media()
|
return await self.get_all_media()
|
||||||
|
|
||||||
def get_torrents_for_movie(self, movie: Movie) -> RichMovieTorrent:
|
async def get_torrents_for_movie(self, movie: Movie) -> RichMovieTorrent:
|
||||||
"""
|
"""
|
||||||
Get torrents for a given movie.
|
Get torrents for a given movie.
|
||||||
|
|
||||||
:param movie: The movie.
|
:param movie: The movie.
|
||||||
:return: A rich movie torrent.
|
:return: A rich movie torrent.
|
||||||
"""
|
"""
|
||||||
movie_torrents = self.movie_repository.get_torrents_by_movie_id(
|
movie_torrents = await self.movie_repository.get_torrents_by_movie_id(
|
||||||
movie_id=movie.id
|
movie_id=movie.id
|
||||||
)
|
)
|
||||||
return RichMovieTorrent(
|
return RichMovieTorrent(
|
||||||
@@ -223,16 +224,16 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
torrents=movie_torrents,
|
torrents=movie_torrents,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_all_movies_with_torrents(self) -> list[RichMovieTorrent]:
|
async def get_all_movies_with_torrents(self) -> list[RichMovieTorrent]:
|
||||||
"""
|
"""
|
||||||
Get all movies with torrents.
|
Get all movies with torrents.
|
||||||
|
|
||||||
:return: A list of rich movie torrents.
|
:return: A list of rich movie torrents.
|
||||||
"""
|
"""
|
||||||
movies = self.movie_repository.get_all_movies_with_torrents()
|
movies = await self.movie_repository.get_all_movies_with_torrents()
|
||||||
return [self.get_torrents_for_movie(movie=movie) for movie in movies]
|
return [await self.get_torrents_for_movie(movie=movie) for movie in movies]
|
||||||
|
|
||||||
def download_torrent(
|
async def download_torrent(
|
||||||
self,
|
self,
|
||||||
public_indexer_result_id: IndexerQueryResultId,
|
public_indexer_result_id: IndexerQueryResultId,
|
||||||
movie: Movie,
|
movie: Movie,
|
||||||
@@ -246,11 +247,11 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
:param override_movie_file_path_suffix: Optional override for the file path suffix.
|
:param override_movie_file_path_suffix: Optional override for the file path suffix.
|
||||||
:return: The downloaded torrent.
|
:return: The downloaded torrent.
|
||||||
"""
|
"""
|
||||||
indexer_result = self.indexer_service.get_result(
|
indexer_result = await self.indexer_service.get_result(
|
||||||
result_id=public_indexer_result_id
|
result_id=public_indexer_result_id
|
||||||
)
|
)
|
||||||
movie_torrent = self.torrent_service.download(indexer_result=indexer_result)
|
movie_torrent = await self.torrent_service.download(indexer_result=indexer_result)
|
||||||
self.torrent_service.pause_download(torrent=movie_torrent)
|
await self.torrent_service.pause_download(torrent=movie_torrent)
|
||||||
movie_file = MovieFile(
|
movie_file = MovieFile(
|
||||||
movie_id=movie.id,
|
movie_id=movie.id,
|
||||||
quality=indexer_result.quality,
|
quality=indexer_result.quality,
|
||||||
@@ -258,12 +259,12 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
file_path_suffix=override_movie_file_path_suffix,
|
file_path_suffix=override_movie_file_path_suffix,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.movie_repository.add_movie_file(movie_file=movie_file)
|
await self.movie_repository.add_movie_file(movie_file=movie_file)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
log.warning(
|
log.warning(
|
||||||
f"Movie file for movie {movie.name} and torrent {movie_torrent.title} already exists"
|
f"Movie file for movie {movie.name} and torrent {movie_torrent.title} already exists"
|
||||||
)
|
)
|
||||||
self.torrent_service.cancel_download(
|
await self.torrent_service.cancel_download(
|
||||||
torrent=movie_torrent, delete_files=True
|
torrent=movie_torrent, delete_files=True
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
@@ -271,7 +272,7 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
log.info(
|
log.info(
|
||||||
f"Added movie file for movie {movie.name} and torrent {movie_torrent.title}"
|
f"Added movie file for movie {movie.name} and torrent {movie_torrent.title}"
|
||||||
)
|
)
|
||||||
self.torrent_service.resume_download(torrent=movie_torrent)
|
await self.torrent_service.resume_download(torrent=movie_torrent)
|
||||||
return movie_torrent
|
return movie_torrent
|
||||||
|
|
||||||
def get_movie_root_path(self, movie: Movie) -> Path:
|
def get_movie_root_path(self, movie: Movie) -> Path:
|
||||||
@@ -282,14 +283,14 @@ class MovieService(BaseMediaService[Movie, Movie]):
|
|||||||
libraries=misc_config.movie_libraries,
|
libraries=misc_config.movie_libraries,
|
||||||
)
|
)
|
||||||
|
|
||||||
def import_all_torrents(self) -> None:
|
async def import_all_torrents(self) -> None:
|
||||||
"""
|
"""
|
||||||
Delegate to MovieImportService.
|
Delegate to MovieImportService.
|
||||||
"""
|
"""
|
||||||
self.movie_import_service.import_all_torrents()
|
await self.movie_import_service.import_all_torrents()
|
||||||
|
|
||||||
def update_all_metadata(self) -> None:
|
async def update_all_metadata(self) -> None:
|
||||||
"""
|
"""
|
||||||
Delegate to MovieMetadataService.
|
Delegate to MovieMetadataService.
|
||||||
"""
|
"""
|
||||||
self.movie_metadata_service.update_all_metadata()
|
await self.movie_metadata_service.update_all_metadata()
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class NotificationManager:
|
|||||||
|
|
||||||
logger.info(f"Initialized {len(self.providers)} notification providers")
|
logger.info(f"Initialized {len(self.providers)} notification providers")
|
||||||
|
|
||||||
def send_notification(self, title: str, message: str) -> None:
|
async def send_notification(self, title: str, message: str) -> None:
|
||||||
if not self.providers:
|
if not self.providers:
|
||||||
logger.warning("No notification providers configured")
|
logger.warning("No notification providers configured")
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ class NotificationManager:
|
|||||||
for provider in self.providers:
|
for provider in self.providers:
|
||||||
provider_name = provider.__class__.__name__
|
provider_name = provider.__class__.__name__
|
||||||
try:
|
try:
|
||||||
success = provider.send_notification(notification)
|
success = await provider.send_notification(notification)
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"Notification sent successfully via {provider_name}")
|
logger.info(f"Notification sent successfully via {provider_name}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from sqlalchemy.exc import (
|
|||||||
IntegrityError,
|
IntegrityError,
|
||||||
SQLAlchemyError,
|
SQLAlchemyError,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.sql.expression import false
|
from sqlalchemy.sql.expression import false
|
||||||
|
|
||||||
from media_manager.exceptions import ConflictError, NotFoundError
|
from media_manager.exceptions import ConflictError, NotFoundError
|
||||||
@@ -21,11 +21,11 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class NotificationRepository:
|
class NotificationRepository:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def get_notification(self, nid: NotificationId) -> NotificationSchema:
|
async def get_notification(self, nid: NotificationId) -> NotificationSchema:
|
||||||
result = self.db.get(Notification, nid)
|
result = await self.db.get(Notification, nid)
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
msg = f"Notification with id {nid} not found."
|
msg = f"Notification with id {nid} not found."
|
||||||
@@ -33,14 +33,14 @@ class NotificationRepository:
|
|||||||
|
|
||||||
return NotificationSchema.model_validate(result)
|
return NotificationSchema.model_validate(result)
|
||||||
|
|
||||||
def get_unread_notifications(self) -> list[NotificationSchema]:
|
async def get_unread_notifications(self) -> list[NotificationSchema]:
|
||||||
try:
|
try:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Notification)
|
select(Notification)
|
||||||
.where(Notification.read == false())
|
.where(Notification.read == false())
|
||||||
.order_by(Notification.timestamp.desc())
|
.order_by(Notification.timestamp.desc())
|
||||||
)
|
)
|
||||||
results = self.db.execute(stmt).scalars().all()
|
results = (await self.db.execute(stmt)).scalars().all()
|
||||||
return [
|
return [
|
||||||
NotificationSchema.model_validate(notification)
|
NotificationSchema.model_validate(notification)
|
||||||
for notification in results
|
for notification in results
|
||||||
@@ -49,10 +49,10 @@ class NotificationRepository:
|
|||||||
log.exception("Database error while retrieving unread notifications")
|
log.exception("Database error while retrieving unread notifications")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def get_all_notifications(self) -> list[NotificationSchema]:
|
async def get_all_notifications(self) -> list[NotificationSchema]:
|
||||||
try:
|
try:
|
||||||
stmt = select(Notification).order_by(Notification.timestamp.desc())
|
stmt = select(Notification).order_by(Notification.timestamp.desc())
|
||||||
results = self.db.execute(stmt).scalars().all()
|
results = (await self.db.execute(stmt)).scalars().all()
|
||||||
return [
|
return [
|
||||||
NotificationSchema.model_validate(notification)
|
NotificationSchema.model_validate(notification)
|
||||||
for notification in results
|
for notification in results
|
||||||
@@ -61,7 +61,7 @@ class NotificationRepository:
|
|||||||
log.exception("Database error while retrieving notifications")
|
log.exception("Database error while retrieving notifications")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def save_notification(self, notification: NotificationSchema) -> None:
|
async def save_notification(self, notification: NotificationSchema) -> None:
|
||||||
try:
|
try:
|
||||||
self.db.add(
|
self.db.add(
|
||||||
Notification(
|
Notification(
|
||||||
@@ -71,28 +71,31 @@ class NotificationRepository:
|
|||||||
message=notification.message,
|
message=notification.message,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
|
# AsyncSession leaves the txn in invalid state on IntegrityError;
|
||||||
|
# without rollback the request-end commit raises PendingRollbackError.
|
||||||
|
await self.db.rollback()
|
||||||
log.exception("Could not save notification")
|
log.exception("Could not save notification")
|
||||||
msg = f"Notification with id {notification.id} already exists."
|
msg = f"Notification with id {notification.id} already exists."
|
||||||
raise ConflictError(msg) from None
|
raise ConflictError(msg) from None
|
||||||
return
|
return
|
||||||
|
|
||||||
def mark_notification_as_read(self, nid: NotificationId) -> None:
|
async def mark_notification_as_read(self, nid: NotificationId) -> None:
|
||||||
stmt = update(Notification).where(Notification.id == nid).values(read=True)
|
stmt = update(Notification).where(Notification.id == nid).values(read=True)
|
||||||
self.db.execute(stmt)
|
await self.db.execute(stmt)
|
||||||
return
|
return
|
||||||
|
|
||||||
def mark_notification_as_unread(self, nid: NotificationId) -> None:
|
async def mark_notification_as_unread(self, nid: NotificationId) -> None:
|
||||||
stmt = update(Notification).where(Notification.id == nid).values(read=False)
|
stmt = update(Notification).where(Notification.id == nid).values(read=False)
|
||||||
self.db.execute(stmt)
|
await self.db.execute(stmt)
|
||||||
return
|
return
|
||||||
|
|
||||||
def delete_notification(self, nid: NotificationId) -> None:
|
async def delete_notification(self, nid: NotificationId) -> None:
|
||||||
stmt = delete(Notification).where(Notification.id == nid)
|
stmt = delete(Notification).where(Notification.id == nid)
|
||||||
result = self.db.execute(stmt)
|
result = await self.db.execute(stmt)
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
msg = f"Notification with id {nid} not found."
|
msg = f"Notification with id {nid} not found."
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,26 +16,26 @@ router = APIRouter()
|
|||||||
"",
|
"",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_all_notifications(
|
async def get_all_notifications(
|
||||||
notification_service: notification_service_dep,
|
notification_service: notification_service_dep,
|
||||||
) -> list[Notification]:
|
) -> list[Notification]:
|
||||||
"""
|
"""
|
||||||
Get all notifications.
|
Get all notifications.
|
||||||
"""
|
"""
|
||||||
return notification_service.get_all_notifications()
|
return await notification_service.get_all_notifications()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/unread",
|
"/unread",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_unread_notifications(
|
async def get_unread_notifications(
|
||||||
notification_service: notification_service_dep,
|
notification_service: notification_service_dep,
|
||||||
) -> list[Notification]:
|
) -> list[Notification]:
|
||||||
"""
|
"""
|
||||||
Get all unread notifications.
|
Get all unread notifications.
|
||||||
"""
|
"""
|
||||||
return notification_service.get_unread_notifications()
|
return await notification_service.get_unread_notifications()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -45,13 +45,13 @@ def get_unread_notifications(
|
|||||||
status.HTTP_404_NOT_FOUND: {"description": "Notification not found"},
|
status.HTTP_404_NOT_FOUND: {"description": "Notification not found"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def get_notification(
|
async def get_notification(
|
||||||
notification_id: NotificationId, notification_service: notification_service_dep
|
notification_id: NotificationId, notification_service: notification_service_dep
|
||||||
) -> Notification:
|
) -> Notification:
|
||||||
"""
|
"""
|
||||||
Get a specific notification by ID.
|
Get a specific notification by ID.
|
||||||
"""
|
"""
|
||||||
return notification_service.get_notification(nid=notification_id)
|
return await notification_service.get_notification(nid=notification_id)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------
|
# --------------------------------
|
||||||
@@ -67,13 +67,13 @@ def get_notification(
|
|||||||
status.HTTP_404_NOT_FOUND: {"description": "Notification not found"},
|
status.HTTP_404_NOT_FOUND: {"description": "Notification not found"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def mark_notification_as_read(
|
async def mark_notification_as_read(
|
||||||
notification_id: NotificationId, notification_service: notification_service_dep
|
notification_id: NotificationId, notification_service: notification_service_dep
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Mark a notification as read.
|
Mark a notification as read.
|
||||||
"""
|
"""
|
||||||
notification_service.mark_notification_as_read(nid=notification_id)
|
await notification_service.mark_notification_as_read(nid=notification_id)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
@@ -84,13 +84,13 @@ def mark_notification_as_read(
|
|||||||
status.HTTP_404_NOT_FOUND: {"description": "Notification not found"},
|
status.HTTP_404_NOT_FOUND: {"description": "Notification not found"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def mark_notification_as_unread(
|
async def mark_notification_as_unread(
|
||||||
notification_id: NotificationId, notification_service: notification_service_dep
|
notification_id: NotificationId, notification_service: notification_service_dep
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Mark a notification as unread.
|
Mark a notification as unread.
|
||||||
"""
|
"""
|
||||||
notification_service.mark_notification_as_unread(nid=notification_id)
|
await notification_service.mark_notification_as_unread(nid=notification_id)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -101,10 +101,10 @@ def mark_notification_as_unread(
|
|||||||
status.HTTP_404_NOT_FOUND: {"description": "Notification not found"},
|
status.HTTP_404_NOT_FOUND: {"description": "Notification not found"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def delete_notification(
|
async def delete_notification(
|
||||||
notification_id: NotificationId, notification_service: notification_service_dep
|
notification_id: NotificationId, notification_service: notification_service_dep
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Delete a notification.
|
Delete a notification.
|
||||||
"""
|
"""
|
||||||
notification_service.delete_notification(nid=notification_id)
|
await notification_service.delete_notification(nid=notification_id)
|
||||||
|
|||||||
@@ -11,30 +11,30 @@ class NotificationService:
|
|||||||
self.notification_repository = notification_repository
|
self.notification_repository = notification_repository
|
||||||
self.notification_manager = notification_manager
|
self.notification_manager = notification_manager
|
||||||
|
|
||||||
def get_notification(self, nid: NotificationId) -> Notification:
|
async def get_notification(self, nid: NotificationId) -> Notification:
|
||||||
return self.notification_repository.get_notification(nid=nid)
|
return await self.notification_repository.get_notification(nid=nid)
|
||||||
|
|
||||||
def get_unread_notifications(self) -> list[Notification]:
|
async def get_unread_notifications(self) -> list[Notification]:
|
||||||
return self.notification_repository.get_unread_notifications()
|
return await self.notification_repository.get_unread_notifications()
|
||||||
|
|
||||||
def get_all_notifications(self) -> list[Notification]:
|
async def get_all_notifications(self) -> list[Notification]:
|
||||||
return self.notification_repository.get_all_notifications()
|
return await self.notification_repository.get_all_notifications()
|
||||||
|
|
||||||
def save_notification(self, notification: Notification) -> None:
|
async def save_notification(self, notification: Notification) -> None:
|
||||||
return self.notification_repository.save_notification(notification)
|
return await self.notification_repository.save_notification(notification)
|
||||||
|
|
||||||
def mark_notification_as_read(self, nid: NotificationId) -> None:
|
async def mark_notification_as_read(self, nid: NotificationId) -> None:
|
||||||
return self.notification_repository.mark_notification_as_read(nid=nid)
|
return await self.notification_repository.mark_notification_as_read(nid=nid)
|
||||||
|
|
||||||
def mark_notification_as_unread(self, nid: NotificationId) -> None:
|
async def mark_notification_as_unread(self, nid: NotificationId) -> None:
|
||||||
return self.notification_repository.mark_notification_as_unread(nid=nid)
|
return await self.notification_repository.mark_notification_as_unread(nid=nid)
|
||||||
|
|
||||||
def delete_notification(self, nid: NotificationId) -> None:
|
async def delete_notification(self, nid: NotificationId) -> None:
|
||||||
return self.notification_repository.delete_notification(nid=nid)
|
return await self.notification_repository.delete_notification(nid=nid)
|
||||||
|
|
||||||
def send_notification_to_all_providers(self, title: str, message: str) -> None:
|
async def send_notification_to_all_providers(self, title: str, message: str) -> None:
|
||||||
self.notification_manager.send_notification(title, message)
|
await self.notification_manager.send_notification(title, message)
|
||||||
|
|
||||||
internal_notification = Notification(message=f"{title}: {message}", read=False)
|
internal_notification = Notification(message=f"{title}: {message}", read=False)
|
||||||
self.save_notification(internal_notification)
|
await self.save_notification(internal_notification)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from media_manager.notification.schemas import MessageNotification
|
|||||||
|
|
||||||
class AbstractNotificationServiceProvider(ABC):
|
class AbstractNotificationServiceProvider(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def send_notification(self, message: MessageNotification) -> bool:
|
async def send_notification(self, message: MessageNotification) -> bool:
|
||||||
"""
|
"""
|
||||||
Sends a notification with the given message.
|
Sends a notification with the given message.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
import media_manager.notification.utils
|
import media_manager.notification.utils
|
||||||
from media_manager.config import MediaManagerConfig
|
from media_manager.config import MediaManagerConfig
|
||||||
from media_manager.notification.schemas import MessageNotification
|
from media_manager.notification.schemas import MessageNotification
|
||||||
@@ -10,7 +12,7 @@ class EmailNotificationServiceProvider(AbstractNotificationServiceProvider):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.config = MediaManagerConfig().notifications.email_notifications
|
self.config = MediaManagerConfig().notifications.email_notifications
|
||||||
|
|
||||||
def send_notification(self, message: MessageNotification) -> bool:
|
async def send_notification(self, message: MessageNotification) -> bool:
|
||||||
subject = "MediaManager - " + message.title
|
subject = "MediaManager - " + message.title
|
||||||
html = f"""\
|
html = f"""\
|
||||||
<html>
|
<html>
|
||||||
@@ -25,8 +27,11 @@ class EmailNotificationServiceProvider(AbstractNotificationServiceProvider):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
for email in self.config.emails:
|
for email in self.config.emails:
|
||||||
media_manager.notification.utils.send_email(
|
await asyncio.to_thread(
|
||||||
subject=subject, html=html, addressee=email
|
media_manager.notification.utils.send_email,
|
||||||
|
subject=subject,
|
||||||
|
html=html,
|
||||||
|
addressee=email,
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import requests
|
import httpx
|
||||||
|
|
||||||
from media_manager.config import MediaManagerConfig
|
from media_manager.config import MediaManagerConfig
|
||||||
from media_manager.notification.schemas import MessageNotification
|
from media_manager.notification.schemas import MessageNotification
|
||||||
@@ -15,15 +15,15 @@ class GotifyNotificationServiceProvider(AbstractNotificationServiceProvider):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.config = MediaManagerConfig().notifications.gotify
|
self.config = MediaManagerConfig().notifications.gotify
|
||||||
|
|
||||||
def send_notification(self, message: MessageNotification) -> bool:
|
async def send_notification(self, message: MessageNotification) -> bool:
|
||||||
response = requests.post(
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
url=f"{self.config.url}/message?token={self.config.api_key}",
|
response = await client.post(
|
||||||
json={
|
url=f"{self.config.url}/message?token={self.config.api_key}",
|
||||||
"message": message.message,
|
json={
|
||||||
"title": message.title,
|
"message": message.message,
|
||||||
},
|
"title": message.title,
|
||||||
timeout=60,
|
},
|
||||||
)
|
)
|
||||||
if response.status_code not in range(200, 300):
|
if response.status_code not in range(200, 300):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import requests
|
import httpx
|
||||||
|
|
||||||
from media_manager.config import MediaManagerConfig
|
from media_manager.config import MediaManagerConfig
|
||||||
from media_manager.notification.schemas import MessageNotification
|
from media_manager.notification.schemas import MessageNotification
|
||||||
@@ -15,15 +15,15 @@ class NtfyNotificationServiceProvider(AbstractNotificationServiceProvider):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.config = MediaManagerConfig().notifications.ntfy
|
self.config = MediaManagerConfig().notifications.ntfy
|
||||||
|
|
||||||
def send_notification(self, message: MessageNotification) -> bool:
|
async def send_notification(self, message: MessageNotification) -> bool:
|
||||||
response = requests.post(
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
url=self.config.url,
|
response = await client.post(
|
||||||
data=message.message.encode(encoding="utf-8"),
|
url=self.config.url,
|
||||||
headers={
|
content=message.message.encode(encoding="utf-8"),
|
||||||
"Title": "MediaManager - " + message.title,
|
headers={
|
||||||
},
|
"Title": "MediaManager - " + message.title,
|
||||||
timeout=60,
|
},
|
||||||
)
|
)
|
||||||
if response.status_code not in range(200, 300):
|
if response.status_code not in range(200, 300):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import requests
|
import httpx
|
||||||
|
|
||||||
from media_manager.config import MediaManagerConfig
|
from media_manager.config import MediaManagerConfig
|
||||||
from media_manager.notification.schemas import MessageNotification
|
from media_manager.notification.schemas import MessageNotification
|
||||||
@@ -11,17 +11,17 @@ class PushoverNotificationServiceProvider(AbstractNotificationServiceProvider):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.config = MediaManagerConfig().notifications.pushover
|
self.config = MediaManagerConfig().notifications.pushover
|
||||||
|
|
||||||
def send_notification(self, message: MessageNotification) -> bool:
|
async def send_notification(self, message: MessageNotification) -> bool:
|
||||||
response = requests.post(
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
url="https://api.pushover.net/1/messages.json",
|
response = await client.post(
|
||||||
params={
|
url="https://api.pushover.net/1/messages.json",
|
||||||
"token": self.config.api_key,
|
params={
|
||||||
"user": self.config.user,
|
"token": self.config.api_key,
|
||||||
"message": message.message,
|
"user": self.config.user,
|
||||||
"title": "MediaManager - " + message.title,
|
"message": message.message,
|
||||||
},
|
"title": "MediaManager - " + message.title,
|
||||||
timeout=60,
|
},
|
||||||
)
|
)
|
||||||
if response.status_code not in range(200, 300):
|
if response.status_code not in range(200, 300):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ async def import_all_movie_torrents_task(
|
|||||||
movie_service: MovieService = TaskiqDepends(get_movie_service),
|
movie_service: MovieService = TaskiqDepends(get_movie_service),
|
||||||
) -> None:
|
) -> None:
|
||||||
log.info("Importing all Movie torrents")
|
log.info("Importing all Movie torrents")
|
||||||
await asyncio.to_thread(movie_service.import_all_torrents)
|
await movie_service.import_all_torrents()
|
||||||
|
|
||||||
|
|
||||||
@broker.task
|
@broker.task
|
||||||
@@ -52,21 +51,21 @@ async def import_all_show_torrents_task(
|
|||||||
tv_service: TvService = TaskiqDepends(get_tv_service),
|
tv_service: TvService = TaskiqDepends(get_tv_service),
|
||||||
) -> None:
|
) -> None:
|
||||||
log.info("Importing all Show torrents")
|
log.info("Importing all Show torrents")
|
||||||
await asyncio.to_thread(tv_service.import_all_torrents)
|
await tv_service.import_all_torrents()
|
||||||
|
|
||||||
|
|
||||||
@broker.task
|
@broker.task
|
||||||
async def update_all_movies_metadata_task(
|
async def update_all_movies_metadata_task(
|
||||||
movie_service: MovieService = TaskiqDepends(get_movie_service),
|
movie_service: MovieService = TaskiqDepends(get_movie_service),
|
||||||
) -> None:
|
) -> None:
|
||||||
await asyncio.to_thread(movie_service.update_all_metadata)
|
await movie_service.update_all_metadata()
|
||||||
|
|
||||||
|
|
||||||
@broker.task
|
@broker.task
|
||||||
async def update_all_non_ended_shows_metadata_task(
|
async def update_all_non_ended_shows_metadata_task(
|
||||||
tv_service: TvService = TaskiqDepends(get_tv_service),
|
tv_service: TvService = TaskiqDepends(get_tv_service),
|
||||||
) -> None:
|
) -> None:
|
||||||
await asyncio.to_thread(tv_service.update_all_non_ended_shows_metadata)
|
await tv_service.update_all_non_ended_shows_metadata()
|
||||||
|
|
||||||
|
|
||||||
# Maps each task to its cron schedule so PostgresqlSchedulerSource can seed
|
# Maps each task to its cron schedule so PostgresqlSchedulerSource can seed
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def get_torrent_service(torrent_repository: torrent_repository_dep) -> TorrentSe
|
|||||||
torrent_service_dep = Annotated[TorrentService, Depends(get_torrent_service)]
|
torrent_service_dep = Annotated[TorrentService, Depends(get_torrent_service)]
|
||||||
|
|
||||||
|
|
||||||
def get_torrent_by_id(
|
async def get_torrent_by_id(
|
||||||
torrent_service: torrent_service_dep, torrent_id: TorrentId
|
torrent_service: torrent_service_dep, torrent_id: TorrentId
|
||||||
) -> Torrent:
|
) -> Torrent:
|
||||||
"""
|
"""
|
||||||
@@ -35,7 +35,7 @@ def get_torrent_by_id(
|
|||||||
:return: The TorrentService instance with the specified torrent.
|
:return: The TorrentService instance with the specified torrent.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
torrent = torrent_service.get_torrent_by_id(torrent_id=torrent_id)
|
torrent = await torrent_service.get_torrent_by_id(torrent_id=torrent_id)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail=f"Torrent with ID {torrent_id} not found"
|
status_code=404, detail=f"Torrent with ID {torrent_id} not found"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from media_manager.database import DbSessionDependency
|
from media_manager.database import DbSessionDependency
|
||||||
from media_manager.exceptions import NotFoundError
|
from media_manager.exceptions import NotFoundError
|
||||||
@@ -17,78 +18,83 @@ class TorrentRepository:
|
|||||||
def __init__(self, db: DbSessionDependency) -> None:
|
def __init__(self, db: DbSessionDependency) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def get_episode_files_of_torrent(
|
async def get_episode_files_of_torrent(
|
||||||
self, torrent_id: TorrentId
|
self, torrent_id: TorrentId
|
||||||
) -> list[EpisodeFileSchema]:
|
) -> list[EpisodeFileSchema]:
|
||||||
stmt = select(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id)
|
stmt = select(EpisodeFile).where(EpisodeFile.torrent_id == torrent_id)
|
||||||
result = self.db.execute(stmt).scalars().all()
|
result = (await self.db.execute(stmt)).scalars().all()
|
||||||
return [
|
return [
|
||||||
EpisodeFileSchema.model_validate(episode_file) for episode_file in result
|
EpisodeFileSchema.model_validate(episode_file) for episode_file in result
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_show_of_torrent(self, torrent_id: TorrentId) -> ShowSchema | None:
|
async def get_show_of_torrent(self, torrent_id: TorrentId) -> ShowSchema | None:
|
||||||
|
# Eager-load the show tree; ShowSchema requires seasons -> episodes and
|
||||||
|
# AsyncSession can't satisfy implicit lazy loads during validation.
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Show)
|
select(Show)
|
||||||
.join(Show.seasons)
|
.join(Show.seasons)
|
||||||
.join(Season.episodes)
|
.join(Season.episodes)
|
||||||
.join(Episode.episode_files)
|
.join(Episode.episode_files)
|
||||||
.where(EpisodeFile.torrent_id == torrent_id)
|
.where(EpisodeFile.torrent_id == torrent_id)
|
||||||
|
.options(selectinload(Show.seasons).selectinload(Season.episodes))
|
||||||
)
|
)
|
||||||
result = self.db.execute(stmt).unique().scalar_one_or_none()
|
result = (await self.db.execute(stmt)).unique().scalar_one_or_none()
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
return ShowSchema.model_validate(result)
|
return ShowSchema.model_validate(result)
|
||||||
|
|
||||||
def save_torrent(self, torrent: TorrentSchema) -> TorrentSchema:
|
async def save_torrent(self, torrent: TorrentSchema) -> TorrentSchema:
|
||||||
self.db.merge(Torrent(**torrent.model_dump()))
|
await self.db.merge(Torrent(**torrent.model_dump()))
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
return TorrentSchema.model_validate(torrent)
|
return TorrentSchema.model_validate(torrent)
|
||||||
|
|
||||||
def get_all_torrents(self) -> list[TorrentSchema]:
|
async def get_all_torrents(self) -> list[TorrentSchema]:
|
||||||
stmt = select(Torrent)
|
stmt = select(Torrent)
|
||||||
result = self.db.execute(stmt).scalars().all()
|
result = (await self.db.execute(stmt)).scalars().all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
TorrentSchema.model_validate(torrent_schema) for torrent_schema in result
|
TorrentSchema.model_validate(torrent_schema) for torrent_schema in result
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_torrent_by_id(self, torrent_id: TorrentId) -> TorrentSchema:
|
async def get_torrent_by_id(self, torrent_id: TorrentId) -> TorrentSchema:
|
||||||
result = self.db.get(Torrent, torrent_id)
|
result = await self.db.get(Torrent, torrent_id)
|
||||||
if result is None:
|
if result is None:
|
||||||
msg = f"Torrent with ID {torrent_id} not found."
|
msg = f"Torrent with ID {torrent_id} not found."
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
return TorrentSchema.model_validate(result)
|
return TorrentSchema.model_validate(result)
|
||||||
|
|
||||||
def delete_torrent(
|
async def delete_torrent(
|
||||||
self, torrent_id: TorrentId, delete_associated_media_files: bool = False
|
self, torrent_id: TorrentId, delete_associated_media_files: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
if delete_associated_media_files:
|
if delete_associated_media_files:
|
||||||
movie_files_stmt = delete(MovieFile).where(
|
movie_files_stmt = delete(MovieFile).where(
|
||||||
MovieFile.torrent_id == torrent_id
|
MovieFile.torrent_id == torrent_id
|
||||||
)
|
)
|
||||||
self.db.execute(movie_files_stmt)
|
await self.db.execute(movie_files_stmt)
|
||||||
|
|
||||||
episode_files_stmt = delete(EpisodeFile).where(
|
episode_files_stmt = delete(EpisodeFile).where(
|
||||||
EpisodeFile.torrent_id == torrent_id
|
EpisodeFile.torrent_id == torrent_id
|
||||||
)
|
)
|
||||||
self.db.execute(episode_files_stmt)
|
await self.db.execute(episode_files_stmt)
|
||||||
|
|
||||||
self.db.delete(self.db.get(Torrent, torrent_id))
|
obj = await self.db.get(Torrent, torrent_id)
|
||||||
|
if obj is not None:
|
||||||
|
await self.db.delete(obj)
|
||||||
|
|
||||||
def get_movie_of_torrent(self, torrent_id: TorrentId) -> MovieSchema | None:
|
async def get_movie_of_torrent(self, torrent_id: TorrentId) -> MovieSchema | None:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Movie)
|
select(Movie)
|
||||||
.join(MovieFile, Movie.id == MovieFile.movie_id)
|
.join(MovieFile, Movie.id == MovieFile.movie_id)
|
||||||
.where(MovieFile.torrent_id == torrent_id)
|
.where(MovieFile.torrent_id == torrent_id)
|
||||||
)
|
)
|
||||||
result = self.db.execute(stmt).unique().scalar_one_or_none()
|
result = (await self.db.execute(stmt)).unique().scalar_one_or_none()
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
return MovieSchema.model_validate(result)
|
return MovieSchema.model_validate(result)
|
||||||
|
|
||||||
def get_movie_files_of_torrent(
|
async def get_movie_files_of_torrent(
|
||||||
self, torrent_id: TorrentId
|
self, torrent_id: TorrentId
|
||||||
) -> list[MovieFileSchema]:
|
) -> list[MovieFileSchema]:
|
||||||
stmt = select(MovieFile).where(MovieFile.torrent_id == torrent_id)
|
stmt = select(MovieFile).where(MovieFile.torrent_id == torrent_id)
|
||||||
result = self.db.execute(stmt).scalars().all()
|
result = (await self.db.execute(stmt)).scalars().all()
|
||||||
return [MovieFileSchema.model_validate(movie_file) for movie_file in result]
|
return [MovieFileSchema.model_validate(movie_file) for movie_file in result]
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ router = APIRouter()
|
|||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_all_torrents(service: torrent_service_dep) -> list[Torrent]:
|
async def get_all_torrents(service: torrent_service_dep) -> list[Torrent]:
|
||||||
return service.get_all_torrents()
|
return await service.get_all_torrents()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{torrent_id}", status_code=status.HTTP_200_OK)
|
@router.get("/{torrent_id}", status_code=status.HTTP_200_OK)
|
||||||
def get_torrent(service: torrent_service_dep, torrent: torrent_dep) -> Torrent:
|
async def get_torrent(service: torrent_service_dep, torrent: torrent_dep) -> Torrent:
|
||||||
return service.get_torrent_by_id(torrent_id=torrent.id)
|
return await service.get_torrent_by_id(torrent_id=torrent.id)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -32,18 +32,18 @@ def get_torrent(service: torrent_service_dep, torrent: torrent_dep) -> Torrent:
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def delete_torrent(
|
async def delete_torrent(
|
||||||
service: torrent_service_dep,
|
service: torrent_service_dep,
|
||||||
torrent: torrent_dep,
|
torrent: torrent_dep,
|
||||||
delete_files: bool = False,
|
delete_files: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if delete_files:
|
if delete_files:
|
||||||
try:
|
try:
|
||||||
service.cancel_download(torrent=torrent, delete_files=False)
|
await service.cancel_download(torrent=torrent, delete_files=False)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
service.delete_torrent(torrent_id=torrent.id)
|
await service.delete_torrent(torrent_id=torrent.id)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -51,12 +51,12 @@ def delete_torrent(
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def retry_torrent_download(
|
async def retry_torrent_download(
|
||||||
service: torrent_service_dep,
|
service: torrent_service_dep,
|
||||||
torrent: torrent_dep,
|
torrent: torrent_dep,
|
||||||
) -> None:
|
) -> None:
|
||||||
service.pause_download(torrent=torrent)
|
await service.pause_download(torrent=torrent)
|
||||||
service.resume_download(torrent=torrent)
|
await service.resume_download(torrent=torrent)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
@@ -64,7 +64,7 @@ def retry_torrent_download(
|
|||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def update_torrent_status(
|
async def update_torrent_status(
|
||||||
rep: torrent_repository_dep,
|
rep: torrent_repository_dep,
|
||||||
torrent: torrent_dep,
|
torrent: torrent_dep,
|
||||||
state: TorrentStatus | None = None,
|
state: TorrentStatus | None = None,
|
||||||
@@ -80,5 +80,5 @@ def update_torrent_status(
|
|||||||
detail="No status or imported value provided",
|
detail="No status or imported value provided",
|
||||||
)
|
)
|
||||||
|
|
||||||
rep.save_torrent(torrent=torrent)
|
await rep.save_torrent(torrent=torrent)
|
||||||
return torrent
|
return torrent
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from media_manager.indexer.schemas import IndexerQueryResult
|
from media_manager.indexer.schemas import IndexerQueryResult
|
||||||
@@ -19,44 +20,50 @@ class TorrentService:
|
|||||||
self.torrent_repository = torrent_repository
|
self.torrent_repository = torrent_repository
|
||||||
self.download_manager = download_manager or DownloadManager()
|
self.download_manager = download_manager or DownloadManager()
|
||||||
|
|
||||||
def get_episode_files_of_torrent(self, torrent: Torrent) -> list[EpisodeFile]:
|
async def get_episode_files_of_torrent(
|
||||||
|
self, torrent: Torrent
|
||||||
|
) -> list[EpisodeFile]:
|
||||||
"""
|
"""
|
||||||
Returns all episode files of a torrent
|
Returns all episode files of a torrent
|
||||||
:param torrent: the torrent to get the episode files of
|
:param torrent: the torrent to get the episode files of
|
||||||
:return: list of episode files
|
:return: list of episode files
|
||||||
"""
|
"""
|
||||||
return self.torrent_repository.get_episode_files_of_torrent(
|
return await self.torrent_repository.get_episode_files_of_torrent(
|
||||||
torrent_id=torrent.id
|
torrent_id=torrent.id
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_show_of_torrent(self, torrent: Torrent) -> Show | None:
|
async def get_show_of_torrent(self, torrent: Torrent) -> Show | None:
|
||||||
"""
|
"""
|
||||||
Returns the show of a torrent
|
Returns the show of a torrent
|
||||||
:param torrent: the torrent to get the show of
|
:param torrent: the torrent to get the show of
|
||||||
:return: the show of the torrent
|
:return: the show of the torrent
|
||||||
"""
|
"""
|
||||||
return self.torrent_repository.get_show_of_torrent(torrent_id=torrent.id)
|
return await self.torrent_repository.get_show_of_torrent(torrent_id=torrent.id)
|
||||||
|
|
||||||
def get_movie_of_torrent(self, torrent: Torrent) -> Movie | None:
|
async def get_movie_of_torrent(self, torrent: Torrent) -> Movie | None:
|
||||||
"""
|
"""
|
||||||
Returns the movie of a torrent
|
Returns the movie of a torrent
|
||||||
:param torrent: the torrent to get the movie of
|
:param torrent: the torrent to get the movie of
|
||||||
:return: the movie of the torrent
|
:return: the movie of the torrent
|
||||||
"""
|
"""
|
||||||
return self.torrent_repository.get_movie_of_torrent(torrent_id=torrent.id)
|
return await self.torrent_repository.get_movie_of_torrent(torrent_id=torrent.id)
|
||||||
|
|
||||||
def download(self, indexer_result: IndexerQueryResult) -> Torrent:
|
async def download(self, indexer_result: IndexerQueryResult) -> Torrent:
|
||||||
log.info(f"Starting download for torrent: {indexer_result.title}")
|
log.info(f"Starting download for torrent: {indexer_result.title}")
|
||||||
torrent = self.download_manager.download(indexer_result)
|
torrent = await asyncio.to_thread(self.download_manager.download, indexer_result)
|
||||||
|
|
||||||
return self.torrent_repository.save_torrent(torrent=torrent)
|
return await self.torrent_repository.save_torrent(torrent=torrent)
|
||||||
|
|
||||||
def get_torrent_status(self, torrent: Torrent) -> Torrent:
|
async def get_torrent_status(self, torrent: Torrent) -> Torrent:
|
||||||
torrent.status = self.download_manager.get_torrent_status(torrent)
|
torrent.status = await asyncio.to_thread(
|
||||||
self.torrent_repository.save_torrent(torrent=torrent)
|
self.download_manager.get_torrent_status, torrent
|
||||||
|
)
|
||||||
|
await self.torrent_repository.save_torrent(torrent=torrent)
|
||||||
return torrent
|
return torrent
|
||||||
|
|
||||||
def cancel_download(self, torrent: Torrent, delete_files: bool = False) -> Torrent:
|
async def cancel_download(
|
||||||
|
self, torrent: Torrent, delete_files: bool = False
|
||||||
|
) -> Torrent:
|
||||||
"""
|
"""
|
||||||
cancels download of a torrent
|
cancels download of a torrent
|
||||||
|
|
||||||
@@ -64,57 +71,62 @@ class TorrentService:
|
|||||||
:param torrent: the torrent to cancel
|
:param torrent: the torrent to cancel
|
||||||
"""
|
"""
|
||||||
log.info(f"Cancelling download for torrent: {torrent.title}")
|
log.info(f"Cancelling download for torrent: {torrent.title}")
|
||||||
self.download_manager.remove_torrent(torrent, delete_data=delete_files)
|
await asyncio.to_thread(
|
||||||
return self.get_torrent_status(torrent=torrent)
|
self.download_manager.remove_torrent, torrent, delete_data=delete_files
|
||||||
|
)
|
||||||
|
return await self.get_torrent_status(torrent=torrent)
|
||||||
|
|
||||||
def pause_download(self, torrent: Torrent) -> Torrent:
|
async def pause_download(self, torrent: Torrent) -> Torrent:
|
||||||
"""
|
"""
|
||||||
pauses download of a torrent
|
pauses download of a torrent
|
||||||
|
|
||||||
:param torrent: the torrent to pause
|
:param torrent: the torrent to pause
|
||||||
"""
|
"""
|
||||||
log.info(f"Pausing download for torrent: {torrent.title}")
|
log.info(f"Pausing download for torrent: {torrent.title}")
|
||||||
self.download_manager.pause_torrent(torrent)
|
await asyncio.to_thread(self.download_manager.pause_torrent, torrent)
|
||||||
return self.get_torrent_status(torrent=torrent)
|
return await self.get_torrent_status(torrent=torrent)
|
||||||
|
|
||||||
def resume_download(self, torrent: Torrent) -> Torrent:
|
async def resume_download(self, torrent: Torrent) -> Torrent:
|
||||||
"""
|
"""
|
||||||
resumes download of a torrent
|
resumes download of a torrent
|
||||||
|
|
||||||
:param torrent: the torrent to resume
|
:param torrent: the torrent to resume
|
||||||
"""
|
"""
|
||||||
log.info(f"Resuming download for torrent: {torrent.title}")
|
log.info(f"Resuming download for torrent: {torrent.title}")
|
||||||
self.download_manager.resume_torrent(torrent)
|
await asyncio.to_thread(self.download_manager.resume_torrent, torrent)
|
||||||
return self.get_torrent_status(torrent=torrent)
|
return await self.get_torrent_status(torrent=torrent)
|
||||||
|
|
||||||
def get_all_torrents(self) -> list[Torrent]:
|
async def get_all_torrents(self) -> list[Torrent]:
|
||||||
torrents = []
|
all_torrents = await self.torrent_repository.get_all_torrents()
|
||||||
for x in self.torrent_repository.get_all_torrents():
|
torrents: list[Torrent] = []
|
||||||
|
for t in all_torrents:
|
||||||
try:
|
try:
|
||||||
torrents.append(self.get_torrent_status(x))
|
torrents.append(await self.get_torrent_status(t))
|
||||||
except RuntimeError:
|
except Exception:
|
||||||
log.exception(f"Error fetching status for torrent {x.title}")
|
log.exception(f"Error fetching status for torrent {t.title}")
|
||||||
return torrents
|
return torrents
|
||||||
|
|
||||||
def get_completed_torrents(self) -> list[Torrent]:
|
async def get_completed_torrents(self) -> list[Torrent]:
|
||||||
return [
|
return [
|
||||||
t
|
t
|
||||||
for t in self.get_all_torrents()
|
for t in await self.get_all_torrents()
|
||||||
if t.status == TorrentStatus.finished and not t.imported
|
if t.status == TorrentStatus.finished and not t.imported
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_torrent_by_id(self, torrent_id: TorrentId) -> Torrent:
|
async def get_torrent_by_id(self, torrent_id: TorrentId) -> Torrent:
|
||||||
return self.get_torrent_status(
|
return await self.get_torrent_status(
|
||||||
self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
|
await self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete_torrent(self, torrent_id: TorrentId) -> None:
|
async def delete_torrent(self, torrent_id: TorrentId) -> None:
|
||||||
log.info(f"Deleting torrent with ID: {torrent_id}")
|
log.info(f"Deleting torrent with ID: {torrent_id}")
|
||||||
t = self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
|
t = await self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
|
||||||
delete_media_files = not t.imported
|
delete_media_files = not t.imported
|
||||||
self.torrent_repository.delete_torrent(
|
await self.torrent_repository.delete_torrent(
|
||||||
torrent_id=torrent_id, delete_associated_media_files=delete_media_files
|
torrent_id=torrent_id, delete_associated_media_files=delete_media_files
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_movie_files_of_torrent(self, torrent: Torrent) -> list[MovieFile]:
|
async def get_movie_files_of_torrent(self, torrent: Torrent) -> list[MovieFile]:
|
||||||
return self.torrent_repository.get_movie_files_of_torrent(torrent_id=torrent.id)
|
return await self.torrent_repository.get_movie_files_of_torrent(
|
||||||
|
torrent_id=torrent.id
|
||||||
|
)
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ def get_tv_service(
|
|||||||
tv_service_dep = Annotated[TvService, Depends(get_tv_service)]
|
tv_service_dep = Annotated[TvService, Depends(get_tv_service)]
|
||||||
|
|
||||||
|
|
||||||
def get_show_by_id(
|
async def get_show_by_id(
|
||||||
tv_service: tv_service_dep,
|
tv_service: tv_service_dep,
|
||||||
show_id: ShowId = Path(..., description="The ID of the show"),
|
show_id: ShowId = Path(..., description="The ID of the show"),
|
||||||
) -> Show:
|
) -> Show:
|
||||||
try:
|
try:
|
||||||
show = tv_service.get_show_by_id(show_id)
|
show = await tv_service.get_show_by_id(show_id)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
@@ -85,12 +85,12 @@ def get_show_by_id(
|
|||||||
show_dep = Annotated[Show, Depends(get_show_by_id)]
|
show_dep = Annotated[Show, Depends(get_show_by_id)]
|
||||||
|
|
||||||
|
|
||||||
def get_season_by_id(
|
async def get_season_by_id(
|
||||||
tv_service: tv_service_dep,
|
tv_service: tv_service_dep,
|
||||||
season_id: SeasonId = Path(..., description="The ID of the season"),
|
season_id: SeasonId = Path(..., description="The ID of the season"),
|
||||||
) -> Season:
|
) -> Season:
|
||||||
try:
|
try:
|
||||||
season = tv_service.get_season(season_id=season_id)
|
season = await tv_service.get_season(season_id=season_id)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -51,7 +52,7 @@ class TvImportService(BaseMediaService[Show, Show]):
|
|||||||
libraries=misc_config.tv_libraries,
|
libraries=misc_config.tv_libraries,
|
||||||
)
|
)
|
||||||
|
|
||||||
def import_tv_show(
|
async def import_tv_show(
|
||||||
self,
|
self,
|
||||||
show: Show,
|
show: Show,
|
||||||
source_directory: Path,
|
source_directory: Path,
|
||||||
@@ -59,7 +60,10 @@ class TvImportService(BaseMediaService[Show, Show]):
|
|||||||
torrent_id: str | None = None,
|
torrent_id: str | None = None,
|
||||||
file_path_suffix: str = "",
|
file_path_suffix: str = "",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
video_files, _, _ = get_files_for_import(directory=source_directory)
|
# Filesystem scan + archive extraction; offload off the event loop.
|
||||||
|
video_files, _, _ = await asyncio.to_thread(
|
||||||
|
get_files_for_import, directory=source_directory
|
||||||
|
)
|
||||||
if not video_files:
|
if not video_files:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -79,17 +83,19 @@ class TvImportService(BaseMediaService[Show, Show]):
|
|||||||
target_name += f" - {file_path_suffix}"
|
target_name += f" - {file_path_suffix}"
|
||||||
target_file = season_dir / f"{target_name}{video_file.suffix}"
|
target_file = season_dir / f"{target_name}{video_file.suffix}"
|
||||||
|
|
||||||
import_file(target_file=target_file, source_file=video_file)
|
await asyncio.to_thread(
|
||||||
|
import_file, target_file=target_file, source_file=video_file
|
||||||
|
)
|
||||||
any_imported = True
|
any_imported = True
|
||||||
|
|
||||||
# Update DB
|
# Update DB
|
||||||
try:
|
try:
|
||||||
season = self.tv_repository.get_season_by_number(s_num, show.id)
|
season = await self.tv_repository.get_season_by_number(s_num, show.id)
|
||||||
episode = next(
|
episode = next(
|
||||||
(e for e in season.episodes if e.number == e_num), None
|
(e for e in season.episodes if e.number == e_num), None
|
||||||
)
|
)
|
||||||
if episode:
|
if episode:
|
||||||
self.tv_repository.add_episode_file(
|
await self.tv_repository.add_episode_file(
|
||||||
EpisodeFile(
|
EpisodeFile(
|
||||||
episode_id=episode.id,
|
episode_id=episode.id,
|
||||||
quality=quality,
|
quality=quality,
|
||||||
@@ -101,8 +107,8 @@ class TvImportService(BaseMediaService[Show, Show]):
|
|||||||
log.exception(f"Could not update DB for {video_file.name}")
|
log.exception(f"Could not update DB for {video_file.name}")
|
||||||
return any_imported
|
return any_imported
|
||||||
|
|
||||||
def import_torrent_files(self, torrent: Torrent, show: Show) -> None:
|
async def import_torrent_files(self, torrent: Torrent, show: Show) -> None:
|
||||||
success = self.import_tv_show(
|
success = await self.import_tv_show(
|
||||||
show=show,
|
show=show,
|
||||||
source_directory=get_torrent_filepath(torrent),
|
source_directory=get_torrent_filepath(torrent),
|
||||||
quality=torrent.quality,
|
quality=torrent.quality,
|
||||||
@@ -110,44 +116,45 @@ class TvImportService(BaseMediaService[Show, Show]):
|
|||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
torrent.imported = True
|
torrent.imported = True
|
||||||
self.torrent_service.torrent_repository.save_torrent(torrent=torrent)
|
await self.torrent_service.torrent_repository.save_torrent(torrent=torrent)
|
||||||
self.notify_import_success(show.name, "TV show")
|
await self.notify_import_success(show.name, "TV show")
|
||||||
else:
|
else:
|
||||||
self.notify_import_failure(show.name, "TV show")
|
await self.notify_import_failure(show.name, "TV show")
|
||||||
|
|
||||||
def get_import_candidates(
|
async def get_import_candidates(
|
||||||
self, tv_path: Path, metadata_provider: AbstractMetadataProvider
|
self, tv_path: Path, metadata_provider: AbstractMetadataProvider
|
||||||
) -> MediaImportSuggestion:
|
) -> MediaImportSuggestion:
|
||||||
return super().get_import_candidates(
|
return await super().get_import_candidates(
|
||||||
directory=tv_path,
|
directory=tv_path,
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
search_func=self.tv_metadata_service.search_for_show,
|
search_func=self.tv_metadata_service.search_for_show,
|
||||||
)
|
)
|
||||||
|
|
||||||
def import_existing_tv_show(self, tv_show: Show, source_directory: Path) -> bool:
|
async def import_existing_tv_show(self, tv_show: Show, source_directory: Path) -> bool:
|
||||||
def _logic(s: Show, path: Path, _: Callable[[Any], Any]) -> bool:
|
async def _logic(s: Show, path: Path, _: Callable[[Any], Any]) -> bool:
|
||||||
return self.import_tv_show(s, path, file_path_suffix="IMPORTED")
|
return await self.import_tv_show(s, path, file_path_suffix="IMPORTED")
|
||||||
|
|
||||||
return self.import_existing_media(
|
async def _noop(_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await self.import_existing_media(
|
||||||
media=tv_show,
|
media=tv_show,
|
||||||
source_directory=source_directory,
|
source_directory=source_directory,
|
||||||
import_func=_logic,
|
import_func=_logic,
|
||||||
add_file_record_func=lambda _: (
|
add_file_record_func=_noop,
|
||||||
None
|
|
||||||
), # Handled inside import_tv_show for now
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_importable_tv_shows(
|
async def get_importable_tv_shows(
|
||||||
self, metadata_provider: AbstractMetadataProvider
|
self, metadata_provider: AbstractMetadataProvider
|
||||||
) -> list[MediaImportSuggestion]:
|
) -> list[MediaImportSuggestion]:
|
||||||
return self.get_importable_media(
|
return await self.get_importable_media(
|
||||||
root_path=MediaManagerConfig().misc.tv_directory,
|
root_path=MediaManagerConfig().misc.tv_directory,
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
get_candidates_func=self.get_import_candidates,
|
get_candidates_func=self.get_import_candidates,
|
||||||
)
|
)
|
||||||
|
|
||||||
def import_all_torrents(self) -> None:
|
async def import_all_torrents(self) -> None:
|
||||||
self.import_all_torrents_base(
|
await self.import_all_torrents_base(
|
||||||
get_media_func=self.torrent_service.get_show_of_torrent,
|
get_media_func=self.torrent_service.get_show_of_torrent,
|
||||||
import_torrent_func=self.import_torrent_files,
|
import_torrent_func=self.import_torrent_files,
|
||||||
media_type_name="tv show",
|
media_type_name="tv show",
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ class TvMetadataService(BaseMetadataService[Show, Show]):
|
|||||||
super().__init__(repository=tv_repository)
|
super().__init__(repository=tv_repository)
|
||||||
self.tv_repository = tv_repository
|
self.tv_repository = tv_repository
|
||||||
|
|
||||||
def add_show(
|
async def add_show(
|
||||||
self,
|
self,
|
||||||
external_id: int,
|
external_id: int,
|
||||||
metadata_provider: AbstractMetadataProvider,
|
metadata_provider: AbstractMetadataProvider,
|
||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
) -> Show:
|
) -> Show:
|
||||||
return self.add_media_base(
|
return await self.add_media_base(
|
||||||
external_id=external_id,
|
external_id=external_id,
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
get_metadata_func=metadata_provider.get_show_metadata,
|
get_metadata_func=metadata_provider.get_show_metadata,
|
||||||
@@ -33,39 +33,39 @@ class TvMetadataService(BaseMetadataService[Show, Show]):
|
|||||||
language=language,
|
language=language,
|
||||||
)
|
)
|
||||||
|
|
||||||
def search_for_show(
|
async def search_for_show(
|
||||||
self, query: str, metadata_provider: AbstractMetadataProvider
|
self, query: str, metadata_provider: AbstractMetadataProvider
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
return self.search_for_media_base(
|
return await self.search_for_media_base(
|
||||||
query=query,
|
query=query,
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
search_func=metadata_provider.search_show,
|
search_func=metadata_provider.search_show,
|
||||||
get_by_external_id_func=self.tv_repository.get_show_by_external_id,
|
get_by_external_id_func=self.tv_repository.get_show_by_external_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_popular_shows(
|
async def get_popular_shows(
|
||||||
self, metadata_provider: AbstractMetadataProvider
|
self, metadata_provider: AbstractMetadataProvider
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
return self.get_popular_media_base(
|
return await self.get_popular_media_base(
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
search_func=metadata_provider.search_show,
|
search_func=metadata_provider.search_show,
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_show_metadata(
|
async def update_show_metadata(
|
||||||
self, db_show: Show, metadata_provider: AbstractMetadataProvider
|
self, db_show: Show, metadata_provider: AbstractMetadataProvider
|
||||||
) -> Show | None:
|
) -> Show | None:
|
||||||
"""
|
"""
|
||||||
Updates the metadata of a show.
|
Updates the metadata of a show.
|
||||||
"""
|
"""
|
||||||
log.debug(f"Found show: {db_show.name} for metadata update.")
|
log.debug(f"Found show: {db_show.name} for metadata update.")
|
||||||
fresh_show_data = metadata_provider.get_show_metadata(
|
fresh_show_data = await metadata_provider.get_show_metadata(
|
||||||
show_id=db_show.external_id, language=db_show.original_language
|
show_id=db_show.external_id, language=db_show.original_language
|
||||||
)
|
)
|
||||||
if not fresh_show_data:
|
if not fresh_show_data:
|
||||||
log.warning(f"Could not fetch fresh metadata for show {db_show.name}")
|
log.warning(f"Could not fetch fresh metadata for show {db_show.name}")
|
||||||
return db_show
|
return db_show
|
||||||
|
|
||||||
self.tv_repository.update_show_attributes(
|
await self.tv_repository.update_show_attributes(
|
||||||
show_id=db_show.id,
|
show_id=db_show.id,
|
||||||
name=fresh_show_data.name,
|
name=fresh_show_data.name,
|
||||||
overview=fresh_show_data.overview,
|
overview=fresh_show_data.overview,
|
||||||
@@ -83,7 +83,7 @@ class TvMetadataService(BaseMetadataService[Show, Show]):
|
|||||||
existing_season = existing_season_external_ids[
|
existing_season = existing_season_external_ids[
|
||||||
fresh_season_data.external_id
|
fresh_season_data.external_id
|
||||||
]
|
]
|
||||||
self.tv_repository.update_season_attributes(
|
await self.tv_repository.update_season_attributes(
|
||||||
season_id=existing_season.id,
|
season_id=existing_season.id,
|
||||||
name=fresh_season_data.name,
|
name=fresh_season_data.name,
|
||||||
overview=fresh_season_data.overview,
|
overview=fresh_season_data.overview,
|
||||||
@@ -96,7 +96,7 @@ class TvMetadataService(BaseMetadataService[Show, Show]):
|
|||||||
existing_episode = existing_episode_external_ids[
|
existing_episode = existing_episode_external_ids[
|
||||||
fresh_episode_data.external_id
|
fresh_episode_data.external_id
|
||||||
]
|
]
|
||||||
self.tv_repository.update_episode_attributes(
|
await self.tv_repository.update_episode_attributes(
|
||||||
episode_id=existing_episode.id,
|
episode_id=existing_episode.id,
|
||||||
title=fresh_episode_data.title,
|
title=fresh_episode_data.title,
|
||||||
overview=fresh_episode_data.overview,
|
overview=fresh_episode_data.overview,
|
||||||
@@ -109,7 +109,7 @@ class TvMetadataService(BaseMetadataService[Show, Show]):
|
|||||||
title=fresh_episode_data.title,
|
title=fresh_episode_data.title,
|
||||||
overview=fresh_episode_data.overview,
|
overview=fresh_episode_data.overview,
|
||||||
)
|
)
|
||||||
self.tv_repository.add_episode_to_season(
|
await self.tv_repository.add_episode_to_season(
|
||||||
season_id=existing_season.id, episode_data=episode_schema
|
season_id=existing_season.id, episode_data=episode_schema
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -130,19 +130,19 @@ class TvMetadataService(BaseMetadataService[Show, Show]):
|
|||||||
for ep_data in fresh_season_data.episodes
|
for ep_data in fresh_season_data.episodes
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.tv_repository.add_season_to_show(
|
await self.tv_repository.add_season_to_show(
|
||||||
show_id=db_show.id, season_data=season_schema
|
show_id=db_show.id, season_data=season_schema
|
||||||
)
|
)
|
||||||
|
|
||||||
updated_show = self.tv_repository.get_show_by_id(show_id=db_show.id)
|
updated_show = await self.tv_repository.get_show_by_id(show_id=db_show.id)
|
||||||
metadata_provider.download_show_poster_image(show=updated_show)
|
await metadata_provider.download_show_poster_image(show=updated_show)
|
||||||
return updated_show
|
return updated_show
|
||||||
|
|
||||||
def update_all_non_ended_shows_metadata(self) -> None:
|
async def update_all_non_ended_shows_metadata(self) -> None:
|
||||||
def get_non_ended_shows() -> list[Show]:
|
async def get_non_ended_shows() -> list[Show]:
|
||||||
return [show for show in self.tv_repository.get_shows() if not show.ended]
|
return [show for show in await self.tv_repository.get_shows() if not show.ended]
|
||||||
|
|
||||||
self.update_all_metadata_base(
|
await self.update_all_metadata_base(
|
||||||
get_all_to_update_func=get_non_ended_shows,
|
get_all_to_update_func=get_non_ended_shows,
|
||||||
update_single_func=self.update_show_metadata,
|
update_single_func=self.update_show_metadata,
|
||||||
tmdb_provider_class=TmdbMetadataProvider,
|
tmdb_provider_class=TmdbMetadataProvider,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from sqlalchemy import distinct, func, select
|
from sqlalchemy import distinct, func, select
|
||||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
|
||||||
from media_manager.common.repository import BaseRepository
|
from media_manager.common.repository import BaseRepository, EntityId
|
||||||
from media_manager.exceptions import ConflictError, NotFoundError
|
from media_manager.exceptions import ConflictError, NotFoundError
|
||||||
from media_manager.torrent.models import Torrent as TorrentModel
|
from media_manager.torrent.models import Torrent as TorrentModel
|
||||||
from media_manager.torrent.schemas import Torrent as TorrentSchema
|
from media_manager.torrent.schemas import Torrent as TorrentSchema
|
||||||
@@ -22,23 +23,27 @@ from media_manager.tv.schemas import Season as SeasonSchema
|
|||||||
from media_manager.tv.schemas import Show as ShowSchema
|
from media_manager.tv.schemas import Show as ShowSchema
|
||||||
|
|
||||||
|
|
||||||
|
def _load_show_tree(): # noqa: ANN202
|
||||||
|
return selectinload(Show.seasons).selectinload(Season.episodes)
|
||||||
|
|
||||||
|
|
||||||
class TvRepository(BaseRepository[Show, ShowSchema]):
|
class TvRepository(BaseRepository[Show, ShowSchema]):
|
||||||
"""
|
"""
|
||||||
Repository for managing TV shows, seasons, and episodes in the database.
|
Repository for managing TV shows, seasons, and episodes in the database.
|
||||||
Provides methods to retrieve, save, and delete shows and seasons.
|
Provides methods to retrieve, save, and delete shows and seasons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
super().__init__(db, Show, ShowSchema)
|
super().__init__(db, Show, ShowSchema)
|
||||||
|
|
||||||
def get_show_by_id(self, show_id: ShowId) -> ShowSchema:
|
async def get_show_by_id(self, show_id: ShowId) -> ShowSchema:
|
||||||
try:
|
try:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Show)
|
select(Show)
|
||||||
.where(Show.id == show_id)
|
.where(Show.id == show_id)
|
||||||
.options(joinedload(Show.seasons).joinedload(Season.episodes))
|
.options(_load_show_tree())
|
||||||
)
|
)
|
||||||
result = self.db.execute(stmt).unique().scalar_one_or_none()
|
result = (await self.db.execute(stmt)).unique().scalar_one_or_none()
|
||||||
if not result:
|
if not result:
|
||||||
msg = f"Show with id {show_id} not found."
|
msg = f"Show with id {show_id} not found."
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
@@ -48,7 +53,7 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
else:
|
else:
|
||||||
return ShowSchema.model_validate(result)
|
return ShowSchema.model_validate(result)
|
||||||
|
|
||||||
def get_show_by_external_id(
|
async def get_show_by_external_id(
|
||||||
self, external_id: int, metadata_provider: str
|
self, external_id: int, metadata_provider: str
|
||||||
) -> ShowSchema:
|
) -> ShowSchema:
|
||||||
try:
|
try:
|
||||||
@@ -56,9 +61,9 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
select(Show)
|
select(Show)
|
||||||
.where(Show.external_id == external_id)
|
.where(Show.external_id == external_id)
|
||||||
.where(Show.metadata_provider == metadata_provider)
|
.where(Show.metadata_provider == metadata_provider)
|
||||||
.options(joinedload(Show.seasons).joinedload(Season.episodes))
|
.options(_load_show_tree())
|
||||||
)
|
)
|
||||||
result = self.db.execute(stmt).unique().scalar_one_or_none()
|
result = (await self.db.execute(stmt)).unique().scalar_one_or_none()
|
||||||
if not result:
|
if not result:
|
||||||
msg = f"Show with external_id {external_id} and provider {metadata_provider} not found."
|
msg = f"Show with external_id {external_id} and provider {metadata_provider} not found."
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
@@ -70,42 +75,46 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
else:
|
else:
|
||||||
return ShowSchema.model_validate(result)
|
return ShowSchema.model_validate(result)
|
||||||
|
|
||||||
def get_shows(self) -> list[ShowSchema]:
|
async def get_shows(self) -> list[ShowSchema]:
|
||||||
try:
|
try:
|
||||||
stmt = select(Show).options(
|
stmt = select(Show).options(_load_show_tree())
|
||||||
joinedload(Show.seasons).joinedload(Season.episodes)
|
results = (await self.db.execute(stmt)).scalars().unique().all()
|
||||||
)
|
|
||||||
results = self.db.execute(stmt).scalars().unique().all()
|
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
log.exception("Database error while retrieving all shows")
|
log.exception("Database error while retrieving all shows")
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
return [ShowSchema.model_validate(show) for show in results]
|
return [ShowSchema.model_validate(show) for show in results]
|
||||||
|
|
||||||
delete_show = BaseRepository.delete
|
async def delete_show(self, entity_id: EntityId) -> None:
|
||||||
set_show_library = BaseRepository.set_library
|
await self.delete(entity_id)
|
||||||
|
|
||||||
def get_total_downloaded_episodes_count(self) -> int:
|
async def set_show_library(self, entity_id: EntityId, library: str) -> None:
|
||||||
|
await self.set_library(entity_id, library)
|
||||||
|
|
||||||
|
async def get_total_downloaded_episodes_count(self) -> int:
|
||||||
try:
|
try:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(func.count(distinct(Episode.id)))
|
select(func.count(distinct(Episode.id)))
|
||||||
.select_from(Episode)
|
.select_from(Episode)
|
||||||
.join(EpisodeFile)
|
.join(EpisodeFile)
|
||||||
)
|
)
|
||||||
result = self.db.execute(stmt).scalar_one_or_none()
|
result = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
log.exception("Database error while calculating downloaded episodes count")
|
log.exception("Database error while calculating downloaded episodes count")
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
return result or 0
|
return result or 0
|
||||||
|
|
||||||
def save_show(self, show: ShowSchema) -> ShowSchema:
|
async def save_show(self, show: ShowSchema) -> ShowSchema:
|
||||||
db_show = self.db.get(Show, show.id) if show.id else None
|
db_show = await self.db.get(Show, show.id) if show.id else None
|
||||||
|
|
||||||
if db_show: # Use base for update
|
if db_show: # Use base for update
|
||||||
return self.save_media_base(
|
await self.save_media_base(
|
||||||
media_schema=show, model_class=Show, exclude={"seasons", "episodes"}
|
media_schema=show, model_class=Show, exclude={"seasons", "episodes"}
|
||||||
)
|
)
|
||||||
|
# save_media_base returns a non-eager-loaded schema; reload with
|
||||||
|
# selectinload so ShowSchema.seasons/episodes don't lazy-load.
|
||||||
|
return await self.get_show_by_id(db_show.id)
|
||||||
|
|
||||||
# Custom insertion for nested seasons/episodes
|
# Custom insertion for nested seasons/episodes
|
||||||
db_show = Show(
|
db_show = Show(
|
||||||
@@ -145,80 +154,92 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
)
|
)
|
||||||
self.db.add(db_show)
|
self.db.add(db_show)
|
||||||
try:
|
try:
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
self.db.refresh(db_show)
|
await self.db.refresh(db_show, ["seasons"])
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
msg = f"Integrity error: {e.orig}"
|
msg = f"Integrity error: {e.orig}"
|
||||||
raise ConflictError(msg) from e
|
raise ConflictError(msg) from e
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
return ShowSchema.model_validate(db_show)
|
# AsyncSession forbids implicit lazy loads after commit; reload eagerly.
|
||||||
|
return await self.get_show_by_id(db_show.id)
|
||||||
|
|
||||||
def get_season(self, season_id: SeasonId) -> SeasonSchema:
|
async def get_season(self, season_id: SeasonId) -> SeasonSchema:
|
||||||
season = self.db.get(Season, season_id)
|
season = await self.db.get(
|
||||||
|
Season, season_id, options=[selectinload(Season.episodes)]
|
||||||
|
)
|
||||||
if not season:
|
if not season:
|
||||||
msg = f"Season {season_id} not found"
|
msg = f"Season {season_id} not found"
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
return SeasonSchema.model_validate(season)
|
return SeasonSchema.model_validate(season)
|
||||||
|
|
||||||
def get_episode(self, episode_id: EpisodeId) -> EpisodeSchema:
|
async def get_episode(self, episode_id: EpisodeId) -> EpisodeSchema:
|
||||||
episode = self.db.get(Episode, episode_id)
|
episode = await self.db.get(Episode, episode_id)
|
||||||
if not episode:
|
if not episode:
|
||||||
msg = f"Episode {episode_id} not found"
|
msg = f"Episode {episode_id} not found"
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
return EpisodeSchema.model_validate(episode)
|
return EpisodeSchema.model_validate(episode)
|
||||||
|
|
||||||
def get_season_by_episode(self, episode_id: EpisodeId) -> SeasonSchema:
|
async def get_season_by_episode(self, episode_id: EpisodeId) -> SeasonSchema:
|
||||||
stmt = select(Season).join(Season.episodes).where(Episode.id == episode_id)
|
stmt = (
|
||||||
season = self.db.scalar(stmt)
|
select(Season)
|
||||||
|
.join(Season.episodes)
|
||||||
|
.where(Episode.id == episode_id)
|
||||||
|
.options(selectinload(Season.episodes))
|
||||||
|
)
|
||||||
|
season = (await self.db.execute(stmt)).unique().scalar_one_or_none()
|
||||||
if not season:
|
if not season:
|
||||||
msg = f"Season for episode {episode_id} not found"
|
msg = f"Season for episode {episode_id} not found"
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
return SeasonSchema.model_validate(season)
|
return SeasonSchema.model_validate(season)
|
||||||
|
|
||||||
def get_season_by_number(self, season_number: int, show_id: ShowId) -> SeasonSchema:
|
async def get_season_by_number(
|
||||||
|
self, season_number: int, show_id: ShowId
|
||||||
|
) -> SeasonSchema:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Season)
|
select(Season)
|
||||||
.where(Season.show_id == show_id)
|
.where(Season.show_id == show_id)
|
||||||
.where(Season.number == season_number)
|
.where(Season.number == season_number)
|
||||||
.options(joinedload(Season.episodes), joinedload(Season.show))
|
.options(selectinload(Season.episodes), joinedload(Season.show))
|
||||||
)
|
)
|
||||||
result = self.db.execute(stmt).unique().scalar_one_or_none()
|
result = (await self.db.execute(stmt)).unique().scalar_one_or_none()
|
||||||
if not result:
|
if not result:
|
||||||
msg = f"Season {season_number} for show {show_id} not found"
|
msg = f"Season {season_number} for show {show_id} not found"
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
return SeasonSchema.model_validate(result)
|
return SeasonSchema.model_validate(result)
|
||||||
|
|
||||||
def add_episode_file(self, episode_file: EpisodeFileSchema) -> EpisodeFileSchema:
|
async def add_episode_file(
|
||||||
return self.add_media_file_base(
|
self, episode_file: EpisodeFileSchema
|
||||||
|
) -> EpisodeFileSchema:
|
||||||
|
return await self.add_media_file_base(
|
||||||
file_schema=episode_file,
|
file_schema=episode_file,
|
||||||
model_class=EpisodeFile,
|
model_class=EpisodeFile,
|
||||||
schema_class=EpisodeFileSchema,
|
schema_class=EpisodeFileSchema,
|
||||||
)
|
)
|
||||||
|
|
||||||
def remove_episode_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
|
async def remove_episode_files_by_torrent_id(self, torrent_id: TorrentId) -> int:
|
||||||
return self.remove_files_by_torrent_id_base(
|
return await self.remove_files_by_torrent_id_base(
|
||||||
torrent_id=torrent_id, model_class=EpisodeFile
|
torrent_id=torrent_id, model_class=EpisodeFile
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_episode_files_by_season_id(
|
async def get_episode_files_by_season_id(
|
||||||
self, season_id: SeasonId
|
self, season_id: SeasonId
|
||||||
) -> list[EpisodeFileSchema]:
|
) -> list[EpisodeFileSchema]:
|
||||||
stmt = select(EpisodeFile).join(Episode).where(Episode.season_id == season_id)
|
stmt = select(EpisodeFile).join(Episode).where(Episode.season_id == season_id)
|
||||||
results = self.db.execute(stmt).scalars().all()
|
results = (await self.db.execute(stmt)).scalars().all()
|
||||||
return [EpisodeFileSchema.model_validate(ef) for ef in results]
|
return [EpisodeFileSchema.model_validate(ef) for ef in results]
|
||||||
|
|
||||||
def get_episode_files_by_episode_id(
|
async def get_episode_files_by_episode_id(
|
||||||
self, episode_id: EpisodeId
|
self, episode_id: EpisodeId
|
||||||
) -> list[EpisodeFileSchema]:
|
) -> list[EpisodeFileSchema]:
|
||||||
stmt = select(EpisodeFile).where(EpisodeFile.episode_id == episode_id)
|
stmt = select(EpisodeFile).where(EpisodeFile.episode_id == episode_id)
|
||||||
results = self.db.execute(stmt).scalars().all()
|
results = (await self.db.execute(stmt)).scalars().all()
|
||||||
return [EpisodeFileSchema.model_validate(sf) for sf in results]
|
return [EpisodeFileSchema.model_validate(sf) for sf in results]
|
||||||
|
|
||||||
def get_torrents_by_show_id(self, show_id: ShowId) -> list[TorrentSchema]:
|
async def get_torrents_by_show_id(self, show_id: ShowId) -> list[TorrentSchema]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(TorrentModel)
|
select(TorrentModel)
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -227,10 +248,10 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
.join(Season, Season.id == Episode.season_id)
|
.join(Season, Season.id == Episode.season_id)
|
||||||
.where(Season.show_id == show_id)
|
.where(Season.show_id == show_id)
|
||||||
)
|
)
|
||||||
results = self.db.execute(stmt).scalars().unique().all()
|
results = (await self.db.execute(stmt)).scalars().unique().all()
|
||||||
return [TorrentSchema.model_validate(t) for t in results]
|
return [TorrentSchema.model_validate(t) for t in results]
|
||||||
|
|
||||||
def get_all_shows_with_torrents(self) -> list[ShowSchema]:
|
async def get_all_shows_with_torrents(self) -> list[ShowSchema]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Show)
|
select(Show)
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -238,13 +259,15 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
.join(Episode, Season.id == Episode.season_id)
|
.join(Episode, Season.id == Episode.season_id)
|
||||||
.join(EpisodeFile, Episode.id == EpisodeFile.episode_id)
|
.join(EpisodeFile, Episode.id == EpisodeFile.episode_id)
|
||||||
.join(TorrentModel, EpisodeFile.torrent_id == TorrentModel.id)
|
.join(TorrentModel, EpisodeFile.torrent_id == TorrentModel.id)
|
||||||
.options(joinedload(Show.seasons).joinedload(Season.episodes))
|
.options(_load_show_tree())
|
||||||
.order_by(Show.name)
|
.order_by(Show.name)
|
||||||
)
|
)
|
||||||
results = self.db.execute(stmt).scalars().unique().all()
|
results = (await self.db.execute(stmt)).scalars().unique().all()
|
||||||
return [ShowSchema.model_validate(show) for show in results]
|
return [ShowSchema.model_validate(show) for show in results]
|
||||||
|
|
||||||
def get_seasons_by_torrent_id(self, torrent_id: TorrentId) -> list[SeasonNumber]:
|
async def get_seasons_by_torrent_id(
|
||||||
|
self, torrent_id: TorrentId
|
||||||
|
) -> list[SeasonNumber]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Season.number)
|
select(Season.number)
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -252,10 +275,12 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
.join(EpisodeFile, EpisodeFile.episode_id == Episode.id)
|
.join(EpisodeFile, EpisodeFile.episode_id == Episode.id)
|
||||||
.where(EpisodeFile.torrent_id == torrent_id)
|
.where(EpisodeFile.torrent_id == torrent_id)
|
||||||
)
|
)
|
||||||
results = self.db.execute(stmt).scalars().unique().all()
|
results = (await self.db.execute(stmt)).scalars().unique().all()
|
||||||
return [SeasonNumber(x) for x in results]
|
return [SeasonNumber(x) for x in results]
|
||||||
|
|
||||||
def get_episodes_by_torrent_id(self, torrent_id: TorrentId) -> list[EpisodeNumber]:
|
async def get_episodes_by_torrent_id(
|
||||||
|
self, torrent_id: TorrentId
|
||||||
|
) -> list[EpisodeNumber]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Episode.number)
|
select(Episode.number)
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -263,33 +288,35 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
.where(EpisodeFile.torrent_id == torrent_id)
|
.where(EpisodeFile.torrent_id == torrent_id)
|
||||||
.order_by(Episode.number)
|
.order_by(Episode.number)
|
||||||
)
|
)
|
||||||
episode_numbers = self.db.execute(stmt).scalars().all()
|
episode_numbers = (await self.db.execute(stmt)).scalars().all()
|
||||||
return [EpisodeNumber(n) for n in episode_numbers]
|
return [EpisodeNumber(n) for n in episode_numbers]
|
||||||
|
|
||||||
def get_show_by_season_id(self, season_id: SeasonId) -> ShowSchema:
|
async def get_show_by_season_id(self, season_id: SeasonId) -> ShowSchema:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Show)
|
select(Show)
|
||||||
.join(Season, Show.id == Season.show_id)
|
.join(Season, Show.id == Season.show_id)
|
||||||
.where(Season.id == season_id)
|
.where(Season.id == season_id)
|
||||||
.options(joinedload(Show.seasons).joinedload(Season.episodes))
|
.options(_load_show_tree())
|
||||||
)
|
)
|
||||||
result = self.db.execute(stmt).unique().scalar_one_or_none()
|
result = (await self.db.execute(stmt)).unique().scalar_one_or_none()
|
||||||
if not result:
|
if not result:
|
||||||
msg = f"Show for season {season_id} not found"
|
msg = f"Show for season {season_id} not found"
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
return ShowSchema.model_validate(result)
|
return ShowSchema.model_validate(result)
|
||||||
|
|
||||||
def add_season_to_show(
|
async def add_season_to_show(
|
||||||
self, show_id: ShowId, season_data: SeasonSchema
|
self, show_id: ShowId, season_data: SeasonSchema
|
||||||
) -> SeasonSchema:
|
) -> SeasonSchema:
|
||||||
db_show = self.db.get(Show, show_id)
|
db_show = await self.db.get(Show, show_id)
|
||||||
if not db_show:
|
if not db_show:
|
||||||
msg = f"Show {show_id} not found"
|
msg = f"Show {show_id} not found"
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
stmt = select(Season).where(
|
stmt = (
|
||||||
Season.show_id == show_id, Season.number == season_data.number
|
select(Season)
|
||||||
|
.where(Season.show_id == show_id, Season.number == season_data.number)
|
||||||
|
.options(selectinload(Season.episodes))
|
||||||
)
|
)
|
||||||
existing = self.db.execute(stmt).scalar_one_or_none()
|
existing = (await self.db.execute(stmt)).unique().scalar_one_or_none()
|
||||||
if existing:
|
if existing:
|
||||||
return SeasonSchema.model_validate(existing)
|
return SeasonSchema.model_validate(existing)
|
||||||
db_season = Season(
|
db_season = Season(
|
||||||
@@ -312,28 +339,28 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
)
|
)
|
||||||
self.db.add(db_season)
|
self.db.add(db_season)
|
||||||
try:
|
try:
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
self.db.refresh(db_season)
|
await self.db.refresh(db_season, ["episodes"])
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
msg = f"Integrity error: {e.orig}"
|
msg = f"Integrity error: {e.orig}"
|
||||||
raise ConflictError(msg) from e
|
raise ConflictError(msg) from e
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
raise
|
raise
|
||||||
return SeasonSchema.model_validate(db_season)
|
return SeasonSchema.model_validate(db_season)
|
||||||
|
|
||||||
def add_episode_to_season(
|
async def add_episode_to_season(
|
||||||
self, season_id: SeasonId, episode_data: EpisodeSchema
|
self, season_id: SeasonId, episode_data: EpisodeSchema
|
||||||
) -> EpisodeSchema:
|
) -> EpisodeSchema:
|
||||||
db_season = self.db.get(Season, season_id)
|
db_season = await self.db.get(Season, season_id)
|
||||||
if not db_season:
|
if not db_season:
|
||||||
msg = f"Season {season_id} not found"
|
msg = f"Season {season_id} not found"
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
stmt = select(Episode).where(
|
stmt = select(Episode).where(
|
||||||
Episode.season_id == season_id, Episode.number == episode_data.number
|
Episode.season_id == season_id, Episode.number == episode_data.number
|
||||||
)
|
)
|
||||||
existing = self.db.execute(stmt).scalar_one_or_none()
|
existing = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||||
if existing:
|
if existing:
|
||||||
return EpisodeSchema.model_validate(existing)
|
return EpisodeSchema.model_validate(existing)
|
||||||
db_episode = Episode(
|
db_episode = Episode(
|
||||||
@@ -346,18 +373,18 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
)
|
)
|
||||||
self.db.add(db_episode)
|
self.db.add(db_episode)
|
||||||
try:
|
try:
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
self.db.refresh(db_episode)
|
await self.db.refresh(db_episode)
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
msg = f"Integrity error: {e.orig}"
|
msg = f"Integrity error: {e.orig}"
|
||||||
raise ConflictError(msg) from e
|
raise ConflictError(msg) from e
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
raise
|
raise
|
||||||
return EpisodeSchema.model_validate(db_episode)
|
return EpisodeSchema.model_validate(db_episode)
|
||||||
|
|
||||||
def update_show_attributes(
|
async def update_show_attributes(
|
||||||
self,
|
self,
|
||||||
show_id: ShowId,
|
show_id: ShowId,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
@@ -367,7 +394,7 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
continuous_download: bool | None = None,
|
continuous_download: bool | None = None,
|
||||||
imdb_id: str | None = None,
|
imdb_id: str | None = None,
|
||||||
) -> ShowSchema:
|
) -> ShowSchema:
|
||||||
return self.update_media_attributes_base(
|
await self.update_media_attributes_base(
|
||||||
media_id=show_id,
|
media_id=show_id,
|
||||||
model_class=Show,
|
model_class=Show,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -377,11 +404,18 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
continuous_download=continuous_download,
|
continuous_download=continuous_download,
|
||||||
imdb_id=imdb_id,
|
imdb_id=imdb_id,
|
||||||
)
|
)
|
||||||
|
# Reload with seasons/episodes eagerly; the base returns a schema built
|
||||||
|
# from a non-eager db.get() which can't lazy-load under AsyncSession.
|
||||||
|
return await self.get_show_by_id(show_id)
|
||||||
|
|
||||||
def update_season_attributes(
|
async def update_season_attributes(
|
||||||
self, season_id: SeasonId, name: str | None = None, overview: str | None = None
|
self, season_id: SeasonId, name: str | None = None, overview: str | None = None
|
||||||
) -> SeasonSchema:
|
) -> SeasonSchema:
|
||||||
db_season = self.db.get(Season, season_id)
|
# selectinload episodes so SeasonSchema.model_validate doesn't trip
|
||||||
|
# an implicit lazy load under AsyncSession.
|
||||||
|
db_season = await self.db.get(
|
||||||
|
Season, season_id, options=[selectinload(Season.episodes)]
|
||||||
|
)
|
||||||
if not db_season:
|
if not db_season:
|
||||||
msg = f"Season {season_id} not found"
|
msg = f"Season {season_id} not found"
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
@@ -394,20 +428,20 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
updated = True
|
updated = True
|
||||||
if updated:
|
if updated:
|
||||||
try:
|
try:
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
self.db.refresh(db_season)
|
await self.db.refresh(db_season, ["episodes"])
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
raise
|
raise
|
||||||
return SeasonSchema.model_validate(db_season)
|
return SeasonSchema.model_validate(db_season)
|
||||||
|
|
||||||
def update_episode_attributes(
|
async def update_episode_attributes(
|
||||||
self,
|
self,
|
||||||
episode_id: EpisodeId,
|
episode_id: EpisodeId,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
overview: str | None = None,
|
overview: str | None = None,
|
||||||
) -> EpisodeSchema:
|
) -> EpisodeSchema:
|
||||||
db_episode = self.db.get(Episode, episode_id)
|
db_episode = await self.db.get(Episode, episode_id)
|
||||||
if not db_episode:
|
if not db_episode:
|
||||||
msg = f"Episode {episode_id} not found"
|
msg = f"Episode {episode_id} not found"
|
||||||
raise NotFoundError(msg)
|
raise NotFoundError(msg)
|
||||||
@@ -420,9 +454,9 @@ class TvRepository(BaseRepository[Show, ShowSchema]):
|
|||||||
updated = True
|
updated = True
|
||||||
if updated:
|
if updated:
|
||||||
try:
|
try:
|
||||||
self.db.commit()
|
await self.db.commit()
|
||||||
self.db.refresh(db_episode)
|
await self.db.refresh(db_episode)
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
self.db.rollback()
|
await self.db.rollback()
|
||||||
raise
|
raise
|
||||||
return EpisodeSchema.model_validate(db_episode)
|
return EpisodeSchema.model_validate(db_episode)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ router = APIRouter()
|
|||||||
"/search",
|
"/search",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def search_metadata_providers_for_a_show(
|
async def search_metadata_providers_for_a_show(
|
||||||
tv_metadata_service: tv_metadata_service_dep,
|
tv_metadata_service: tv_metadata_service_dep,
|
||||||
query: str,
|
query: str,
|
||||||
metadata_provider: metadata_provider_dep,
|
metadata_provider: metadata_provider_dep,
|
||||||
@@ -49,7 +49,7 @@ def search_metadata_providers_for_a_show(
|
|||||||
"""
|
"""
|
||||||
Search for a show on the configured metadata provider.
|
Search for a show on the configured metadata provider.
|
||||||
"""
|
"""
|
||||||
return tv_metadata_service.search_for_show(
|
return await tv_metadata_service.search_for_show(
|
||||||
query=query, metadata_provider=metadata_provider
|
query=query, metadata_provider=metadata_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,14 +58,16 @@ def search_metadata_providers_for_a_show(
|
|||||||
"/recommended",
|
"/recommended",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_recommended_shows(
|
async def get_recommended_shows(
|
||||||
tv_metadata_service: tv_metadata_service_dep,
|
tv_metadata_service: tv_metadata_service_dep,
|
||||||
metadata_provider: metadata_provider_dep,
|
metadata_provider: metadata_provider_dep,
|
||||||
) -> list[MetaDataProviderSearchResult]:
|
) -> list[MetaDataProviderSearchResult]:
|
||||||
"""
|
"""
|
||||||
Get a list of recommended/popular shows from the metadata provider.
|
Get a list of recommended/popular shows from the metadata provider.
|
||||||
"""
|
"""
|
||||||
return tv_metadata_service.get_popular_shows(metadata_provider=metadata_provider)
|
return await tv_metadata_service.get_popular_shows(
|
||||||
|
metadata_provider=metadata_provider
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -78,13 +80,13 @@ def get_recommended_shows(
|
|||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def get_all_importable_shows(
|
async def get_all_importable_shows(
|
||||||
tv_import_service: tv_import_service_dep, metadata_provider: metadata_provider_dep
|
tv_import_service: tv_import_service_dep, metadata_provider: metadata_provider_dep
|
||||||
) -> list[MediaImportSuggestion]:
|
) -> list[MediaImportSuggestion]:
|
||||||
"""
|
"""
|
||||||
Get a list of unknown shows that were detected in the TV directory and are importable.
|
Get a list of unknown shows that were detected in the TV directory and are importable.
|
||||||
"""
|
"""
|
||||||
return tv_import_service.get_importable_tv_shows(
|
return await tv_import_service.get_importable_tv_shows(
|
||||||
metadata_provider=metadata_provider
|
metadata_provider=metadata_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@ def get_all_importable_shows(
|
|||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
)
|
)
|
||||||
def import_detected_show(
|
async def import_detected_show(
|
||||||
tv_import_service: tv_import_service_dep, tv_show: show_dep, directory: str
|
tv_import_service: tv_import_service_dep, tv_show: show_dep, directory: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -105,7 +107,7 @@ def import_detected_show(
|
|||||||
MediaManagerConfig().misc.tv_directory
|
MediaManagerConfig().misc.tv_directory
|
||||||
):
|
):
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")
|
||||||
tv_import_service.import_existing_tv_show(
|
await tv_import_service.import_existing_tv_show(
|
||||||
tv_show=tv_show, source_directory=source_directory
|
tv_show=tv_show, source_directory=source_directory
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,11 +121,11 @@ def import_detected_show(
|
|||||||
"/shows",
|
"/shows",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_all_shows(tv_service: tv_service_dep) -> list[Show]:
|
async def get_all_shows(tv_service: tv_service_dep) -> list[Show]:
|
||||||
"""
|
"""
|
||||||
Get all shows in the library.
|
Get all shows in the library.
|
||||||
"""
|
"""
|
||||||
return tv_service.get_all_shows()
|
return await tv_service.get_all_shows()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -137,7 +139,7 @@ def get_all_shows(tv_service: tv_service_dep) -> list[Show]:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def add_a_show(
|
async def add_a_show(
|
||||||
tv_metadata_service: tv_metadata_service_dep,
|
tv_metadata_service: tv_metadata_service_dep,
|
||||||
metadata_provider: metadata_provider_dep,
|
metadata_provider: metadata_provider_dep,
|
||||||
show_id: int,
|
show_id: int,
|
||||||
@@ -147,13 +149,13 @@ def add_a_show(
|
|||||||
Add a new show to the library.
|
Add a new show to the library.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
show = tv_metadata_service.add_show(
|
show = await tv_metadata_service.add_show(
|
||||||
external_id=show_id,
|
external_id=show_id,
|
||||||
metadata_provider=metadata_provider,
|
metadata_provider=metadata_provider,
|
||||||
language=language,
|
language=language,
|
||||||
)
|
)
|
||||||
except MediaAlreadyExistsError:
|
except MediaAlreadyExistsError:
|
||||||
show = tv_metadata_service.tv_repository.get_show_by_external_id(
|
show = await tv_metadata_service.tv_repository.get_show_by_external_id(
|
||||||
show_id, metadata_provider=metadata_provider.name
|
show_id, metadata_provider=metadata_provider.name
|
||||||
)
|
)
|
||||||
if not show:
|
if not show:
|
||||||
@@ -165,11 +167,11 @@ def add_a_show(
|
|||||||
"/shows/torrents",
|
"/shows/torrents",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_shows_with_torrents(tv_service: tv_service_dep) -> list[RichShowTorrent]:
|
async def get_shows_with_torrents(tv_service: tv_service_dep) -> list[RichShowTorrent]:
|
||||||
"""
|
"""
|
||||||
Get all shows that are associated with torrents.
|
Get all shows that are associated with torrents.
|
||||||
"""
|
"""
|
||||||
return tv_service.get_all_shows_with_torrents()
|
return await tv_service.get_all_shows_with_torrents()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -192,11 +194,11 @@ def get_available_libraries() -> list[LibraryItem]:
|
|||||||
"/shows/{show_id}",
|
"/shows/{show_id}",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_a_show(show: show_dep, tv_service: tv_service_dep) -> PublicShow:
|
async def get_a_show(show: show_dep, tv_service: tv_service_dep) -> PublicShow:
|
||||||
"""
|
"""
|
||||||
Get details for a specific show.
|
Get details for a specific show.
|
||||||
"""
|
"""
|
||||||
return tv_service.get_public_show_by_id(show=show)
|
return await tv_service.get_public_show_by_id(show=show)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -204,7 +206,7 @@ def get_a_show(show: show_dep, tv_service: tv_service_dep) -> PublicShow:
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def delete_a_show(
|
async def delete_a_show(
|
||||||
tv_service: tv_service_dep,
|
tv_service: tv_service_dep,
|
||||||
show: show_dep,
|
show: show_dep,
|
||||||
delete_files_on_disk: bool = False,
|
delete_files_on_disk: bool = False,
|
||||||
@@ -213,7 +215,7 @@ def delete_a_show(
|
|||||||
"""
|
"""
|
||||||
Delete a show from the library.
|
Delete a show from the library.
|
||||||
"""
|
"""
|
||||||
tv_service.delete_show(
|
await tv_service.delete_show(
|
||||||
show=show,
|
show=show,
|
||||||
delete_files_on_disk=delete_files_on_disk,
|
delete_files_on_disk=delete_files_on_disk,
|
||||||
delete_torrents=delete_torrents,
|
delete_torrents=delete_torrents,
|
||||||
@@ -224,7 +226,7 @@ def delete_a_show(
|
|||||||
"/shows/{show_id}/metadata",
|
"/shows/{show_id}/metadata",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def update_shows_metadata(
|
async def update_shows_metadata(
|
||||||
show: show_dep,
|
show: show_dep,
|
||||||
tv_metadata_service: tv_metadata_service_dep,
|
tv_metadata_service: tv_metadata_service_dep,
|
||||||
tv_service: tv_service_dep,
|
tv_service: tv_service_dep,
|
||||||
@@ -233,26 +235,26 @@ def update_shows_metadata(
|
|||||||
"""
|
"""
|
||||||
Update a show's metadata from the provider.
|
Update a show's metadata from the provider.
|
||||||
"""
|
"""
|
||||||
tv_metadata_service.update_show_metadata(
|
await tv_metadata_service.update_show_metadata(
|
||||||
db_show=show, metadata_provider=metadata_provider
|
db_show=show, metadata_provider=metadata_provider
|
||||||
)
|
)
|
||||||
return tv_service.get_public_show_by_id(show=show)
|
return await tv_service.get_public_show_by_id(show=show)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/shows/{show_id}/continuousDownload",
|
"/shows/{show_id}/continuousDownload",
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def set_continuous_download(
|
async def set_continuous_download(
|
||||||
show: show_dep, tv_service: tv_service_dep, continuous_download: bool
|
show: show_dep, tv_service: tv_service_dep, continuous_download: bool
|
||||||
) -> PublicShow:
|
) -> PublicShow:
|
||||||
"""
|
"""
|
||||||
Toggle whether future seasons of a show will be automatically downloaded.
|
Toggle whether future seasons of a show will be automatically downloaded.
|
||||||
"""
|
"""
|
||||||
tv_service.set_show_continuous_download(
|
updated_show = await tv_service.set_show_continuous_download(
|
||||||
show=show, continuous_download=continuous_download
|
show=show, continuous_download=continuous_download
|
||||||
)
|
)
|
||||||
return tv_service.get_public_show_by_id(show=show)
|
return await tv_service.get_public_show_by_id(show=updated_show)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -260,7 +262,7 @@ def set_continuous_download(
|
|||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
)
|
)
|
||||||
def set_library(
|
async def set_library(
|
||||||
show: show_dep,
|
show: show_dep,
|
||||||
tv_service: tv_service_dep,
|
tv_service: tv_service_dep,
|
||||||
library: str,
|
library: str,
|
||||||
@@ -268,7 +270,7 @@ def set_library(
|
|||||||
"""
|
"""
|
||||||
Set the library path for a Show.
|
Set the library path for a Show.
|
||||||
"""
|
"""
|
||||||
tv_service.set_show_library(show=show, library=library)
|
await tv_service.set_show_library(show=show, library=library)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@@ -276,11 +278,13 @@ def set_library(
|
|||||||
"/shows/{show_id}/torrents",
|
"/shows/{show_id}/torrents",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_a_shows_torrents(show: show_dep, tv_service: tv_service_dep) -> RichShowTorrent:
|
async def get_a_shows_torrents(
|
||||||
|
show: show_dep, tv_service: tv_service_dep
|
||||||
|
) -> RichShowTorrent:
|
||||||
"""
|
"""
|
||||||
Get torrents associated with a specific show.
|
Get torrents associated with a specific show.
|
||||||
"""
|
"""
|
||||||
return tv_service.get_torrents_for_show(show=show)
|
return await tv_service.get_torrents_for_show(show=show)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -292,7 +296,7 @@ def get_a_shows_torrents(show: show_dep, tv_service: tv_service_dep) -> RichShow
|
|||||||
"/seasons/{season_id}",
|
"/seasons/{season_id}",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_season(season: season_dep) -> Season:
|
async def get_season(season: season_dep) -> Season:
|
||||||
"""
|
"""
|
||||||
Get details for a specific season.
|
Get details for a specific season.
|
||||||
"""
|
"""
|
||||||
@@ -303,13 +307,13 @@ def get_season(season: season_dep) -> Season:
|
|||||||
"/seasons/{season_id}/files",
|
"/seasons/{season_id}/files",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_episode_files(
|
async def get_episode_files(
|
||||||
season: season_dep, tv_service: tv_service_dep
|
season: season_dep, tv_service: tv_service_dep
|
||||||
) -> list[PublicEpisodeFile]:
|
) -> list[PublicEpisodeFile]:
|
||||||
"""
|
"""
|
||||||
Get episode files associated with a specific season.
|
Get episode files associated with a specific season.
|
||||||
"""
|
"""
|
||||||
return tv_service.get_public_episode_files_by_season_id(season=season)
|
return await tv_service.get_public_episode_files_by_season_id(season=season)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -322,7 +326,7 @@ def get_episode_files(
|
|||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def get_torrents_for_a_season(
|
async def get_torrents_for_a_season(
|
||||||
tv_service: tv_service_dep,
|
tv_service: tv_service_dep,
|
||||||
show_id: ShowId,
|
show_id: ShowId,
|
||||||
season_number: int = 1,
|
season_number: int = 1,
|
||||||
@@ -332,7 +336,7 @@ def get_torrents_for_a_season(
|
|||||||
Search for torrents for a specific season of a show.
|
Search for torrents for a specific season of a show.
|
||||||
Default season_number is 1 because it often returns multi-season torrents.
|
Default season_number is 1 because it often returns multi-season torrents.
|
||||||
"""
|
"""
|
||||||
return tv_service.get_all_available_torrents_for_a_season(
|
return await tv_service.get_all_available_torrents_for_a_season(
|
||||||
season_number=season_number,
|
season_number=season_number,
|
||||||
show_id=show_id,
|
show_id=show_id,
|
||||||
search_query_override=search_query_override,
|
search_query_override=search_query_override,
|
||||||
@@ -344,7 +348,7 @@ def get_torrents_for_a_season(
|
|||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
dependencies=[Depends(current_superuser)],
|
dependencies=[Depends(current_superuser)],
|
||||||
)
|
)
|
||||||
def download_a_torrent(
|
async def download_a_torrent(
|
||||||
tv_service: tv_service_dep,
|
tv_service: tv_service_dep,
|
||||||
public_indexer_result_id: IndexerQueryResultId,
|
public_indexer_result_id: IndexerQueryResultId,
|
||||||
show_id: ShowId,
|
show_id: ShowId,
|
||||||
@@ -353,7 +357,7 @@ def download_a_torrent(
|
|||||||
"""
|
"""
|
||||||
Trigger a download for a specific torrent.
|
Trigger a download for a specific torrent.
|
||||||
"""
|
"""
|
||||||
return tv_service.download_torrent(
|
return await tv_service.download_torrent(
|
||||||
public_indexer_result_id=public_indexer_result_id,
|
public_indexer_result_id=public_indexer_result_id,
|
||||||
show_id=show_id,
|
show_id=show_id,
|
||||||
override_show_file_path_suffix=override_file_path_suffix,
|
override_show_file_path_suffix=override_file_path_suffix,
|
||||||
@@ -371,8 +375,8 @@ def download_a_torrent(
|
|||||||
description="Total number of episodes downloaded",
|
description="Total number of episodes downloaded",
|
||||||
dependencies=[Depends(current_active_user)],
|
dependencies=[Depends(current_active_user)],
|
||||||
)
|
)
|
||||||
def get_total_count_of_downloaded_episodes(tv_service: tv_service_dep) -> int:
|
async def get_total_count_of_downloaded_episodes(tv_service: tv_service_dep) -> int:
|
||||||
"""
|
"""
|
||||||
Get the total count of downloaded episodes across all shows.
|
Get the total count of downloaded episodes across all shows.
|
||||||
"""
|
"""
|
||||||
return tv_service.get_total_downloaded_episodes_count()
|
return await tv_service.get_total_downloaded_episodes_count()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -54,23 +55,25 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
self.tv_import_service = tv_import_service
|
self.tv_import_service = tv_import_service
|
||||||
self.tv_metadata_service = tv_metadata_service
|
self.tv_metadata_service = tv_metadata_service
|
||||||
|
|
||||||
def get_total_downloaded_episodes_count(self) -> int:
|
async def get_total_downloaded_episodes_count(self) -> int:
|
||||||
"""
|
"""
|
||||||
Get total number of downloaded episodes.
|
Get total number of downloaded episodes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.tv_repository.get_total_downloaded_episodes_count()
|
return await self.tv_repository.get_total_downloaded_episodes_count()
|
||||||
|
|
||||||
def set_show_library(self, show: Show, library: str) -> None:
|
async def set_show_library(self, show: Show, library: str) -> None:
|
||||||
self.tv_repository.set_show_library(show.id, library)
|
await self.tv_repository.set_show_library(show.id, library)
|
||||||
|
|
||||||
def get_all_shows(self) -> list[Show]:
|
async def get_all_shows(self) -> list[Show]:
|
||||||
"""
|
"""
|
||||||
Get all shows in the library.
|
Get all shows in the library.
|
||||||
"""
|
"""
|
||||||
return self.get_all_media()
|
# Use the TV-specific eager-loaded query so ShowSchema validation
|
||||||
|
# doesn't trigger lazy loads on seasons/episodes under AsyncSession.
|
||||||
|
return await self.tv_repository.get_shows()
|
||||||
|
|
||||||
def delete_show(
|
async def delete_show(
|
||||||
self,
|
self,
|
||||||
show: Show,
|
show: Show,
|
||||||
delete_files_on_disk: bool = False,
|
delete_files_on_disk: bool = False,
|
||||||
@@ -91,24 +94,24 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
|
|
||||||
log.debug(f"Attempt to delete show directory: {show_dir}")
|
log.debug(f"Attempt to delete show directory: {show_dir}")
|
||||||
if show_dir.exists() and show_dir.is_dir():
|
if show_dir.exists() and show_dir.is_dir():
|
||||||
shutil.rmtree(show_dir)
|
await asyncio.to_thread(shutil.rmtree, show_dir)
|
||||||
log.info(f"Deleted show directory: {show_dir}")
|
log.info(f"Deleted show directory: {show_dir}")
|
||||||
|
|
||||||
if delete_torrents:
|
if delete_torrents:
|
||||||
torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id)
|
torrents = await self.tv_repository.get_torrents_by_show_id(show_id=show.id)
|
||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
try:
|
try:
|
||||||
self.torrent_service.cancel_download(torrent, delete_files=True)
|
await self.torrent_service.cancel_download(torrent, delete_files=True)
|
||||||
self.torrent_service.delete_torrent(torrent_id=torrent.id)
|
await self.torrent_service.delete_torrent(torrent_id=torrent.id)
|
||||||
log.info(f"Deleted torrent: {torrent.hash}")
|
log.info(f"Deleted torrent: {torrent.hash}")
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning(
|
log.warning(
|
||||||
f"Failed to delete torrent {torrent.hash}", exc_info=True
|
f"Failed to delete torrent {torrent.hash}", exc_info=True
|
||||||
)
|
)
|
||||||
|
|
||||||
self.tv_repository.delete_show(show.id)
|
await self.tv_repository.delete_show(show.id)
|
||||||
|
|
||||||
def get_public_episode_files_by_season_id(
|
async def get_public_episode_files_by_season_id(
|
||||||
self, season: Season
|
self, season: Season
|
||||||
) -> list[PublicEpisodeFile]:
|
) -> list[PublicEpisodeFile]:
|
||||||
"""
|
"""
|
||||||
@@ -117,7 +120,7 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
:param season: The season object.
|
:param season: The season object.
|
||||||
:return: A list of public episode files.
|
:return: A list of public episode files.
|
||||||
"""
|
"""
|
||||||
episode_files = self.tv_repository.get_episode_files_by_season_id(
|
episode_files = await self.tv_repository.get_episode_files_by_season_id(
|
||||||
season_id=season.id
|
season_id=season.id
|
||||||
)
|
)
|
||||||
public_episode_files = [
|
public_episode_files = [
|
||||||
@@ -125,12 +128,12 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
]
|
]
|
||||||
result = []
|
result = []
|
||||||
for episode_file in public_episode_files:
|
for episode_file in public_episode_files:
|
||||||
if self.episode_file_exists_on_file(episode_file=episode_file):
|
if await self.episode_file_exists_on_file(episode_file=episode_file):
|
||||||
episode_file.downloaded = True
|
episode_file.downloaded = True
|
||||||
result.append(episode_file)
|
result.append(episode_file)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_all_available_torrents_for_a_season(
|
async def get_all_available_torrents_for_a_season(
|
||||||
self,
|
self,
|
||||||
season_number: int,
|
season_number: int,
|
||||||
show_id: ShowId,
|
show_id: ShowId,
|
||||||
@@ -146,11 +149,11 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if search_query_override:
|
if search_query_override:
|
||||||
return self.indexer_service.search(query=search_query_override, is_tv=True)
|
return await self.indexer_service.search(query=search_query_override, is_tv=True)
|
||||||
|
|
||||||
show = self.tv_repository.get_show_by_id(show_id=show_id)
|
show = await self.tv_repository.get_show_by_id(show_id=show_id)
|
||||||
|
|
||||||
torrents = self.indexer_service.search_season(
|
torrents = await self.indexer_service.search_season(
|
||||||
show=show, season_number=season_number
|
show=show, season_number=season_number
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -160,7 +163,7 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
is_tv=True, query_results=results, media=show
|
is_tv=True, query_results=results, media=show
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_public_show_by_id(self, show: Show) -> PublicShow:
|
async def get_public_show_by_id(self, show: Show) -> PublicShow:
|
||||||
"""
|
"""
|
||||||
Get a public show from a Show object.
|
Get a public show from a Show object.
|
||||||
|
|
||||||
@@ -174,7 +177,7 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
public_season = PublicSeason.model_validate(season)
|
public_season = PublicSeason.model_validate(season)
|
||||||
|
|
||||||
for episode in public_season.episodes:
|
for episode in public_season.episodes:
|
||||||
episode.downloaded = self.is_episode_downloaded(
|
episode.downloaded = await self.is_episode_downloaded(
|
||||||
episode=episode,
|
episode=episode,
|
||||||
season=season,
|
season=season,
|
||||||
show=show,
|
show=show,
|
||||||
@@ -190,16 +193,16 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
public_show.seasons = public_seasons
|
public_show.seasons = public_seasons
|
||||||
return public_show
|
return public_show
|
||||||
|
|
||||||
def get_show_by_id(self, show_id: ShowId) -> Show:
|
async def get_show_by_id(self, show_id: ShowId) -> Show:
|
||||||
"""
|
"""
|
||||||
Get a show by its ID.
|
Get a show by its ID.
|
||||||
|
|
||||||
:param show_id: The ID of the show.
|
:param show_id: The ID of the show.
|
||||||
:return: The show.
|
:return: The show.
|
||||||
"""
|
"""
|
||||||
return self.tv_repository.get_show_by_id(show_id=show_id)
|
return await self.tv_repository.get_show_by_id(show_id=show_id)
|
||||||
|
|
||||||
def is_season_downloaded(self, season: Season, show: Show) -> bool:
|
async def is_season_downloaded(self, season: Season, show: Show) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a season is downloaded.
|
Check if a season is downloaded.
|
||||||
|
|
||||||
@@ -213,13 +216,13 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
for episode in episodes:
|
for episode in episodes:
|
||||||
if not self.is_episode_downloaded(
|
if not await self.is_episode_downloaded(
|
||||||
episode=episode, season=season, show=show
|
episode=episode, season=season, show=show
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_episode_downloaded(
|
async def is_episode_downloaded(
|
||||||
self, episode: Episode, season: Season, show: Show
|
self, episode: Episode, season: Season, show: Show
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -234,7 +237,7 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
:param show: The show object.
|
:param show: The show object.
|
||||||
:return: True if the episode is downloaded and imported, False otherwise.
|
:return: True if the episode is downloaded and imported, False otherwise.
|
||||||
"""
|
"""
|
||||||
episode_files = self.tv_repository.get_episode_files_by_episode_id(
|
episode_files = await self.tv_repository.get_episode_files_by_episode_id(
|
||||||
episode_id=episode.id
|
episode_id=episode.id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -266,7 +269,7 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def episode_file_exists_on_file(self, episode_file: EpisodeFile) -> bool:
|
async def episode_file_exists_on_file(self, episode_file: EpisodeFile) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if an episode file exists on the filesystem.
|
Check if an episode file exists on the filesystem.
|
||||||
|
|
||||||
@@ -276,7 +279,7 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
if episode_file.torrent_id is None:
|
if episode_file.torrent_id is None:
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
torrent_file = self.torrent_service.get_torrent_by_id(
|
torrent_file = await self.torrent_service.get_torrent_by_id(
|
||||||
torrent_id=episode_file.torrent_id
|
torrent_id=episode_file.torrent_id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -287,7 +290,7 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_show_by_external_id(
|
async def get_show_by_external_id(
|
||||||
self, external_id: int, metadata_provider: str
|
self, external_id: int, metadata_provider: str
|
||||||
) -> Show | None:
|
) -> Show | None:
|
||||||
"""
|
"""
|
||||||
@@ -297,54 +300,54 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
:param metadata_provider: The metadata provider.
|
:param metadata_provider: The metadata provider.
|
||||||
:return: The show or None if not found.
|
:return: The show or None if not found.
|
||||||
"""
|
"""
|
||||||
return self.tv_repository.get_show_by_external_id(
|
return await self.tv_repository.get_show_by_external_id(
|
||||||
external_id=external_id, metadata_provider=metadata_provider
|
external_id=external_id, metadata_provider=metadata_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_season(self, season_id: SeasonId) -> Season:
|
async def get_season(self, season_id: SeasonId) -> Season:
|
||||||
"""
|
"""
|
||||||
Get a season by its ID.
|
Get a season by its ID.
|
||||||
|
|
||||||
:param season_id: The ID of the season.
|
:param season_id: The ID of the season.
|
||||||
:return: The season.
|
:return: The season.
|
||||||
"""
|
"""
|
||||||
return self.tv_repository.get_season(season_id=season_id)
|
return await self.tv_repository.get_season(season_id=season_id)
|
||||||
|
|
||||||
def get_episode(self, episode_id: EpisodeId) -> Episode:
|
async def get_episode(self, episode_id: EpisodeId) -> Episode:
|
||||||
"""
|
"""
|
||||||
Get an episode by its ID.
|
Get an episode by its ID.
|
||||||
|
|
||||||
:param episode_id: The ID of the episode.
|
:param episode_id: The ID of the episode.
|
||||||
:return: The episode.
|
:return: The episode.
|
||||||
"""
|
"""
|
||||||
return self.tv_repository.get_episode(episode_id=episode_id)
|
return await self.tv_repository.get_episode(episode_id=episode_id)
|
||||||
|
|
||||||
def get_season_by_episode(self, episode_id: EpisodeId) -> Season:
|
async def get_season_by_episode(self, episode_id: EpisodeId) -> Season:
|
||||||
"""
|
"""
|
||||||
Get a season by the episode ID.
|
Get a season by the episode ID.
|
||||||
|
|
||||||
:param episode_id: The ID of the episode.
|
:param episode_id: The ID of the episode.
|
||||||
:return: The season.
|
:return: The season.
|
||||||
"""
|
"""
|
||||||
return self.tv_repository.get_season_by_episode(episode_id=episode_id)
|
return await self.tv_repository.get_season_by_episode(episode_id=episode_id)
|
||||||
|
|
||||||
def get_torrents_for_show(self, show: Show) -> RichShowTorrent:
|
async def get_torrents_for_show(self, show: Show) -> RichShowTorrent:
|
||||||
"""
|
"""
|
||||||
Get torrents for a given show.
|
Get torrents for a given show.
|
||||||
|
|
||||||
:param show: The show.
|
:param show: The show.
|
||||||
:return: A rich show torrent.
|
:return: A rich show torrent.
|
||||||
"""
|
"""
|
||||||
show_torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id)
|
show_torrents = await self.tv_repository.get_torrents_by_show_id(show_id=show.id)
|
||||||
rich_season_torrents = []
|
rich_season_torrents = []
|
||||||
for show_torrent in show_torrents:
|
for show_torrent in show_torrents:
|
||||||
seasons = self.tv_repository.get_seasons_by_torrent_id(
|
seasons = await self.tv_repository.get_seasons_by_torrent_id(
|
||||||
torrent_id=show_torrent.id
|
torrent_id=show_torrent.id
|
||||||
)
|
)
|
||||||
episodes = self.tv_repository.get_episodes_by_torrent_id(
|
episodes = await self.tv_repository.get_episodes_by_torrent_id(
|
||||||
torrent_id=show_torrent.id
|
torrent_id=show_torrent.id
|
||||||
)
|
)
|
||||||
episode_files = self.torrent_service.get_episode_files_of_torrent(
|
episode_files = await self.torrent_service.get_episode_files_of_torrent(
|
||||||
torrent=show_torrent
|
torrent=show_torrent
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -372,16 +375,16 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
torrents=rich_season_torrents,
|
torrents=rich_season_torrents,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_all_shows_with_torrents(self) -> list[RichShowTorrent]:
|
async def get_all_shows_with_torrents(self) -> list[RichShowTorrent]:
|
||||||
"""
|
"""
|
||||||
Get all shows with torrents.
|
Get all shows with torrents.
|
||||||
|
|
||||||
:return: A list of rich show torrents.
|
:return: A list of rich show torrents.
|
||||||
"""
|
"""
|
||||||
shows = self.tv_repository.get_all_shows_with_torrents()
|
shows = await self.tv_repository.get_all_shows_with_torrents()
|
||||||
return [self.get_torrents_for_show(show=show) for show in shows]
|
return [await self.get_torrents_for_show(show=show) for show in shows]
|
||||||
|
|
||||||
def download_torrent(
|
async def download_torrent(
|
||||||
self,
|
self,
|
||||||
public_indexer_result_id: IndexerQueryResultId,
|
public_indexer_result_id: IndexerQueryResultId,
|
||||||
show_id: ShowId,
|
show_id: ShowId,
|
||||||
@@ -395,15 +398,15 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
:param override_show_file_path_suffix: Optional override for the file path suffix.
|
:param override_show_file_path_suffix: Optional override for the file path suffix.
|
||||||
:return: The downloaded torrent.
|
:return: The downloaded torrent.
|
||||||
"""
|
"""
|
||||||
indexer_result = self.indexer_service.get_result(
|
indexer_result = await self.indexer_service.get_result(
|
||||||
result_id=public_indexer_result_id
|
result_id=public_indexer_result_id
|
||||||
)
|
)
|
||||||
show_torrent = self.torrent_service.download(indexer_result=indexer_result)
|
show_torrent = await self.torrent_service.download(indexer_result=indexer_result)
|
||||||
self.torrent_service.pause_download(torrent=show_torrent)
|
await self.torrent_service.pause_download(torrent=show_torrent)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for season_number in indexer_result.season:
|
for season_number in indexer_result.season:
|
||||||
season = self.tv_repository.get_season_by_number(
|
season = await self.tv_repository.get_season_by_number(
|
||||||
season_number=season_number, show_id=show_id
|
season_number=season_number, show_id=show_id
|
||||||
)
|
)
|
||||||
episodes = {episode.number: episode.id for episode in season.episodes}
|
episodes = {episode.number: episode.id for episode in season.episodes}
|
||||||
@@ -435,14 +438,14 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
torrent_id=show_torrent.id,
|
torrent_id=show_torrent.id,
|
||||||
file_path_suffix=override_show_file_path_suffix,
|
file_path_suffix=override_show_file_path_suffix,
|
||||||
)
|
)
|
||||||
self.tv_repository.add_episode_file(episode_file=episode_file)
|
await self.tv_repository.add_episode_file(episode_file=episode_file)
|
||||||
|
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
log.error(
|
log.error(
|
||||||
f"Episode file for episode {episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping."
|
f"Episode file for episode {episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping."
|
||||||
)
|
)
|
||||||
self.tv_repository.remove_episode_files_by_torrent_id(show_torrent.id)
|
await self.tv_repository.remove_episode_files_by_torrent_id(show_torrent.id)
|
||||||
self.torrent_service.cancel_download(
|
await self.torrent_service.cancel_download(
|
||||||
torrent=show_torrent, delete_files=True
|
torrent=show_torrent, delete_files=True
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
@@ -450,7 +453,7 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
log.info(
|
log.info(
|
||||||
f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}"
|
f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}"
|
||||||
)
|
)
|
||||||
self.torrent_service.resume_download(torrent=show_torrent)
|
await self.torrent_service.resume_download(torrent=show_torrent)
|
||||||
|
|
||||||
return show_torrent
|
return show_torrent
|
||||||
|
|
||||||
@@ -465,7 +468,7 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
def get_root_season_directory(self, show: Show, season_number: int) -> Path:
|
def get_root_season_directory(self, show: Show, season_number: int) -> Path:
|
||||||
return self.get_root_show_directory(show) / Path(f"Season {season_number}")
|
return self.get_root_show_directory(show) / Path(f"Season {season_number}")
|
||||||
|
|
||||||
def set_show_continuous_download(
|
async def set_show_continuous_download(
|
||||||
self, show: Show, continuous_download: bool
|
self, show: Show, continuous_download: bool
|
||||||
) -> Show:
|
) -> Show:
|
||||||
"""
|
"""
|
||||||
@@ -475,18 +478,18 @@ class TvService(BaseMediaService[Show, Show]):
|
|||||||
:param continuous_download: True to enable continuous download, False to disable.
|
:param continuous_download: True to enable continuous download, False to disable.
|
||||||
:return: The updated Show object.
|
:return: The updated Show object.
|
||||||
"""
|
"""
|
||||||
return self.tv_repository.update_show_attributes(
|
return await self.tv_repository.update_show_attributes(
|
||||||
show_id=show.id, continuous_download=continuous_download
|
show_id=show.id, continuous_download=continuous_download
|
||||||
)
|
)
|
||||||
|
|
||||||
def import_all_torrents(self) -> None:
|
async def import_all_torrents(self) -> None:
|
||||||
"""
|
"""
|
||||||
Delegate to TvImportService.
|
Delegate to TvImportService.
|
||||||
"""
|
"""
|
||||||
self.tv_import_service.import_all_torrents()
|
await self.tv_import_service.import_all_torrents()
|
||||||
|
|
||||||
def update_all_non_ended_shows_metadata(self) -> None:
|
async def update_all_non_ended_shows_metadata(self) -> None:
|
||||||
"""
|
"""
|
||||||
Delegate to TvMetadataService.
|
Delegate to TvMetadataService.
|
||||||
"""
|
"""
|
||||||
self.tv_metadata_service.update_all_non_ended_shows_metadata()
|
await self.tv_metadata_service.update_all_non_ended_shows_metadata()
|
||||||
|
|||||||
Reference in New Issue
Block a user