From c8f660b7eeb0a867d3469a71e1dcb099ba320cc9 Mon Sep 17 00:00:00 2001 From: Mohamed Zumair Date: Wed, 18 Sep 2024 06:54:37 +0530 Subject: [PATCH] Add Support for download streams via web browser & support disabling the metadata for imdb title (#287) * Add Support for download streams via web browser & support disabling the metadata for imdb title * Add env config to enable or disable download via browser feature --- api/main.py | 77 ++++++++++++++++++- db/config.py | 1 + db/schemas.py | 2 + mediafusion_scrapy/task.py | 2 +- resources/html/configure.html | 38 ++++++++-- resources/html/download_info.html | 121 ++++++++++++++++++++++++++++++ resources/js/config_script.js | 8 +- streaming_providers/routes.py | 19 ++++- utils/network.py | 5 ++ utils/parser.py | 20 +++++ 10 files changed, 278 insertions(+), 15 deletions(-) create mode 100644 resources/html/download_info.html diff --git a/api/main.py b/api/main.py index 7d0db38..6b55d61 100644 --- a/api/main.py +++ b/api/main.py @@ -3,7 +3,7 @@ import json import logging from contextlib import asynccontextmanager from io import BytesIO -from typing import Literal +from typing import Literal, Annotated import aiohttp from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -18,6 +18,7 @@ from fastapi import ( from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles +from starlette.responses import HTMLResponse from api import middleware from api.scheduler import setup_scheduler @@ -193,6 +194,7 @@ async def configure( "authentication_required": settings.api_password is not None and not settings.is_public_instance, "kodi_code": kodi_code, + "disable_download_via_browser": settings.disable_download_via_browser, }, ) @@ -643,6 +645,79 @@ async def get_poster( raise HTTPException(status_code=404, detail="Failed to create poster.") +@app.get( + "/download/{secret_str}/{catalog_type}/{video_id}", + response_class=HTMLResponse, + tags=["download"], +) +@app.get( + "/download/{secret_str}/{catalog_type}/{video_id}/{season}/{episode}", + response_class=HTMLResponse, + tags=["download"], +) +@wrappers.auth_required +async def download_info( + request: Request, + secret_str: str, + catalog_type: Literal["movie", "series"], + video_id: str, + user_data: Annotated[schemas.UserData, Depends(get_user_data)], + background_tasks: BackgroundTasks, + season: int = None, + episode: int = None, +): + if ( + not user_data.streaming_provider + or not user_data.streaming_provider.download_via_browser + or settings.disable_download_via_browser + ): + raise HTTPException( + status_code=403, + detail="Download option is not enabled or no streaming provider configured", + ) + + metadata = ( + await crud.get_movie_data_by_id(video_id) + if catalog_type == "movie" + else await crud.get_series_data_by_id(video_id) + ) + if not metadata: + raise HTTPException(status_code=404, detail="Metadata not found") + + user_ip = await get_user_public_ip(request, user_data) + + if catalog_type == "movie": + streams = await crud.get_movie_streams( + user_data, secret_str, video_id, user_ip, background_tasks + ) + else: + streams = await crud.get_series_streams( + user_data, secret_str, video_id, season, episode, user_ip, background_tasks + ) + + streaming_provider_path = f"{settings.host_url}/streaming_provider/" + downloadable_streams = [ + stream + for stream in streams + if stream.url and stream.url.startswith(streaming_provider_path) + ] + + context = { + "title": metadata.title, + "year": metadata.year, + "poster": metadata.poster, + "description": metadata.description, + "streams": downloadable_streams, + "catalog_type": catalog_type, + "season": season, + "episode": episode, + } + + return TEMPLATES.TemplateResponse( + "html/download_info.html", {"request": request, **context} + ) + + app.include_router( streaming_provider_router, prefix="/streaming_provider", tags=["streaming_provider"] ) diff --git a/db/config.py b/db/config.py index 83029bf..90d463b 100644 --- a/db/config.py +++ b/db/config.py @@ -45,6 +45,7 @@ class Settings(BaseSettings): prowlarr_live_title_search: bool = False prowlarr_background_title_search: bool = True prowlarr_search_query_timeout: int = 120 + disable_download_via_browser: bool = False # Scheduler settings disable_all_scheduler: bool = False diff --git a/db/schemas.py b/db/schemas.py index 9d95235..7392b44 100644 --- a/db/schemas.py +++ b/db/schemas.py @@ -142,6 +142,7 @@ class StreamingProvider(BaseModel): password: str | None = Field(default=None, alias="pw") enable_watchlist_catalogs: bool = Field(default=True, alias="ewc") qbittorrent_config: QBittorrentConfig | None = Field(default=None, alias="qbc") + download_via_browser: bool = Field(default=False, alias="dvb") @model_validator(mode="after") def validate_token_or_username_password(self) -> "StreamingProvider": @@ -172,6 +173,7 @@ class UserData(BaseModel): default=const.RESOLUTIONS, alias="sr" ) enable_catalogs: bool = Field(default=True, alias="ec") + enable_imdb_metadata: bool = Field(default=True, alias="eim") max_size: int | str | float = Field(default=math.inf, alias="ms") max_streams_per_resolution: int = Field(default=3, alias="mspr") show_full_torrent_name: bool = Field(default=True, alias="sftn") diff --git a/mediafusion_scrapy/task.py b/mediafusion_scrapy/task.py index a433783..dcb9e8d 100644 --- a/mediafusion_scrapy/task.py +++ b/mediafusion_scrapy/task.py @@ -25,4 +25,4 @@ def run_spider(spider_name: str, *args, **kwargs): if __name__ == "__main__": - run_spider_in_process("streamed") + run_spider_in_process("sport_video") diff --git a/resources/html/configure.html b/resources/html/configure.html index 09fb64a..9679fd1 100644 --- a/resources/html/configure.html +++ b/resources/html/configure.html @@ -207,16 +207,26 @@ - - + +
+
Enable IMDb Title Meta Data Response:
+
+ +
diff --git a/resources/html/download_info.html b/resources/html/download_info.html new file mode 100644 index 0000000..867d4b8 --- /dev/null +++ b/resources/html/download_info.html @@ -0,0 +1,121 @@ + + + + + + + Download Options - {{ title }} + + + + + + +
+
+
+ +

Download Options - {{ title }} ({{ year }})

+ +
+
+ {{ title }} Poster +
+
+ {% if description %}

{{ description }}

{% endif %} + {% if catalog_type == "series" %} +

Season: {{ season }}, Episode: {{ episode }}

+ {% endif %} + +

🎬 Available Streams

+ {% if streams %} +
+ {% for stream in streams %} +
+

{{ stream.name }}

+
+ {% for line in stream.description.split('\n') %} +

{{ line | safe }}

+ {% endfor %} +
+ +
+ {% endfor %} +
+ {% else %} +

No download options available.

+ {% endif %} +
+
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/resources/js/config_script.js b/resources/js/config_script.js index 22a15fc..45a2eaa 100644 --- a/resources/js/config_script.js +++ b/resources/js/config_script.js @@ -194,12 +194,12 @@ function updateProviderFields(isChangeEvent = false) { setElementDisplay('token_input', 'block'); setElementDisplay('qbittorrent_config', 'none'); } - setElementDisplay('watchlist_section', 'block'); + setElementDisplay('streaming_provider_options', 'block'); watchlistLabel.textContent = `Enable ${provider.charAt(0).toUpperCase() + provider.slice(1)} Watchlist`; } else { setElementDisplay('credentials', 'none'); setElementDisplay('token_input', 'none'); - setElementDisplay('watchlist_section', 'none'); + setElementDisplay('streaming_provider_options', 'none'); setElementDisplay('qbittorrent_config', 'none'); } @@ -300,6 +300,9 @@ function getUserData() { } streamingProviderData.service = provider; streamingProviderData.enable_watchlist_catalogs = document.getElementById('enable_watchlist').checked; + if (document.getElementById('download_via_browser')) { + streamingProviderData.download_via_browser = document.getElementById('download_via_browser').checked; + } } else { streamingProviderData = null; } @@ -365,6 +368,7 @@ function getUserData() { selected_catalogs: Array.from(document.querySelectorAll('input[name="selected_catalogs"]:checked')).map(el => el.value), selected_resolutions: Array.from(document.querySelectorAll('input[name="selected_resolutions"]:checked')).map(el => el.value || null), enable_catalogs: document.getElementById('enable_catalogs').checked, + enable_imdb_metadata: document.getElementById('enable_imdb_metadata').checked, max_size: maxSizeBytes, max_streams_per_resolution: maxStreamsPerResolution, torrent_sorting_priority: selectedSortingOptions, diff --git a/streaming_providers/routes.py b/streaming_providers/routes.py index 0f8174f..bf6d0b0 100644 --- a/streaming_providers/routes.py +++ b/streaming_providers/routes.py @@ -1,5 +1,6 @@ import asyncio import logging +from os import path from fastapi import ( Request, @@ -54,6 +55,11 @@ async def get_cached_stream_url_and_redirect( "/proxy/stream", cached_stream_url, query_params={"api_password": user_data.mediaflow_config.api_password}, + response_headers={ + "Content-Disposition": "attachment, filename={}".format( + path.basename(cached_stream_url) + ) + }, ) return RedirectResponse( url=cached_stream_url, headers=response.headers, status_code=302 @@ -131,11 +137,16 @@ def apply_mediaflow_proxy_if_needed(video_url, user_data): "/proxy/stream", video_url, query_params={"api_password": user_data.mediaflow_config.api_password}, + response_headers={ + "Content-Disposition": "attachment, filename={}".format( + path.basename(video_url) + ) + }, ) return video_url -def handle_provider_exception(error, usage): +def handle_provider_exception(error, usage) -> str: """ Handles exceptions raised by the provider and logs them. """ @@ -148,7 +159,7 @@ def handle_provider_exception(error, usage): return f"{settings.host_url}/static/exceptions/{error.video_file_name}" -def handle_generic_exception(exception, info_hash): +def handle_generic_exception(exception, info_hash) -> str: """ Handles generic exceptions and logs them. """ @@ -272,11 +283,11 @@ async def delete_all_watchlist( except ProviderException as error: # Handle provider-specific exceptions - video_url, _ = handle_provider_exception(error, "delete_watchlist") + video_url = handle_provider_exception(error, "delete_watchlist") except Exception as e: # Handle generic exceptions - video_url, _ = handle_generic_exception(e, "delete_watchlist") + video_url = handle_generic_exception(e, "delete_watchlist") return RedirectResponse(url=video_url, headers=response.headers) diff --git a/utils/network.py b/utils/network.py index ee2841d..643bd2c 100644 --- a/utils/network.py +++ b/utils/network.py @@ -252,6 +252,7 @@ def encode_mediaflow_proxy_url( destination_url: str | None = None, query_params: dict | None = None, request_headers: dict | None = None, + response_headers: dict | None = None, ) -> str: query_params = query_params or {} if destination_url is not None: @@ -262,6 +263,10 @@ def encode_mediaflow_proxy_url( query_params.update( {f"h_{key}": value for key, value in request_headers.items()} ) + if response_headers: + query_params.update( + {f"r_{key}": value for key, value in response_headers.items()} + ) # Encode the query parameters encoded_params = parse.urlencode(query_params, quote_via=parse.quote) diff --git a/utils/parser.py b/utils/parser.py index 6c58b06..58097e9 100644 --- a/utils/parser.py +++ b/utils/parser.py @@ -159,6 +159,11 @@ async def parse_stream_data( else "P2P" ) has_streaming_provider = user_data.streaming_provider is not None + download_via_browser = ( + has_streaming_provider + and user_data.streaming_provider.download_via_browser + and not settings.disable_download_via_browser + ) base_proxy_url_template = "" if has_streaming_provider: @@ -263,6 +268,18 @@ async def parse_stream_data( stream_list.append(Stream(**stream_details)) + if stream_list and download_via_browser: + download_url = f"{settings.host_url}/download/{secret_str}/{'series' if is_series else 'movie'}/{streams[0].meta_id}" + if is_series: + download_url += f"/{season}/{episode}" + stream_list.append( + Stream( + name=f"{settings.addon_name} {streaming_provider_name} 📥", + description="📥 Download Torrent Streams via WebBrowser", + externalUrl=download_url, + ) + ) + return stream_list @@ -405,6 +422,9 @@ async def generate_manifest(manifest: dict, user_data: UserData) -> dict: break manifest["catalogs"] = ordered_catalogs + if not user_data.enable_imdb_metadata: + # Remove IMDb metadata if disabled + resources[-1]["idPrefixes"].remove("tt") else: # If catalogs are not enabled, clear them from the manifest manifest["catalogs"] = []