Add support for MDBList Catalog support

This commit is contained in:
mhdzumair
2025-01-05 22:01:13 +05:30
parent 2826070507
commit e2d1e07086
13 changed files with 1526 additions and 9 deletions
+32 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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):
+222 -1
View File
@@ -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>
+18 -2
View File
@@ -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 () {
+833
View File
@@ -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
}));
}
});
}
}
+13 -2
View File
@@ -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() }}
{
+185
View File
@@ -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)
+3
View File
@@ -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
+1 -1
View File
@@ -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
+10
View File
@@ -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)
+13
View File
@@ -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.",
)