mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-21 16:25:36 +02:00
**Description** As explained on #322, MediaManager currently only matches torrents that represent full seasons or season packs. As a result, valid episode-based releases — commonly returned by indexers such as EZTV — are filtered out during scoring and never considered for download. Initial changes to the season parsing logic allow these torrents to be discovered. However, additional changes are required beyond season parsing to properly support single-episode imports. This PR is intended as a work-in-progress / RFC to discuss the required changes and align on the correct approach before completing the implementation. **Things planned to do** [X] Update Web UI to better display episode-level details [ ] Update TV show import logic to handle single episode files, instead of assuming full season files (to avoid integrity errors when episodes are missing) [ ] Create episode file tables to store episode-level data, similar to season files [ ] Implement fetching and downloading logic for single-episode torrents **Notes / current limitations** At the moment, the database and import logic assume one file per season per quality, which works for season packs but not for episode-based releases. These changes are intentionally not completed yet and are part of the discussion this PR aims to start. **Request for feedback** This represents a significant change in how TV content is handled in MediaManager. Before proceeding further, feedback from @maxdorninger on the overall direction and next steps would be greatly appreciated. Once aligned, the remaining tasks can be implemented incrementally. --------- Co-authored-by: Maximilian Dorninger <97409287+maxdorninger@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
478 lines
14 KiB
Python
478 lines
14 KiB
Python
from pathlib import Path
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
from media_manager.auth.db import User
|
|
from media_manager.auth.schemas import UserRead
|
|
from media_manager.auth.users import current_active_user, current_superuser
|
|
from media_manager.config import LibraryItem, MediaManagerConfig
|
|
from media_manager.exceptions import MediaAlreadyExistsError, NotFoundError
|
|
from media_manager.indexer.schemas import (
|
|
IndexerQueryResult,
|
|
IndexerQueryResultId,
|
|
)
|
|
from media_manager.metadataProvider.dependencies import metadata_provider_dep
|
|
from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult
|
|
from media_manager.schemas import MediaImportSuggestion
|
|
from media_manager.torrent.schemas import Torrent
|
|
from media_manager.torrent.utils import get_importable_media_directories
|
|
from media_manager.tv import log
|
|
from media_manager.tv.dependencies import (
|
|
season_dep,
|
|
show_dep,
|
|
tv_service_dep,
|
|
)
|
|
from media_manager.tv.schemas import (
|
|
CreateSeasonRequest,
|
|
PublicEpisodeFile,
|
|
PublicShow,
|
|
RichSeasonRequest,
|
|
RichShowTorrent,
|
|
Season,
|
|
SeasonRequest,
|
|
SeasonRequestId,
|
|
Show,
|
|
ShowId,
|
|
UpdateSeasonRequest,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# METADATA & SEARCH
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/search",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def search_metadata_providers_for_a_show(
|
|
tv_service: tv_service_dep, query: str, metadata_provider: metadata_provider_dep
|
|
) -> list[MetaDataProviderSearchResult]:
|
|
"""
|
|
Search for a show on the configured metadata provider.
|
|
"""
|
|
return tv_service.search_for_show(query=query, metadata_provider=metadata_provider)
|
|
|
|
|
|
@router.get(
|
|
"/recommended",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def get_recommended_shows(
|
|
tv_service: tv_service_dep, metadata_provider: metadata_provider_dep
|
|
) -> list[MetaDataProviderSearchResult]:
|
|
"""
|
|
Get a list of recommended/popular shows from the metadata provider.
|
|
"""
|
|
return tv_service.get_popular_shows(metadata_provider=metadata_provider)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# IMPORTING
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/importable",
|
|
status_code=status.HTTP_200_OK,
|
|
dependencies=[Depends(current_superuser)],
|
|
)
|
|
def get_all_importable_shows(
|
|
tv_service: tv_service_dep, metadata_provider: metadata_provider_dep
|
|
) -> list[MediaImportSuggestion]:
|
|
"""
|
|
Get a list of unknown shows that were detected in the TV directory and are importable.
|
|
"""
|
|
return tv_service.get_importable_tv_shows(metadata_provider=metadata_provider)
|
|
|
|
|
|
@router.post(
|
|
"/importable/{show_id}",
|
|
dependencies=[Depends(current_superuser)],
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
)
|
|
def import_detected_show(
|
|
tv_service: tv_service_dep, tv_show: show_dep, directory: str
|
|
) -> None:
|
|
"""
|
|
Import a detected show from the specified directory into the library.
|
|
"""
|
|
source_directory = Path(directory)
|
|
if source_directory not in get_importable_media_directories(
|
|
MediaManagerConfig().misc.tv_directory
|
|
):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No such directory")
|
|
tv_service.import_existing_tv_show(
|
|
tv_show=tv_show, source_directory=source_directory
|
|
)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# SHOWS
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/shows",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def get_all_shows(tv_service: tv_service_dep) -> list[Show]:
|
|
"""
|
|
Get all shows in the library.
|
|
"""
|
|
return tv_service.get_all_shows()
|
|
|
|
|
|
@router.post(
|
|
"/shows",
|
|
status_code=status.HTTP_201_CREATED,
|
|
dependencies=[Depends(current_active_user)],
|
|
responses={
|
|
status.HTTP_201_CREATED: {
|
|
"model": Show,
|
|
"description": "Successfully created show",
|
|
}
|
|
},
|
|
)
|
|
def add_a_show(
|
|
tv_service: tv_service_dep,
|
|
metadata_provider: metadata_provider_dep,
|
|
show_id: int,
|
|
language: str | None = None,
|
|
) -> Show:
|
|
"""
|
|
Add a new show to the library.
|
|
"""
|
|
try:
|
|
show = tv_service.add_show(
|
|
external_id=show_id,
|
|
metadata_provider=metadata_provider,
|
|
language=language,
|
|
)
|
|
except MediaAlreadyExistsError:
|
|
show = tv_service.get_show_by_external_id(
|
|
show_id, metadata_provider=metadata_provider.name
|
|
)
|
|
if not show:
|
|
raise NotFoundError from MediaAlreadyExistsError
|
|
return show
|
|
|
|
|
|
@router.get(
|
|
"/shows/torrents",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def get_shows_with_torrents(tv_service: tv_service_dep) -> list[RichShowTorrent]:
|
|
"""
|
|
Get all shows that are associated with torrents.
|
|
"""
|
|
return tv_service.get_all_shows_with_torrents()
|
|
|
|
|
|
@router.get(
|
|
"/shows/libraries",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def get_available_libraries() -> list[LibraryItem]:
|
|
"""
|
|
Get available TV libraries from configuration.
|
|
"""
|
|
return MediaManagerConfig().misc.tv_libraries
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# SHOWS - INDIVIDUAL
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/shows/{show_id}",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def get_a_show(show: show_dep, tv_service: tv_service_dep) -> PublicShow:
|
|
"""
|
|
Get details for a specific show.
|
|
"""
|
|
return tv_service.get_public_show_by_id(show=show)
|
|
|
|
|
|
@router.delete(
|
|
"/shows/{show_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
dependencies=[Depends(current_superuser)],
|
|
)
|
|
def delete_a_show(
|
|
tv_service: tv_service_dep,
|
|
show: show_dep,
|
|
delete_files_on_disk: bool = False,
|
|
delete_torrents: bool = False,
|
|
) -> None:
|
|
"""
|
|
Delete a show from the library.
|
|
"""
|
|
tv_service.delete_show(
|
|
show=show,
|
|
delete_files_on_disk=delete_files_on_disk,
|
|
delete_torrents=delete_torrents,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/shows/{show_id}/metadata",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def update_shows_metadata(
|
|
show: show_dep, tv_service: tv_service_dep, metadata_provider: metadata_provider_dep
|
|
) -> PublicShow:
|
|
"""
|
|
Update a show's metadata from the provider.
|
|
"""
|
|
tv_service.update_show_metadata(db_show=show, metadata_provider=metadata_provider)
|
|
return tv_service.get_public_show_by_id(show=show)
|
|
|
|
|
|
@router.post(
|
|
"/shows/{show_id}/continuousDownload",
|
|
dependencies=[Depends(current_superuser)],
|
|
)
|
|
def set_continuous_download(
|
|
show: show_dep, tv_service: tv_service_dep, continuous_download: bool
|
|
) -> PublicShow:
|
|
"""
|
|
Toggle whether future seasons of a show will be automatically downloaded.
|
|
"""
|
|
tv_service.set_show_continuous_download(
|
|
show=show, continuous_download=continuous_download
|
|
)
|
|
return tv_service.get_public_show_by_id(show=show)
|
|
|
|
|
|
@router.post(
|
|
"/shows/{show_id}/library",
|
|
dependencies=[Depends(current_superuser)],
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
)
|
|
def set_library(
|
|
show: show_dep,
|
|
tv_service: tv_service_dep,
|
|
library: str,
|
|
) -> None:
|
|
"""
|
|
Set the library path for a Show.
|
|
"""
|
|
tv_service.set_show_library(show=show, library=library)
|
|
return
|
|
|
|
|
|
@router.get(
|
|
"/shows/{show_id}/torrents",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def get_a_shows_torrents(show: show_dep, tv_service: tv_service_dep) -> RichShowTorrent:
|
|
"""
|
|
Get torrents associated with a specific show.
|
|
"""
|
|
return tv_service.get_torrents_for_show(show=show)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# SEASONS - REQUESTS
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/seasons/requests",
|
|
status_code=status.HTTP_200_OK,
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def get_season_requests(tv_service: tv_service_dep) -> list[RichSeasonRequest]:
|
|
"""
|
|
Get all season requests.
|
|
"""
|
|
return tv_service.get_all_season_requests()
|
|
|
|
|
|
@router.post("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT)
|
|
def request_a_season(
|
|
user: Annotated[User, Depends(current_active_user)],
|
|
season_request: CreateSeasonRequest,
|
|
tv_service: tv_service_dep,
|
|
) -> None:
|
|
"""
|
|
Create a new season request.
|
|
"""
|
|
request: SeasonRequest = SeasonRequest.model_validate(season_request)
|
|
request.requested_by = UserRead.model_validate(user)
|
|
if user.is_superuser:
|
|
request.authorized = True
|
|
request.authorized_by = UserRead.model_validate(user)
|
|
tv_service.add_season_request(request)
|
|
return
|
|
|
|
|
|
@router.put("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT)
|
|
def update_request(
|
|
tv_service: tv_service_dep,
|
|
user: Annotated[User, Depends(current_active_user)],
|
|
season_request: UpdateSeasonRequest,
|
|
) -> None:
|
|
"""
|
|
Update an existing season request.
|
|
"""
|
|
updated_season_request: SeasonRequest = SeasonRequest.model_validate(season_request)
|
|
request = tv_service.get_season_request_by_id(
|
|
season_request_id=updated_season_request.id
|
|
)
|
|
if request.requested_by.id == user.id or user.is_superuser:
|
|
updated_season_request.requested_by = UserRead.model_validate(user)
|
|
tv_service.update_season_request(season_request=updated_season_request)
|
|
return
|
|
|
|
|
|
@router.patch(
|
|
"/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT
|
|
)
|
|
def authorize_request(
|
|
tv_service: tv_service_dep,
|
|
user: Annotated[User, Depends(current_superuser)],
|
|
season_request_id: SeasonRequestId,
|
|
authorized_status: bool = False,
|
|
) -> None:
|
|
"""
|
|
Authorize or de-authorize a season request.
|
|
"""
|
|
season_request = tv_service.get_season_request_by_id(
|
|
season_request_id=season_request_id
|
|
)
|
|
if not season_request:
|
|
raise NotFoundError
|
|
season_request.authorized_by = UserRead.model_validate(user)
|
|
season_request.authorized = authorized_status
|
|
if not authorized_status:
|
|
season_request.authorized_by = None
|
|
tv_service.update_season_request(season_request=season_request)
|
|
|
|
|
|
@router.delete(
|
|
"/seasons/requests/{request_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
)
|
|
def delete_season_request(
|
|
tv_service: tv_service_dep,
|
|
user: Annotated[User, Depends(current_active_user)],
|
|
request_id: SeasonRequestId,
|
|
) -> None:
|
|
"""
|
|
Delete a season request.
|
|
"""
|
|
request = tv_service.get_season_request_by_id(season_request_id=request_id)
|
|
if user.is_superuser or request.requested_by.id == user.id:
|
|
tv_service.delete_season_request(season_request_id=request_id)
|
|
log.info(f"User {user.id} deleted season request {request_id}.")
|
|
return
|
|
log.warning(
|
|
f"User {user.id} tried to delete season request {request_id} but is not authorized."
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Not authorized to delete this request",
|
|
)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# SEASONS
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/seasons/{season_id}",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def get_season(season: season_dep) -> Season:
|
|
"""
|
|
Get details for a specific season.
|
|
"""
|
|
return season
|
|
|
|
|
|
@router.get(
|
|
"/seasons/{season_id}/files",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def get_episode_files(
|
|
season: season_dep, tv_service: tv_service_dep
|
|
) -> list[PublicEpisodeFile]:
|
|
"""
|
|
Get episode files associated with a specific season.
|
|
"""
|
|
return tv_service.get_public_episode_files_by_season_id(season=season)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# TORRENTS
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/torrents",
|
|
status_code=status.HTTP_200_OK,
|
|
dependencies=[Depends(current_superuser)],
|
|
)
|
|
def get_torrents_for_a_season(
|
|
tv_service: tv_service_dep,
|
|
show_id: ShowId,
|
|
season_number: int = 1,
|
|
search_query_override: str | None = None,
|
|
) -> list[IndexerQueryResult]:
|
|
"""
|
|
Search for torrents for a specific season of a show.
|
|
Default season_number is 1 because it often returns multi-season torrents.
|
|
"""
|
|
return tv_service.get_all_available_torrents_for_a_season(
|
|
season_number=season_number,
|
|
show_id=show_id,
|
|
search_query_override=search_query_override,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/torrents",
|
|
status_code=status.HTTP_200_OK,
|
|
dependencies=[Depends(current_superuser)],
|
|
)
|
|
def download_a_torrent(
|
|
tv_service: tv_service_dep,
|
|
public_indexer_result_id: IndexerQueryResultId,
|
|
show_id: ShowId,
|
|
override_file_path_suffix: str = "",
|
|
) -> Torrent:
|
|
"""
|
|
Trigger a download for a specific torrent.
|
|
"""
|
|
return tv_service.download_torrent(
|
|
public_indexer_result_id=public_indexer_result_id,
|
|
show_id=show_id,
|
|
override_show_file_path_suffix=override_file_path_suffix,
|
|
)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# STATISTICS
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/episodes/count",
|
|
status_code=status.HTTP_200_OK,
|
|
description="Total number of episodes downloaded",
|
|
dependencies=[Depends(current_active_user)],
|
|
)
|
|
def get_total_count_of_downloaded_episodes(tv_service: tv_service_dep) -> int:
|
|
"""
|
|
Get the total count of downloaded episodes across all shows.
|
|
"""
|
|
return tv_service.get_total_downloaded_episoded_count()
|