mirror of
https://github.com/Viren070/MediaFusion.git
synced 2025-12-01 23:21:11 +01:00
Add support for MDBList Catalog support
This commit is contained in:
+32
-2
@@ -51,6 +51,7 @@ from utils.runtime_const import (
|
||||
from utils.validation_helper import (
|
||||
validate_mediaflow_proxy_credentials,
|
||||
validate_rpdb_token,
|
||||
validate_mdblist_token,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -160,6 +161,8 @@ async def configure(
|
||||
response.headers.update(const.NO_CACHE_HEADERS)
|
||||
|
||||
configured_fields = []
|
||||
mdblist_configured_lists = []
|
||||
catalogs_data = const.CATALOG_DATA.copy()
|
||||
# Remove the password from the streaming provider
|
||||
if user_data.streaming_provider:
|
||||
user_data.streaming_provider.password = "••••••••"
|
||||
@@ -183,11 +186,21 @@ async def configure(
|
||||
user_data.rpdb_config.api_key = "••••••••"
|
||||
configured_fields.append("rpdb_api_key")
|
||||
|
||||
# Check MDBList configuration
|
||||
if user_data.mdblist_config:
|
||||
# user_data.mdblist_config.api_key = "••••••••"
|
||||
# configured_fields.append("mdblist_api_key")
|
||||
for mdblist in user_data.mdblist_config.lists:
|
||||
mdblist_configured_lists.append(mdblist.model_dump())
|
||||
catalogs_data[f"mdblist_{mdblist.catalog_type}_{mdblist.id}"] = (
|
||||
f"MDBList: {mdblist.title}"
|
||||
)
|
||||
|
||||
user_data.api_password = None
|
||||
|
||||
# Prepare catalogs based on user preferences or default order
|
||||
sorted_catalogs = sorted(
|
||||
const.CATALOG_DATA.items(),
|
||||
catalogs_data.items(),
|
||||
key=lambda x: (
|
||||
user_data.selected_catalogs.index(x[0])
|
||||
if x[0] in user_data.selected_catalogs
|
||||
@@ -226,6 +239,7 @@ async def configure(
|
||||
"disabled_providers": settings.disabled_providers,
|
||||
"configured_fields": configured_fields,
|
||||
"secret_str": secret_str,
|
||||
"mdblist_configured_lists": mdblist_configured_lists,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -316,6 +330,7 @@ async def get_catalog(
|
||||
skip: int = 0,
|
||||
genre: str = None,
|
||||
user_data: schemas.UserData = Depends(get_user_data),
|
||||
background_tasks: BackgroundTasks = BackgroundTasks(),
|
||||
):
|
||||
cache_key, is_watchlist_catalog = get_cache_key(
|
||||
catalog_type, catalog_id, skip, genre, user_data
|
||||
@@ -333,7 +348,14 @@ async def get_catalog(
|
||||
response.headers.update(const.NO_CACHE_HEADERS)
|
||||
|
||||
metas = await fetch_metas(
|
||||
catalog_type, catalog_id, genre, skip, user_data, request, is_watchlist_catalog
|
||||
catalog_type,
|
||||
catalog_id,
|
||||
genre,
|
||||
skip,
|
||||
user_data,
|
||||
request,
|
||||
is_watchlist_catalog,
|
||||
background_tasks,
|
||||
)
|
||||
|
||||
if cache_key:
|
||||
@@ -379,6 +401,7 @@ async def fetch_metas(
|
||||
user_data: schemas.UserData,
|
||||
request: Request,
|
||||
is_watchlist_catalog: bool,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> schemas.Metas:
|
||||
metas = schemas.Metas()
|
||||
|
||||
@@ -390,6 +413,12 @@ async def fetch_metas(
|
||||
)
|
||||
elif catalog_type == "events":
|
||||
metas.metas.extend(await crud.get_events_meta_list(genre, skip))
|
||||
elif catalog_id.startswith("mdblist"):
|
||||
metas.metas.extend(
|
||||
await crud.get_mdblist_meta_list(
|
||||
user_data, background_tasks, catalog_id, catalog_type, genre, skip
|
||||
)
|
||||
)
|
||||
else:
|
||||
user_ip = await get_user_public_ip(request, user_data)
|
||||
metas.metas.extend(
|
||||
@@ -655,6 +684,7 @@ async def encrypt_user_data(
|
||||
validate_provider_credentials(request, user_data),
|
||||
validate_mediaflow_proxy_credentials(user_data),
|
||||
validate_rpdb_token(user_data),
|
||||
validate_mdblist_token(user_data),
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*validation_tasks, return_exceptions=True)
|
||||
|
||||
+170
@@ -30,6 +30,7 @@ from db.models import (
|
||||
)
|
||||
from db.redis_database import REDIS_ASYNC_CLIENT
|
||||
from db.schemas import Stream, TorrentStreamsList
|
||||
from scrapers.mdblist import initialize_mdblist_scraper
|
||||
from scrapers.scraper_tasks import run_scrapers, meta_fetcher
|
||||
from streaming_providers.cache_helpers import store_cached_info_hashes
|
||||
from utils import crypto
|
||||
@@ -142,6 +143,157 @@ async def get_meta_list(
|
||||
return meta_list
|
||||
|
||||
|
||||
async def get_mdblist_meta_list(
|
||||
user_data: schemas.UserData,
|
||||
background_tasks: BackgroundTasks,
|
||||
catalog: str,
|
||||
catalog_type: str,
|
||||
genre: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 25,
|
||||
) -> list[schemas.Meta]:
|
||||
"""Get a list of metadata entries from MDBList with improved pagination handling"""
|
||||
if not user_data.mdblist_config:
|
||||
return []
|
||||
|
||||
# Extract list info from catalog ID
|
||||
_, media_type, list_id = catalog.split("_", 2)
|
||||
list_config = next(
|
||||
(
|
||||
list_item
|
||||
for list_item in user_data.mdblist_config.lists
|
||||
if str(list_item.id) == list_id and list_item.catalog_type == media_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not list_config:
|
||||
return []
|
||||
|
||||
meta_class = (
|
||||
MediaFusionMovieMetaData
|
||||
if catalog_type == "movie"
|
||||
else MediaFusionSeriesMetaData
|
||||
)
|
||||
|
||||
# Initialize MDBList scraper
|
||||
mdblist_scraper = await initialize_mdblist_scraper(user_data.mdblist_config.api_key)
|
||||
try:
|
||||
if not list_config.use_filters:
|
||||
return await mdblist_scraper.get_list_items(
|
||||
list_id=list_id,
|
||||
media_type=media_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
genre=genre,
|
||||
use_filters=False,
|
||||
)
|
||||
|
||||
# For filtered results, we need to maintain a window of filtered results
|
||||
# that's larger than the requested page to handle pagination properly
|
||||
WINDOW_SIZE = max(500, skip + limit * 2) # Maintain a larger window of results
|
||||
|
||||
# Get a window of IMDb IDs from MDBList
|
||||
imdb_ids = await mdblist_scraper.get_list_items(
|
||||
list_id=list_id,
|
||||
media_type=media_type,
|
||||
skip=0, # Always start from beginning for filtered results
|
||||
limit=WINDOW_SIZE,
|
||||
genre=genre,
|
||||
use_filters=True,
|
||||
)
|
||||
|
||||
if not imdb_ids:
|
||||
return []
|
||||
|
||||
# Prepare the filter pipeline
|
||||
match_filter = {
|
||||
"_id": {"$in": imdb_ids},
|
||||
"type": catalog_type,
|
||||
"total_streams": {"$gt": 0},
|
||||
}
|
||||
|
||||
# Handle nudity filter
|
||||
if "Disable" not in user_data.nudity_filter:
|
||||
if "Unknown" in user_data.nudity_filter:
|
||||
match_filter["parent_guide_nudity_status"] = {"$exists": True}
|
||||
elif user_data.nudity_filter:
|
||||
match_filter["parent_guide_nudity_status"] = {
|
||||
"$nin": user_data.nudity_filter
|
||||
}
|
||||
|
||||
# Handle certification filter
|
||||
if "Disable" not in user_data.certification_filter:
|
||||
cert_filters = []
|
||||
if "Unknown" in user_data.certification_filter:
|
||||
cert_filters.append(
|
||||
{"parent_guide_certificates": {"$exists": True, "$ne": []}}
|
||||
)
|
||||
filter_values = get_filter_certification_values(user_data)
|
||||
if filter_values:
|
||||
cert_filters.append(
|
||||
{"parent_guide_certificates": {"$nin": filter_values}}
|
||||
)
|
||||
if cert_filters:
|
||||
match_filter["$or"] = cert_filters
|
||||
|
||||
# Use facet to get both filtered results and total count efficiently
|
||||
pipeline = [
|
||||
{"$match": match_filter},
|
||||
{"$sort": {"last_stream_added": -1}},
|
||||
{
|
||||
"$facet": {
|
||||
"results": [{"$skip": skip}, {"$limit": limit}],
|
||||
"total": [{"$count": "count"}],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
results = await meta_class.get_motor_collection().aggregate(pipeline).to_list()
|
||||
if not results:
|
||||
return []
|
||||
|
||||
filtered_results = [
|
||||
schemas.Meta.model_validate(result) for result in results[0]["results"]
|
||||
]
|
||||
total_count = results[0]["total"][0]["count"] if results[0]["total"] else 0
|
||||
|
||||
# If we're close to the end of our window and there might be more results,
|
||||
# trigger background fetch of next batch of metadata
|
||||
if total_count >= WINDOW_SIZE - limit:
|
||||
# Get the next batch of IMDb IDs for background processing
|
||||
next_batch_ids = await mdblist_scraper.get_list_items(
|
||||
list_id=list_id,
|
||||
media_type=media_type,
|
||||
skip=WINDOW_SIZE,
|
||||
limit=100, # Fetch next batch
|
||||
genre=genre,
|
||||
use_filters=True,
|
||||
)
|
||||
|
||||
if next_batch_ids:
|
||||
# Check which IDs are missing from our database
|
||||
existing_meta_ids = set(
|
||||
doc["_id"]
|
||||
for doc in await meta_class.get_motor_collection()
|
||||
.find({"_id": {"$in": next_batch_ids}}, {"_id": 1})
|
||||
.to_list(None)
|
||||
)
|
||||
missing_ids = list(set(next_batch_ids) - existing_meta_ids)
|
||||
|
||||
if missing_ids:
|
||||
background_tasks.add_task(
|
||||
fetch_metadata,
|
||||
missing_ids,
|
||||
catalog_type,
|
||||
)
|
||||
|
||||
return filtered_results
|
||||
|
||||
finally:
|
||||
await mdblist_scraper.close()
|
||||
|
||||
|
||||
async def get_tv_meta_list(
|
||||
namespace: str, genre: Optional[str] = None, skip: int = 0, limit: int = 25
|
||||
) -> list[schemas.Meta]:
|
||||
@@ -1502,3 +1654,21 @@ async def update_metadata(imdb_ids: list[str], metadata_type: str):
|
||||
cache_keys = await REDIS_ASYNC_CLIENT.keys(f"{metadata_type}_{meta_id}_meta*")
|
||||
cache_keys.append(f"{metadata_type}_data:{meta_id}")
|
||||
await REDIS_ASYNC_CLIENT.delete(*cache_keys)
|
||||
|
||||
|
||||
async def fetch_metadata(imdb_ids: list[str], metadata_type: str):
|
||||
circuit_breaker = CircuitBreaker(
|
||||
failure_threshold=2, recovery_timeout=5, half_open_attempts=2
|
||||
)
|
||||
|
||||
async for result in batch_process_with_circuit_breaker(
|
||||
get_movie_data_by_id if metadata_type == "movie" else get_series_data_by_id,
|
||||
imdb_ids,
|
||||
5,
|
||||
rate_limit_delay=1,
|
||||
cb=circuit_breaker,
|
||||
):
|
||||
if not result:
|
||||
continue
|
||||
|
||||
logging.info(f"Stored metadata for {metadata_type} {result.id}")
|
||||
|
||||
+1
-1
@@ -282,10 +282,10 @@ class TorrentStreams(Document):
|
||||
class TVStreams(Document):
|
||||
meta_id: str
|
||||
name: str
|
||||
source: str
|
||||
url: str | None = None
|
||||
ytId: str | None = None
|
||||
externalUrl: str | None = None
|
||||
source: str
|
||||
behaviorHints: dict[str, Any] | None = None
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
country: str | None = None
|
||||
|
||||
@@ -196,6 +196,30 @@ class SortingOption(BaseModel):
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class MDBListItem(BaseModel):
|
||||
id: int = Field(alias="i")
|
||||
title: str = Field(alias="t")
|
||||
catalog_type: Literal["movie", "series"] = Field(alias="ct")
|
||||
use_filters: bool = Field(default=False, alias="uf")
|
||||
|
||||
@property
|
||||
def catalog_id(self) -> str:
|
||||
return f"mdblist_{self.catalog_type}_{self.id}"
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class MDBListConfig(BaseModel):
|
||||
api_key: str = Field(alias="ak")
|
||||
lists: list[MDBListItem] = Field(default_factory=list, alias="l")
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class UserData(BaseModel):
|
||||
streaming_provider: StreamingProvider | None = Field(default=None, alias="sp")
|
||||
selected_catalogs: list[str] = Field(alias="sc", default_factory=list)
|
||||
@@ -237,6 +261,7 @@ class UserData(BaseModel):
|
||||
live_search_streams: bool = Field(default=False, alias="lss")
|
||||
contribution_streams: bool = Field(default=False, alias="cs")
|
||||
show_language_country_flag: bool = Field(default=False, alias="slcf")
|
||||
mdblist_config: MDBListConfig | None = Field(default=None, alias="mdb")
|
||||
|
||||
@field_validator("selected_resolutions", mode="after")
|
||||
def validate_selected_resolutions(cls, v):
|
||||
|
||||
@@ -625,7 +625,8 @@
|
||||
class="btn btn-outline-primary sort-direction-toggle px-3"
|
||||
data-sort-id="{{ sorting_option.key }}"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ tooltip_text[sorting_option.key].split('|')[1 if active_sort.direction == 'asc' else 0] }}" sorting_info="{{ tooltip_text[sorting_option.key] }}"
|
||||
title="{{ tooltip_text[sorting_option.key].split('|')[1 if active_sort.direction == 'asc' else 0] }}"
|
||||
sorting_info="{{ tooltip_text[sorting_option.key] }}"
|
||||
{% if not active_sort.found %}disabled{% endif %}>
|
||||
<span class="sort-text">
|
||||
{% if active_sort.direction == 'asc' %}
|
||||
@@ -840,6 +841,133 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6>MDBList Configuration</h6>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="enable_mdblist" name="enable_mdblist"
|
||||
{% if user_data.mdblist_config %}checked{% endif %}>
|
||||
<label class="form-check-label" for="enable_mdblist">
|
||||
Enable MDBList Integration
|
||||
<i class="bi bi-question-circle" data-bs-toggle="tooltip" data-bs-placement="top"
|
||||
title="Enable to use MDBList for custom movie and TV show lists."></i>
|
||||
</label>
|
||||
</div>
|
||||
<div id="mdblist_config" style="display: {% if user_data.mdblist_config %}block{% else %}none{% endif %};">
|
||||
<!-- API Key Configuration -->
|
||||
<div class="mb-3">
|
||||
<label for="mdblist_api_key">MDBList API Key:</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="mdblist_api_key" name="mdblist_api_key"
|
||||
value="{{ user_data.mdblist_config.api_key if user_data.mdblist_config else '' }}"
|
||||
placeholder="Enter your MDBList API key">
|
||||
<button class="btn btn-outline-secondary" type="button" id="toggleMDBListApiKey">
|
||||
<i id="toggleMDBListApiKeyIcon" class="bi bi-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" type="button" id="verifyMDBListApiKey">
|
||||
<i class="bi bi-check-circle"></i> Verify
|
||||
</button>
|
||||
</div>
|
||||
<div class="invalid-feedback">
|
||||
Please enter a valid MDBList API Key.
|
||||
</div>
|
||||
<small class="form-text text-secondary">
|
||||
To get your API key:
|
||||
<ol>
|
||||
<li>Sign up/Login to <a href="https://mdblist.com" target="_blank">MDBList</a></li>
|
||||
<li>Go to <a href="https://mdblist.com/preferences/" target="_blank">API Key Preferences</a></li>
|
||||
<li>Create a new API key if you don't have one</li>
|
||||
</ol>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- List Management Section -->
|
||||
<div id="mdblist_management" style="display: none">
|
||||
<!-- Tabs for different list sources -->
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="my-lists-tab" data-bs-toggle="tab" data-bs-target="#my-lists" type="button" role="tab">
|
||||
<i class="bi bi-person"></i> My Lists
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="top-lists-tab" data-bs-toggle="tab" data-bs-target="#top-lists" type="button" role="tab">
|
||||
<i class="bi bi-trophy"></i> Top Lists
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="search-lists-tab" data-bs-toggle="tab" data-bs-target="#search-lists" type="button" role="tab">
|
||||
<i class="bi bi-search"></i> Search Lists
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="add-list-tab" data-bs-toggle="tab" data-bs-target="#add-list" type="button" role="tab">
|
||||
<i class="bi bi-plus-circle"></i> Add List
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content">
|
||||
<!-- My Lists -->
|
||||
<div class="tab-pane fade show active" id="my-lists" role="tabpanel">
|
||||
<div class="my-lists-container">
|
||||
<div class="text-center" id="my-lists-loading">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="my-lists-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Lists -->
|
||||
<div class="tab-pane fade" id="top-lists" role="tabpanel">
|
||||
<div class="top-lists-container">
|
||||
<div class="text-center" id="top-lists-loading">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="top-lists-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Lists -->
|
||||
<div class="tab-pane fade" id="search-lists" role="tabpanel">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" id="list-search-input" placeholder="Search for lists...">
|
||||
<button class="btn btn-outline-primary" type="button" id="search-lists-btn">
|
||||
<i class="bi bi-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
<div id="search-lists-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add List Manually -->
|
||||
<div class="tab-pane fade" id="add-list" role="tabpanel">
|
||||
<div class="mb-3">
|
||||
<label for="list-url-input" class="form-label">MDBList URL or ID</label>
|
||||
<input type="text" class="form-control" id="list-url-input"
|
||||
placeholder="https://mdblist.com/lists/username/list-name or list-id">
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="add-list-btn">
|
||||
<i class="bi bi-plus-circle"></i> Add List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Lists -->
|
||||
<div class="mt-4">
|
||||
<h6>Selected Lists:</h6>
|
||||
<div id="selected-lists" class="list-group">
|
||||
<!-- Selected lists will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if authentication_required %}
|
||||
@@ -921,8 +1049,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- List Item Template -->
|
||||
<template id="list-item-template">
|
||||
<div class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<div class="me-auto">
|
||||
<h6 class="mb-1 list-title"></h6>
|
||||
<div class="text-secondary list-details">
|
||||
<span class="item-count"></span>
|
||||
<span class="like-count badge bg-success"></span>
|
||||
<span class="media-type badge bg-primary"></span>
|
||||
<span class="list-owner badge bg-info"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a href="#" class="btn btn-sm btn-outline-primary list-link" target="_blank">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary add-list-btn">
|
||||
<i class="bi bi-plus-circle"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Selected List Item Template -->
|
||||
<template id="selected-list-item-template">
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="me-auto">
|
||||
<h6 class="mb-1 list-title"></h6>
|
||||
<div class="text-secondary">
|
||||
<span class="media-type badge bg-primary"></span>
|
||||
<span class="use-filters badge bg-secondary"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary edit-btn">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-btn">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Edit List Modal -->
|
||||
<div class="modal fade" id="editListModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit List</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="edit-list-id">
|
||||
<input type="hidden" id="edit-list-original-id">
|
||||
<div class="mb-3">
|
||||
<label for="edit-list-title" class="form-label">List Title</label>
|
||||
<input type="text" class="form-control" id="edit-list-title">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Media Type</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="edit-list-type" id="edit-list-type-movie" value="movie">
|
||||
<label class="form-check-label" for="edit-list-type-movie">Movies</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="edit-list-type" id="edit-list-type-show" value="series">
|
||||
<label class="form-check-label" for="edit-list-type-show">Series</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Catalog Filtering</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="edit-list-use-filters">
|
||||
<label class="form-check-label" for="edit-list-use-filters">
|
||||
Apply MediaFusion Filters
|
||||
<i class="bi bi-question-circle" data-bs-toggle="tooltip" data-bs-placement="top"
|
||||
title="When enabled, this list will only show items that match your configured filters (nudity, certification, etc.) and have available streams."></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="save-list-edit">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="configured_fields" value='{{ configured_fields|tojson|safe }}'>
|
||||
<input type="hidden" id="existing_config" value="{{ secret_str if secret_str else '' }}">
|
||||
<input type="hidden" id="mdblist_configured_lists" value='{{ mdblist_configured_lists|tojson|safe }}'>
|
||||
|
||||
<!-- JS for Bootstrap and form validation -->
|
||||
<script src="/static/js/jquery-3.6.0.min.js"></script>
|
||||
@@ -930,6 +1150,7 @@
|
||||
<script src="/static/js/bootstrap.min.js"></script>
|
||||
<script src="/static/js/Sortable.min.js"></script>
|
||||
<script src="/static/js/toastr.min.js"></script>
|
||||
<script src="/static/js/mdblist.js"></script>
|
||||
<script src="/static/js/config_script.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// ---- Variables ----
|
||||
const oAuthBtn = document.getElementById('oauth_btn');
|
||||
let currentAuthorizationToken = null;
|
||||
let mdbListUI;
|
||||
const servicesRequiringCredentials = ['pikpak',];
|
||||
const servicesRequiringUrl = ['stremthru'];
|
||||
const servicesNotNeedingDebridProxy = ['stremthru'];
|
||||
@@ -425,6 +426,14 @@ function getUserData() {
|
||||
validateInput('rpdb_api_key', rpdbConfig.api_key.trim() !== '');
|
||||
}
|
||||
|
||||
let mdblistConfig = null;
|
||||
if (document.getElementById('enable_mdblist').checked) {
|
||||
mdblistConfig = {
|
||||
api_key: document.getElementById('mdblist_api_key').value,
|
||||
lists: mdbListUI.getSelectedListsData()
|
||||
};
|
||||
}
|
||||
|
||||
// Collect and validate other user data
|
||||
const maxSizeSlider = document.getElementById('max_size_slider');
|
||||
const maxSizeValue = maxSizeSlider.value;
|
||||
@@ -489,6 +498,7 @@ function getUserData() {
|
||||
rpdb_config: rpdbConfig,
|
||||
live_search_streams: document.getElementById('liveSearchStreams').checked,
|
||||
contribution_streams: document.getElementById('contributionStreams').checked,
|
||||
mdblist_config: mdblistConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -663,6 +673,10 @@ document.getElementById('enable_rpdb').addEventListener('change', function () {
|
||||
setElementDisplay('rpdb_config', this.checked ? 'block' : 'none');
|
||||
});
|
||||
|
||||
document.getElementById('enable_mdblist').addEventListener('change', function() {
|
||||
setElementDisplay('mdblist_config', this.checked ? 'block' : 'none');
|
||||
});
|
||||
|
||||
|
||||
// Event listener for the slider
|
||||
document.getElementById('max_size_slider').addEventListener('input', updateSizeOutput);
|
||||
@@ -730,13 +744,14 @@ document.getElementById('copyBtn').addEventListener('click', async function (eve
|
||||
// ---- Initial Setup ----
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
mdbListUI = new MDBListUI();
|
||||
updateProviderFields();
|
||||
updateSizeOutput();
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
let tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
let tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
});
|
||||
|
||||
@@ -755,6 +770,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
setupPasswordToggle('webdav_password', 'toggleWebdavPassword', 'toggleWebdavPasswordIcon');
|
||||
setupPasswordToggle('mediaflow_api_password', 'toggleMediaFlowPassword', 'toggleMediaFlowPasswordIcon');
|
||||
setupPasswordToggle('rpdb_api_key', 'toggleRPDBApiKey', 'toggleRPDBApiKeyIcon');
|
||||
setupPasswordToggle('mdblist_api_key', 'toggleMDBListApiKey', 'toggleMDBListApiKeyIcon');
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
const MDBList = {
|
||||
BASE_URL: 'https://api.mdblist.com',
|
||||
|
||||
/**
|
||||
* Makes an API request with error handling
|
||||
*/
|
||||
async makeRequest(endpoint, apiKey, options = {}) {
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${MDBList.BASE_URL}${endpoint}`;
|
||||
const finalUrl = new URL(url);
|
||||
finalUrl.searchParams.append('apikey', apiKey);
|
||||
|
||||
try {
|
||||
const response = await fetch(finalUrl, options);
|
||||
|
||||
// Handle different response statuses
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
return await response.json();
|
||||
case 403:
|
||||
throw new Error('Invalid API key');
|
||||
case 404:
|
||||
throw new Error('Resource not found');
|
||||
case 429:
|
||||
throw new Error('Rate limit exceeded');
|
||||
default:
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed with status ${response.status}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Convert fetch network errors to user-friendly messages
|
||||
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||
throw new Error('Network error - please check your connection');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async verifyApiKey(apiKey) {
|
||||
if (!apiKey?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await MDBList.makeRequest('/user', apiKey);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('API key verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async getUserLists(apiKey) {
|
||||
try {
|
||||
return await MDBList.makeRequest('/lists/user', apiKey);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user lists:', error);
|
||||
throw MDBList.handleApiError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async getTopLists(apiKey) {
|
||||
try {
|
||||
return await MDBList.makeRequest('/lists/top', apiKey);
|
||||
} catch (error) {
|
||||
console.error('Error fetching top lists:', error);
|
||||
throw MDBList.handleApiError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async searchLists(apiKey, query) {
|
||||
if (!query?.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const url = '/lists/search?query=' + encodeURIComponent(query.trim());
|
||||
return await MDBList.makeRequest(url, apiKey);
|
||||
} catch (error) {
|
||||
console.error('Error searching lists:', error);
|
||||
throw MDBList.handleApiError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async getListDetails(apiKey, listId) {
|
||||
if (!listId) {
|
||||
throw new Error('List ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
return await MDBList.makeRequest(`/lists/${listId}`, apiKey);
|
||||
} catch (error) {
|
||||
console.error('Error fetching list details:', error);
|
||||
throw MDBList.handleApiError(error);
|
||||
}
|
||||
},
|
||||
|
||||
handleApiError(error) {
|
||||
const errorMessages = {
|
||||
'Invalid API key': 'Your API key is invalid or has expired. Please verify your API key.',
|
||||
'Rate limit exceeded': 'You have made too many requests. Please try again later.',
|
||||
'Network error': 'Unable to connect to MDBList. Please check your internet connection.',
|
||||
'Resource not found': 'The requested list could not be found.'
|
||||
};
|
||||
|
||||
const message = errorMessages[error.message] || 'An error occurred while fetching data from MDBList';
|
||||
return new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
// MDBList UI handler
|
||||
class MDBListUI {
|
||||
constructor() {
|
||||
this.selectedLists = new Map();
|
||||
this.editModal = new bootstrap.Modal(document.getElementById('editListModal'));
|
||||
this.apiKey = document.getElementById('mdblist_api_key').value;
|
||||
this.setupEventListeners();
|
||||
this.initializeFromConfig();
|
||||
setElementDisplay('mdblist_management', this.apiKey ? 'block' : 'none');
|
||||
}
|
||||
|
||||
// Helper functions for consistent ID management
|
||||
generateListId(baseId, mediaType) {
|
||||
return `${baseId}_${mediaType}`;
|
||||
}
|
||||
|
||||
parseListId(listId) {
|
||||
const [baseId, mediaType] = String(listId).split('_');
|
||||
return {baseId: parseInt(baseId), mediaType};
|
||||
}
|
||||
|
||||
isListAdded(baseId) {
|
||||
// Check if the list is added in either movie or show format
|
||||
const movieId = this.generateListId(baseId, 'movie');
|
||||
const showId = this.generateListId(baseId, 'show');
|
||||
|
||||
return this.selectedLists.has(movieId) || this.selectedLists.has(showId);
|
||||
}
|
||||
|
||||
initializeFromConfig() {
|
||||
const configElement = document.getElementById('mdblist_configured_lists');
|
||||
if (!configElement?.value) return;
|
||||
|
||||
try {
|
||||
const config = JSON.parse(configElement.value);
|
||||
config.forEach(list => {
|
||||
const mediaType = list.catalog_type === 'series' ? 'show' : 'movie';
|
||||
const listId = this.generateListId(list.id, mediaType);
|
||||
|
||||
this.selectedLists.set(listId, {
|
||||
id: listId,
|
||||
baseId: list.id,
|
||||
title: list.title,
|
||||
catalogType: list.catalog_type,
|
||||
useFilters: list.use_filters,
|
||||
// Preserve other properties if available
|
||||
items: list.items || 0,
|
||||
owner: list.owner || '',
|
||||
slug: list.slug || ''
|
||||
});
|
||||
});
|
||||
|
||||
this.renderSelectedLists();
|
||||
this.updateCatalogs();
|
||||
} catch (error) {
|
||||
console.error('Error initializing from config:', error);
|
||||
showNotification('Error loading configured lists', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// API key verification
|
||||
document.getElementById('verifyMDBListApiKey')?.addEventListener('click',
|
||||
() => this.verifyApiKey());
|
||||
document.getElementById('mdblist_api_key')?.addEventListener('change',
|
||||
(e) => this.handleApiKeyChange(e));
|
||||
|
||||
// Tab navigation
|
||||
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(tab => {
|
||||
tab.addEventListener('shown.bs.tab', (e) => this.handleTabChange(e.target.id));
|
||||
});
|
||||
|
||||
// Search functionality
|
||||
document.getElementById('search-lists-btn')?.addEventListener('click',
|
||||
() => this.handleSearch());
|
||||
document.getElementById('list-search-input')?.addEventListener('keypress',
|
||||
(e) => e.key === 'Enter' && this.handleSearch());
|
||||
|
||||
// Manual list addition
|
||||
document.getElementById('add-list-btn')?.addEventListener('click',
|
||||
() => this.handleManualAdd());
|
||||
|
||||
// Edit modal
|
||||
document.getElementById('save-list-edit')?.addEventListener('click',
|
||||
() => this.saveListEdit());
|
||||
}
|
||||
|
||||
async verifyApiKey() {
|
||||
const apiKeyInput = document.getElementById('mdblist_api_key');
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
|
||||
if (!apiKey) {
|
||||
showNotification('Please enter an API key', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoadingWidget('Verifying API key...');
|
||||
try {
|
||||
const isValid = await MDBList.verifyApiKey(apiKey);
|
||||
if (isValid) {
|
||||
this.apiKey = apiKey;
|
||||
setElementDisplay('mdblist_management', 'block');
|
||||
showNotification('API key verified successfully', 'success');
|
||||
await this.loadUserLists();
|
||||
} else {
|
||||
showNotification('Invalid API key', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification(error.message, 'error');
|
||||
} finally {
|
||||
hideLoadingWidget();
|
||||
}
|
||||
}
|
||||
|
||||
handleApiKeyChange(e) {
|
||||
// Update the API key
|
||||
this.apiKey = e.target?.value?.trim() || '';
|
||||
|
||||
// Toggle management section visibility based on API key presence
|
||||
setElementDisplay('mdblist_management', this.apiKey ? 'block' : 'none');
|
||||
|
||||
// Clear lists when API key is removed
|
||||
if (!this.apiKey) {
|
||||
this.selectedLists.clear();
|
||||
this.renderSelectedLists();
|
||||
this.updateCatalogs();
|
||||
}
|
||||
}
|
||||
|
||||
async handleTabChange(tabId) {
|
||||
if (!this.apiKey) return;
|
||||
|
||||
const tabContent = {
|
||||
'my-lists-tab': () => this.loadUserLists(),
|
||||
'top-lists-tab': () => this.loadTopLists()
|
||||
};
|
||||
|
||||
await (tabContent[tabId] || (() => {
|
||||
}))();
|
||||
}
|
||||
|
||||
async loadLists(fetchFunction, containerId, loadingId) {
|
||||
const container = document.getElementById(containerId);
|
||||
document.getElementById(loadingId);
|
||||
if (!container) return;
|
||||
|
||||
setElementDisplay(loadingId, 'block');
|
||||
container.innerHTML = '';
|
||||
|
||||
try {
|
||||
const lists = await fetchFunction(this.apiKey);
|
||||
setElementDisplay(loadingId, 'none');
|
||||
|
||||
if (lists?.length > 0) {
|
||||
lists.forEach(list => this.renderListItem(container, list));
|
||||
} else {
|
||||
container.innerHTML = '<div class="alert alert-info">No lists found</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading lists:', error);
|
||||
container.innerHTML = '<div class="alert alert-danger">Error loading lists</div>';
|
||||
setElementDisplay(loadingId, 'none');
|
||||
}
|
||||
}
|
||||
|
||||
async loadUserLists() {
|
||||
await this.loadLists(
|
||||
MDBList.getUserLists,
|
||||
'my-lists-content',
|
||||
'my-lists-loading'
|
||||
);
|
||||
}
|
||||
|
||||
async loadTopLists() {
|
||||
await this.loadLists(
|
||||
MDBList.getTopLists,
|
||||
'top-lists-content',
|
||||
'top-lists-loading'
|
||||
);
|
||||
}
|
||||
|
||||
async handleSearch() {
|
||||
const query = document.getElementById('list-search-input')?.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
const container = document.getElementById('search-lists-content');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '<div class="text-center"><div class="spinner-border"></div></div>';
|
||||
|
||||
try {
|
||||
const results = await MDBList.searchLists(this.apiKey, query);
|
||||
container.innerHTML = '';
|
||||
|
||||
if (results?.length > 0) {
|
||||
results.forEach(list => this.renderListItem(container, list));
|
||||
} else {
|
||||
container.innerHTML = '<div class="alert alert-info">No lists found</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching lists:', error);
|
||||
container.innerHTML = '<div class="alert alert-danger">Error searching lists</div>';
|
||||
}
|
||||
}
|
||||
|
||||
renderListItem(container, list) {
|
||||
const template = document.getElementById('list-item-template');
|
||||
if (!template) return;
|
||||
|
||||
const listItem = template.content.cloneNode(true);
|
||||
const listElement = listItem.querySelector('.list-group-item');
|
||||
|
||||
// Set basic information
|
||||
listItem.querySelector('.list-title').textContent = list.name;
|
||||
listElement.dataset.listId = list.id;
|
||||
|
||||
// Set counts and badges
|
||||
this.setItemBadges(listItem, list);
|
||||
|
||||
// Set link and owner info
|
||||
this.setListLinks(listItem, list);
|
||||
|
||||
// Setup add button
|
||||
const addBtn = listItem.querySelector('.add-list-btn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', () => this.handleAddList(list));
|
||||
if (this.isListAdded(list.id)) {
|
||||
this.markListAsAdded(addBtn);
|
||||
}
|
||||
}
|
||||
|
||||
container.appendChild(listItem);
|
||||
}
|
||||
|
||||
setItemBadges(listItem, list) {
|
||||
// Item count badge
|
||||
listItem.querySelector('.item-count').innerHTML = `
|
||||
<i class="bi bi-collection"></i>
|
||||
<span class="ms-1">${list.items || 0}</span>
|
||||
`;
|
||||
|
||||
// Likes badge
|
||||
listItem.querySelector('.like-count').innerHTML = `
|
||||
<i class="bi bi-heart${list.likes ? '-fill' : ''}"></i>
|
||||
<span class="ms-1">${list.likes || 0}</span>
|
||||
`;
|
||||
|
||||
// Media type badge
|
||||
const mediaTypeEl = listItem.querySelector('.media-type');
|
||||
const mediaIcon = list.mediatype === 'show' ? 'tv' :
|
||||
list.mediatype === 'movie' ? 'film' : 'collection-play';
|
||||
const mediaText = list.mediatype === 'show' ? 'Series' :
|
||||
list.mediatype === 'movie' ? 'Movies' : 'Mixed';
|
||||
|
||||
mediaTypeEl.innerHTML = `
|
||||
<i class="bi bi-${mediaIcon}"></i>
|
||||
<span class="ms-1">${mediaText}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
setListLinks(listItem, list) {
|
||||
const linkView = listItem.querySelector('.list-link');
|
||||
const ownerEl = listItem.querySelector('.list-owner');
|
||||
|
||||
if (list.user_name && list.slug) {
|
||||
ownerEl.textContent = `by ${list.user_name}`;
|
||||
linkView.href = `https://mdblist.com/lists/${list.user_name}/${list.slug}`;
|
||||
} else {
|
||||
ownerEl.textContent = 'My List';
|
||||
linkView.href = 'https://mdblist.com/mylists/';
|
||||
}
|
||||
|
||||
linkView.title = 'View on MDBList';
|
||||
}
|
||||
|
||||
markListAsAdded(button) {
|
||||
button.classList.replace('btn-outline-primary', 'btn-success');
|
||||
button.innerHTML = '<i class="bi bi-check"></i> Added';
|
||||
button.disabled = true;
|
||||
}
|
||||
|
||||
async handleManualAdd() {
|
||||
const input = document.getElementById('list-url-input');
|
||||
if (!input?.value) return;
|
||||
|
||||
const urlOrId = input.value.trim();
|
||||
showLoadingWidget('Fetching list details...');
|
||||
|
||||
try {
|
||||
// Extract list ID from URL if needed
|
||||
const listId = urlOrId.includes('mdblist.com/lists/') ?
|
||||
urlOrId.split('mdblist.com/lists/')[1].replace(/\/$/, '') :
|
||||
urlOrId;
|
||||
|
||||
const listDetails = await MDBList.getListDetails(this.apiKey, listId);
|
||||
if (listDetails) {
|
||||
this.handleAddList(listDetails);
|
||||
} else {
|
||||
throw new Error('Failed to fetch list details');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding list:', error);
|
||||
showNotification('Failed to fetch list details', 'error');
|
||||
} finally {
|
||||
hideLoadingWidget();
|
||||
}
|
||||
}
|
||||
|
||||
editList(list) {
|
||||
const modal = document.getElementById('editListModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Populate form fields
|
||||
const fields = {
|
||||
'edit-list-title': list.title || list.name,
|
||||
'edit-list-id': list.id,
|
||||
'edit-list-original-id': list.baseId || list.id
|
||||
};
|
||||
|
||||
Object.entries(fields).forEach(([id, value]) => {
|
||||
const element = modal.querySelector(`#${id}`);
|
||||
if (element) element.value = value;
|
||||
});
|
||||
|
||||
// Handle checkboxes
|
||||
const movieCheck = modal.querySelector('#edit-list-type-movie');
|
||||
const showCheck = modal.querySelector('#edit-list-type-show');
|
||||
const useFiltersCheck = modal.querySelector('#edit-list-use-filters');
|
||||
|
||||
if (movieCheck && showCheck) {
|
||||
const isMovie = list.catalogType === 'movie';
|
||||
const isShow = list.catalogType === 'series';
|
||||
|
||||
movieCheck.checked = isMovie || list.allowBothTypes;
|
||||
showCheck.checked = isShow || list.allowBothTypes;
|
||||
|
||||
// Handle disabled states
|
||||
const shouldDisable = !list.allowBothTypes;
|
||||
movieCheck.disabled = shouldDisable && !isMovie;
|
||||
showCheck.disabled = shouldDisable && !isShow;
|
||||
}
|
||||
|
||||
if (useFiltersCheck) {
|
||||
useFiltersCheck.checked = list.useFilters || false;
|
||||
}
|
||||
|
||||
this.editModal.show();
|
||||
}
|
||||
|
||||
|
||||
handleAddList(list) {
|
||||
const lists = Array.isArray(list) ? list : [list];
|
||||
let listId;
|
||||
let originalId;
|
||||
|
||||
// Group lists by mediatype
|
||||
const movieList = lists.find(l => l.mediatype === 'movie');
|
||||
const showList = lists.find(l => l.mediatype === 'show');
|
||||
|
||||
if (!movieList && !showList) {
|
||||
// If no specific mediatype, show edit modal to choose
|
||||
listId = lists[0].id;
|
||||
originalId = lists[0].id;
|
||||
this.editList({
|
||||
id: lists[0].id,
|
||||
name: lists[0].name,
|
||||
originalTitle: lists[0].name,
|
||||
items: lists[0].items,
|
||||
owner: lists[0].user_name,
|
||||
slug: lists[0].slug,
|
||||
likes: lists[0].likes,
|
||||
allowBothTypes: true
|
||||
});
|
||||
} else {
|
||||
// Add specific media type lists
|
||||
if (movieList) {
|
||||
listId = this.generateListId(movieList.id, 'movie');
|
||||
originalId = movieList.id;
|
||||
this.addListToSelected(listId, {
|
||||
id: listId,
|
||||
baseId: movieList.id,
|
||||
title: movieList.name,
|
||||
catalogType: 'movie',
|
||||
items: movieList.items,
|
||||
owner: movieList.user_name,
|
||||
slug: movieList.slug,
|
||||
likes: movieList.likes,
|
||||
useFilters: false // default value
|
||||
});
|
||||
}
|
||||
if (showList) {
|
||||
listId = this.generateListId(showList.id, 'show');
|
||||
originalId = showList.id;
|
||||
this.addListToSelected(listId, {
|
||||
id: listId,
|
||||
baseId: showList.id,
|
||||
title: showList.name,
|
||||
catalogType: 'series',
|
||||
items: showList.items,
|
||||
owner: showList.user_name,
|
||||
slug: showList.slug,
|
||||
likes: showList.likes,
|
||||
useFilters: false // default value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI immediately
|
||||
const addBtn = document.querySelector(`[data-list-id="${originalId}"] .add-list-btn`);
|
||||
if (addBtn) {
|
||||
this.markListAsAdded(addBtn);
|
||||
}
|
||||
}
|
||||
|
||||
addListToSelected(listId, listData) {
|
||||
// Validate required data
|
||||
if (!listId || !listData.title || !listData.catalogType) {
|
||||
console.error('Invalid list data:', listData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store with consistent format
|
||||
this.selectedLists.set(listId, {
|
||||
...listData,
|
||||
id: listId,
|
||||
baseId: this.parseListId(listId).baseId,
|
||||
useFilters: listData.useFilters || false,
|
||||
items: listData.items || 0
|
||||
});
|
||||
|
||||
// Update UI
|
||||
this.renderSelectedLists();
|
||||
this.updateCatalogs();
|
||||
}
|
||||
|
||||
saveListEdit() {
|
||||
const modal = document.getElementById('editListModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Get form values
|
||||
const baseId = parseInt(modal.querySelector('#edit-list-original-id').value);
|
||||
const title = modal.querySelector('#edit-list-title').value;
|
||||
const movieSelected = modal.querySelector('#edit-list-type-movie').checked;
|
||||
const showSelected = modal.querySelector('#edit-list-type-show').checked;
|
||||
const useFilters = modal.querySelector('#edit-list-use-filters').checked;
|
||||
|
||||
// Store current catalog positions
|
||||
const currentPositions = this.getCurrentCatalogPositions();
|
||||
|
||||
// Remove existing entries for this base ID
|
||||
this.removeList(baseId);
|
||||
|
||||
// Add new entries based on selection
|
||||
if (movieSelected) {
|
||||
const movieId = this.generateListId(baseId, 'movie');
|
||||
this.addListToSelected(movieId, {
|
||||
baseId,
|
||||
title,
|
||||
catalogType: 'movie',
|
||||
useFilters
|
||||
});
|
||||
}
|
||||
|
||||
if (showSelected) {
|
||||
const showId = this.generateListId(baseId, 'show');
|
||||
this.addListToSelected(showId, {
|
||||
baseId,
|
||||
title,
|
||||
catalogType: 'series',
|
||||
useFilters
|
||||
});
|
||||
}
|
||||
|
||||
// Restore catalog positions
|
||||
this.restoreCatalogPositions(currentPositions);
|
||||
this.editModal.hide();
|
||||
}
|
||||
|
||||
removeList(listId) {
|
||||
this.selectedLists.delete(listId);
|
||||
this.renderSelectedLists();
|
||||
this.updateCatalogs();
|
||||
|
||||
// Update the add button state in the list view
|
||||
const addBtn = document.querySelector(`[data-list-id="${this.parseListId(listId).baseId}"] .add-list-btn`);
|
||||
if (addBtn) {
|
||||
addBtn.classList.replace('btn-success', 'btn-outline-primary');
|
||||
addBtn.innerHTML = '<i class="bi bi-plus-circle"></i> Add';
|
||||
addBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentCatalogPositions() {
|
||||
const positions = new Map();
|
||||
const catalogs = document.querySelectorAll('.draggable-catalog');
|
||||
|
||||
catalogs.forEach((catalog, index) => {
|
||||
const checkbox = catalog.querySelector('input[type="checkbox"]');
|
||||
positions.set(catalog.dataset.id, {
|
||||
index,
|
||||
checked: checkbox?.checked ?? true,
|
||||
isGeneral: !catalog.dataset.id.startsWith('mdblist_')
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
restoreCatalogPositions(positions) {
|
||||
const container = document.getElementById('catalogs');
|
||||
if (!container) return;
|
||||
|
||||
const currentCatalogs = Array.from(container.querySelectorAll('.draggable-catalog'));
|
||||
|
||||
// Sort catalogs based on their stored positions
|
||||
currentCatalogs.sort((a, b) => {
|
||||
const posA = positions.get(a.dataset.id);
|
||||
const posB = positions.get(b.dataset.id);
|
||||
|
||||
// Keep non-MDBList catalogs in their original position
|
||||
if (posA?.isGeneral && !posB?.isGeneral) return -1;
|
||||
if (!posA?.isGeneral && posB?.isGeneral) return 1;
|
||||
|
||||
// Sort based on stored positions
|
||||
if (!posA) return 1;
|
||||
if (!posB) return -1;
|
||||
return posA.index - posB.index;
|
||||
});
|
||||
|
||||
// Reorder catalogs
|
||||
currentCatalogs.forEach(catalog => {
|
||||
container.appendChild(catalog);
|
||||
|
||||
// Restore checkbox state
|
||||
const position = positions.get(catalog.dataset.id);
|
||||
if (position) {
|
||||
const checkbox = catalog.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) checkbox.checked = position.checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateCatalogs() {
|
||||
const catalogsContainer = document.getElementById('catalogs');
|
||||
if (!catalogsContainer) return;
|
||||
|
||||
// Get current positions and states
|
||||
const currentState = this.getCurrentCatalogPositions();
|
||||
const currentCatalogs = Array.from(catalogsContainer.children);
|
||||
|
||||
// Store all existing catalogs with their data
|
||||
const existingCatalogs = new Map(
|
||||
currentCatalogs.map(catalog => [
|
||||
catalog.dataset.id,
|
||||
{
|
||||
element: catalog,
|
||||
isMDBList: catalog.dataset.id.startsWith('mdblist_')
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
// Clear container
|
||||
catalogsContainer.innerHTML = '';
|
||||
|
||||
// Create new MDBList catalogs
|
||||
this.selectedLists.forEach(list => {
|
||||
const catalogId = `mdblist_${list.catalogType}_${list.baseId}`;
|
||||
if (!existingCatalogs.has(catalogId)) {
|
||||
existingCatalogs.set(catalogId, {
|
||||
element: this.createCatalogElement(catalogId, list.title),
|
||||
isMDBList: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort catalogs based on their previous positions
|
||||
const sortedCatalogIds = Array.from(currentState.keys())
|
||||
.sort((a, b) => {
|
||||
const posA = currentState.get(a);
|
||||
const posB = currentState.get(b);
|
||||
return posA.index - posB.index;
|
||||
});
|
||||
|
||||
// Add catalogs in the correct order
|
||||
sortedCatalogIds.forEach(catalogId => {
|
||||
const catalogInfo = existingCatalogs.get(catalogId);
|
||||
if (!catalogInfo) return; // Skip if catalog no longer exists
|
||||
|
||||
// Skip MDBList catalogs that aren't in selectedLists
|
||||
if (catalogInfo.isMDBList) {
|
||||
const [, type, baseId] = catalogId.split('_');
|
||||
const listId = this.generateListId(baseId, type === 'series' ? 'show' : 'movie');
|
||||
if (!this.selectedLists.has(listId)) return;
|
||||
}
|
||||
|
||||
// Clone the element to avoid reference issues
|
||||
const newElement = catalogInfo.element.cloneNode(true);
|
||||
|
||||
// Restore checkbox state
|
||||
const state = currentState.get(catalogId);
|
||||
if (state) {
|
||||
const checkbox = newElement.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) checkbox.checked = state.checked;
|
||||
}
|
||||
|
||||
catalogsContainer.appendChild(newElement);
|
||||
});
|
||||
|
||||
// Add any new MDBList catalogs that weren't in the previous state
|
||||
this.selectedLists.forEach(list => {
|
||||
const catalogId = `mdblist_${list.catalogType}_${list.baseId}`;
|
||||
if (!currentState.has(catalogId)) {
|
||||
const catalogDiv = this.createCatalogElement(catalogId, list.title);
|
||||
catalogsContainer.appendChild(catalogDiv);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createCatalogElement(catalogId, title) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'col-12 col-md-6 col-lg-4 draggable-catalog';
|
||||
div.dataset.id = catalogId;
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="selected_catalogs"
|
||||
value="${catalogId}"
|
||||
id="${catalogId}"
|
||||
checked>
|
||||
<label class="form-check-label" for="${catalogId}">
|
||||
<span class="label-text">MDBList: ${title}</span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
renderSelectedLists() {
|
||||
const container = document.getElementById('selected-lists');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// Render each list separately
|
||||
this.selectedLists.forEach(list => {
|
||||
const template = document.getElementById('selected-list-item-template');
|
||||
if (!template) return;
|
||||
|
||||
const item = template.content.cloneNode(true);
|
||||
|
||||
// Set list title
|
||||
item.querySelector('.list-title').textContent = list.title;
|
||||
|
||||
// Set media type
|
||||
const mediaType = item.querySelector('.media-type');
|
||||
mediaType.textContent = list.catalogType === 'series' ? 'Series' : 'Movies';
|
||||
|
||||
// Set filters status
|
||||
const useFilters = item.querySelector('.use-filters');
|
||||
useFilters.textContent = list.useFilters ? 'Filters enabled' : 'No filters';
|
||||
|
||||
// Setup buttons with individual list data
|
||||
const editBtn = item.querySelector('.edit-btn');
|
||||
const removeBtn = item.querySelector('.remove-btn');
|
||||
|
||||
editBtn?.addEventListener('click', () => this.editList({
|
||||
...list,
|
||||
allowBothTypes: false // Since we're managing them separately
|
||||
}));
|
||||
|
||||
removeBtn?.addEventListener('click', () => this.removeList(list.id));
|
||||
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
getSelectedListsData() {
|
||||
// Group by baseId to combine movie/show pairs
|
||||
const groupedLists = new Map();
|
||||
|
||||
this.selectedLists.forEach(list => {
|
||||
const {baseId} = this.parseListId(list.id);
|
||||
if (!groupedLists.has(baseId)) {
|
||||
groupedLists.set(baseId, {
|
||||
id: baseId,
|
||||
title: list.title,
|
||||
catalog_types: new Set(),
|
||||
use_filters: list.useFilters
|
||||
});
|
||||
}
|
||||
|
||||
const group = groupedLists.get(baseId);
|
||||
group.catalog_types.add(list.catalogType);
|
||||
group.use_filters = group.use_filters || list.useFilters;
|
||||
});
|
||||
|
||||
// Convert to array format
|
||||
return Array.from(groupedLists.values()).flatMap(group => {
|
||||
const types = Array.from(group.catalog_types);
|
||||
if (types.length === 1) {
|
||||
// Single type list
|
||||
return [{
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
catalog_type: types[0],
|
||||
use_filters: group.use_filters
|
||||
}];
|
||||
} else {
|
||||
// Split into separate entries for each type
|
||||
return types.map(type => ({
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
catalog_type: type,
|
||||
use_filters: group.use_filters
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -94,8 +94,19 @@
|
||||
"arabic_series": {"type": "series", "name": "Arabic Series"}
|
||||
} %}
|
||||
{% for catalog_id in selected_catalogs %}
|
||||
{% set catalog = catalog_definitions[catalog_id] %}
|
||||
{% if catalog %}
|
||||
{% set catalog = catalog_definitions[catalog_id] | default(mdblist_data[catalog_id]) %}
|
||||
{% if "mdblist" in catalog_id %}
|
||||
{{ comma() }}
|
||||
{
|
||||
"id": "{{ catalog_id }}",
|
||||
"type": "{{ catalog.catalog_type }}",
|
||||
"name": "{{ catalog.title }}",
|
||||
"extra": [
|
||||
{"name": "skip", "isRequired": false},
|
||||
{"name": "genre", "isRequired": false, "options": ["action","anime","comedy","crime","documentary","drama","family","fantasy","history","holiday","horror","music","musical","mystery","science-fiction","short","sporting-event","superhero","suspense","thriller","war","western","animation","adventure","romance","reality","soap","news","talk-show","biography","sci-fi","sport","adult","film-noir","reality-tv","game-show","special-interest","children","home-and-garden","tv-movie","sports","eastern","disaster","donghua","sci-fi-fantasy","action-adventure","talk","war-politics","kids"]}
|
||||
]
|
||||
}
|
||||
{% elif catalog %}
|
||||
{% set genres = catalog.genres | default(genres[catalog.type]) | default([]) %}
|
||||
{{ comma() }}
|
||||
{
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
import httpx
|
||||
|
||||
from db import schemas
|
||||
from db.config import settings
|
||||
from db.redis_database import REDIS_ASYNC_CLIENT
|
||||
|
||||
|
||||
class MDBListScraper:
|
||||
def __init__(self, api_key: str):
|
||||
self.api_key = api_key
|
||||
self.base_url = "https://api.mdblist.com"
|
||||
self.client = httpx.AsyncClient(proxy=settings.requests_proxy_url, timeout=30.0)
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
|
||||
async def _fetch_list(
|
||||
self,
|
||||
list_id: str,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
genre: Optional[str] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""Fetch a list from MDBList API"""
|
||||
params = {
|
||||
"apikey": self.api_key,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"append_to_response": "genre",
|
||||
}
|
||||
if genre:
|
||||
params["filter_genre"] = genre
|
||||
|
||||
cache_key = (
|
||||
f"mdblist:list:{list_id}:limit_{limit}:offset_{offset}:{genre or 'all'}"
|
||||
)
|
||||
cached_data = await REDIS_ASYNC_CLIENT.get(cache_key)
|
||||
|
||||
if cached_data:
|
||||
return json.loads(cached_data)
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/lists/{list_id}/items", params=params
|
||||
)
|
||||
if response.status_code == 200:
|
||||
# Cache for 15 minutes
|
||||
await REDIS_ASYNC_CLIENT.set(cache_key, response.text, ex=900)
|
||||
return response.json()
|
||||
logging.error(f"Failed to fetch MDBList data: {response.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"Error fetching MDBList data: {e}")
|
||||
return None
|
||||
|
||||
def _convert_to_meta(self, item: Dict, media_type: str) -> schemas.Meta:
|
||||
"""Convert MDBList item to Meta object"""
|
||||
genres = item.get("genre", [])
|
||||
if genres and genres[0] is None:
|
||||
# clean up genre data
|
||||
genres = None
|
||||
return schemas.Meta.model_validate(
|
||||
{
|
||||
"_id": item["imdb_id"],
|
||||
"type": media_type,
|
||||
"title": item["title"],
|
||||
"year": item["release_year"],
|
||||
"genres": genres,
|
||||
"poster": f"{settings.poster_host_url}/poster/{media_type}/{item['imdb_id']}.jpg",
|
||||
}
|
||||
)
|
||||
|
||||
async def _fetch_and_process_batch(
|
||||
self,
|
||||
list_id: str,
|
||||
media_type: str,
|
||||
offset: int,
|
||||
limit: int,
|
||||
skip: int,
|
||||
genre: Optional[str],
|
||||
use_filters: bool = False,
|
||||
) -> List[schemas.Meta] | List[str]:
|
||||
"""Helper method to fetch and process a batch of results"""
|
||||
data = await self._fetch_list(list_id, 100, offset, genre)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
items = data.get("movies" if media_type == "movie" else "shows", [])
|
||||
|
||||
if not use_filters:
|
||||
# Convert directly to Meta objects
|
||||
meta_list = [
|
||||
self._convert_to_meta(item, media_type)
|
||||
for item in items
|
||||
if item.get("imdb_id", "").startswith("tt")
|
||||
]
|
||||
# Calculate the slice we need from this batch
|
||||
start_idx = skip % 100
|
||||
end_idx = start_idx + limit
|
||||
return meta_list[start_idx:end_idx]
|
||||
|
||||
return [
|
||||
item["imdb_id"]
|
||||
for item in items
|
||||
if item.get("imdb_id", "").startswith("tt")
|
||||
]
|
||||
|
||||
async def get_list_items(
|
||||
self,
|
||||
list_id: str,
|
||||
media_type: str,
|
||||
skip: int = 0,
|
||||
limit: int = 25,
|
||||
genre: Optional[str] = None,
|
||||
use_filters: bool = True,
|
||||
) -> List[schemas.Meta] | List[str]:
|
||||
"""
|
||||
Get items from a MDBList list with pagination support.
|
||||
For filtered results, keeps fetching until we have enough items after filtering.
|
||||
"""
|
||||
if not use_filters:
|
||||
# Direct return for unfiltered results
|
||||
fetch_limit = 100
|
||||
offset = (skip // fetch_limit) * fetch_limit
|
||||
return await self._fetch_and_process_batch(
|
||||
list_id, media_type, offset, limit, skip, genre, use_filters=False
|
||||
)
|
||||
|
||||
# For filtered results, we need to handle pagination differently
|
||||
cache_key = f"mdblist:filtered:{list_id}:genre_{genre or 'all'}"
|
||||
cached_filtered_ids = await REDIS_ASYNC_CLIENT.lrange(cache_key, 0, -1)
|
||||
|
||||
if cached_filtered_ids:
|
||||
# Use cached filtered results
|
||||
start_idx = skip
|
||||
end_idx = skip + limit
|
||||
return [
|
||||
cached_id.decode()
|
||||
for cached_id in cached_filtered_ids[start_idx:end_idx]
|
||||
]
|
||||
|
||||
# No cache - need to fetch and filter
|
||||
all_imdb_ids = []
|
||||
offset = 0
|
||||
batch_size = 100
|
||||
|
||||
while len(all_imdb_ids) < skip + limit:
|
||||
batch = await self._fetch_list(list_id, batch_size, offset, genre)
|
||||
if not batch:
|
||||
break
|
||||
|
||||
items = batch.get("movies" if media_type == "movie" else "shows", [])
|
||||
if not items:
|
||||
break
|
||||
|
||||
new_ids = [
|
||||
item["imdb_id"]
|
||||
for item in items
|
||||
if item.get("imdb_id", "").startswith("tt")
|
||||
]
|
||||
all_imdb_ids.extend(new_ids)
|
||||
|
||||
if len(items) < batch_size: # No more results available
|
||||
break
|
||||
|
||||
offset += batch_size
|
||||
|
||||
# Cache the full result for 15 minutes
|
||||
if all_imdb_ids:
|
||||
pipeline = await REDIS_ASYNC_CLIENT.pipeline()
|
||||
pipeline.delete(cache_key)
|
||||
pipeline.rpush(cache_key, *all_imdb_ids)
|
||||
pipeline.expire(cache_key, 900) # 15 minutes
|
||||
await pipeline.execute()
|
||||
|
||||
return all_imdb_ids[skip : skip + limit]
|
||||
|
||||
|
||||
async def initialize_mdblist_scraper(api_key: str) -> MDBListScraper:
|
||||
"""Initialize MDBList scraper with API key"""
|
||||
return MDBListScraper(api_key)
|
||||
@@ -20,6 +20,9 @@ async def check_rpdb_poster_availability(rpdb_poster_url: str) -> bool:
|
||||
) as client:
|
||||
response = await client.head(rpdb_poster_url)
|
||||
return response.status_code == 200
|
||||
except httpx.TimeoutException as exc:
|
||||
logging.error(f"Timeout for {rpdb_poster_url} - {exc}")
|
||||
return False
|
||||
except httpx.HTTPError as exc:
|
||||
logging.error(f"HTTP Exception for {exc.request.url} - {exc}")
|
||||
return False
|
||||
|
||||
@@ -256,7 +256,7 @@ class MetadataFetcher:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching from {source.value}: {e}")
|
||||
logger.exception(f"Error fetching from {source.value}: {e}")
|
||||
continue
|
||||
|
||||
return metadata
|
||||
|
||||
@@ -581,6 +581,15 @@ async def generate_manifest(user_data: UserData, genres: dict) -> dict:
|
||||
if user_data.mediaflow_config:
|
||||
addon_name += " 🕵🏼♂️"
|
||||
|
||||
mdblist_data = {}
|
||||
if user_data.mdblist_config:
|
||||
mdblist_data = {
|
||||
f"mdblist_{mdblist.catalog_type}_{mdblist.id}": mdblist.model_dump(
|
||||
include={"title", "catalog_type"}
|
||||
)
|
||||
for mdblist in user_data.mdblist_config.lists
|
||||
}
|
||||
|
||||
manifest_data = {
|
||||
"addon_name": addon_name,
|
||||
"version": settings.version,
|
||||
@@ -594,6 +603,7 @@ async def generate_manifest(user_data: UserData, genres: dict) -> dict:
|
||||
"enable_watchlist_catalogs": enable_watchlist_catalogs,
|
||||
"selected_catalogs": user_data.selected_catalogs,
|
||||
"genres": genres,
|
||||
"mdblist_data": mdblist_data,
|
||||
}
|
||||
|
||||
manifest_json = MANIFEST_TEMPLATE.render(manifest_data)
|
||||
|
||||
@@ -346,3 +346,16 @@ async def validate_rpdb_token(user_data: schemas.UserData) -> dict:
|
||||
url=validation_url,
|
||||
invalid_creds_message="Invalid RPDB API Key. Please check your RPDB API Key.",
|
||||
)
|
||||
|
||||
|
||||
async def validate_mdblist_token(user_data: schemas.UserData) -> dict:
|
||||
if not user_data.mdblist_config:
|
||||
return {"status": "success", "message": "MDBList is not enabled."}
|
||||
|
||||
validation_url = (
|
||||
f"https://api.mdblist.com/user?apikey={user_data.mdblist_config.api_key}"
|
||||
)
|
||||
return await validate_service(
|
||||
url=validation_url,
|
||||
invalid_creds_message="Invalid MDBList API Key. Please check your MDBList API Key.",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user