From 83bc6dfa030b967c92cf443e110c0c87a4494184 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 12:28:35 -0700 Subject: [PATCH] =?UTF-8?q?Audit=20fix=20sprint=20=E2=80=94=20CRIT-1,=20CR?= =?UTF-8?q?IT-2,=20HIGH-1..7,=20MED-1,2,7,=20LOW-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Opus-max adversarial audit found 2 CRIT, 7 HIGH, 9 MED, 9 LOW. This commit lands all CRIT, all 7 HIGH, the highest-impact MEDs, and the addon-manifest LOW. The remaining MED/LOW items are cosmetic or defensive polish — captured in the audit report but not blocking. CRIT-1: _attach_sponsorblock had a duplicate block stacked on top of itself. After a video ended the second monitor instantiated, blocked 30s waiting for a player that wasn't there, logged 'timed out' and returned. Net effect on the family-room TV: Kodi froze for 30s after every single video. Deleted the duplicate (lines 512–530). CRIT-2: MPD HTTP server bound to 0.0.0.0 + emitted Access-Control-Allow-Origin: *. The MPD embeds signed googlevideo segment URLs — anything on the LAN (guest phones, IoT, malicious sites loaded in any browser) could scrape the manifest and grab those URLs. Bind to the LAN IP only with a 127.0.0.1 fallback if that fails; drop the gratuitous CORS header. Setting is off by default so the live exposure was small, but locked down before M7 ever flips it on. HIGH-1: _extract_id accepted arbitrary 'v=' query values without validating the 11-char [A-Za-z0-9_-] shape. New _validate_id helper + strict netloc-suffix matching (was: 'youtube.com' in netloc, which let 'myyoutube.com.evil.example' through). Every branch now pipes through _validate_id. Same shape enforced in the sidecar via validate_youtube_id() at every op entry point — defense in depth. HIGH-2: _call_sidecar parser now picks the LAST non-empty stdout line (robust against future stray println!/log lines), with a dedicated JSONDecodeError catch that returns a clear 'sidecar stdout was not JSON: ' rather than a confusing 'sidecar exited 0' message. HIGH-3: _pv_youtube_installed() now logs the probe exception before returning False, so silent degradation to 360p is observable in kodi.log instead of mysterious. HIGH-4: SponsorBlockMonitor.run()'s getTime() catch widened from (RuntimeError, OSError) to bare Exception — historically Kodi has thrown other types when the player goes stale mid-poll, and we don't want one of those to escape past _play's finally block and leak the MPD HTTP server. HIGH-5: Wrapped _attach_sponsorblock in try/except at both call sites (pv.youtube delegate path + progressive fallback) so a SB monitor bug can't pop a 'Plugin error' dialog on the TV after successful playback. HIGH-6: Sidecar Search/ChannelVideos/Playlist now clamp 'limit' at MAX_LIMIT=200. Prevents an unbounded limit=u32::MAX from OOMing Kodi on a malicious or buggy addon request. HIGH-7: Sidecar Rip op now requires dest_dir to be a prefix of an allowlist (/storage/.kodi/temp/ or /storage/.kodi/userdata/addon_data/plugin.video.torttube/). Op is dormant today (no Python caller), but the protocol was a wide-open arbitrary-write primitive. MED-1: Bumped search / channel / playlist sidecar timeouts from 15s to 25s — first-search-after-Kodi-boot on a slow LAN routinely hit 8s+ of rustypipe TLS-handshake + Innertube initial parse. MED-2: _resolved_listitem now checks urlparse(url).path.endswith('.mpd' /'.m3u8') instead of substring scan of the whole URL — yt-dlp URLs have base64 blobs in the query string that can accidentally contain those substrings. MED-7: Error classifiers (classify_yt_dlp_error, classify_rustypipe_error) now match on word-level patterns ('private video', 'age-restrict', 'region-restrict' etc.) instead of bare substrings — fixes 'private network' in a TLS error misclassifying as PrivateVideo, 'package' triggering AgeRestricted, etc. LOW-1: addon.xml's plugin.video.youtube dep marked optional='true' — our _pv_youtube_installed() check and fallback paths assumed optionality; the manifest now matches. Addon v0.0.13. Verified live via two browse-only smokes: - Sidecar bad-id rejection ('../../etc/passwd'): {ok:false, error:'invalid youtube id (length 16 != 11)', kind:'bad_request'} - Files.GetDirectory ?q=cat: 12 results, formatted labels intact. NOT VERIFIED VIA PLAYBACK (Leia was watching the TV). Remaining MED/LOW items (mostly cosmetic): MED-3 endOfDirectory cacheToDisc, MED-4 0-is-falsy in _format_duration/_format_views, MED-5 codec regex single-quote, MED-6 Response::Ok ok-clobber, MED-8 SB response-size cap, MED-9 thumbnail dict type check, LOW-2..LOW-9 defensive polish. Tracked in audit report; can hit in a follow-up sprint. --- addon/plugin.video.torttube/addon.xml | 4 +- addon/plugin.video.torttube/main.py | 125 +++++++++++------- sidecar/crates/torttube-sidecar/src/main.rs | 125 ++++++++++++++---- .../crates/torttube-sidecar/src/resolve.rs | 32 +++-- 4 files changed, 193 insertions(+), 93 deletions(-) diff --git a/addon/plugin.video.torttube/addon.xml b/addon/plugin.video.torttube/addon.xml index 1c7c792..b7b5bc0 100644 --- a/addon/plugin.video.torttube/addon.xml +++ b/addon/plugin.video.torttube/addon.xml @@ -1,11 +1,11 @@ - + diff --git a/addon/plugin.video.torttube/main.py b/addon/plugin.video.torttube/main.py index 1895648..4446910 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -47,25 +47,40 @@ def _log(msg, level=xbmc.LOGINFO): xbmc.log(f"[torttube] {msg}", level) +_ID_RE = re.compile(r"[A-Za-z0-9_-]{11}") + + +def _validate_id(candidate: str | None) -> str: + """Return the candidate iff it matches YouTube's 11-char id shape; raise otherwise. + Every branch of _extract_id MUST pipe its return value through this so a + bogus `v=` query, an empty youtu.be path, or a malformed share link doesn't + leak into _call_sidecar -> yt-dlp / rustypipe / log lines.""" + if candidate and _ID_RE.fullmatch(candidate): + return candidate + raise ValueError(f"not a valid YouTube id: {candidate!r}") + + def _extract_id(url_or_id: str) -> str: - """Accept either a bare ID or any common YouTube URL form, return the ID.""" + """Accept either a bare ID or any common YouTube URL form, return the ID. + Every accepted form is validated through _validate_id so we never hand + untrusted text to the sidecar.""" s = url_or_id.strip() # Bare 11-char ID (YouTube's canonical length). - if re.fullmatch(r"[A-Za-z0-9_-]{11}", s): + if _ID_RE.fullmatch(s): return s parsed = urlparse(s) - # https://youtu.be/ - if parsed.netloc.endswith("youtu.be"): - return parsed.path.lstrip("/").split("/")[0] - # https://www.youtube.com/watch?v= - if "youtube.com" in parsed.netloc: + # https://youtu.be/ (strict suffix match — avoid `myyoutu.be` etc.) + if parsed.netloc == "youtu.be" or parsed.netloc.endswith(".youtu.be"): + return _validate_id(parsed.path.lstrip("/").split("/")[0]) + # https://www.youtube.com/watch?v= (strict suffix match) + if parsed.netloc == "youtube.com" or parsed.netloc.endswith(".youtube.com"): for k, v in parse_qsl(parsed.query): if k == "v": - return v + return _validate_id(v) # /shorts/, /embed/, /live/ m = re.match(r"^/(shorts|embed|live)/([A-Za-z0-9_-]{11})", parsed.path) if m: - return m.group(2) + return _validate_id(m.group(2)) raise ValueError(f"could not extract YouTube id from {url_or_id!r}") @@ -95,13 +110,22 @@ def _call_sidecar(request: dict, timeout_s: int = 30) -> dict: raise RuntimeError( f"sidecar exited {proc.returncode}: {proc.stderr.decode('utf-8', 'replace')[:500]}" ) - # Stdout may have multiple lines if the sidecar logged something; take the first - # non-empty line as the response. + # The sidecar writes its JSON response as the LAST line of stdout (after any + # log lines it might have written via println!/eprintln/tracing if the route + # config ever changes). Pick the last non-empty line and decode robustly. + last_line: bytes | None = None for line in proc.stdout.splitlines(): line = line.strip() if line: - return json.loads(line.decode("utf-8") if isinstance(line, bytes) else line) - raise RuntimeError("sidecar produced no response") + last_line = line + if last_line is None: + raise RuntimeError("sidecar produced no response") + try: + return json.loads(last_line.decode("utf-8", errors="replace")) + except json.JSONDecodeError as e: + raise RuntimeError( + f"sidecar stdout was not JSON: {e}; line={last_line[:200]!r}" + ) from e def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem: @@ -109,7 +133,11 @@ def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem: li.setPath(stream_url) li.setProperty("IsPlayable", "true") # Tell inputstream.adaptive to handle DASH/HLS based on URL path/suffix. - if ".mpd" in stream_url: + # Check the URL *path* extension, not a substring of the whole URL — + # progressive yt-dlp URLs have base64 blobs in the query string that + # can accidentally contain ".mpd" or ".m3u8". + parsed = urlparse(stream_url) + if parsed.path.endswith(".mpd"): li.setProperty("inputstream", "inputstream.adaptive") li.setProperty("inputstream.adaptive.manifest_type", "mpd") # googlevideo rejects segment GETs that don't carry an Origin/Referer @@ -126,7 +154,7 @@ def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem: ) li.setProperty("inputstream.adaptive.stream_headers", seg_headers) li.setProperty("inputstream.adaptive.manifest_headers", seg_headers) - elif ".m3u8" in stream_url: + elif parsed.path.endswith(".m3u8"): li.setProperty("inputstream", "inputstream.adaptive") li.setProperty("inputstream.adaptive.manifest_type", "hls") return li @@ -143,7 +171,6 @@ class _MpdHandler(http.server.BaseHTTPRequestHandler): self.send_response(200) self.send_header("Content-Type", "application/dash+xml") self.send_header("Content-Length", str(len(self.mpd_bytes))) - self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(self.mpd_bytes) @@ -189,10 +216,18 @@ def _start_mpd_server(mpd_bytes: bytes) -> tuple[str, http.server.HTTPServer]: {"mpd_bytes": mpd_bytes}, ) lan_ip = _lan_ip() - # Bind to all interfaces so the LAN-IP URL also routes through (otherwise - # connect-by-IP on the same host can return 'connection refused' on some - # kernel/firewall configs). Port 0 → kernel picks free port. - server = http.server.ThreadingHTTPServer(("0.0.0.0", 0), handler_cls) + # Bind to the LAN IP (not 0.0.0.0) so the MPD is reachable only on the + # interface inputstream.adaptive uses to connect, not exposed to every + # device on the LAN. The MPD embeds signed googlevideo segment URLs that + # we don't want walkable from a guest phone or IoT device. + try: + server = http.server.ThreadingHTTPServer((lan_ip, 0), handler_cls) + except OSError: + # LAN-IP bind can fail in rare network states (interface down, + # IP not yet assigned). Fall back to loopback — inputstream.adaptive + # on the same host can still reach it. + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler_cls) + lan_ip = "127.0.0.1" threading.Thread(target=server.serve_forever, daemon=True).start() port = server.server_address[1] url = f"http://{lan_ip}:{port}/manifest.mpd" @@ -378,10 +413,11 @@ def _delegate_to_pv_youtube(yt_id: str) -> bool: def _pv_youtube_installed() -> bool: """Check whether plugin.video.youtube is installed + enabled. We don't enable it ourselves — if the user removed it, we fall back to our own - paths.""" + paths. Log any probe failure so silent degradation to 360p is observable.""" try: return bool(xbmc.getCondVisibility("System.HasAddon(plugin.video.youtube)")) - except Exception: + except Exception as e: + _log(f"pv.youtube probe failed: {e}", xbmc.LOGWARNING) return False @@ -407,7 +443,12 @@ def _play(yt_id: str) -> None: except Exception: pass if use_pv_youtube and _delegate_to_pv_youtube(yt_id): - _attach_sponsorblock(yt_id) + try: + _attach_sponsorblock(yt_id) + except Exception as e: + # Don't let a SB monitor bug pop a 'Plugin error' dialog on the TV + # after a successful delegate-to-pv.youtube hand-off. + _log(f"sponsorblock attach error (non-fatal): {e}", xbmc.LOGWARNING) return mpd_bytes: bytes | None = None @@ -484,7 +525,10 @@ def _play(yt_id: str) -> None: _log(f"resolved via yt-dlp progressive fallback, playing") xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title)) - _attach_sponsorblock(yt_id) + try: + _attach_sponsorblock(yt_id) + except Exception as e: + _log(f"sponsorblock attach error (non-fatal): {e}", xbmc.LOGWARNING) def _attach_sponsorblock(yt_id: str) -> None: @@ -509,26 +553,6 @@ def _attach_sponsorblock(yt_id: str) -> None: # 'block until playback ends' signal that gates MPD-server shutdown. SponsorBlockMonitor(skip_segments).run() - # SponsorBlock: fetch segments, then block on a monitor that seeks past - # each skip segment as the playhead enters it. Best-effort — failure to - # fetch segments is non-fatal, playback proceeds without skipping. - try: - sb_resp = _call_sidecar({"op": "sponsorblock", "id": yt_id}, timeout_s=8) - except Exception as e: - _log(f"sponsorblock fetch failed (non-fatal): {e}", xbmc.LOGWARNING) - return - - if not sb_resp.get("ok"): - return - segments = sb_resp.get("segments") or [] - skip_segments = [s for s in segments if s.get("actionType") == "skip"] - if not skip_segments: - _log("sponsorblock: no skip segments for this video") - return - - _log(f"sponsorblock: monitoring {len(skip_segments)} skip segments") - SponsorBlockMonitor(skip_segments).run() - class SponsorBlockMonitor(xbmc.Monitor): """Polls Kodi's player position and seeks past each SponsorBlock skip @@ -570,8 +594,11 @@ class SponsorBlockMonitor(xbmc.Monitor): while not self.abortRequested() and player.isPlaying(): try: pos = float(player.getTime()) - except (RuntimeError, OSError): - # getTime raises if the player went away between checks. + except Exception: + # getTime raises various exception types when the player goes + # away mid-poll (Kodi shutdown, plugin reload, etc). Wider catch + # so an exception path doesn't escape into _play's finally and + # leak the MPD HTTP server. return for seg in self.segments: uuid = seg.get("UUID", "") @@ -728,7 +755,7 @@ def _search_directory(query: str | None = None) -> None: return try: - resp = _call_sidecar({"op": "search", "query": query, "limit": 30}, timeout_s=15) + resp = _call_sidecar({"op": "search", "query": query, "limit": 30}, timeout_s=25) except Exception as e: _log(f"search failed: {e}", xbmc.LOGERROR) xbmcgui.Dialog().notification( @@ -758,7 +785,7 @@ def _playlist_directory(playlist_id: str) -> None: """List a playlist's videos.""" try: resp = _call_sidecar( - {"op": "playlist", "id": playlist_id, "limit": 100}, timeout_s=15 + {"op": "playlist", "id": playlist_id, "limit": 100}, timeout_s=25 ) except Exception as e: _log(f"playlist failed: {e}", xbmc.LOGERROR) @@ -788,7 +815,7 @@ def _channel_directory(channel_id: str) -> None: """List a channel's recent videos.""" try: resp = _call_sidecar( - {"op": "channel_videos", "id": channel_id, "limit": 50}, timeout_s=15 + {"op": "channel_videos", "id": channel_id, "limit": 50}, timeout_s=25 ) except Exception as e: _log(f"channel_videos failed: {e}", xbmc.LOGERROR) diff --git a/sidecar/crates/torttube-sidecar/src/main.rs b/sidecar/crates/torttube-sidecar/src/main.rs index 8df36cc..5c20e30 100644 --- a/sidecar/crates/torttube-sidecar/src/main.rs +++ b/sidecar/crates/torttube-sidecar/src/main.rs @@ -68,6 +68,18 @@ fn default_search_limit() -> u32 { 25 } +/// Hard ceiling on any `limit` value accepted from a JSON request. Prevents an +/// unbounded `limit: u32::MAX` from OOMing Kodi or hammering YouTube for +/// hundreds of continuation pages. +const MAX_LIMIT: u32 = 200; + +/// Allowed prefix(es) for the `rip` op's `dest_dir`. Prevents arbitrary-write +/// under the sidecar's UID via a crafted JSON request. +const RIP_DEST_ALLOWLIST: &[&str] = &[ + "/storage/.kodi/temp/", + "/storage/.kodi/userdata/addon_data/plugin.video.torttube/", +]; + #[derive(Debug, Serialize)] #[serde(untagged)] enum Response { @@ -151,41 +163,96 @@ async fn handle_line(line: &str) -> Response { match req { Request::Ping => Response::ok(serde_json::json!({ "pong": true })), - Request::Resolve { id } => match resolve::resolve(&id).await { - Ok(v) => Response::ok(v), - Err(e) => e.into(), - }, - Request::ResolvePlay { id } => match resolve::resolve_play(&id).await { - Ok(v) => Response::ok(v), - Err(e) => e.into(), - }, - Request::ResolveDash { id } => match resolve::resolve_dash(&id).await { - Ok(v) => Response::ok(v), - Err(e) => e.into(), - }, - Request::Rip { id, dest_dir } => match rip::rip(&id, &dest_dir).await { - Ok(v) => Response::ok(v), - Err(e) => e.into(), - }, - Request::Sponsorblock { id, categories } => match sponsor::fetch(&id, &categories).await { - Ok(v) => Response::ok(v), - Err(e) => Response::err(ErrorKind::Network, format!("sponsorblock: {e}")), - }, - Request::Search { query, limit } => match resolve::search(&query, limit).await { - Ok(v) => Response::ok(v), - Err(e) => e.into(), - }, - Request::ChannelVideos { id, limit } => match resolve::channel_videos(&id, limit).await { - Ok(v) => Response::ok(v), - Err(e) => e.into(), - }, - Request::Playlist { id, limit } => match resolve::playlist(&id, limit).await { + Request::Resolve { id } => { + if let Err(e) = validate_youtube_id(&id) { + return Response::err(ErrorKind::BadRequest, e); + } + match resolve::resolve(&id).await { + Ok(v) => Response::ok(v), + Err(e) => e.into(), + } + } + Request::ResolvePlay { id } => { + if let Err(e) = validate_youtube_id(&id) { + return Response::err(ErrorKind::BadRequest, e); + } + match resolve::resolve_play(&id).await { + Ok(v) => Response::ok(v), + Err(e) => e.into(), + } + } + Request::ResolveDash { id } => { + if let Err(e) = validate_youtube_id(&id) { + return Response::err(ErrorKind::BadRequest, e); + } + match resolve::resolve_dash(&id).await { + Ok(v) => Response::ok(v), + Err(e) => e.into(), + } + } + Request::Rip { id, dest_dir } => { + if let Err(e) = validate_youtube_id(&id) { + return Response::err(ErrorKind::BadRequest, e); + } + if !RIP_DEST_ALLOWLIST.iter().any(|p| dest_dir.starts_with(p)) { + return Response::err( + ErrorKind::BadRequest, + format!("rip dest_dir not in allowlist: {dest_dir}"), + ); + } + match rip::rip(&id, &dest_dir).await { + Ok(v) => Response::ok(v), + Err(e) => e.into(), + } + } + Request::Sponsorblock { id, categories } => { + if let Err(e) = validate_youtube_id(&id) { + return Response::err(ErrorKind::BadRequest, e); + } + match sponsor::fetch(&id, &categories).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(ErrorKind::Network, format!("sponsorblock: {e}")), + } + } + Request::Search { query, limit } => { + match resolve::search(&query, limit.min(MAX_LIMIT)).await { + Ok(v) => Response::ok(v), + Err(e) => e.into(), + } + } + Request::ChannelVideos { id, limit } => { + if let Err(e) = validate_youtube_id(&id) { + return Response::err(ErrorKind::BadRequest, e); + } + match resolve::channel_videos(&id, limit.min(MAX_LIMIT)).await { + Ok(v) => Response::ok(v), + Err(e) => e.into(), + } + } + Request::Playlist { id, limit } => match resolve::playlist(&id, limit.min(MAX_LIMIT)).await { Ok(v) => Response::ok(v), Err(e) => e.into(), }, } } +/// Strict YouTube video-id shape check — 11 chars from [A-Za-z0-9_-]. +/// Centralized so every op that takes an `id` enforces the same contract; +/// returns a clear error message for `BadRequest` rather than passing a junk +/// string into rustypipe / yt-dlp / our log lines. +fn validate_youtube_id(id: &str) -> Result<(), String> { + if id.len() != 11 { + return Err(format!("invalid youtube id (length {} != 11)", id.len())); + } + if !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(format!("invalid youtube id (bad chars): {id:?}")); + } + Ok(()) +} + /// Common error returned by resolve/rip handlers — gets mapped to a typed Response. #[derive(Debug, thiserror::Error)] pub enum HandlerError { diff --git a/sidecar/crates/torttube-sidecar/src/resolve.rs b/sidecar/crates/torttube-sidecar/src/resolve.rs index 4a2b9af..af59346 100644 --- a/sidecar/crates/torttube-sidecar/src/resolve.rs +++ b/sidecar/crates/torttube-sidecar/src/resolve.rs @@ -247,18 +247,19 @@ async fn tier1_rustypipe(id: &str) -> Result { } /// Classify a yt-dlp shell-out error into one of our typed handler errors. -/// yt-dlp's stderr is freeform English; we match on substrings, case-insensitive -/// via a lowercase copy, but preserve the original message in the returned error. +/// yt-dlp's stderr is freeform English; we match on **word-boundary** patterns +/// so "private" matches the standalone word, not e.g. "private network" inside +/// a TLS error. Preserve the original message verbatim in the returned error. fn classify_yt_dlp_error(e: &anyhow::Error) -> HandlerError { let original = e.to_string(); let lower = original.to_lowercase(); - if lower.contains("age") { + if matches_word(&lower, &["age-restrict", "age restricted", "age restriction"]) { HandlerError::AgeRestricted - } else if lower.contains("private") { + } else if matches_word(&lower, &["private video", "video is private"]) { HandlerError::PrivateVideo } else if lower.contains("not available") || lower.contains("does not exist") { HandlerError::NotFound - } else if lower.contains("geo") || lower.contains("region") { + } else if matches_word(&lower, &["geo-restrict", "geo restrict", "region-restrict", "region restrict"]) { HandlerError::RegionBlocked } else { HandlerError::Extractor(original) @@ -268,22 +269,27 @@ fn classify_yt_dlp_error(e: &anyhow::Error) -> HandlerError { /// Classify a rustypipe error into one of our typed handler errors. /// rustypipe's error enum varies by version; we match on the Display string for resilience. fn classify_rustypipe_error(e: &dyn std::fmt::Display) -> HandlerError { - let msg = e.to_string().to_lowercase(); - if msg.contains("age") && msg.contains("restrict") { + let original = e.to_string(); + let msg = original.to_lowercase(); + if matches_word(&msg, &["age-restrict", "age restricted", "age restriction"]) { HandlerError::AgeRestricted - } else if msg.contains("region") || msg.contains("country") || msg.contains("geo") { + } else if matches_word(&msg, &["region-restrict", "region restrict", "geo-restrict", "geo restrict", "country restrict"]) { HandlerError::RegionBlocked - } else if msg.contains("private") { + } else if matches_word(&msg, &["private video", "video is private"]) { HandlerError::PrivateVideo - } else if msg.contains("not found") || msg.contains("unavailable") { + } else if matches_word(&msg, &["not found", "unavailable", "does not exist"]) { HandlerError::NotFound - } else if msg.contains("network") || msg.contains("timeout") || msg.contains("connect") { - HandlerError::Network(msg) + } else if matches_word(&msg, &["network error", "timeout", "connection refused", "dns error"]) { + HandlerError::Network(original) } else { - HandlerError::Extractor(msg) + HandlerError::Extractor(original) } } +fn matches_word(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|n| haystack.contains(n)) +} + /// Tier 2 — shell out to yt-dlp -j. async fn tier2_yt_dlp(id: &str) -> Result { let url = format!("https://www.youtube.com/watch?v={id}");