diff --git a/api/main.py b/api/main.py index d86e34c..709e43b 100644 --- a/api/main.py +++ b/api/main.py @@ -138,13 +138,22 @@ async def configure( if user_data.streaming_provider: user_data.streaming_provider.password = None + # Prepare catalogs based on user preferences or default order + sorted_catalogs = sorted( + zip(const.CATALOG_ID_DATA, const.CATALOG_NAME_DATA), + key=lambda x: user_data.selected_catalogs.index(x[0]) + if x[0] in user_data.selected_catalogs + else len(user_data.selected_catalogs), + ) + return TEMPLATES.TemplateResponse( "html/configure.html", { "request": request, "user_data": user_data.model_dump(), - "catalogs": zip(const.CATALOG_ID_DATA, const.CATALOG_NAME_DATA), + "catalogs": sorted_catalogs, "resolutions": const.RESOLUTIONS, + "sorting_options": const.TORRENT_SORTING_PRIORITY, }, ) diff --git a/db/schemas.py b/db/schemas.py index 497e706..924146a 100644 --- a/db/schemas.py +++ b/db/schemas.py @@ -95,6 +95,9 @@ class UserData(BaseModel): selected_resolutions: list[str | None] = Field(default=const.RESOLUTIONS) enable_catalogs: bool = True max_size: int | str | float = math.inf + max_streams_per_resolution: int = 3 + show_full_torrent_name: bool = False + torrent_sorting_priority: list[str] = Field(default=const.TORRENT_SORTING_PRIORITY) @model_validator(mode="after") def validate_selected_resolutions(self) -> "UserData": @@ -118,6 +121,13 @@ class UserData(BaseModel): return int(v) raise ValueError("Invalid max_size") + @field_validator("torrent_sorting_priority", mode="after") + def validate_torrent_sorting_priority(cls, v): + for priority in v: + if priority not in const.TORRENT_SORTING_PRIORITY: + raise ValueError("Invalid priority") + return v + class Config: extra = "ignore" diff --git a/resources/html/configure.html b/resources/html/configure.html index d9dde8e..402ee20 100644 --- a/resources/html/configure.html +++ b/resources/html/configure.html @@ -107,8 +107,8 @@
-
Select Catalogs:
+
Select & Arrange Catalogs:
{% for catalog in catalogs %}
@@ -153,7 +153,9 @@
-

Streaming Filters

+

Streaming Preferences +


@@ -179,8 +181,8 @@
-
Select File Size Filter:
+
Set File Size Filter:
@@ -189,6 +191,56 @@
+ +
+
Select & Arrange Sorting Priority:
+ +
+ {% for sorting_option in sorting_options %} +
+
+ + +
+
+ {% endfor %} +
+ +
+ + +
+ + +
+ + +
+
Torrent Stream Display option:
+
+ + +
+
+ + +
+
+
diff --git a/resources/js/config_script.js b/resources/js/config_script.js index 5f50b9d..9453952 100644 --- a/resources/js/config_script.js +++ b/resources/js/config_script.js @@ -222,6 +222,7 @@ document.getElementById('configForm').addEventListener('submit', async function const provider = document.getElementById('provider_service').value; let isValid = true; + const maxStreamsPerResolution = document.getElementById('maxStreamsPerResolution').value; const validateInput = (elementId, condition) => { const element = document.getElementById(elementId); @@ -243,6 +244,9 @@ document.getElementById('configForm').addEventListener('submit', async function validateInput('password', document.getElementById('password').value); } + // Validation for Max Streams Per Resolution + validateInput('maxStreamsPerResolution', !isNaN(maxStreamsPerResolution) && maxStreamsPerResolution > 0); + if (isValid) { let streamingProviderData = {}; if (provider) { @@ -268,6 +272,12 @@ document.getElementById('configForm').addEventListener('submit', async function // Check if the max size is set to the slider's max value, which we treat as 'infinity' const maxSizeBytes = maxSizeValue === maxSize ? 'inf' : maxSizeValue; + // Capturing data from the Stream Sorting Priority + const selectedSortingOptions = Array.from(document.querySelectorAll('#streamSortOrder .form-check-input:checked')).map(el => el.value); + + // Capturing the selected Torrent Display Option + const torrentDisplayOption = document.querySelector('input[name="torrentDisplayOption"]:checked').value; + const userData = { streaming_provider: streamingProviderData, @@ -275,6 +285,9 @@ document.getElementById('configForm').addEventListener('submit', async function selected_resolutions: Array.from(document.querySelectorAll('input[name="selected_resolutions"]:checked')).map(el => el.value), enable_catalogs: document.getElementById('enable_catalogs').checked, max_size: maxSizeBytes, + max_streams_per_resolution: maxStreamsPerResolution, + torrent_sorting_priority: selectedSortingOptions, + show_full_torrent_name: torrentDisplayOption === 'fullName', }; try { @@ -317,4 +330,10 @@ document.addEventListener('DOMContentLoaded', function () { ghostClass: 'sortable-ghost', // Class for the ghost element dragClass: 'sortable-drag', // Class applied to the element being dragged }); + new Sortable(document.getElementById('streamSortOrder'), { + handle: '.sortable-list', + animation: 150, + ghostClass: 'sortable-ghost', + dragClass: 'sortable-drag', + }); }); diff --git a/utils/const.py b/utils/const.py index 236ee30..594eeca 100644 --- a/utils/const.py +++ b/utils/const.py @@ -100,3 +100,5 @@ NO_CACHE_HEADERS = { "Pragma": "no-cache", "Expires": "0", } + +TORRENT_SORTING_PRIORITY = ["cached", "size", "seeders", "created_at"] diff --git a/utils/parser.py b/utils/parser.py index a4886b9..f1d7d27 100644 --- a/utils/parser.py +++ b/utils/parser.py @@ -46,49 +46,83 @@ 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, +} + +# 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, +} async def filter_and_sort_streams( streams: list[TorrentStreams], user_data: UserData ) -> list[TorrentStreams]: - # Filter streams by selected catalogs and resolutions + # Convert to sets for faster lookups + selected_catalogs_set = set(user_data.selected_catalogs) + selected_resolutions_set = set(user_data.selected_resolutions) + + # Step 1: Filter streams by selected catalogs, resolutions, and size filtered_streams = [ stream for stream in streams - if any(catalog in stream.catalog for catalog in user_data.selected_catalogs) - and stream.resolution in user_data.selected_resolutions + if any(catalog_id in selected_catalogs_set for catalog_id in stream.catalog) + and stream.resolution in selected_resolutions_set and stream.size <= user_data.max_size ] if not filtered_streams: return [] - # 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, - } - - # Update cache status based on provider - if user_data.streaming_provider: - if cache_update_function := cache_update_functions.get( - user_data.streaming_provider.service - ): - if asyncio.iscoroutinefunction(cache_update_function): - await cache_update_function(streams, user_data) - else: - await asyncio.to_thread(cache_update_function, streams, user_data) - - # Sort streams by cache status, creation date, and size - return sorted( - filtered_streams, key=lambda x: (x.cached, x.size, x.created_at), reverse=True + # Step 2: Update cache status based on provider + cache_update_function = CACHE_UPDATE_FUNCTIONS.get( + user_data.streaming_provider.service + if user_data.streaming_provider + else "torrent" ) + if cache_update_function: + if asyncio.iscoroutinefunction(cache_update_function): + await cache_update_function(filtered_streams, user_data) + else: + await asyncio.to_thread(cache_update_function, filtered_streams, user_data) + + # Step 3: Dynamically sort streams based on user preferences + def dynamic_sort_key(stream): + return tuple( + (getattr(stream, sort_key) if getattr(stream, sort_key) is not None else 0) + for sort_key in user_data.torrent_sorting_priority + ) + + dynamically_sorted_streams = sorted( + filtered_streams, key=dynamic_sort_key, reverse=True + ) + + # Step 4: Limit streams per resolution based on user preference, after dynamic sorting + limited_streams = [] + streams_count_per_resolution = {} + for stream in dynamically_sorted_streams: + count = streams_count_per_resolution.get(stream.resolution, 0) + if count < user_data.max_streams_per_resolution: + limited_streams.append(stream) + streams_count_per_resolution[stream.resolution] = count + 1 + + return limited_streams async def parse_stream_data( @@ -102,7 +136,26 @@ async def parse_stream_data( streams = await filter_and_sort_streams(streams, user_data) + # Pre-determined values + show_full_torrent_name = user_data.show_full_torrent_name + has_streaming_provider = user_data.streaming_provider is not None + streaming_provider_name = ( + user_data.streaming_provider.service.title() + if has_streaming_provider + else "Torrent" + ) + base_proxy_url_template = ( + f"{settings.host_url}/streaming_provider/{secret_str}/stream?info_hash={{}}" + if has_streaming_provider + else None + ) + for stream_data in streams: + torrent_name = ( + stream_data.torrent_name.replace(".torrent", "").replace(".", " ") + if show_full_torrent_name + else None + ) quality_detail = " - ".join( filter( None, @@ -116,20 +169,17 @@ async def parse_stream_data( ) episode_data = stream_data.get_episode(season, episode) - - if user_data.streaming_provider: - streaming_provider = user_data.streaming_provider.service.title() - if stream_data.cached: - streaming_provider += " ⚡️" - else: - streaming_provider += " ⏳" - else: - streaming_provider = "Torrent ⏳" - - seeders = f"👤 {stream_data.seeders}" if stream_data.seeders else None + streaming_provider = ( + f"{streaming_provider_name} ⚡️" + if stream_data.cached + else f"{streaming_provider_name} ⏳" + ) + seeders = ( + f"👤 {stream_data.seeders}" if stream_data.seeders is not None else None + ) description_parts = [ - quality_detail, + torrent_name or quality_detail, convert_bytes_to_readable( episode_data.size or stream_data.size if episode_data @@ -151,13 +201,13 @@ async def parse_stream_data( "behaviorHints": {"bingeGroup": f"MediaFusion-{quality_detail}"}, } - if user_data.streaming_provider: - base_proxy_url = f"{settings.host_url}/streaming_provider/{secret_str}/stream?info_hash={stream_data.id}" + if has_streaming_provider: + base_proxy_url = base_proxy_url_template.format(stream_data.id) if episode_data: base_proxy_url += f"&season={season}&episode={episode}" stream_details["url"] = base_proxy_url - stream_details.pop("infoHash") - stream_details.pop("fileIdx") + stream_details.pop("infoHash", None) + stream_details.pop("fileIdx", None) stream_details["behaviorHints"]["notWebReady"] = True stream_list.append(Stream(**stream_details)) @@ -170,7 +220,7 @@ def convert_bytes_to_readable(size_bytes: int) -> str: Convert a size in bytes into a more human-readable format. """ if not size_bytes: - return "0B" + return "" size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") i = int(math.floor(math.log(size_bytes, 1024))) p = math.pow(1024, i) @@ -258,18 +308,7 @@ def parse_tv_stream_data(tv_data: MediaFusionTVMetaData) -> list[Stream]: async def fetch_downloaded_info_hashes(user_data: UserData) -> list[str]: - 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, - } - - if fetch_downloaded_info_hashes_function := fetch_downloaded_info_hashes_functions.get( + if fetch_downloaded_info_hashes_function := FETCH_DOWNLOADED_INFO_HASHES_FUNCTIONS.get( user_data.streaming_provider.service ): if asyncio.iscoroutinefunction(fetch_downloaded_info_hashes_function):