mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 11:04:07 +00:00
Switched api endpoints
Switched from api.php to episode.php
This commit is contained in:
parent
380e36635e
commit
8149b98bf5
2 changed files with 308 additions and 35 deletions
|
|
@ -19,6 +19,7 @@ METADATA_CACHE_TIMEOUT = 60 * 60 * 6 # 6 hours
|
|||
TMDB_VALIDATION_CACHE_SUCCESS_TTL = 60 * 30 # 30 minutes
|
||||
TMDB_VALIDATION_CACHE_FAILURE_TTL = 60 * 5 # 5 minutes
|
||||
MOVIEDB_SEARCH_URL = "https://movie-db.org/api.php"
|
||||
MOVIEDB_EPISODE_URL = "https://movie-db.org/episode.php"
|
||||
MOVIEDB_HEALTH_CACHE_KEY = "movie-db:health"
|
||||
MOVIEDB_HEALTH_SUCCESS_TTL = 60 * 15 # 15 minutes
|
||||
MOVIEDB_HEALTH_FAILURE_TTL = 60 * 2 # 2 minutes
|
||||
|
|
@ -30,6 +31,9 @@ _REQUESTS_SESSION.mount(
|
|||
tmdb.REQUESTS_SESSION = _REQUESTS_SESSION
|
||||
|
||||
|
||||
_MOVIEDB_SEARCH_CACHE: dict[tuple[str, str], tuple[dict | None, str | None]] = {}
|
||||
|
||||
|
||||
def validate_tmdb_api_key(api_key: str, *, use_cache: bool = True) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Validate a TMDB API key by calling the configuration endpoint.
|
||||
|
|
@ -329,29 +333,106 @@ def _parse_release_year(value: Optional[str]) -> Optional[int]:
|
|||
return None
|
||||
|
||||
|
||||
def fetch_movie_db_metadata(media_item: MediaItem) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
if media_item.item_type not in {
|
||||
MediaItem.TYPE_MOVIE,
|
||||
MediaItem.TYPE_SHOW,
|
||||
MediaItem.TYPE_EPISODE,
|
||||
}:
|
||||
return None, "Movie-DB fallback does not support this media type."
|
||||
def _php_value_from_string(value: str):
|
||||
lowered = value.lower()
|
||||
if lowered == "true":
|
||||
return True
|
||||
if lowered == "false":
|
||||
return False
|
||||
if lowered in {"null", ""}:
|
||||
return None
|
||||
try:
|
||||
if "." in value:
|
||||
return float(value)
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def _convert_numeric_container(value):
|
||||
if isinstance(value, dict):
|
||||
converted = {k: _convert_numeric_container(v) for k, v in value.items()}
|
||||
numeric = True
|
||||
indices: list[int] = []
|
||||
for key in converted.keys():
|
||||
try:
|
||||
indices.append(int(key))
|
||||
except (TypeError, ValueError):
|
||||
numeric = False
|
||||
break
|
||||
if numeric:
|
||||
return [converted[str(idx)] for idx in sorted(indices)]
|
||||
return converted
|
||||
if isinstance(value, list):
|
||||
return [_convert_numeric_container(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _parse_php_array_lines(lines: list[str], idx: int = 0):
|
||||
def parse_body(current_idx: int):
|
||||
result: dict[str, Any] = {}
|
||||
while current_idx < len(lines):
|
||||
token = lines[current_idx]
|
||||
if token == ")":
|
||||
return result, current_idx + 1
|
||||
if token.startswith("[") and "] =>" in token:
|
||||
key_part, value_part = token.split("] =>", 1)
|
||||
key = key_part[1:].strip()
|
||||
value = value_part.strip()
|
||||
if value == "Array":
|
||||
sub_value, next_idx = _parse_php_array_lines(lines, current_idx + 1)
|
||||
result[key] = sub_value
|
||||
current_idx = next_idx
|
||||
continue
|
||||
result[key] = _php_value_from_string(value)
|
||||
current_idx += 1
|
||||
return result, current_idx
|
||||
|
||||
if idx >= len(lines):
|
||||
return {}, idx
|
||||
|
||||
token = lines[idx]
|
||||
if token.lower() == "array":
|
||||
idx += 1
|
||||
if idx < len(lines) and lines[idx] == "(":
|
||||
idx += 1
|
||||
parsed, idx = parse_body(idx)
|
||||
return _convert_numeric_container(parsed), idx
|
||||
return {}, idx
|
||||
|
||||
|
||||
def _parse_movie_db_php_response(payload: str) -> Optional[dict | list]:
|
||||
if not payload:
|
||||
return None
|
||||
stripped = payload.replace("<pre>", "").replace("</pre>", "").strip()
|
||||
if not stripped.lower().startswith("array"):
|
||||
return None
|
||||
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
|
||||
parsed, _ = _parse_php_array_lines(lines, 0)
|
||||
return parsed
|
||||
|
||||
|
||||
def _movie_db_search(media_type: str, title: str) -> tuple[Optional[dict], Optional[str]]:
|
||||
normalized_key = (media_type, (title or "").strip().lower())
|
||||
if normalized_key in _MOVIEDB_SEARCH_CACHE:
|
||||
return _MOVIEDB_SEARCH_CACHE[normalized_key]
|
||||
|
||||
media_type = "movie" if media_item.is_movie else "tv"
|
||||
try:
|
||||
response = _REQUESTS_SESSION.get(
|
||||
MOVIEDB_SEARCH_URL,
|
||||
params={"type": media_type, "query": media_item.title},
|
||||
params={"type": media_type, "query": title},
|
||||
timeout=6,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
message = f"Movie-DB search failed: {exc}"
|
||||
logger.warning(message)
|
||||
_MOVIEDB_SEARCH_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
if response.status_code != 200:
|
||||
message = f"Movie-DB search returned status {response.status_code}."
|
||||
logger.debug(message)
|
||||
_MOVIEDB_SEARCH_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
try:
|
||||
|
|
@ -359,6 +440,7 @@ def fetch_movie_db_metadata(media_item: MediaItem) -> Tuple[Optional[Dict[str, A
|
|||
except ValueError:
|
||||
message = "Movie-DB search returned an unexpected response format."
|
||||
logger.debug(message)
|
||||
_MOVIEDB_SEARCH_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
results = []
|
||||
|
|
@ -370,28 +452,189 @@ def fetch_movie_db_metadata(media_item: MediaItem) -> Tuple[Optional[Dict[str, A
|
|||
if not results:
|
||||
message = "Movie-DB search returned no matches."
|
||||
logger.debug(message)
|
||||
_MOVIEDB_SEARCH_CACHE[normalized_key] = (None, message)
|
||||
return None, message
|
||||
|
||||
candidate = results[0]
|
||||
release_date = candidate.get("release_date") or candidate.get("first_air_date")
|
||||
release_year = _parse_release_year(release_date) or media_item.release_year
|
||||
_MOVIEDB_SEARCH_CACHE[normalized_key] = (candidate, None)
|
||||
return candidate, None
|
||||
|
||||
|
||||
def _movie_db_fetch_episode_details(tv_id: Any, season: int, episode: int) -> tuple[Optional[dict], Optional[str]]:
|
||||
try:
|
||||
response = _REQUESTS_SESSION.get(
|
||||
MOVIEDB_EPISODE_URL,
|
||||
params={"tvId": tv_id, "season": season, "episode": episode},
|
||||
timeout=6,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
message = f"Movie-DB episode lookup failed: {exc}"
|
||||
logger.warning(message)
|
||||
return None, message
|
||||
|
||||
if response.status_code != 200:
|
||||
message = f"Movie-DB episode endpoint returned status {response.status_code}."
|
||||
logger.debug(message)
|
||||
return None, message
|
||||
|
||||
parsed = _parse_movie_db_php_response(response.text)
|
||||
if not parsed or not isinstance(parsed, dict):
|
||||
message = "Movie-DB episode endpoint returned an unexpected payload."
|
||||
logger.debug(message)
|
||||
return None, message
|
||||
|
||||
return _convert_numeric_container(parsed), None
|
||||
|
||||
|
||||
def fetch_movie_db_metadata(media_item: MediaItem) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
if media_item.item_type not in {
|
||||
MediaItem.TYPE_MOVIE,
|
||||
MediaItem.TYPE_SHOW,
|
||||
MediaItem.TYPE_EPISODE,
|
||||
}:
|
||||
return None, "Movie-DB fallback does not support this media type."
|
||||
|
||||
if media_item.item_type == MediaItem.TYPE_MOVIE:
|
||||
candidate, message = _movie_db_search("movie", media_item.title)
|
||||
if not candidate:
|
||||
return None, message
|
||||
|
||||
release_date = candidate.get("release_date") or candidate.get("first_air_date")
|
||||
release_year = _parse_release_year(release_date) or media_item.release_year
|
||||
metadata = _to_serializable(
|
||||
{
|
||||
"tmdb_id": str(candidate.get("id") or media_item.tmdb_id or ""),
|
||||
"title": candidate.get("title")
|
||||
or candidate.get("name")
|
||||
or media_item.title,
|
||||
"synopsis": candidate.get("overview") or media_item.synopsis,
|
||||
"release_year": release_year,
|
||||
"poster": build_image_url(candidate.get("poster_path")),
|
||||
"backdrop": build_image_url(candidate.get("backdrop_path")),
|
||||
"runtime_minutes": candidate.get("runtime"),
|
||||
"genres": [],
|
||||
"cast": [],
|
||||
"crew": [],
|
||||
"source": "movie-db",
|
||||
"raw": candidate,
|
||||
}
|
||||
)
|
||||
return metadata, None
|
||||
|
||||
if media_item.item_type == MediaItem.TYPE_SHOW:
|
||||
candidate, message = _movie_db_search("tv", media_item.title)
|
||||
if not candidate:
|
||||
return None, message
|
||||
tv_id = candidate.get("id")
|
||||
release_date = candidate.get("first_air_date") or candidate.get("release_date")
|
||||
release_year = _parse_release_year(release_date) or media_item.release_year
|
||||
metadata = _to_serializable(
|
||||
{
|
||||
"tmdb_id": str(candidate.get("id") or media_item.tmdb_id or ""),
|
||||
"title": candidate.get("name") or candidate.get("title") or media_item.title,
|
||||
"synopsis": candidate.get("overview") or media_item.synopsis,
|
||||
"release_year": release_year,
|
||||
"poster": build_image_url(candidate.get("poster_path")),
|
||||
"backdrop": build_image_url(candidate.get("backdrop_path")),
|
||||
"runtime_minutes": candidate.get("episode_run_time"),
|
||||
"genres": [],
|
||||
"cast": [],
|
||||
"crew": [],
|
||||
"movie_db_tv_id": str(tv_id) if tv_id is not None else None,
|
||||
"source": "movie-db",
|
||||
"raw": candidate,
|
||||
}
|
||||
)
|
||||
return metadata, None
|
||||
|
||||
# Episode fallback
|
||||
parent_series = media_item.parent or MediaItem.objects.filter(pk=media_item.parent_id).first()
|
||||
if not parent_series:
|
||||
return None, "Unable to determine parent series for episode."
|
||||
|
||||
tv_id = None
|
||||
if isinstance(parent_series.metadata, dict):
|
||||
tv_id = parent_series.metadata.get("movie_db_tv_id")
|
||||
|
||||
if not tv_id:
|
||||
series_candidate, message = _movie_db_search("tv", parent_series.title)
|
||||
if not series_candidate:
|
||||
return None, message
|
||||
tv_id = series_candidate.get("id")
|
||||
if isinstance(parent_series.metadata, dict):
|
||||
updated_metadata = dict(parent_series.metadata)
|
||||
else:
|
||||
updated_metadata = {}
|
||||
updated_metadata["movie_db_tv_id"] = str(tv_id) if tv_id is not None else None
|
||||
parent_series.metadata = updated_metadata
|
||||
parent_series.save(update_fields=["metadata", "updated_at"])
|
||||
|
||||
if tv_id is None:
|
||||
return None, "Movie-DB did not return a tvId for this series."
|
||||
|
||||
if media_item.season_number is None or media_item.episode_number is None:
|
||||
return None, "Episode numbers are required for Movie-DB fallback."
|
||||
|
||||
try:
|
||||
season_number = int(media_item.season_number)
|
||||
episode_number = int(media_item.episode_number)
|
||||
except (TypeError, ValueError):
|
||||
return None, "Episode numbers are required for Movie-DB fallback."
|
||||
|
||||
episode_details, message = _movie_db_fetch_episode_details(tv_id, season_number, episode_number)
|
||||
if not episode_details:
|
||||
return None, message
|
||||
|
||||
release_year = _parse_release_year(episode_details.get("air_date")) or media_item.release_year
|
||||
guest_stars = episode_details.get("guest_stars") or []
|
||||
crew_entries = episode_details.get("crew") or []
|
||||
|
||||
cast: list[dict[str, Any]] = []
|
||||
for star in guest_stars:
|
||||
if not isinstance(star, dict):
|
||||
continue
|
||||
name = star.get("name") or star.get("original_name")
|
||||
if not name:
|
||||
continue
|
||||
cast.append(
|
||||
{
|
||||
"name": name,
|
||||
"character": star.get("character"),
|
||||
"profile_url": build_image_url(star.get("profile_path")),
|
||||
}
|
||||
)
|
||||
|
||||
crew: list[dict[str, Any]] = []
|
||||
for member in crew_entries:
|
||||
if not isinstance(member, dict):
|
||||
continue
|
||||
name = member.get("name") or member.get("original_name")
|
||||
if not name:
|
||||
continue
|
||||
crew.append(
|
||||
{
|
||||
"name": name,
|
||||
"job": member.get("job"),
|
||||
"department": member.get("department"),
|
||||
"profile_url": build_image_url(member.get("profile_path")),
|
||||
}
|
||||
)
|
||||
|
||||
metadata = _to_serializable(
|
||||
{
|
||||
"tmdb_id": str(candidate.get("id") or media_item.tmdb_id or ""),
|
||||
"title": candidate.get("title")
|
||||
or candidate.get("name")
|
||||
or media_item.title,
|
||||
"synopsis": candidate.get("overview") or media_item.synopsis,
|
||||
"tmdb_id": str(episode_details.get("id") or media_item.tmdb_id or ""),
|
||||
"title": episode_details.get("name") or media_item.title,
|
||||
"synopsis": episode_details.get("overview") or media_item.synopsis,
|
||||
"release_year": release_year,
|
||||
"poster": build_image_url(candidate.get("poster_path")),
|
||||
"backdrop": build_image_url(candidate.get("backdrop_path")),
|
||||
"runtime_minutes": candidate.get("runtime"),
|
||||
"poster": build_image_url(episode_details.get("still_path")),
|
||||
"backdrop": build_image_url(episode_details.get("still_path")),
|
||||
"runtime_minutes": episode_details.get("runtime"),
|
||||
"genres": [],
|
||||
"cast": [],
|
||||
"crew": [],
|
||||
"cast": cast,
|
||||
"crew": crew,
|
||||
"movie_db_tv_id": str(tv_id) if tv_id is not None else None,
|
||||
"source": "movie-db",
|
||||
"raw": candidate,
|
||||
"raw": episode_details,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,14 +18,30 @@ EPISODE_PATTERN = re.compile(
|
|||
(?:[\.\-_\s]*e?(?P<episode_end>\d{1,4}))?
|
||||
)
|
||||
|
|
||||
(?:
|
||||
(?P<abs_episode>\d{1,4})
|
||||
)
|
||||
(?P<abs_episode>\d{1,4})
|
||||
""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _safe_number(value):
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
for entry in value:
|
||||
normalized = _safe_number(entry)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
return None
|
||||
if value in (None, "", []):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _strip_extension(file_name: str) -> str:
|
||||
base, _ext = os.path.splitext(file_name)
|
||||
return base
|
||||
|
|
@ -83,29 +99,40 @@ def classify_media_entry(
|
|||
series_name = _ensure_series_name_from_path(relative_path, default=guess_data.get("title") or base_name)
|
||||
|
||||
if library.library_type == Library.LIBRARY_TYPE_SHOWS:
|
||||
season_number = guess_data.get("season")
|
||||
episode_number = guess_data.get("episode")
|
||||
season_number = _safe_number(guess_data.get("season"))
|
||||
episode_number = _safe_number(guess_data.get("episode"))
|
||||
|
||||
if season_number is None:
|
||||
season_number = _season_from_segments(segments)
|
||||
|
||||
if episode_number is None:
|
||||
episode_number = guess_data.get("episode_list", [None])[0] if guess_data.get("episode_list") else None
|
||||
episode_number = _safe_number(guess_data.get("episode_list"))
|
||||
|
||||
episode_list_raw = guess_data.get("episode_list") or []
|
||||
if not episode_list_raw and isinstance(guess_data.get("episode"), (list, tuple)):
|
||||
episode_list_raw = list(guess_data.get("episode") or [])
|
||||
episode_list = []
|
||||
for item in episode_list_raw:
|
||||
number = _safe_number(item)
|
||||
if number is not None and number not in episode_list:
|
||||
episode_list.append(number)
|
||||
|
||||
if episode_number is None:
|
||||
match = EPISODE_PATTERN.search(file_name)
|
||||
if match:
|
||||
episode_number = match.group("episode")
|
||||
if episode_number:
|
||||
try:
|
||||
episode_number = int(episode_number)
|
||||
except (TypeError, ValueError):
|
||||
episode_number = None
|
||||
episode_number = _safe_number(match.group("episode") or match.group("abs_episode"))
|
||||
if season_number is None and match.group("season"):
|
||||
try:
|
||||
season_number = int(match.group("season"))
|
||||
except (TypeError, ValueError):
|
||||
season_number = None
|
||||
if match.group("episode_end"):
|
||||
end_number = _safe_number(match.group("episode_end"))
|
||||
if end_number:
|
||||
if end_number not in episode_list:
|
||||
episode_list.append(end_number)
|
||||
if episode_number is not None and episode_number not in episode_list:
|
||||
episode_list.append(episode_number)
|
||||
|
||||
detected_type = MediaItem.TYPE_EPISODE if season_number is not None and episode_number is not None else MediaItem.TYPE_SHOW
|
||||
normalized_title = normalize_title(series_name or base_name)
|
||||
|
|
@ -114,6 +141,10 @@ def classify_media_entry(
|
|||
data["season"] = season_number
|
||||
if episode_number is not None:
|
||||
data["episode"] = episode_number
|
||||
if episode_list:
|
||||
episode_list_sorted = sorted({n for n in episode_list if isinstance(n, int)})
|
||||
if episode_list_sorted:
|
||||
data["episode_list"] = episode_list_sorted
|
||||
if series_name:
|
||||
data["series"] = series_name
|
||||
|
||||
|
|
@ -137,4 +168,3 @@ def classify_media_entry(
|
|||
year=guess_data.get("year"),
|
||||
data=data,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue