update screenshot

This commit is contained in:
2026-04-21 10:31:38 -07:00
parent f8230bab9c
commit 10e3cd009b
8 changed files with 1218 additions and 439 deletions
+6 -6
View File
@@ -71,7 +71,7 @@
"(wayupload\\.com/[a-z0-9]{12}\\.html)"
],
"regexp": "(turbobit5?a?\\.(net|cc|com)/([a-z0-9]{12}))|(turbobif\\.(net|cc|com)/([a-z0-9]{12}))|(turb[o]?\\.(to|cc|pw)\\/([a-z0-9]{12}))|(turbobit\\.(net|cc)/download/free/([a-z0-9]{12}))|((trbbt|tourbobit|torbobit|tbit|turbobita|trbt)\\.(net|cc|com|to)/([a-z0-9]{12}))|((turbobit\\.cloud/turbo/[a-z0-9]+))|((wayupload\\.com/[a-z0-9]{12}\\.html))",
"status": true
"status": false
},
"hitfile": {
"name": "hitfile",
@@ -92,7 +92,7 @@
"(hitfile\\.net/[a-z0-9A-Z]{4,9})"
],
"regexp": "(hitf\\.(to|cc)/([a-z0-9A-Z]{4,9}))|(htfl\\.(net|to|cc)/([a-z0-9A-Z]{4,9}))|(hitfile\\.(net)/download/free/([a-z0-9A-Z]{4,9}))|((hitfile\\.net/[a-z0-9A-Z]{4,9}))",
"status": true
"status": false
},
"mega": {
"name": "mega",
@@ -478,10 +478,10 @@
"katfile.vip"
],
"regexps": [
"katfile\\.(cloud|online|vip)/([0-9a-zA-Z]{12})",
"katfile\\.(cloud|online|vip|ws)/([0-9a-zA-Z]{12})",
"(katfile\\.com/[0-9a-zA-Z]{12})"
],
"regexp": "(katfile\\.(cloud|online|vip)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))",
"regexp": "(katfile\\.(cloud|online|vip|ws)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))",
"status": true
},
"mediafire": {
@@ -494,7 +494,7 @@
"mediafire\\.com/(\\?|download/|file/|download\\.php\\?)([0-9a-z]{15})"
],
"regexp": "mediafire\\.com/(\\?|download/|file/|download\\.php\\?)([0-9a-z]{15})",
"status": false
"status": true
},
"mixdrop": {
"name": "mixdrop",
@@ -618,7 +618,7 @@
"(upload42\\.com/[0-9a-zA-Z]{12})"
],
"regexp": "(upload42\\.com/[0-9a-zA-Z]{12})",
"status": true
"status": false
},
"uploadbank": {
"name": "uploadbank",
+279 -35
View File
@@ -60,6 +60,7 @@ _LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"
# to a store via the store DB.
_ITEM_STORE_PROP = "user-data/medeia-item-store"
_ITEM_HASH_PROP = "user-data/medeia-item-hash"
_LEGACY_SUB_TRACK_TITLES = ("medeia-note-sub", "medeia-lyric-sub", "medeia-sub")
# Note: We previously used `osd-overlay`, but some mpv builds return
# error='invalid parameter' for that command. We now use `show-text`, which is
@@ -540,6 +541,39 @@ def _lyric_duration_ms(idx: int, times: List[float], current_t: float) -> int:
return 1200
def _format_vtt_timestamp(seconds: float) -> str:
total_ms = max(0, int(round(float(seconds or 0.0) * 1000.0)))
hours = total_ms // 3600000
minutes = (total_ms // 60000) % 60
secs = (total_ms // 1000) % 60
millis = total_ms % 1000
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}"
def _lrc_entries_to_vtt_text(entries: List[LrcLine]) -> str:
if not entries:
return "WEBVTT\n\n"
lines: List[str] = ["WEBVTT", ""]
times = [entry.time_s for entry in entries]
for idx, entry in enumerate(entries, start=1):
start_s = max(0.0, float(entry.time_s or 0.0))
if idx < len(entries):
end_s = max(start_s + 0.25, float(times[idx]))
else:
end_s = start_s + 1.2
text = str(entry.text or "").replace("\r\n", "\n").replace("\r", "\n")
cue_text = text if text.strip() else " "
lines.append(str(idx))
lines.append(f"{_format_vtt_timestamp(start_s)} --> {_format_vtt_timestamp(end_s)}")
lines.extend(cue_text.split("\n"))
lines.append("")
return "\n".join(lines)
def _unwrap_memory_m3u(text: Optional[str]) -> Optional[str]:
"""Extract the real target URL/path from a memory:// M3U payload."""
if not isinstance(text, str) or not text.startswith("memory://"):
@@ -596,6 +630,12 @@ def _notes_cache_root() -> Path:
return root
def _generated_sub_root() -> Path:
root = Path(tempfile.gettempdir()) / "medeia-mpv-notes"
root.mkdir(parents=True, exist_ok=True)
return root
def _notes_cache_key(store: str, file_hash: str) -> str:
return hashlib.sha1(
f"{str(store or '').strip().lower()}:{str(file_hash or '').strip().lower()}".encode(
@@ -790,6 +830,26 @@ def _extract_note_text(notes: Dict[str, str], name: str) -> Optional[str]:
return text if text.strip() else None
def _extract_first_note_text(
notes: Dict[str, str],
names: List[str],
*,
predicate: Optional[Any] = None,
) -> tuple[Optional[str], Optional[str]]:
for name in names:
candidate = _extract_note_text(notes, name)
if not candidate:
continue
if predicate is not None:
try:
if not bool(predicate(candidate)):
continue
except Exception:
continue
return name, candidate
return None, None
def _extract_lrc_from_notes(notes: Dict[str, str]) -> Optional[str]:
"""Return raw LRC text from the note named 'lyric'."""
return _extract_note_text(notes, "lyric")
@@ -811,18 +871,61 @@ def _looks_like_subtitle_text(text: str) -> bool:
return False
def _extract_sub_from_notes(notes: Dict[str, str]) -> Optional[str]:
"""Return raw subtitle text from note-backed subtitle/transcript keys."""
def _extract_sub_from_notes(notes: Dict[str, str]) -> tuple[Optional[str], Optional[str]]:
"""Return (note_name, subtitle_text) from note-backed subtitle/transcript keys."""
primary = _extract_note_text(notes, "sub")
if primary:
return primary
for note_name in _SUBTITLE_NOTE_ALIASES:
candidate = _extract_note_text(notes, note_name)
if candidate and _looks_like_subtitle_text(candidate):
return candidate
return "sub", primary
return _extract_first_note_text(
notes,
list(_SUBTITLE_NOTE_ALIASES),
predicate=_looks_like_subtitle_text,
)
def _display_note_name(note_name: Optional[str]) -> str:
text = re.sub(r"\s+", " ", str(note_name or "").replace("_", " ")).strip()
if not text:
return "subtitle"
lowered = text.casefold()
if lowered == "lyric":
return "lyrics"
if lowered == "sub":
return "subtitles"
return text
def _display_media_title(client: MPVIPCClient) -> Optional[str]:
for key in ("metadata/by-key/title", "metadata/by-key/Title", "media-title"):
try:
value = _ipc_get_property(client, key, None)
except Exception:
value = None
if isinstance(value, str):
text = re.sub(r"\s+", " ", value).strip()
if text:
return text
return None
def _generated_subtitle_title(client: MPVIPCClient, *, note_name: Optional[str]) -> str:
note_label = _display_note_name(note_name)
media_title = _display_media_title(client)
if media_title:
title = f"{note_label}: {media_title}"
else:
title = note_label
title = re.sub(r"\s+", " ", title).strip()
return title[:96] if len(title) > 96 else title
def _filename_slug(text: Optional[str], *, default: str) -> str:
value = re.sub(r"[^A-Za-z0-9._ -]+", " ", str(text or ""))
value = re.sub(r"\s+", "-", value).strip("- ._")
value = value[:48]
return value or default
def _infer_sub_extension(text: str) -> str:
# Best-effort: mpv generally understands SRT/VTT; choose based on content.
t = (text or "").lstrip("\ufeff\r\n").lstrip()
@@ -839,39 +942,122 @@ def _infer_sub_extension(text: str) -> str:
return ".vtt"
def _write_temp_sub_file(*, key: str, text: str) -> Path:
def _write_temp_sub_file(*, key: str, text: str, label: Optional[str] = None) -> Path:
# Write to a content-addressed temp path so updates force mpv reload.
tmp_dir = Path(tempfile.gettempdir()) / "medeia-mpv-notes"
tmp_dir.mkdir(parents=True, exist_ok=True)
tmp_dir = _generated_sub_root()
ext = _infer_sub_extension(text)
digest = hashlib.sha1((key + "\n" + (text or "")).encode("utf-8",
errors="ignore")
).hexdigest()[:16]
safe_key = hashlib.sha1((key or "").encode("utf-8",
errors="ignore")).hexdigest()[:12]
path = (tmp_dir / f"sub-{safe_key}-{digest}{ext}").resolve()
prefix = _filename_slug(label, default="subtitle")
path = (tmp_dir / f"{prefix}-{digest}{ext}").resolve()
path.write_text(text or "", encoding="utf-8", errors="replace")
return path
def _try_remove_selected_external_sub(client: MPVIPCClient) -> None:
def _subtitle_track_snapshot(client: MPVIPCClient) -> List[Dict[str, Any]]:
raw = _ipc_get_property(client, "track-list", [])
return raw if isinstance(raw, list) else []
def _track_external_sub_path(track: Dict[str, Any]) -> Optional[Path]:
if not isinstance(track, dict):
return None
for key in ("external-filename", "external_filename", "demux-filename", "demux_filename"):
raw = track.get(key)
if not isinstance(raw, str):
continue
text = raw.strip()
if not text:
continue
try:
return Path(text).expanduser().resolve()
except Exception:
return Path(text)
return None
def _is_medeia_generated_sub_track(track: Dict[str, Any]) -> bool:
if not isinstance(track, dict):
return False
title = str(track.get("title") or "").strip()
if title in _LEGACY_SUB_TRACK_TITLES:
return True
path = _track_external_sub_path(track)
if path is None:
return False
try:
client.send_command({
"command": ["sub-remove"]
})
path.relative_to(_generated_sub_root().resolve())
return True
except Exception:
return False
def _find_medeia_sub_track_ids(client: MPVIPCClient) -> List[int]:
out: List[int] = []
for track in _subtitle_track_snapshot(client):
if not isinstance(track, dict):
continue
if str(track.get("type") or "") != "sub":
continue
if not _is_medeia_generated_sub_track(track):
continue
try:
track_id = int(track.get("id"))
except Exception:
continue
out.append(track_id)
return out
def _log_medeia_sub_tracks(client: MPVIPCClient, reason: str) -> None:
parts: List[str] = []
for track in _subtitle_track_snapshot(client):
if not isinstance(track, dict):
continue
if str(track.get("type") or "") != "sub":
continue
if not _is_medeia_generated_sub_track(track):
continue
title = str(track.get("title") or "").strip()
source = _track_external_sub_path(track)
parts.append(
f"id={track.get('id')}"
f" title={title!r}"
f" selected={bool(track.get('selected'))}"
f" external={bool(track.get('external'))}"
f" source={source.name if source is not None else '<none>'}"
)
if parts:
_log(f"Medeia subtitle tracks {reason}: " + " | ".join(parts))
else:
_log(f"Medeia subtitle tracks {reason}: <none>")
def _remove_medeia_external_subs(client: MPVIPCClient, *, reason: str = "") -> None:
track_ids = _find_medeia_sub_track_ids(client)
if not track_ids:
return
_log(f"Removing Medeia subtitle tracks reason={reason or 'unknown'} ids={track_ids}")
for track_id in track_ids:
try:
client.send_command({
"command": ["sub-remove", int(track_id)]
})
except Exception:
continue
_log_medeia_sub_tracks(client, f"after-remove:{reason or 'unknown'}")
def _try_add_external_sub(client: MPVIPCClient, path: Path) -> None:
def _try_add_external_sub(client: MPVIPCClient, path: Path, *, title: str) -> None:
try:
client.send_command(
{
"command": ["sub-add",
str(path),
"select",
"medeia-sub"]
str(title or _NOTE_SUB_TRACK_TITLE)]
}
)
except Exception:
@@ -1099,7 +1285,7 @@ class _PlaybackState:
entries: List[LrcLine] = field(default_factory=list)
times: List[float] = field(default_factory=list)
loaded_key: Optional[str] = None
loaded_mode: Optional[str] = None # 'lyric' | 'sub' | None
loaded_mode: Optional[str] = None # 'lyric' | 'sub' | 'lyric-sub' | None
loaded_sub_path: Optional[Path] = None
last_target: Optional[str] = None
fetch_attempt_key: Optional[str] = None
@@ -1130,7 +1316,7 @@ class _PlaybackState:
self.loaded_key = None
self.loaded_mode = None
if self.loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
_remove_medeia_external_subs(client, reason="state-clear")
self.loaded_sub_path = None
@@ -1153,6 +1339,7 @@ def run_auto_overlay(
return 3
_log(f"Auto overlay connected (ipc={getattr(mpv, 'ipc_path', None)})")
_remove_medeia_external_subs(client, reason="startup-sweep")
state = _PlaybackState()
last_idx: Optional[int] = None
@@ -1196,6 +1383,12 @@ def run_auto_overlay(
if not client.connect():
_log("mpv IPC disconnected; exiting MPV.lyric")
return 4
_remove_medeia_external_subs(client, reason="reconnect-sweep:path")
state.clear(client)
state.last_target = None
last_idx = None
last_text = None
last_visible = None
time.sleep(poll_s)
continue
@@ -1207,11 +1400,14 @@ def run_auto_overlay(
last_visible = visible
elif last_visible is True and visible is False:
_osd_clear_and_restore(client)
_try_remove_selected_external_sub(client)
_remove_medeia_external_subs(client, reason="visibility-off")
state.loaded_sub_path = None
last_idx = None
last_text = None
last_visible = visible
elif last_visible is False and visible is True:
if state.loaded_mode in {"sub", "lyric-sub"} and state.loaded_sub_path is None:
state.loaded_key = None
last_idx = None
last_text = None
last_visible = visible
@@ -1459,21 +1655,26 @@ def run_auto_overlay(
except Exception:
_log("Loaded notes keys: <error>")
sub_text = _extract_note_text(notes, "sub")
sub_note_name, sub_text = _extract_sub_from_notes(notes)
if sub_text:
# Hand subtitles to mpv's track subsystem; suppress OSD lyric overlay.
_osd_clear_and_restore(client)
sub_path: Optional[Path] = None
sub_title = _generated_subtitle_title(client, note_name=sub_note_name)
try:
sub_path = _write_temp_sub_file(key=state.key, text=sub_text)
sub_path = _write_temp_sub_file(key=state.key, text=sub_text, label=sub_title)
except Exception as exc:
_log(f"Failed to write sub note temp file: {exc}")
if sub_path is not None:
if state.loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
_try_add_external_sub(client, sub_path)
_remove_medeia_external_subs(client, reason="load-note-sub")
_try_add_external_sub(client, sub_path, title=sub_title)
state.loaded_sub_path = sub_path
_log(
f"Loaded note-backed native subtitle track"
f" note={sub_note_name!r} title={sub_title!r} path={sub_path}"
)
_log_medeia_sub_tracks(client, "after-add-note-sub")
state.entries = []
state.times = []
@@ -1481,12 +1682,12 @@ def run_auto_overlay(
state.loaded_mode = "sub"
else:
# Switching away from sub mode: unload the external subtitle.
if state.loaded_mode == "sub" and state.loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
# Switching away from native subtitle mode: unload the external subtitle.
if state.loaded_sub_path is not None:
_remove_medeia_external_subs(client, reason="switch-away-native-sub")
state.loaded_sub_path = None
lrc_text = _extract_note_text(notes, "lyric")
lrc_text = _extract_lrc_from_notes(notes)
if not lrc_text:
_log("No lyric note found (note name: 'lyric')")
@@ -1569,10 +1770,47 @@ def run_auto_overlay(
else:
_log(f"Loaded lyric note ({len(lrc_text)} chars)")
parsed = parse_lrc(lrc_text)
state.entries = parsed
state.times = [e.time_s for e in parsed]
state.loaded_key = state.key
state.loaded_mode = "lyric"
if not parsed:
_log("Lyric note contained no timestamped entries")
_osd_clear_and_restore(client)
state.entries = []
state.times = []
state.loaded_key = state.key
state.loaded_mode = None
else:
lyric_sub_path: Optional[Path] = None
lyric_sub_title = _generated_subtitle_title(client, note_name="lyric")
try:
lyric_sub_text = _lrc_entries_to_vtt_text(parsed)
lyric_sub_path = _write_temp_sub_file(
key=f"{state.key}:lyric",
text=lyric_sub_text,
label=lyric_sub_title,
)
except Exception as exc:
_log(f"Failed to write lyric note temp subtitle: {exc}")
if lyric_sub_path is None:
_osd_clear_and_restore(client)
state.entries = []
state.times = []
state.loaded_key = state.key
state.loaded_mode = None
else:
_osd_clear_and_restore(client)
_remove_medeia_external_subs(client, reason="load-lyric-sub")
_try_add_external_sub(client, lyric_sub_path, title=lyric_sub_title)
state.loaded_sub_path = lyric_sub_path
state.entries = []
state.times = []
state.loaded_key = state.key
state.loaded_mode = "lyric-sub"
_log(
f"Loaded lyric note as native subtitle track"
f" title={lyric_sub_title!r} entries={len(parsed)}"
f" path={lyric_sub_path}"
)
_log_medeia_sub_tracks(client, "after-add-lyric-sub")
# ----------------------------------------------------------------
# 8. Render the current lyric line.
@@ -1590,6 +1828,12 @@ def run_auto_overlay(
if not client.connect():
_log("mpv IPC disconnected; exiting MPV.lyric")
return 4
_remove_medeia_external_subs(client, reason="reconnect-sweep:time")
state.clear(client)
state.last_target = None
last_idx = None
last_text = None
last_visible = None
time.sleep(poll_s)
continue
-10
View File
@@ -756,7 +756,6 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
refresh = bool(data.get("refresh") or data.get("reload"))
if cached_choices and not refresh:
debug(f"[store-choices] using cached choices={len(cached_choices)}")
return {
"success": True,
"stdout": "",
@@ -767,20 +766,14 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
}
try:
config_root = _runtime_config_root()
choices = _load_store_choices_from_config(force_reload=refresh)
if not choices and cached_choices:
choices = cached_choices
debug(
f"[store-choices] config returned empty; falling back to cached choices={len(choices)}"
)
if choices:
choices = _set_cached_store_choices(choices)
debug(f"[store-choices] config_dir={config_root} choices={len(choices)}")
return {
"success": True,
"stdout": "",
@@ -791,9 +784,6 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
}
except Exception as exc:
if cached_choices:
debug(
f"[store-choices] refresh failed; returning cached choices={len(cached_choices)} error={type(exc).__name__}: {exc}"
)
return {
"success": True,
"stdout": "",
+2 -11
View File
@@ -101,10 +101,7 @@ class SharedArgs:
if not force and hasattr(SharedArgs, "_cached_available_stores"):
return SharedArgs._cached_available_stores or []
if not force:
SharedArgs._refresh_store_choices_cache(config, skip_instantiation=True)
else:
SharedArgs._refresh_store_choices_cache(config, skip_instantiation=False)
SharedArgs._refresh_store_choices_cache(config, skip_instantiation=False)
return SharedArgs._cached_available_stores or []
@staticmethod
@@ -119,13 +116,7 @@ class SharedArgs:
SharedArgs._cached_available_stores = []
return
try:
from Store.registry import list_configured_backend_names
SharedArgs._cached_available_stores = list_configured_backend_names(config) or []
except Exception:
SharedArgs._cached_available_stores = []
SharedArgs._cached_available_stores = []
if skip_instantiation:
return
+9 -20
View File
@@ -228,23 +228,18 @@ class SharedArgs:
if not force and hasattr(SharedArgs, "_cached_available_stores"):
return SharedArgs._cached_available_stores or []
# Refresh the cache. When not forcing, prefer a lightweight configured-name
# pass to avoid instantiating backends (which may perform work such as opening DBs).
if not force:
SharedArgs._refresh_store_choices_cache(config, skip_instantiation=True)
else:
SharedArgs._refresh_store_choices_cache(config, skip_instantiation=False)
# Autocomplete and shared arg choices must only expose backends that actually
# initialized successfully. Do a full refresh when the cache is missing.
SharedArgs._refresh_store_choices_cache(config, skip_instantiation=False)
return SharedArgs._cached_available_stores or []
@staticmethod
def _refresh_store_choices_cache(config: Optional[Dict[str, Any]] = None, skip_instantiation: bool = False) -> None:
"""Refresh the cached store choices list. Should be called once at startup.
This performs a lightweight pass first (reads configured names only, without
instantiating backend classes) to avoid side-effects during autocompletion or
other quick lookups. When `skip_instantiation` is False, the function will
attempt a full StoreRegistry initialization to filter out backends that failed
to initialize properly.
Store choices are user-facing and should only include backends that actually
initialized successfully. When `skip_instantiation` is True, this method keeps
the cache empty rather than surfacing configured-but-disabled store names.
Args:
config: Config dict. If not provided, will try to load from config module.
@@ -259,15 +254,10 @@ class SharedArgs:
SharedArgs._cached_available_stores = []
return
# Lightweight pass: return configured names without instantiating backends
try:
from Store.registry import list_configured_backend_names
SharedArgs._cached_available_stores = list_configured_backend_names(config) or []
except Exception:
SharedArgs._cached_available_stores = []
SharedArgs._cached_available_stores = []
# If caller explicitly requested a full scan, instantiate registry to get
# only backends that actually initialized successfully.
# If caller requested a lightweight pass, avoid exposing configured names
# that may be disabled or unavailable.
if skip_instantiation:
return
@@ -278,7 +268,6 @@ class SharedArgs:
if available:
SharedArgs._cached_available_stores = available
except Exception:
# Keep the lightweight list if full initialization fails
pass
except Exception:
SharedArgs._cached_available_stores = []
+29
View File
@@ -338,6 +338,14 @@ class Add_File(Cmdlet):
except Exception:
is_storage_backend_location = False
if location and not plugin_name and not is_storage_backend_location:
if not Add_File._looks_like_local_export_target(str(location)):
log(
f"Storage backend '{location}' not found. Use -path for local export or configure that store backend.",
file=sys.stderr,
)
return 1
# Decide which items to process.
# - If directory scan was performed, use those results
# - If user provided -path (and it was not reinterpreted as destination), treat this invocation as single-item.
@@ -1262,6 +1270,27 @@ class Add_File(Cmdlet):
pass
return None
@staticmethod
def _looks_like_local_export_target(location: str) -> bool:
target = str(location or "").strip()
if not target:
return False
target_path = Path(target).expanduser()
try:
if target_path.exists():
return True
except Exception:
pass
if target.startswith((".", "~")):
return True
if "\\" in target or "/" in target:
return True
if len(target) >= 2 and target[1] == ":":
return True
return False
@staticmethod
def _resolve_source(
result: Any,
+890 -344
View File
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -191,23 +191,19 @@ class PlaywrightTool:
if candidate and Path(candidate).exists():
ffmpeg_path = candidate
else:
debug(f"Configured ffmpeg path does not exist: {candidate}")
ffmpeg_path = None
if not ffmpeg_path:
# Prefer a global FFMPEG_PATH env var (shared by tools) before Playwright-specific one
env_ffmpeg = os.environ.get("FFMPEG_PATH")
if env_ffmpeg and Path(env_ffmpeg).exists():
ffmpeg_path = env_ffmpeg
elif env_ffmpeg:
debug(f"FFMPEG_PATH set but path does not exist: {env_ffmpeg}")
if not ffmpeg_path:
# Backward-compatible Playwright-specific env var
env_ffmpeg2 = os.environ.get("PLAYWRIGHT_FFMPEG_PATH")
if env_ffmpeg2 and Path(env_ffmpeg2).exists():
ffmpeg_path = env_ffmpeg2
elif env_ffmpeg2:
debug(f"PLAYWRIGHT_FFMPEG_PATH set but path does not exist: {env_ffmpeg2}")
if not ffmpeg_path:
# Try to find bundled ffmpeg in the project (Windows-only, in MPV/ffmpeg/bin)
@@ -218,20 +214,14 @@ class PlaywrightTool:
ffmpeg_exe = bundled_ffmpeg / ("ffmpeg.exe" if os.name == "nt" else "ffmpeg")
if ffmpeg_exe.exists():
ffmpeg_path = str(ffmpeg_exe)
debug(f"Found bundled ffmpeg at: {ffmpeg_path}")
except Exception as e:
debug(f"Error checking for bundled ffmpeg: {e}")
except Exception:
pass
if not ffmpeg_path:
# Try system ffmpeg if bundled not found
system_ffmpeg = shutil.which("ffmpeg")
if system_ffmpeg:
ffmpeg_path = system_ffmpeg
debug(f"Found system ffmpeg at: {ffmpeg_path}")
else:
# ffmpeg not found - log a debug message but don't fail
# ffmpeg-python may still work with system installation, or user might not need it
debug("ffmpeg not found on PATH. For best compatibility, install ffmpeg: Windows (use bundled or choco install ffmpeg), macOS (brew install ffmpeg), Linux (apt install ffmpeg or equivalent)")
return PlaywrightDefaults(
browser=browser,