Files
natarelli22 d8a0ec66c3 Support for handling Single Episode Torrents (#331)
**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>
2026-02-22 15:21:19 +01:00

114 lines
4.4 KiB
Python

import logging
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.movies.schemas import Movie, MovieFile
from media_manager.torrent.manager import DownloadManager
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.schemas import Torrent, TorrentId
from media_manager.tv.schemas import EpisodeFile, Show
log = logging.getLogger(__name__)
class TorrentService:
def __init__(
self,
torrent_repository: TorrentRepository,
download_manager: DownloadManager | None = None,
) -> None:
self.torrent_repository = torrent_repository
self.download_manager = download_manager or DownloadManager()
def get_episode_files_of_torrent(self, torrent: Torrent) -> list[EpisodeFile]:
"""
Returns all episode files of a torrent
:param torrent: the torrent to get the episode files of
:return: list of episode files
"""
return self.torrent_repository.get_episode_files_of_torrent(
torrent_id=torrent.id
)
def get_show_of_torrent(self, torrent: Torrent) -> Show | None:
"""
Returns the show of a torrent
:param torrent: the torrent to get the show of
:return: the show of the torrent
"""
return self.torrent_repository.get_show_of_torrent(torrent_id=torrent.id)
def get_movie_of_torrent(self, torrent: Torrent) -> Movie | None:
"""
Returns the movie of a torrent
:param torrent: the torrent to get the movie of
:return: the movie of the torrent
"""
return self.torrent_repository.get_movie_of_torrent(torrent_id=torrent.id)
def download(self, indexer_result: IndexerQueryResult) -> Torrent:
log.info(f"Starting download for torrent: {indexer_result.title}")
torrent = self.download_manager.download(indexer_result)
return self.torrent_repository.save_torrent(torrent=torrent)
def get_torrent_status(self, torrent: Torrent) -> Torrent:
torrent.status = self.download_manager.get_torrent_status(torrent)
self.torrent_repository.save_torrent(torrent=torrent)
return torrent
def cancel_download(self, torrent: Torrent, delete_files: bool = False) -> Torrent:
"""
cancels download of a torrent
:param delete_files: Deletes the downloaded files of the torrent too, deactivated by default
:param torrent: the torrent to cancel
"""
log.info(f"Cancelling download for torrent: {torrent.title}")
self.download_manager.remove_torrent(torrent, delete_data=delete_files)
return self.get_torrent_status(torrent=torrent)
def pause_download(self, torrent: Torrent) -> Torrent:
"""
pauses download of a torrent
:param torrent: the torrent to pause
"""
log.info(f"Pausing download for torrent: {torrent.title}")
self.download_manager.pause_torrent(torrent)
return self.get_torrent_status(torrent=torrent)
def resume_download(self, torrent: Torrent) -> Torrent:
"""
resumes download of a torrent
:param torrent: the torrent to resume
"""
log.info(f"Resuming download for torrent: {torrent.title}")
self.download_manager.resume_torrent(torrent)
return self.get_torrent_status(torrent=torrent)
def get_all_torrents(self) -> list[Torrent]:
torrents = []
for x in self.torrent_repository.get_all_torrents():
try:
torrents.append(self.get_torrent_status(x))
except RuntimeError:
log.exception(f"Error fetching status for torrent {x.title}")
return torrents
def get_torrent_by_id(self, torrent_id: TorrentId) -> Torrent:
return self.get_torrent_status(
self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
)
def delete_torrent(self, torrent_id: TorrentId) -> None:
log.info(f"Deleting torrent with ID: {torrent_id}")
t = self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
delete_media_files = not t.imported
self.torrent_repository.delete_torrent(
torrent_id=torrent_id, delete_associated_media_files=delete_media_files
)
def get_movie_files_of_torrent(self, torrent: Torrent) -> list[MovieFile]:
return self.torrent_repository.get_movie_files_of_torrent(torrent_id=torrent.id)