dfd
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
from typing import Any, Dict, List, Optional, Type, cast
|
||||
import requests
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from SYS.logger import log, debug
|
||||
|
||||
@@ -13,6 +15,12 @@ except ImportError: # pragma: no cover - optional
|
||||
musicbrainzngs = None
|
||||
|
||||
|
||||
try: # Optional dependency
|
||||
import yt_dlp # type: ignore
|
||||
except ImportError: # pragma: no cover - optional
|
||||
yt_dlp = None
|
||||
|
||||
|
||||
class MetadataProvider(ABC):
|
||||
"""Base class for metadata providers (music, movies, books, etc.)."""
|
||||
|
||||
@@ -351,6 +359,157 @@ class MusicBrainzMetadataProvider(MetadataProvider):
|
||||
return tags
|
||||
|
||||
|
||||
class YtdlpMetadataProvider(MetadataProvider):
|
||||
"""Metadata provider that extracts tags from a supported URL using yt-dlp.
|
||||
|
||||
This does NOT download media; it only probes metadata.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str: # type: ignore[override]
|
||||
return "ytdlp"
|
||||
|
||||
def _extract_info(self, url: str) -> Optional[Dict[str, Any]]:
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
return None
|
||||
|
||||
# Prefer Python module when available.
|
||||
if yt_dlp is not None:
|
||||
try:
|
||||
opts: Any = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"skip_download": True,
|
||||
"noprogress": True,
|
||||
"socket_timeout": 15,
|
||||
"retries": 1,
|
||||
"playlist_items": "1-10",
|
||||
}
|
||||
with yt_dlp.YoutubeDL(opts) as ydl: # type: ignore[attr-defined]
|
||||
info = ydl.extract_info(url, download=False)
|
||||
return cast(Dict[str, Any], info) if isinstance(info, dict) else None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to CLI.
|
||||
try:
|
||||
cmd = [
|
||||
"yt-dlp",
|
||||
"-J",
|
||||
"--no-warnings",
|
||||
"--skip-download",
|
||||
"--playlist-items",
|
||||
"1-10",
|
||||
url,
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
payload = (proc.stdout or "").strip()
|
||||
if not payload:
|
||||
return None
|
||||
data = json.loads(payload)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
url = (query or "").strip()
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return []
|
||||
|
||||
info = self._extract_info(url)
|
||||
if not isinstance(info, dict):
|
||||
return []
|
||||
|
||||
upload_date = str(info.get("upload_date") or "")
|
||||
release_date = str(info.get("release_date") or "")
|
||||
year = (release_date or upload_date)[:4] if (release_date or upload_date) else ""
|
||||
|
||||
# Provide basic columns for the standard metadata selection table.
|
||||
# NOTE: This is best-effort; many extractors don't provide artist/album.
|
||||
artist = (
|
||||
info.get("artist")
|
||||
or info.get("uploader")
|
||||
or info.get("channel")
|
||||
or ""
|
||||
)
|
||||
album = info.get("album") or info.get("playlist_title") or ""
|
||||
title = info.get("title") or ""
|
||||
|
||||
return [
|
||||
{
|
||||
"title": title,
|
||||
"artist": str(artist or ""),
|
||||
"album": str(album or ""),
|
||||
"year": str(year or ""),
|
||||
"provider": self.name,
|
||||
"url": url,
|
||||
"raw": info,
|
||||
}
|
||||
]
|
||||
|
||||
def to_tags(self, item: Dict[str, Any]) -> List[str]:
|
||||
raw = item.get("raw")
|
||||
if not isinstance(raw, dict):
|
||||
return super().to_tags(item)
|
||||
|
||||
tags: List[str] = []
|
||||
try:
|
||||
from metadata import extract_ytdlp_tags
|
||||
except Exception:
|
||||
extract_ytdlp_tags = None # type: ignore[assignment]
|
||||
|
||||
if extract_ytdlp_tags:
|
||||
try:
|
||||
tags.extend(extract_ytdlp_tags(raw))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Subtitle availability tags
|
||||
def _langs(value: Any) -> List[str]:
|
||||
if not isinstance(value, dict):
|
||||
return []
|
||||
out: List[str] = []
|
||||
for k in value.keys():
|
||||
if isinstance(k, str) and k.strip():
|
||||
out.append(k.strip().lower())
|
||||
return sorted(set(out))
|
||||
|
||||
# If this is a playlist container, subtitle/captions are usually per-entry.
|
||||
info_for_subs: Dict[str, Any] = raw
|
||||
entries = raw.get("entries")
|
||||
if isinstance(entries, list) and entries:
|
||||
first = entries[0]
|
||||
if isinstance(first, dict):
|
||||
info_for_subs = first
|
||||
|
||||
for lang in _langs(info_for_subs.get("subtitles")):
|
||||
tags.append(f"subs:{lang}")
|
||||
for lang in _langs(info_for_subs.get("automatic_captions")):
|
||||
tags.append(f"subs_auto:{lang}")
|
||||
|
||||
# Always include source tag for parity with other providers.
|
||||
tags.append(f"source:{self.name}")
|
||||
|
||||
# Dedup case-insensitively, preserve order.
|
||||
seen = set()
|
||||
out: List[str] = []
|
||||
for t in tags:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
s = t.strip()
|
||||
if not s:
|
||||
continue
|
||||
k = s.lower()
|
||||
if k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
# Registry ---------------------------------------------------------------
|
||||
|
||||
_METADATA_PROVIDERS: Dict[str, Type[MetadataProvider]] = {
|
||||
@@ -359,6 +518,7 @@ _METADATA_PROVIDERS: Dict[str, Type[MetadataProvider]] = {
|
||||
"googlebooks": GoogleBooksMetadataProvider,
|
||||
"google": GoogleBooksMetadataProvider,
|
||||
"musicbrainz": MusicBrainzMetadataProvider,
|
||||
"ytdlp": YtdlpMetadataProvider,
|
||||
}
|
||||
|
||||
|
||||
@@ -370,7 +530,7 @@ def list_metadata_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str
|
||||
availability: Dict[str, bool] = {}
|
||||
for name, cls in _METADATA_PROVIDERS.items():
|
||||
try:
|
||||
provider = cls(config)
|
||||
_ = cls(config)
|
||||
# Basic availability check: perform lightweight validation if defined
|
||||
availability[name] = True
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user