diff --git a/api/main.py b/api/main.py index 14ebb33..7ef392d 100644 --- a/api/main.py +++ b/api/main.py @@ -23,6 +23,7 @@ from api.scheduler import setup_scheduler from db import database, crud, schemas from db.config import settings from scrapers.routes import router as scrapers_router +from streaming_providers import mapper from streaming_providers.routes import router as streaming_provider_router from utils import crypto, torrent, poster, const, wrappers from utils.parser import generate_manifest, get_json_data @@ -49,6 +50,15 @@ app.add_middleware(middleware.SecureLoggingMiddleware) app.add_middleware(middleware.UserDataMiddleware) TEMPLATES = Jinja2Templates(directory="resources") +DELETE_ALL_META = schemas.Meta( + **const.DELETE_ALL_WATCHLIST_META, + poster=f"{settings.host_url}/static/images/delete_all_poster.jpg", + background=f"{settings.host_url}/static/images/delete_all_background.png", +) + +DELETE_ALL_META_ITEM = { + "meta": DELETE_ALL_META.model_dump(by_alias=True, exclude_none=True) +} def get_user_data(request: Request) -> schemas.UserData: @@ -226,11 +236,13 @@ async def get_catalog( skip = int(skip) if skip and skip.isdigit() else 0 cache_key = f"{catalog_type}_{catalog_id}_{skip}_{genre}_catalog" + is_watchlist_catalog = False if user_data.streaming_provider and catalog_id.startswith( user_data.streaming_provider.service ): response.headers.update(const.NO_CACHE_HEADERS) cache_key = None + is_watchlist_catalog = True elif catalog_type == "events": response.headers.update(const.NO_CACHE_HEADERS) cache_key = None @@ -249,8 +261,23 @@ async def get_catalog( ) else: metas.metas.extend( - await crud.get_meta_list(user_data, catalog_type, catalog_id, skip) + await crud.get_meta_list( + user_data, catalog_type, catalog_id, is_watchlist_catalog, skip + ) ) + if ( + is_watchlist_catalog + and catalog_type == "movie" + and metas.metas + and mapper.DELETE_ALL_WATCHLIST_FUNCTIONS.get( + user_data.streaming_provider.service + ) + ): + delete_all_meta = DELETE_ALL_META.model_copy() + delete_all_meta.id = delete_all_meta.id.format( + user_data.streaming_provider.service + ) + metas.metas.insert(0, delete_all_meta) if cache_key: await request.app.state.redis.set( @@ -326,7 +353,12 @@ async def get_meta( return meta_data if catalog_type == "movie": - data = await crud.get_movie_meta(meta_id) + if meta_id.startswith("dl"): + delete_all_meta_item = DELETE_ALL_META_ITEM.copy() + delete_all_meta_item["meta"]["_id"] = meta_id + data = delete_all_meta_item + else: + data = await crud.get_movie_meta(meta_id) elif catalog_type == "series": data = await crud.get_series_meta(meta_id) elif catalog_type == "events": @@ -383,9 +415,21 @@ async def get_streams( response.headers.update(const.DEFAULT_HEADERS) if catalog_type == "movie": - fetched_streams = await crud.get_movie_streams( - user_data, secret_str, request.app.state.redis, video_id - ) + if video_id.startswith("dl"): + if video_id == f"dl{user_data.streaming_provider.service}": + fetched_streams = [ + schemas.Stream( + name=f"MediaFusion {user_data.streaming_provider.service.title()} 🗑️💩🚨", + description=f"🚨💀⚠ Delete all files in {user_data.streaming_provider.service} watchlist.", + url=f"{settings.host_url}/streaming_provider/{secret_str}/delete_all_watchlist", + ) + ] + else: + raise HTTPException(status_code=404, detail="Meta ID not found.") + else: + fetched_streams = await crud.get_movie_streams( + user_data, secret_str, request.app.state.redis, video_id + ) elif catalog_type == "series": fetched_streams = await crud.get_series_streams( user_data, secret_str, request.app.state.redis, video_id, season, episode diff --git a/db/crud.py b/db/crud.py index 5c18451..8540865 100644 --- a/db/crud.py +++ b/db/crud.py @@ -41,6 +41,7 @@ async def get_meta_list( user_data: schemas.UserData, catalog_type: str, catalog: str, + is_watchlist_catalog: bool, skip: int = 0, limit: int = 25, ) -> list[schemas.Meta]: @@ -51,9 +52,7 @@ async def get_meta_list( query_conditions = [] - if user_data.streaming_provider and catalog.startswith( - user_data.streaming_provider.service - ): + if is_watchlist_catalog: downloaded_info_hashes = await fetch_downloaded_info_hashes(user_data) if not downloaded_info_hashes: return [] @@ -179,6 +178,16 @@ async def get_cached_torrent_streams( async def get_movie_streams( user_data, secret_str: str, redis: Redis, video_id: str ) -> list[Stream]: + if video_id.startswith("dl"): + if not video_id.endswith(user_data.streaming_provider.service): + return [] + return [ + schemas.Stream( + name=f"MediaFusion {user_data.streaming_provider.service.title()} 🗑️💩", + description="🚨💀⚠️\nDelete all files in streaming provider", + url=f"{settings.host_url}/streaming_provider/{secret_str}/delete_all", + ) + ] streams = await get_cached_torrent_streams(redis, video_id) if video_id.startswith("tt"): diff --git a/resources/exceptions/watchlist_deleted.mp4 b/resources/exceptions/watchlist_deleted.mp4 new file mode 100644 index 0000000..c2d8e77 Binary files /dev/null and b/resources/exceptions/watchlist_deleted.mp4 differ diff --git a/resources/images/delete_all_background.png b/resources/images/delete_all_background.png new file mode 100644 index 0000000..30177b9 Binary files /dev/null and b/resources/images/delete_all_background.png differ diff --git a/resources/images/delete_all_poster.jpg b/resources/images/delete_all_poster.jpg new file mode 100644 index 0000000..be53134 Binary files /dev/null and b/resources/images/delete_all_poster.jpg differ diff --git a/streaming_providers/alldebrid/utils.py b/streaming_providers/alldebrid/utils.py index a514571..29459cb 100644 --- a/streaming_providers/alldebrid/utils.py +++ b/streaming_providers/alldebrid/utils.py @@ -86,3 +86,11 @@ def fetch_downloaded_info_hashes_from_ad(user_data: UserData) -> list[str]: except ProviderException: return [] + + +def delete_all_torrents_from_ad(user_data: UserData): + """Deletes all torrents from the AllDebrid account.""" + ad_client = AllDebrid(token=user_data.streaming_provider.token) + torrents = ad_client.get_user_torrent_list() + for torrent in torrents["data"]["magnets"]: + ad_client.delete_torrent(torrent["id"]) diff --git a/streaming_providers/debridlink/utils.py b/streaming_providers/debridlink/utils.py index 05105e4..73cec60 100644 --- a/streaming_providers/debridlink/utils.py +++ b/streaming_providers/debridlink/utils.py @@ -95,3 +95,11 @@ def fetch_downloaded_info_hashes_from_dl(user_data: UserData) -> list[str]: except ProviderException: return [] + + +def delete_all_torrents_from_dl(user_data: UserData): + """Deletes all torrents from the DebridLink account.""" + dl_client = DebridLink(token=user_data.streaming_provider.token) + torrents = dl_client.get_user_torrent_list() + for torrent in torrents["value"]: + dl_client.delete_torrent(torrent["id"]) diff --git a/streaming_providers/mapper.py b/streaming_providers/mapper.py new file mode 100644 index 0000000..6844258 --- /dev/null +++ b/streaming_providers/mapper.py @@ -0,0 +1,80 @@ +from streaming_providers.alldebrid.utils import ( + update_ad_cache_status, + fetch_downloaded_info_hashes_from_ad, + delete_all_torrents_from_ad, +) +from streaming_providers.debridlink.utils import ( + update_dl_cache_status, + fetch_downloaded_info_hashes_from_dl, + delete_all_torrents_from_dl, +) +from streaming_providers.offcloud.utils import ( + update_oc_cache_status, + fetch_downloaded_info_hashes_from_oc, +) +from streaming_providers.pikpak.utils import ( + update_pikpak_cache_status, + fetch_downloaded_info_hashes_from_pikpak, + delete_all_torrents_from_pikpak, +) +from streaming_providers.premiumize.utils import ( + update_pm_cache_status, + fetch_downloaded_info_hashes_from_premiumize, + delete_all_torrents_from_pm, +) +from streaming_providers.qbittorrent.utils import ( + update_qbittorrent_cache_status, + fetch_info_hashes_from_webdav, + delete_all_torrents_from_qbittorrent, +) +from streaming_providers.realdebrid.utils import ( + update_rd_cache_status, + fetch_downloaded_info_hashes_from_rd, + delete_all_watchlist_rd, +) +from streaming_providers.seedr.utils import ( + update_seedr_cache_status, + fetch_downloaded_info_hashes_from_seedr, + delete_all_torrents_from_seedr, +) +from streaming_providers.torbox.utils import ( + update_torbox_cache_status, + fetch_downloaded_info_hashes_from_torbox, +) + +# Define provider-specific cache update functions +CACHE_UPDATE_FUNCTIONS = { + "alldebrid": update_ad_cache_status, + "debridlink": update_dl_cache_status, + "offcloud": update_oc_cache_status, + "pikpak": update_pikpak_cache_status, + "realdebrid": update_rd_cache_status, + "seedr": update_seedr_cache_status, + "torbox": update_torbox_cache_status, + "premiumize": update_pm_cache_status, + "qbittorrent": update_qbittorrent_cache_status, +} + +# Define provider-specific downloaded info hashes fetch functions +FETCH_DOWNLOADED_INFO_HASHES_FUNCTIONS = { + "alldebrid": fetch_downloaded_info_hashes_from_ad, + "debridlink": fetch_downloaded_info_hashes_from_dl, + "offcloud": fetch_downloaded_info_hashes_from_oc, + "pikpak": fetch_downloaded_info_hashes_from_pikpak, + "realdebrid": fetch_downloaded_info_hashes_from_rd, + "seedr": fetch_downloaded_info_hashes_from_seedr, + "torbox": fetch_downloaded_info_hashes_from_torbox, + "premiumize": fetch_downloaded_info_hashes_from_premiumize, + "qbittorrent": fetch_info_hashes_from_webdav, +} + + +DELETE_ALL_WATCHLIST_FUNCTIONS = { + "alldebrid": delete_all_torrents_from_ad, + "debridlink": delete_all_torrents_from_dl, + "pikpak": delete_all_torrents_from_pikpak, + "premiumize": delete_all_torrents_from_pm, + "qbittorrent": delete_all_torrents_from_qbittorrent, + "realdebrid": delete_all_watchlist_rd, + "seedr": delete_all_torrents_from_seedr, +} diff --git a/streaming_providers/pikpak/utils.py b/streaming_providers/pikpak/utils.py index 3eb8cb3..1c213f2 100644 --- a/streaming_providers/pikpak/utils.py +++ b/streaming_providers/pikpak/utils.py @@ -274,3 +274,17 @@ async def fetch_downloaded_info_hashes_from_pikpak(user_data: UserData) -> list[ return [ file["name"] for file in file_list_content["files"] if file["name"] != "My Pack" ] + + +async def delete_all_torrents_from_pikpak(user_data: UserData): + """Deletes all torrents from the PikPak account.""" + try: + pikpak = await initialize_pikpak(user_data) + except ProviderException: + return + + file_list_content = await pikpak.file_list() + file_ids = [ + file["id"] for file in file_list_content["files"] if file["name"] != "My Pack" + ] + await pikpak.delete_forever(file_ids) diff --git a/streaming_providers/premiumize/client.py b/streaming_providers/premiumize/client.py index 5c5257c..92a51b7 100644 --- a/streaming_providers/premiumize/client.py +++ b/streaming_providers/premiumize/client.py @@ -93,6 +93,11 @@ class Premiumize(DebridClient): params={"id": folder_id} if folder_id else None, ) + def delete_folder(self, folder_id: str): + return self._make_request( + "POST", f"{self.BASE_URL}/folder/delete", data={"id": folder_id} + ) + def delete_torrent(self, torrent_id): return self._make_request( "POST", f"{self.BASE_URL}/transfer/delete", data={"id": torrent_id} diff --git a/streaming_providers/premiumize/utils.py b/streaming_providers/premiumize/utils.py index 18e7baf..f5c622a 100644 --- a/streaming_providers/premiumize/utils.py +++ b/streaming_providers/premiumize/utils.py @@ -125,3 +125,11 @@ def fetch_downloaded_info_hashes_from_premiumize(user_data: UserData) -> list[st except ProviderException: return [] + + +def delete_all_torrents_from_pm(user_data: UserData): + """Deletes all torrents from the Premiumize account.""" + pm_client = Premiumize(token=user_data.streaming_provider.token) + folders = pm_client.get_folder_list() + for folder in folders["content"]: + pm_client.delete_folder(folder["id"]) diff --git a/streaming_providers/qbittorrent/utils.py b/streaming_providers/qbittorrent/utils.py index b7aa013..d1af1f5 100644 --- a/streaming_providers/qbittorrent/utils.py +++ b/streaming_providers/qbittorrent/utils.py @@ -5,7 +5,7 @@ from os import path from urllib.parse import urljoin, urlparse, quote from aiohttp import ClientConnectorError -from aioqbt.api import AddFormBuilder, TorrentInfo +from aioqbt.api import AddFormBuilder, TorrentInfo, InfoFilter from aioqbt.client import create_client, APIClient from aioqbt.exc import LoginError, AddTorrentError from aiowebdav.client import Client as WebDavClient @@ -333,3 +333,12 @@ async def fetch_info_hashes_from_webdav( ] return info_hashes + + +async def delete_all_torrents_from_qbittorrent(user_data: UserData): + """Deletes all torrents from the qBittorrent server.""" + async with initialize_qbittorrent(user_data) as qbittorrent: + torrents = await qbittorrent.torrents.info(filter=InfoFilter.COMPLETED) + await qbittorrent.torrents.delete( + hashes=[torrent.hash for torrent in torrents], delete_files=True + ) diff --git a/streaming_providers/realdebrid/utils.py b/streaming_providers/realdebrid/utils.py index 6be9949..4b95eae 100644 --- a/streaming_providers/realdebrid/utils.py +++ b/streaming_providers/realdebrid/utils.py @@ -104,3 +104,11 @@ def fetch_downloaded_info_hashes_from_rd(user_data: UserData) -> list[str]: except ProviderException: return [] + + +def delete_all_watchlist_rd(user_data: UserData): + """Deletes all torrents from the RealDebrid watchlist.""" + rd_client = RealDebrid(token=user_data.streaming_provider.token) + torrents = rd_client.get_user_torrent_list() + for torrent in torrents: + rd_client.delete_torrent(torrent["id"]) diff --git a/streaming_providers/routes.py b/streaming_providers/routes.py index d240bfb..aff4fdf 100644 --- a/streaming_providers/routes.py +++ b/streaming_providers/routes.py @@ -1,3 +1,4 @@ +import asyncio import logging from fastapi import ( @@ -10,6 +11,7 @@ from fastapi.responses import RedirectResponse from db import crud from db.config import settings +from streaming_providers import mapper from streaming_providers.alldebrid.utils import get_direct_link_from_alldebrid from streaming_providers.debridlink.api import router as debridlink_router from streaming_providers.debridlink.utils import get_direct_link_from_debridlink @@ -109,6 +111,46 @@ async def streaming_provider_endpoint( return RedirectResponse(url=video_url, headers=response.headers) +@router.get("/{secret_str}/delete_all_watchlist", tags=["streaming_provider"]) +@wrappers.exclude_rate_limit +@wrappers.auth_required +async def delete_all_watchlist(request: Request, response: Response, secret_str: str): + response.headers.update(const.NO_CACHE_HEADERS) + + user_data = request.scope.get("user", crypto.decrypt_user_data(secret_str)) + + if not user_data.streaming_provider: + raise HTTPException(status_code=400, detail="No streaming provider set.") + + if delete_all_watchlist_function := mapper.DELETE_ALL_WATCHLIST_FUNCTIONS.get( + user_data.streaming_provider.service + ): + try: + if asyncio.iscoroutinefunction(delete_all_watchlist_function): + await delete_all_watchlist_function(user_data) + else: + delete_all_watchlist_function(user_data) + video_url = f"{settings.host_url}/static/exceptions/watchlist_deleted.mp4" + except ProviderException as error: + logging.error( + "Exception occurred while deleting watchlist: %s", + error.message, + exc_info=True, + ) + video_url = f"{settings.host_url}/static/exceptions/{error.video_file_name}" + except Exception as e: + logging.error( + "Exception occurred while deleting watchlist: %s", e, exc_info=True + ) + video_url = f"{settings.host_url}/static/exceptions/api_error.mp4" + else: + raise HTTPException( + status_code=400, detail="Provider does not support this action." + ) + + return RedirectResponse(url=video_url, headers=response.headers) + + router.include_router(seedr_router, prefix="/seedr", tags=["seedr"]) router.include_router(realdebrid_router, prefix="/realdebrid", tags=["realdebrid"]) router.include_router(debridlink_router, prefix="/debridlink", tags=["debridlink"]) diff --git a/streaming_providers/seedr/utils.py b/streaming_providers/seedr/utils.py index 4fda8ea..fd12f9b 100644 --- a/streaming_providers/seedr/utils.py +++ b/streaming_providers/seedr/utils.py @@ -229,3 +229,13 @@ def fetch_downloaded_info_hashes_from_seedr(user_data: UserData) -> list[str]: return [] return [folder["name"] for folder in seedr.listContents()["folders"]] + + +def delete_all_torrents_from_seedr(user_data: UserData): + """Deletes all torrents from the user's Seedr account.""" + seedr = get_seedr_client(user_data) + + for folder in seedr.listContents()["folders"]: + seedr.deleteFolder(folder["id"]) + for torrent in seedr.listContents()["torrents"]: + seedr.deleteTorrent(torrent["id"]) diff --git a/utils/const.py b/utils/const.py index a61927d..705ec93 100644 --- a/utils/const.py +++ b/utils/const.py @@ -129,6 +129,13 @@ STREAMING_SERVICE_REQUIREMENTS = { "default": ["token"], } +DELETE_ALL_WATCHLIST_META = { + "_id": "dl{}", + "title": "🗑️💩 Delete all files", + "type": "movie", + "description": "🚨💀⚠ Delete all files in streaming provider", +} + UA_HEADER = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" } diff --git a/utils/parser.py b/utils/parser.py index 7b25fd1..de2d1bc 100644 --- a/utils/parser.py +++ b/utils/parser.py @@ -10,42 +10,7 @@ from redis.asyncio import Redis from db.config import settings from db.models import TorrentStreams, TVStreams from db.schemas import Stream, UserData -from streaming_providers.alldebrid.utils import ( - update_ad_cache_status, - fetch_downloaded_info_hashes_from_ad, -) -from streaming_providers.debridlink.utils import ( - update_dl_cache_status, - fetch_downloaded_info_hashes_from_dl, -) -from streaming_providers.offcloud.utils import ( - update_oc_cache_status, - fetch_downloaded_info_hashes_from_oc, -) -from streaming_providers.pikpak.utils import ( - update_pikpak_cache_status, - fetch_downloaded_info_hashes_from_pikpak, -) -from streaming_providers.premiumize.utils import ( - update_pm_cache_status, - fetch_downloaded_info_hashes_from_premiumize, -) -from streaming_providers.qbittorrent.utils import ( - update_qbittorrent_cache_status, - fetch_info_hashes_from_webdav, -) -from streaming_providers.realdebrid.utils import ( - update_rd_cache_status, - fetch_downloaded_info_hashes_from_rd, -) -from streaming_providers.seedr.utils import ( - update_seedr_cache_status, - fetch_downloaded_info_hashes_from_seedr, -) -from streaming_providers.torbox.utils import ( - update_torbox_cache_status, - fetch_downloaded_info_hashes_from_torbox, -) +from streaming_providers import mapper from utils import const from utils.network import get_redirector_url from utils.validation_helper import validate_m3u8_url_with_cache @@ -55,31 +20,6 @@ ADULT_CONTENT_KEYWORDS = re.compile( settings.adult_content_regex_keywords, re.IGNORECASE, ) -# Define provider-specific cache update functions -CACHE_UPDATE_FUNCTIONS = { - "alldebrid": update_ad_cache_status, - "debridlink": update_dl_cache_status, - "offcloud": update_oc_cache_status, - "pikpak": update_pikpak_cache_status, - "realdebrid": update_rd_cache_status, - "seedr": update_seedr_cache_status, - "torbox": update_torbox_cache_status, - "premiumize": update_pm_cache_status, - "qbittorrent": update_qbittorrent_cache_status, -} - -# Define provider-specific downloaded info hashes fetch functions -FETCH_DOWNLOADED_INFO_HASHES_FUNCTIONS = { - "alldebrid": fetch_downloaded_info_hashes_from_ad, - "debridlink": fetch_downloaded_info_hashes_from_dl, - "offcloud": fetch_downloaded_info_hashes_from_oc, - "pikpak": fetch_downloaded_info_hashes_from_pikpak, - "realdebrid": fetch_downloaded_info_hashes_from_rd, - "seedr": fetch_downloaded_info_hashes_from_seedr, - "torbox": fetch_downloaded_info_hashes_from_torbox, - "premiumize": fetch_downloaded_info_hashes_from_premiumize, - "qbittorrent": fetch_info_hashes_from_webdav, -} async def filter_and_sort_streams( @@ -102,7 +42,7 @@ async def filter_and_sort_streams( return [] # Step 2: Update cache status based on provider - cache_update_function = CACHE_UPDATE_FUNCTIONS.get( + cache_update_function = mapper.CACHE_UPDATE_FUNCTIONS.get( user_data.streaming_provider.service if user_data.streaming_provider else "torrent" @@ -361,7 +301,7 @@ async def parse_tv_stream_data( async def fetch_downloaded_info_hashes(user_data: UserData) -> list[str]: - if fetch_downloaded_info_hashes_function := FETCH_DOWNLOADED_INFO_HASHES_FUNCTIONS.get( + if fetch_downloaded_info_hashes_function := mapper.FETCH_DOWNLOADED_INFO_HASHES_FUNCTIONS.get( user_data.streaming_provider.service ): if asyncio.iscoroutinefunction(fetch_downloaded_info_hashes_function):