fdf
This commit is contained in:
810
MPV/LUA/main.lua
810
MPV/LUA/main.lua
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ This helper is intentionally minimal: one request at a time, last-write-wins.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
MEDEIA_MPV_HELPER_VERSION = "2026-03-22.6"
|
MEDEIA_MPV_HELPER_VERSION = "2026-03-23.1"
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -1197,6 +1197,17 @@ def _acquire_ipc_lock(ipc_path: str) -> Optional[Any]:
|
|||||||
lock_path = _get_ipc_lock_path(ipc_path)
|
lock_path = _get_ipc_lock_path(ipc_path)
|
||||||
fh = open(lock_path, "a+", encoding="utf-8", errors="replace")
|
fh = open(lock_path, "a+", encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
# On Windows, locking a zero-length file can fail even when no process
|
||||||
|
# actually owns the lock anymore. Prime the file with a single byte so
|
||||||
|
# stale empty lock files do not wedge future helper startups.
|
||||||
|
try:
|
||||||
|
fh.seek(0, os.SEEK_END)
|
||||||
|
if fh.tell() < 1:
|
||||||
|
fh.write("\n")
|
||||||
|
fh.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
try:
|
try:
|
||||||
import msvcrt # type: ignore
|
import msvcrt # type: ignore
|
||||||
|
|||||||
1
MPV/portable_config/script-opts/medeia-store-cache.json
Normal file
1
MPV/portable_config/script-opts/medeia-store-cache.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"choices":["local","rpi"]}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
# Medeia MPV script options
|
# Medeia MPV script options
|
||||||
store=rpi
|
store=local
|
||||||
|
|||||||
@@ -361,6 +361,44 @@ def is_download_file_url(url: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_item_access(identifier: str) -> Dict[str, Any]:
|
||||||
|
ident = str(identifier or "").strip()
|
||||||
|
if not ident:
|
||||||
|
return {"mediatype": "", "lendable": False, "collection": []}
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
try:
|
||||||
|
response = session.get(f"https://archive.org/metadata/{ident}", timeout=8)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json() if response is not None else {}
|
||||||
|
except Exception:
|
||||||
|
return {"mediatype": "", "lendable": False, "collection": []}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
session.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
meta = data.get("metadata", {}) if isinstance(data, dict) else {}
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
meta = {}
|
||||||
|
|
||||||
|
mediatype = str(meta.get("mediatype") or "").strip().lower()
|
||||||
|
collection = meta.get("collection")
|
||||||
|
values: List[str] = []
|
||||||
|
if isinstance(collection, list):
|
||||||
|
values = [str(x).strip().lower() for x in collection if str(x).strip()]
|
||||||
|
elif isinstance(collection, str) and collection.strip():
|
||||||
|
values = [collection.strip().lower()]
|
||||||
|
|
||||||
|
lendable = any(v in {"inlibrary", "lendinglibrary"} for v in values)
|
||||||
|
return {
|
||||||
|
"mediatype": mediatype,
|
||||||
|
"lendable": lendable,
|
||||||
|
"collection": values,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_download_files(identifier: str) -> List[Dict[str, Any]]:
|
def list_download_files(identifier: str) -> List[Dict[str, Any]]:
|
||||||
"""Return a sorted list of downloadable files for an IA identifier.
|
"""Return a sorted list of downloadable files for an IA identifier.
|
||||||
|
|
||||||
@@ -620,6 +658,11 @@ class InternetArchive(Provider):
|
|||||||
quiet_mode: bool,
|
quiet_mode: bool,
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
"""Generic hook for download-file to show a selection table for IA items."""
|
"""Generic hook for download-file to show a selection table for IA items."""
|
||||||
|
try:
|
||||||
|
if self._should_delegate_borrow(str(url or "")):
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
from SYS.field_access import get_field as sh_get_field
|
from SYS.field_access import get_field as sh_get_field
|
||||||
return maybe_show_formats_table(
|
return maybe_show_formats_table(
|
||||||
raw_urls=[url] if url else [],
|
raw_urls=[url] if url else [],
|
||||||
@@ -638,6 +681,72 @@ class InternetArchive(Provider):
|
|||||||
self._collection = conf.get("collection") or conf.get("default_collection")
|
self._collection = conf.get("collection") or conf.get("default_collection")
|
||||||
self._mediatype = conf.get("mediatype") or conf.get("default_mediatype")
|
self._mediatype = conf.get("mediatype") or conf.get("default_mediatype")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_delegate_borrow(url: str) -> bool:
|
||||||
|
raw = str(url or "").strip()
|
||||||
|
if not is_details_url(raw):
|
||||||
|
return False
|
||||||
|
identifier = extract_identifier(raw)
|
||||||
|
if not identifier:
|
||||||
|
return False
|
||||||
|
access = _archive_item_access(identifier)
|
||||||
|
return bool(access.get("lendable")) and str(access.get("mediatype") or "") == "texts"
|
||||||
|
|
||||||
|
def _download_via_openlibrary(self, url: str, output_dir: Path) -> Optional[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
from Provider.openlibrary import OpenLibrary
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"[internetarchive] OpenLibrary borrow helper unavailable: {exc}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
provider = OpenLibrary(self.config)
|
||||||
|
try:
|
||||||
|
result = provider.download_url(url, output_dir)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
session = getattr(provider, "_session", None)
|
||||||
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return result
|
||||||
|
|
||||||
|
search_result = result.get("search_result")
|
||||||
|
metadata: Dict[str, Any] = {}
|
||||||
|
title = None
|
||||||
|
tags: List[str] = []
|
||||||
|
if search_result is not None:
|
||||||
|
try:
|
||||||
|
title = str(getattr(search_result, "title", "") or "").strip() or None
|
||||||
|
except Exception:
|
||||||
|
title = None
|
||||||
|
try:
|
||||||
|
metadata = dict(getattr(search_result, "full_metadata", {}) or {})
|
||||||
|
except Exception:
|
||||||
|
metadata = {}
|
||||||
|
try:
|
||||||
|
tags_val = getattr(search_result, "tag", None)
|
||||||
|
if isinstance(tags_val, set):
|
||||||
|
tags = [str(t) for t in sorted(tags_val) if t]
|
||||||
|
elif isinstance(tags_val, list):
|
||||||
|
tags = [str(t) for t in tags_val if t]
|
||||||
|
except Exception:
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
normalized: Dict[str, Any] = {"path": result.get("path")}
|
||||||
|
if metadata:
|
||||||
|
normalized["metadata"] = metadata
|
||||||
|
normalized["full_metadata"] = metadata
|
||||||
|
if title:
|
||||||
|
normalized["title"] = title
|
||||||
|
if tags:
|
||||||
|
normalized["tags"] = tags
|
||||||
|
normalized["media_kind"] = "book"
|
||||||
|
normalized["provider_action"] = "borrow"
|
||||||
|
return normalized
|
||||||
|
|
||||||
def validate(self) -> bool:
|
def validate(self) -> bool:
|
||||||
try:
|
try:
|
||||||
_ia()
|
_ia()
|
||||||
@@ -824,13 +933,18 @@ class InternetArchive(Provider):
|
|||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def download_url(self, url: str, output_dir: Path) -> Optional[Path]:
|
def download_url(self, url: str, output_dir: Path) -> Optional[Any]:
|
||||||
"""Download an Internet Archive URL.
|
"""Download an Internet Archive URL.
|
||||||
|
|
||||||
Supports:
|
Supports:
|
||||||
- https://archive.org/details/<identifier>
|
- https://archive.org/details/<identifier>
|
||||||
- https://archive.org/download/<identifier>/<filename>
|
- https://archive.org/download/<identifier>/<filename>
|
||||||
"""
|
"""
|
||||||
|
if self._should_delegate_borrow(url):
|
||||||
|
delegated = self._download_via_openlibrary(url, output_dir)
|
||||||
|
if delegated is not None:
|
||||||
|
return delegated
|
||||||
|
|
||||||
sr = SearchResult(
|
sr = SearchResult(
|
||||||
table="internetarchive",
|
table="internetarchive",
|
||||||
title=str(url),
|
title=str(url),
|
||||||
@@ -842,6 +956,15 @@ class InternetArchive(Provider):
|
|||||||
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
|
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
|
||||||
raw_path = str(getattr(result, "path", "") or "").strip()
|
raw_path = str(getattr(result, "path", "") or "").strip()
|
||||||
|
|
||||||
|
if self._should_delegate_borrow(raw_path):
|
||||||
|
delegated = self._download_via_openlibrary(raw_path, output_dir)
|
||||||
|
if isinstance(delegated, dict):
|
||||||
|
delegated_path = delegated.get("path")
|
||||||
|
if delegated_path:
|
||||||
|
return Path(str(delegated_path))
|
||||||
|
if isinstance(delegated, (str, Path)):
|
||||||
|
return Path(str(delegated))
|
||||||
|
|
||||||
# Fast path for explicit IA file URLs.
|
# Fast path for explicit IA file URLs.
|
||||||
# This uses the shared direct downloader, which already integrates with
|
# This uses the shared direct downloader, which already integrates with
|
||||||
# pipeline transfer progress bars.
|
# pipeline transfer progress bars.
|
||||||
|
|||||||
@@ -210,6 +210,135 @@ def _resolve_archive_id(
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_openlibrary_edition_metadata(
|
||||||
|
session: requests.Session,
|
||||||
|
edition_id: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if not edition_id:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = session.get(
|
||||||
|
f"https://openlibrary.org/books/{edition_id}.json",
|
||||||
|
timeout=6,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json() or {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
identifiers = data.get("identifiers")
|
||||||
|
if not isinstance(identifiers, dict):
|
||||||
|
identifiers = {}
|
||||||
|
|
||||||
|
def _first_clean(value: Any) -> str:
|
||||||
|
raw = _first_str(value)
|
||||||
|
return str(raw or "").strip()
|
||||||
|
|
||||||
|
isbn_10 = _first_clean(identifiers.get("isbn_10"))
|
||||||
|
isbn_13 = _first_clean(identifiers.get("isbn_13"))
|
||||||
|
archive_id = str(data.get("ocaid") or "").strip()
|
||||||
|
if not archive_id:
|
||||||
|
archive_id = _first_clean(identifiers.get("internet_archive"))
|
||||||
|
|
||||||
|
out: Dict[str, Any] = {
|
||||||
|
"openlibrary_id": str(edition_id).strip(),
|
||||||
|
"openlibrary": str(edition_id).strip(),
|
||||||
|
}
|
||||||
|
if isbn_10:
|
||||||
|
out["isbn_10"] = isbn_10
|
||||||
|
if isbn_13:
|
||||||
|
out["isbn_13"] = isbn_13
|
||||||
|
if archive_id:
|
||||||
|
out["archive_id"] = archive_id
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _select_preferred_isbns(values: Any) -> Tuple[str, str]:
|
||||||
|
items: List[Any]
|
||||||
|
if isinstance(values, list):
|
||||||
|
items = values
|
||||||
|
elif values in (None, ""):
|
||||||
|
items = []
|
||||||
|
else:
|
||||||
|
items = [values]
|
||||||
|
|
||||||
|
isbn_10 = ""
|
||||||
|
isbn_13 = ""
|
||||||
|
for raw in items:
|
||||||
|
token = re.sub(r"[^0-9Xx]", "", str(raw or "")).upper().strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
if len(token) == 13 and not isbn_13:
|
||||||
|
isbn_13 = token
|
||||||
|
elif len(token) == 10 and not isbn_10:
|
||||||
|
isbn_10 = token
|
||||||
|
return isbn_10, isbn_13
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pipeline_progress_callback(
|
||||||
|
progress: Any,
|
||||||
|
title: str,
|
||||||
|
) -> Callable[[str, int, Optional[int], str], None]:
|
||||||
|
transfer_label = str(title or "book").strip() or "book"
|
||||||
|
state = {"active": False, "finished": False}
|
||||||
|
|
||||||
|
def _ensure_started(total: Optional[int]) -> None:
|
||||||
|
if state["active"]:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
progress.begin_transfer(label=transfer_label, total=total)
|
||||||
|
state["active"] = True
|
||||||
|
state["finished"] = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _finish() -> None:
|
||||||
|
if not state["active"] or state["finished"]:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
progress.finish_transfer(label=transfer_label)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
state["finished"] = True
|
||||||
|
state["active"] = False
|
||||||
|
|
||||||
|
def _callback(kind: str, completed: int, total: Optional[int], label: str) -> None:
|
||||||
|
text = str(label or kind or "download").strip() or "download"
|
||||||
|
try:
|
||||||
|
progress.set_status(f"openlibrary: {text}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if kind == "step":
|
||||||
|
if text != "download pages":
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
if kind in {"pages", "bytes"}:
|
||||||
|
_ensure_started(total)
|
||||||
|
try:
|
||||||
|
progress.update_transfer(
|
||||||
|
label=transfer_label,
|
||||||
|
completed=int(completed) if completed is not None else None,
|
||||||
|
total=int(total) if total is not None else None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if total is not None:
|
||||||
|
try:
|
||||||
|
if int(completed) >= int(total):
|
||||||
|
_finish()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
setattr(_callback, "_finish_transfer", _finish)
|
||||||
|
return _callback
|
||||||
|
|
||||||
|
|
||||||
def _archive_id_from_url(url: str) -> str:
|
def _archive_id_from_url(url: str) -> str:
|
||||||
"""Best-effort extraction of an Archive.org item identifier from a URL."""
|
"""Best-effort extraction of an Archive.org item identifier from a URL."""
|
||||||
|
|
||||||
@@ -1082,6 +1211,12 @@ class OpenLibrary(Provider):
|
|||||||
|
|
||||||
meta = result.full_metadata or {}
|
meta = result.full_metadata or {}
|
||||||
edition_id = str(meta.get("openlibrary_id") or "").strip()
|
edition_id = str(meta.get("openlibrary_id") or "").strip()
|
||||||
|
edition_meta = _fetch_openlibrary_edition_metadata(self._session, edition_id)
|
||||||
|
if edition_meta and isinstance(meta, dict):
|
||||||
|
for key, value in edition_meta.items():
|
||||||
|
if value and not meta.get(key):
|
||||||
|
meta[key] = value
|
||||||
|
result.full_metadata = meta
|
||||||
|
|
||||||
# Accept direct Archive.org URLs too (details/borrow/download) even when no OL edition id is known.
|
# Accept direct Archive.org URLs too (details/borrow/download) even when no OL edition id is known.
|
||||||
archive_id = str(meta.get("archive_id") or "").strip()
|
archive_id = str(meta.get("archive_id") or "").strip()
|
||||||
@@ -1097,7 +1232,9 @@ class OpenLibrary(Provider):
|
|||||||
archive_id = _first_str(ia_candidates) or ""
|
archive_id = _first_str(ia_candidates) or ""
|
||||||
|
|
||||||
if not archive_id and edition_id:
|
if not archive_id and edition_id:
|
||||||
archive_id = _resolve_archive_id(self._session, edition_id, ia_candidates)
|
archive_id = str(edition_meta.get("archive_id") or "").strip()
|
||||||
|
if not archive_id:
|
||||||
|
archive_id = _resolve_archive_id(self._session, edition_id, ia_candidates)
|
||||||
|
|
||||||
if not archive_id:
|
if not archive_id:
|
||||||
# Try to extract identifier from the SearchResult path (URL).
|
# Try to extract identifier from the SearchResult path (URL).
|
||||||
@@ -1114,17 +1251,49 @@ class OpenLibrary(Provider):
|
|||||||
try:
|
try:
|
||||||
archive_meta = fetch_archive_item_metadata(archive_id)
|
archive_meta = fetch_archive_item_metadata(archive_id)
|
||||||
tags = archive_item_metadata_to_tags(archive_id, archive_meta)
|
tags = archive_item_metadata_to_tags(archive_id, archive_meta)
|
||||||
|
if edition_id:
|
||||||
|
tags.append(f"openlibrary:{edition_id}")
|
||||||
if tags:
|
if tags:
|
||||||
try:
|
try:
|
||||||
result.tag.update(tags)
|
result.tag.update(tags)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback for callers that pass plain dicts.
|
# Fallback for callers that pass plain dicts.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
isbn_10 = str(meta.get("isbn_10") or edition_meta.get("isbn_10") or "").strip()
|
||||||
|
isbn_13 = str(meta.get("isbn_13") or edition_meta.get("isbn_13") or "").strip()
|
||||||
|
if not isbn_10 and not isbn_13:
|
||||||
|
isbn_10, isbn_13 = _select_preferred_isbns(archive_meta.get("isbn"))
|
||||||
|
|
||||||
if isinstance(meta, dict):
|
if isinstance(meta, dict):
|
||||||
meta["archive_id"] = archive_id
|
meta["archive_id"] = archive_id
|
||||||
if archive_meta:
|
if archive_meta:
|
||||||
meta["archive_metadata"] = archive_meta
|
meta["archive_metadata"] = archive_meta
|
||||||
|
if edition_id:
|
||||||
|
meta.setdefault("openlibrary_id", edition_id)
|
||||||
|
meta.setdefault("openlibrary", edition_id)
|
||||||
|
if isbn_10:
|
||||||
|
meta.setdefault("isbn_10", isbn_10)
|
||||||
|
if isbn_13:
|
||||||
|
meta.setdefault("isbn_13", isbn_13)
|
||||||
|
if not meta.get("isbn"):
|
||||||
|
meta["isbn"] = isbn_13 or isbn_10
|
||||||
result.full_metadata = meta
|
result.full_metadata = meta
|
||||||
|
|
||||||
|
extra_identifier_tags: List[str] = []
|
||||||
|
if edition_id:
|
||||||
|
extra_identifier_tags.append(f"openlibrary:{edition_id}")
|
||||||
|
if isbn_13:
|
||||||
|
extra_identifier_tags.append(f"isbn_13:{isbn_13}")
|
||||||
|
extra_identifier_tags.append(f"isbn:{isbn_13}")
|
||||||
|
elif isbn_10:
|
||||||
|
extra_identifier_tags.append(f"isbn_10:{isbn_10}")
|
||||||
|
extra_identifier_tags.append(f"isbn:{isbn_10}")
|
||||||
|
if extra_identifier_tags:
|
||||||
|
try:
|
||||||
|
result.tag.update(extra_identifier_tags)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
# Never block downloads on metadata fetch.
|
# Never block downloads on metadata fetch.
|
||||||
pass
|
pass
|
||||||
@@ -1133,6 +1302,13 @@ class OpenLibrary(Provider):
|
|||||||
if not safe_title or "http" in safe_title.lower():
|
if not safe_title or "http" in safe_title.lower():
|
||||||
safe_title = sanitize_filename(archive_id) or "archive"
|
safe_title = sanitize_filename(archive_id) or "archive"
|
||||||
|
|
||||||
|
internal_progress_finish = None
|
||||||
|
if progress_callback is None and isinstance(self.config, dict):
|
||||||
|
pipeline_progress = self.config.get("_pipeline_progress")
|
||||||
|
if pipeline_progress is not None:
|
||||||
|
progress_callback = _build_pipeline_progress_callback(pipeline_progress, safe_title)
|
||||||
|
internal_progress_finish = getattr(progress_callback, "_finish_transfer", None)
|
||||||
|
|
||||||
# 1) Direct download if available.
|
# 1) Direct download if available.
|
||||||
try:
|
try:
|
||||||
can_direct, pdf_url = self._archive_check_direct_download(archive_id)
|
can_direct, pdf_url = self._archive_check_direct_download(archive_id)
|
||||||
@@ -1318,6 +1494,12 @@ class OpenLibrary(Provider):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"[openlibrary] Borrow workflow error: {exc}", file=sys.stderr)
|
log(f"[openlibrary] Borrow workflow error: {exc}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
if callable(internal_progress_finish):
|
||||||
|
try:
|
||||||
|
internal_progress_finish()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def validate(self) -> bool:
|
def validate(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user