diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20d3303..53ecd0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -349,28 +349,77 @@ jobs: -o - | grep -qF "output ${want}" \ || { echo "stdout config missing output" >&2; exit 1; } - # Default (no -o): SAVE active into the user extractor dir and confirm - # a later --extract-list shows it (item 2: active by default). + # Default (no -o): PRINT the config to stdout and STASH it pending, + # printing a --save-config hint we parse the id out of. A second + # --save-config step then installs it active. udir="${workdir}/cfg" export XDG_CONFIG_HOME="${udir}" "${flux}" --extract-scan "http://127.0.0.1:${port}/watch.html" --yes \ - 2> "${workdir}/save.err" + > "${workdir}/scan.out" 2> "${workdir}/scan.err" + grep -qF "output ${want}" "${workdir}/scan.out" \ + || { echo "default scan did not print the resolved output" >&2; \ + cat "${workdir}/scan.err" >&2; exit 1; } + grep -q -- "--save-config" "${workdir}/scan.err" \ + || { echo "default scan did not hint --save-config" >&2; \ + cat "${workdir}/scan.err" >&2; exit 1; } + id="$(sed -n 's/.*--save-config \([A-Za-z0-9_-]*\).*/\1/p' \ + "${workdir}/scan.err")" + test -n "${id}" || { echo "could not parse a pending id" >&2; exit 1; } + + "${flux}" --save-config "${id}" 2> "${workdir}/save.err" \ + || { echo "--save-config failed" >&2; \ + cat "${workdir}/save.err" >&2; exit 1; } saved="${udir}/hyperflux/extractors/site.conf" test -f "${saved}" \ - || { echo "default scan did not save into the user dir" >&2; \ + || { echo "--save-config did not install into the user dir" >&2; \ cat "${workdir}/save.err" >&2; exit 1; } grep -qF "output ${want}" "${saved}" \ || { echo "saved config missing resolved output" >&2; exit 1; } "${flux}" --extract-list | grep -qF "${saved}" \ || { echo "saved config is not active (not listed)" >&2; exit 1; } - # Re-running must refuse to clobber the hand-tunable auto-path. + # A second --save-config of the same id must refuse to clobber. + if "${flux}" --save-config "${id}" 2>/dev/null; then + echo "second --save-config clobbered an existing config" >&2; exit 1 + fi + + # --extract-scan-depth=3 is accepted (recursion bound, max 3). + "${flux}" --extract-scan "http://127.0.0.1:${port}/watch.html" \ + --extract-scan-depth=3 --yes >/dev/null 2>&1 \ + || { echo "--extract-scan-depth=3 not accepted" >&2; exit 1; } + + # --extract-scan-depth=9 above SCAN_MAX_DEPTH must print a clamp notice. + "${flux}" --extract-scan "http://127.0.0.1:${port}/watch.html" \ + --extract-scan-depth=9 --yes >/dev/null 2>"${workdir}/clamp.err" \ + || true + grep -qi "clamp" "${workdir}/clamp.err" \ + || { echo "--extract-scan-depth=9 did not print a clamp notice" >&2; \ + cat "${workdir}/clamp.err" >&2; exit 1; } + + # --extract-scan-depth=abc (non-numeric) must fail with non-zero exit. if "${flux}" --extract-scan "http://127.0.0.1:${port}/watch.html" \ - --yes 2>/dev/null; then - echo "re-scan clobbered an existing config" >&2; exit 1 + --extract-scan-depth=abc --yes >/dev/null 2>/dev/null; then + echo "--extract-scan-depth=abc should have failed" >&2; exit 1 + fi + + # Path traversal guard: a crafted pending file with name=../../../escape + # must not install outside the extractors dir. + penddir="${udir}/hyperflux/extractors/.pending" + mkdir -p "${penddir}" + evil_id="evil-00000" + printf 'name ../../../../tmp/flux_escape_%s\nmatch evil\\.example\\.com\noutput {url}\n' \ + "$$" > "${penddir}/${evil_id}.conf" + "${flux}" --save-config "${evil_id}" 2>/dev/null || true + if ls /tmp/flux_escape_* 2>/dev/null | grep -q .; then + echo "BUG: path traversal escaped extractors dir" >&2; exit 1 fi + # Any saved file must remain under the extractors dir. + if find "${udir}/hyperflux/extractors" -name "flux_escape_*" | grep -q .; then + echo "BUG: traversal file landed inside extractors dir with bad name" >&2; exit 1 + fi + unset XDG_CONFIG_HOME - echo "Scan e2e OK: --extract-scan emits, saves active, refuses clobber" + echo "Scan e2e OK: --extract-scan prints+stashes, --save-config installs, refuses clobber" - name: Install e2e (bundled extractor config ships active) run: | diff --git a/ChangeLog b/ChangeLog index fde1ab3..1d93711 100644 --- a/ChangeLog +++ b/ChangeLog @@ -4,6 +4,20 @@ kept unchanged for the record, including their original author names. +Version: unreleased + + [ Hyperflux contributors ] + +* scan: --extract-scan follows internal watch/play/embed links up to + --extract-scan-depth hops (default 2, max 3) when the landing page has no direct + media, and emits a multi-step get/var config that resolves the media at download + time +* scan: detect series index pages and emit a list + per-episode pipeline config +* scan: --extract-scan now prints the config and stashes it pending instead of + auto-saving; --save-config installs a pending config active (stable id per + source URL, refuses to clobber an existing active config) + + Version: 2.0.0, 2026-06-18 [ Hyperflux contributors ] diff --git a/NEWS b/NEWS index 3419e89..b3724b9 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,24 @@ +Hyperflux (unreleased) +====================== + +--extract-scan now reaches players that sit a few clicks away. When the landing +page has no direct media, flux follows internal watch/play/embed links a bounded +number of hops, re-scans each page, and when it finds the media it writes a +multi-step config that walks the same path at download time. Series index pages +are recognised too: the generated config lists the episodes and resolves each one +through the per-episode pipeline. The crawl is content-driven: it follows +promising links even when their URLs do not match a known watch/play pattern, +ranks them by relevance, stays inside the series you are scanning, and skips ad +and onclick/popunder traps. Control how far it looks with --extract-scan-depth=N +(default 2, max 3). + +Scanning no longer writes straight into your active config dir. flux prints the +config it generates, stashes a pending copy, and tells you the id to run +"flux --save-config " with when you want it active. The id is stable per +source URL, so re-scanning the same site gives you the same one, and saving +refuses to overwrite an existing active config. + + Hyperflux 2.0.0 (2026-06-18) ============================ diff --git a/README.md b/README.md index 7857f81..580bfdb 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,21 @@ For usage information, see the manual page: man flux +### Generating an extractor config + +Point `flux --extract-scan ` at a streaming page and it writes a config that +resolves the real media URL. When the page has no direct media, it follows the +internal watch/play/embed links a few hops to reach the player and generates a +multi-step config; raise or lower how far it looks with `--extract-scan-depth=N` +(default 2, max 3). Series index pages get a config that lists every episode. The +discovery is content-driven: it follows promising links even without a standard +`/watch` or `/play` pattern, stays within the series you scan, and ignores ad and +onclick/popunder traps. + +The scan prints the config and stashes a pending copy instead of activating it. +When you are happy with it, run the `flux --save-config ` line it suggests to +install it active. Use `-o FILE` to write straight to a path instead. + ## Installation The distro package is named `hyperflux`, but the command it installs is `flux`. diff --git a/po/de.po b/po/de.po index a03b504..24066bd 100644 --- a/po/de.po +++ b/po/de.po @@ -202,7 +202,7 @@ msgstr "Nicht unterstütztes Protokoll\n" msgid "Secure protocol is not supported\n" msgstr "Das sichere Protokoll wird nicht unterstützt\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "Zu viele Weiterleitungen (redirects).\n" @@ -241,7 +241,7 @@ msgstr "Fehler beim Öffnen der Passiv-Verbindung.\n" msgid "Error writing command %s\n" msgstr "Fehler beim Schreiben des Befehls %s\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "Verbindung geschlossen.\n" @@ -251,12 +251,132 @@ msgstr "Verbindung geschlossen.\n" msgid "Invalid proxy string: %s\n" msgstr "Ungültige Proxy-Angabe: %s\n" -#: src/http.c:240 +#: src/http.c:249 #, c-format msgid "Connection gone while writing.\n" msgstr "Verbindung verloren beim Schreiben.\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " @@ -265,97 +385,129 @@ msgstr "" "Zu viele benutzerdefinierte Header (-H)! Derzeit können nur %u " "benutzerdefinierte Header angehängt werden.\n" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "Kann Standardausgabe nicht nach /dev/null umleiten.\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "Fehler beim Lesen der URL (Zu lang?).\n" -#: src/text.c:288 +#: src/text.c:833 #, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "Kann URLs mit mehr als %zu Zeichen nicht nutzen\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "Suche gestartet...\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "Datei nicht gefunden\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "Teste Geschwindigkeiten, das kann etwas dauern...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "Geschwindigkeitstest fehlgeschlagen\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "%i benutzbare Server gefunden, werde diese URLs benutzen:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "Geschwindigkeit" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "Starte Abruf: %s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "Dateiname zu lang!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "Keine Status-Datei, Fortsetzung nicht möglich!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "Status-Datei gefunden, aber noch nichts übertragen. Neustart.\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -364,48 +516,120 @@ msgstr "" "\n" "%s abgerufen in %s. (%.2f KB/s)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "Kilo" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "Mega" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "Giga" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "Tera" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %sbyte(s)" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i:%02i:%02i Stunde(n)" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i:%02i Minute(n)" -#: src/text.c:686 +#: src/text.c:1777 #, c-format msgid "%i second(s)" msgstr "%i Sekunde(n)" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "Alternative Ausgabe kann nicht eingerichtet werden. Ausschalten.\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -429,7 +653,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "Aufruf: axel [Optionen] url1 [url2] [url...]\n" "\n" @@ -453,7 +677,7 @@ msgstr "" "\n" "Besuchen Sie https://github.com/axel-download-accelerator/axel/issues\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -477,10 +701,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "Aufruf: axel [Optionen] url1 [url2] [url...]\n" "\n" @@ -505,16 +741,16 @@ msgstr "" "\n" "Besuchen Sie https://github.com/axel-download-accelerator/axel/issues\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "und andere." -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/es.po b/po/es.po index cf09e05..f0f909c 100644 --- a/po/es.po +++ b/po/es.po @@ -199,7 +199,7 @@ msgstr "Protocolo no soportado\n" msgid "Secure protocol is not supported\n" msgstr "Protocolo seguro no está soportado\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "Demasiadas redirecciones.\n" @@ -238,7 +238,7 @@ msgstr "Error abriendo conexión de datos pasiva.\n" msgid "Error writing command %s\n" msgstr "Error escribiendo el comando %s\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "Conexión desaparecida.\n" @@ -248,12 +248,132 @@ msgstr "Conexión desaparecida.\n" msgid "Invalid proxy string: %s\n" msgstr "Cadena de proxy inválida: %s\n" -#: src/http.c:240 +#: src/http.c:249 #, c-format msgid "Connection gone while writing.\n" msgstr "Conexión desaparecida al escribir.\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " @@ -262,99 +382,131 @@ msgstr "" "Demasiadas cabeceras personalizadas (-H)! Actualmente sólo %u cabeceras " "personalizadas pueden anexarse.\n" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "No se puede redirigir stdout a /dev/null.\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "Error al intentar leer la URL (¿demasiado larga?).\n" -#: src/text.c:288 +#: src/text.c:833 #, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "No se pueden manejar URLs de longitud mayor a %zu\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "Buscando...\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "Archivo no encontrado\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "Probando velocidades, esto puede tomar un tiempo...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "Falló la prueba de velocidad\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "%i servidores usables encontrados, se utilizarán éstas URLs:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "Velocidad" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "Inicializando descarga: %s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "¡Nombre de archivo demasiado largo!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "¡No hay archivo de estado, no se puede reanudar!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "" "Archivo de estado encontrado, pero no hay datos descargados. Comenzando " "desde cero.\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -363,48 +515,120 @@ msgstr "" "\n" "Descargado %s en %s. (%.2f KB/s)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "Kilo" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "Mega" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "Giga" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "Tera" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %sbyte(s)" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i:%02i:%02i hora(s)" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i:%02i minuto(s)" -#: src/text.c:686 +#: src/text.c:1777 #, c-format msgid "%i second(s)" msgstr "%i segundo(s)" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "No se puede configurar la salida alternativa. Desactivando.\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -428,7 +652,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "Uso: axel [opciones] url1 [url2] [url...]\n" "\n" @@ -452,7 +676,7 @@ msgstr "" "\n" "Visita https://github.com/axel-download-accelerator/axel/issues\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -476,10 +700,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "Uso: axel [opciones] url1 [url2] [url...]\n" "\n" @@ -505,16 +741,16 @@ msgstr "" "Visita https://github.com/axel-download-accelerator/axel/issues para " "reportar bugs\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "y otros." -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/id_ID.po b/po/id_ID.po index 7c83b56..190e64f 100644 --- a/po/id_ID.po +++ b/po/id_ID.po @@ -198,7 +198,7 @@ msgstr "Protokol yang tidak didukung\n" msgid "Secure protocol is not supported\n" msgstr "Protokol aman tidak didukung\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "Terlalu banyak pengalihan.\n" @@ -237,7 +237,7 @@ msgstr "Galat saat membuka koneksi data pasif.\n" msgid "Error writing command %s\n" msgstr "Galat menulis perintah %s\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "Koneksi hilang.\n" @@ -247,109 +247,261 @@ msgstr "Koneksi hilang.\n" msgid "Invalid proxy string: %s\n" msgstr "String proxy tidak valid: %s\n" -#: src/http.c:240 +#: src/http.c:249 #, c-format msgid "Connection gone while writing.\n" msgstr "Koneksi hilang saat menulis.\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " "appended.\n" msgstr "" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "Tidak bisa mengalihkan stdout ke /dev/null.\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "Galat saat mencoba membaca URL (Terlalu lama?).\n" -#: src/text.c:288 +#: src/text.c:833 #, fuzzy, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "Tidak dapat menangani URL dengan panjang di atas %zu\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "Melakukan pencarian..\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "Berkas tidak ditemukan\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "Menguji kecepatan, ini bisa memakan waktu beberapa lama...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "Pengujian kecepatan gagal\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "%i server yang dapat digunakan ditemukan, akan menggunakan URL ini:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "Kecepatan" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "Menginisialisasi unduh: %s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "Nama berkas terlalu panjang!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "Tidak ada bagian berkas, tidak bisa melanjutkan!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "Bagian berkas ditemukan, tapi data tidak diunduh. Mulai dari awal.\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -358,48 +510,120 @@ msgstr "" "\n" "Diunduh %s in %s. (%.2f KB/s)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "Kilo" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "Mega" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "Giga" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "Tera" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %sbyte(s)" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i:%02i:%02i jam(s)" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i:%02i menit(s)" -#: src/text.c:686 +#: src/text.c:1777 #, fuzzy, c-format msgid "%i second(s)" msgstr "%i detik" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "Tidak dapat menyiapkan keluaran alternatif. Menonaktifkan.\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -423,7 +647,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "Penggunaan: axel [opsi] url1 [url2] [url ...]\n" "\n" @@ -446,7 +670,7 @@ msgstr "" "\n" "Kunjungi https://github.com/axel-download-accelerator/axel/issues\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -470,10 +694,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "Penggunaan: axel [opsi] url1 [url2] [url ...]\n" "\n" @@ -498,16 +734,16 @@ msgstr "" "Kunjungi https://github.com/axel-download-accelerator/axel/issues untuk " "melaporkan kutu\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "dan lainnya." -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/it.po b/po/it.po index f481fe2..0351052 100644 --- a/po/it.po +++ b/po/it.po @@ -201,7 +201,7 @@ msgstr "Protocollo non supportato\n" msgid "Secure protocol is not supported\n" msgstr "Il protocollo sicuro non è supportato\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "Troppi reindirizzamenti.\n" @@ -240,7 +240,7 @@ msgstr "Errore nella connessione passiva dati.\n" msgid "Error writing command %s\n" msgstr "Errore nella scrittura del comando %s\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "Connessione terminata.\n" @@ -250,12 +250,132 @@ msgstr "Connessione terminata.\n" msgid "Invalid proxy string: %s\n" msgstr "Stringa proxy non valida: %s\n" -#: src/http.c:240 +#: src/http.c:249 #, c-format msgid "Connection gone while writing.\n" msgstr "Connessione terminata durante la scrittura.\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " @@ -264,97 +384,129 @@ msgstr "" "Troppe intestazioni personalizzate (-H)! Attualmente è possibile aggiungere " "solo %u di intestazioni personalizzate.\n" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "Impossibile reindirizzare stdout a /dev/null.\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "Errore nella lettura dell'URL (Troppo lungo?).\n" -#: src/text.c:288 +#: src/text.c:833 #, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "Impossibile gestire URL con lunghezza superiore a %zu\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "Ricerca...\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "File non trovato\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "Testando le velocità, potrebbe volerci un po'...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "Test delle velocità fallito\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "%i server utilizzabili trovati, userò questi URL:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "Velocità" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "Inizializzazione download: %s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "Nome del file troppo lungo!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "Nessun file di stato, non posso riprendere!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "Trovato file di stato, ma dati non scaricati. Riparto da zero.\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -363,49 +515,121 @@ msgstr "" "\n" "Scaricato %s in %s. (%.2f KB/s)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "Kilo" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "Mega" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "Giga" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "Tera" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %sbyte" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i:%02i:%02i ora/e" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i:%02i minuto/i" -#: src/text.c:686 +#: src/text.c:1777 #, c-format msgid "%i second(s)" msgstr "%i secondo/i" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "" "Impossibile impostare barra di progresso alternativo. Disattivazione.\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -429,7 +653,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "Utilizzo: axel [options] url1 [url2] [url...]\n" "\n" @@ -452,7 +676,7 @@ msgstr "" "\n" "Visita il sito https://github.com/axel-download-accelerator/axel/issues\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -476,10 +700,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "Utilizzo: axel [options] url1 [url2] [url...]\n" "\n" @@ -504,16 +740,16 @@ msgstr "" "Visita il sito https://github.com/axel-download-accelerator/axel/issues per " "riportare bug\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "e altri." -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/ja.po b/po/ja.po index a3812a7..0552900 100644 --- a/po/ja.po +++ b/po/ja.po @@ -197,7 +197,7 @@ msgstr "サポートされていないプロトコル\n" msgid "Secure protocol is not supported\n" msgstr "セキュアプロトコルはサポートされていません\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "リディレクト回数が多すぎます。\n" @@ -236,7 +236,7 @@ msgstr "受動的データー接続の開始でエラー。\n" msgid "Error writing command %s\n" msgstr "コマンド %s を書く際にエラー\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "接続が失われています。\n" @@ -246,12 +246,132 @@ msgstr "接続が失われています。\n" msgid "Invalid proxy string: %s\n" msgstr "無効なプロキシストリング: %s\n" -#: src/http.c:240 +#: src/http.c:249 #, fuzzy, c-format msgid "Connection gone while writing.\n" msgstr "接続が失われています。\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, fuzzy, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " @@ -260,100 +380,132 @@ msgstr "" "カスタムヘッダーが多すぎます(-H)! 現在%u個のカスタムヘッダーのみを追加でき" "ます。\n" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "標準出力を /dev/null にリディレクトできません。\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "URL を読もうとした際に(長すぎ?)エラー。\n" -#: src/text.c:288 +#: src/text.c:833 #, fuzzy, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "%zu を超える長さの URL は取り扱えません\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "サーチ中...\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "ファイルが見つかりません\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "スピードをテスト中、時間がかかるかもしれません...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "スピードテストに失敗しました\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "" "使用可能なサーバーが %i つ見つかりましたので、以下の URL を使用します:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "スピード" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "ダウンロードを初期化: %s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "ファイル名が長すぎます!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "状態ファイルがありませんので、再開できません!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "" "状態ファイルが見つかったけれど、ダウンロードされたデーターが見つかりません。" "最初から始めます。\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -362,48 +514,120 @@ msgstr "" "\n" "%s を %s にダウンロード。(%.2f KB/s)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "キロ" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "メガ" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "ギガ" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "テラ" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %sバイト" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i 時 %02i 分 %02i 秒" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i 分 %02i 秒" -#: src/text.c:686 +#: src/text.c:1777 #, fuzzy, c-format msgid "%i second(s)" msgstr "%i 秒" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "代替出力を設定できません。 それを無効に。\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -427,7 +651,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "使用法: axel [options] url1 [url2] [url...]\n" "\n" @@ -448,7 +672,7 @@ msgstr "" "https://github.com/axel-download-accelerator/axel/issues にバグ報告を行なって" "ください\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -472,10 +696,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "使用法: axel [options] url1 [url2] [url...]\n" "\n" @@ -496,16 +732,16 @@ msgstr "" "https://github.com/axel-download-accelerator/axel/issues にバグ報告を行なって" "ください\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "そして他の者。" -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/ka.po b/po/ka.po index 5d576da..5aed14e 100644 --- a/po/ka.po +++ b/po/ka.po @@ -192,7 +192,7 @@ msgstr "მხარდაუჭერელი პროტოკოლი\n" msgid "Secure protocol is not supported\n" msgstr "დაცული პროტოკოლი მხარდაჭერილი არაა\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "მეტისმეტად ბევრი გადამისამართება.\n" @@ -231,7 +231,7 @@ msgstr "პასიური მიერთების გახსნის msgid "Error writing command %s\n" msgstr "ბრძანების (%s) ჩაწერის შეცდომა\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "მიერთება გაქრა.\n" @@ -241,12 +241,132 @@ msgstr "მიერთება გაქრა.\n" msgid "Invalid proxy string: %s\n" msgstr "პროქსის არასწორი სტრიქონი: %s\n" -#: src/http.c:240 +#: src/http.c:249 #, c-format msgid "Connection gone while writing.\n" msgstr "მიერთება მასში ჩაწერისას გაქრა.\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " @@ -255,99 +375,131 @@ msgstr "" "ხელით მითითებული თავსართების (-H) რაოდენობა ძალიან დიდია! ამჟამად მხოლოდ %u " "თავსართი შეგიძლიათ დაუმატოთ.\n" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "შეცდომა stdout-ის /dev/null-ზე გადამისამართებისას.\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "შეცდომა URL-ის წაკითხვისას (მეტისმეტად გრძელია?).\n" -#: src/text.c:288 +#: src/text.c:833 #, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "%zu-ზე გრძელი URL-ების დამუშავება შეუძლებელია\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "ვეძებ...\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "ფაილი ვერ ვიპოვე\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "სიჩქარის ტესტირება. ამას საკმაო დრო შეიძლება დასჭირდეს...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "სიჩქარის ტესტირების შეცდომა\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "ნაპოვნია %i სასარგებლო სერვერი. გამოვიყენებ შემდეგ URL-ებს:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "სიჩქარე" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "გადმოწერის ინიციალიზაცია: %s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "ფაილის სახელი ძალიან გრძელია!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "მდგომარეობის ფაილი არ არსებობს. გაგრძელება შეუძლებელია!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "" "აღმოჩენილია მდგომარეობის ფაილი, მაგრამ არა გადმოწერილი მონაცემები. ვიწყებ " "ნულიდან.\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -356,48 +508,120 @@ msgstr "" "\n" "გადმოწერილია %s %s-ში. (%.2f კბ/წმ)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "კილო" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "მეგა" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "გიგა" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "ტერა" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %sბაიტი" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i:%02i:%02i საათი" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i:%02i წუთი" -#: src/text.c:686 +#: src/text.c:1777 #, c-format msgid "%i second(s)" msgstr "%i წამი" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "გამოტანის შეცვლის შეცდომა. დეაქტივაცია.\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -421,7 +645,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "გამოყენება: axel [პარამეტრები] url1 [url2] [url...]\n" "\n" @@ -446,7 +670,7 @@ msgstr "" "\n" "გვეწვიეთ https://github.com/axel-download-accelerator/axel/issues\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -470,10 +694,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "გამოყენება: axel [პარამეტრები] url1 [url2] [url...]\n" "\n" @@ -501,16 +737,16 @@ msgstr "" "შეცდომების შესახებ მოგვწერეთ ბმულზე https://github.com/axel-download-" "accelerator/axel/issues\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "და სხვები." -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/nl.po b/po/nl.po index 2859cc4..d188492 100644 --- a/po/nl.po +++ b/po/nl.po @@ -196,7 +196,7 @@ msgstr "" msgid "Secure protocol is not supported\n" msgstr "" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "Te veel redirects.\n" @@ -235,7 +235,7 @@ msgstr "Fout bij het openen van een data verbinding.\n" msgid "Error writing command %s\n" msgstr "Fout bij het schrijven van commando %s\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "Verbinding gesloten.\n" @@ -245,109 +245,261 @@ msgstr "Verbinding gesloten.\n" msgid "Invalid proxy string: %s\n" msgstr "Ongeldige proxy string: %s\n" -#: src/http.c:240 +#: src/http.c:249 #, c-format msgid "Connection gone while writing.\n" msgstr "Verbinding is verdwenen tijdens het schrijven.\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " "appended.\n" msgstr "" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "Fout bij het afsluiten van stdout.\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "" -#: src/text.c:288 +#: src/text.c:833 #, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "Zoeken...\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "Bestand niet gevonden\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "Snelheden testen, dit kan even duren...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "%i bruikbare servers gevonden, de volgende worden gebruikt:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "Begin download: %s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "Geen .st bestand, kan niet resumen!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr ".st bestand gevonden maar geen uitvoerbestand. Opnieuw beginnen.\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -356,48 +508,120 @@ msgstr "" "\n" "%s gedownload in %s. (%.2f KB/s)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "Kilo" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "Mega" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "Giga" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "Tera" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %sbyte(s)" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i:%02i:%02i uren" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i:%02i minute(n)" -#: src/text.c:686 +#: src/text.c:1777 #, c-format msgid "%i second(s)" msgstr "%i seconde(n)" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -421,7 +645,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "Gebruik: axel [opties] url1 [url2] [url...]\n" "\n" @@ -445,7 +669,7 @@ msgstr "" "\n" "Bezoek https://github.com/axel-download-accelerator/axel/issues\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -469,10 +693,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "Gebruik: axel [opties] url1 [url2] [url...]\n" "\n" @@ -498,16 +734,16 @@ msgstr "" "Bezoek https://github.com/axel-download-accelerator/axel/issues om bugs te " "melden\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "" -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/pt_BR.po b/po/pt_BR.po index 8dc1f38..9c5e0e1 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -201,7 +201,7 @@ msgstr "Protocolo não suportado\n" msgid "Secure protocol is not supported\n" msgstr "Protocolo seguro não suportado\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "Redirecionamentos excessivos.\n" @@ -240,7 +240,7 @@ msgstr "Erro ao abrir conexão passiva.\n" msgid "Error writing command %s\n" msgstr "Erro ao escrever comando %s\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "Conexão perdida.\n" @@ -250,12 +250,132 @@ msgstr "Conexão perdida.\n" msgid "Invalid proxy string: %s\n" msgstr "String de proxy inválida: %s\n" -#: src/http.c:240 +#: src/http.c:249 #, c-format msgid "Connection gone while writing.\n" msgstr "Conexão perdida enquanto escrevia no disco.\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " @@ -264,99 +384,131 @@ msgstr "" "Demasiados cabeçalhos personalizados (-H)! Atualmente apenas %u cabeçalhos " "personalizados podem ser anexados.\n" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "Não posso redirecionar a saída padrão para /dev/null.\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "Erro ao tentar ler a URL (muito longa?).\n" -#: src/text.c:288 +#: src/text.c:833 #, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "Não posso manipular URLs com tamanho superior a %zu\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "Procurando...\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "Arquivo não encontrado\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "Testando velocidades. Isso pode demorar um pouco...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "Falha no teste de velocidade\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "%i servidores possíveis encontrados. Serão utilizadas essas URLs:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "Velocidade" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "Inicializando download: %s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "Nome de arquivo muito longo!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "Estado do arquivo não encontrado. Não posso reiniciar!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "" "Estado do arquivo encontrado mas não há dados de download. Iniciando do " "zero.\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -365,48 +517,120 @@ msgstr "" "\n" "Baixados %s em %s. (%.2f KB/s)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "Kilo" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "Mega" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "Giga" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "Tera" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %sbyte(s)" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i:%02i:%02i hora(s)" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i:%02i minuto(s)" -#: src/text.c:686 +#: src/text.c:1777 #, c-format msgid "%i second(s)" msgstr "%i segundo(s)" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "Não posso configurar uma saída alternativa. Desativando.\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -430,7 +654,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "Uso: axel [opções] url1 [url2] [url...]\n" "\n" @@ -453,7 +677,7 @@ msgstr "" "\n" "Visite https://github.com/axel-download-accelerator/axel/issues\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -477,10 +701,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "Uso: axel [opções] url1 [url2] [url...]\n" "\n" @@ -506,16 +742,16 @@ msgstr "" "Visite https://github.com/axel-download-accelerator/axel/issues to report " "bugs\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "e outros." -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/ru.po b/po/ru.po index 58877bf..190ea45 100644 --- a/po/ru.po +++ b/po/ru.po @@ -193,7 +193,7 @@ msgstr "Неподдерживаемый протокол\n" msgid "Secure protocol is not supported\n" msgstr "Безопасный протокол не поддерживается\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "Слишком много перенаправлений.\n" @@ -232,7 +232,7 @@ msgstr "Ошибка открытия пассивного соединения. msgid "Error writing command %s\n" msgstr "Ошибка записи команды %s\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "Соединение пропало.\n" @@ -242,111 +242,263 @@ msgstr "Соединение пропало.\n" msgid "Invalid proxy string: %s\n" msgstr "Некорректная стока прокси: %s\n" -#: src/http.c:240 +#: src/http.c:249 #, fuzzy, c-format msgid "Connection gone while writing.\n" msgstr "Соединение пропало.\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " "appended.\n" msgstr "" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "Невозможно перенаправить stdout в /dev/null.\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "Ошибка при попытке прочитать URL (слишком долго?).\n" -#: src/text.c:288 +#: src/text.c:833 #, fuzzy, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "URLs длинной больше %zu не поддерживаются\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "Ищем...\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "Файл не найден\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "Пробуем скорости, это может занять некоторое время...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "Тестирование скорости не удалось\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "Найдено %i полезных серверов, будут использованы следующие URLs:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "скорость" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "Начинаю скачивание: %s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "Имя файла слишком длинное!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "Файл состояния не найден, возобновление невозможно!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "" "Файл состояния найден, но предварительно скачанные данные отсутствуют. " "Начинаем заново.\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -355,48 +507,120 @@ msgstr "" "\n" "%s скачано за %s. (%.2f КБ/с)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "кило" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "мега" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "гига" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "тера" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %sбайта(ов)" -#: src/text.c:682 +#: src/text.c:1773 #, fuzzy, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i:%02i:%02i секунд(ы)" -#: src/text.c:684 +#: src/text.c:1775 #, fuzzy, c-format msgid "%i:%02i minute(s)" msgstr "%i:%02i секунд(ы)" -#: src/text.c:686 +#: src/text.c:1777 #, fuzzy, c-format msgid "%i second(s)" msgstr "%i секунд(ы)" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "Не могу настроить альтернативный выход. Деактивация.\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -420,7 +644,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "Использование: axel [опции] url1 [url2] [url...]\n" "\n" @@ -436,7 +660,7 @@ msgstr "" "-V\tИнформация о версии\n" "\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -460,10 +684,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "Использование: axel [опции] url1 [url2] [url...]\n" "\n" @@ -479,16 +715,16 @@ msgstr "" "--version\t\t-V\tИнформация о версии\n" "\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "и другие." -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/tr.po b/po/tr.po index 88e0db8..73ebc60 100644 --- a/po/tr.po +++ b/po/tr.po @@ -196,7 +196,7 @@ msgstr "Desteklenmeyen protokol\n" msgid "Secure protocol is not supported\n" msgstr "Güvenli protokol desteklenmiyor\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "Çok fazla URL yönlendirmesi.\n" @@ -235,7 +235,7 @@ msgstr "Pasif veri bağlantısı açılırken hata.\n" msgid "Error writing command %s\n" msgstr "Komut yazılırken hata: %s\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "Bağlantı kayboldu.\n" @@ -245,12 +245,132 @@ msgstr "Bağlantı kayboldu.\n" msgid "Invalid proxy string: %s\n" msgstr "Geçersiz vekil sunucu (proxy) girdisi: %s\n" -#: src/http.c:240 +#: src/http.c:249 #, c-format msgid "Connection gone while writing.\n" msgstr "Yazılırken bağlantı kayboldu.\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " @@ -259,99 +379,131 @@ msgstr "" "Çok fazla özelleştirilmiş üstbilgi (-H)! Şimdilik sadece %u özel " "üstbilgileri eklenebilir.\n" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "stdout, /dev/null'a yönlendirilemiyor.\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "URL okunurken hata (Çok mu uzun?).\n" -#: src/text.c:288 +#: src/text.c:833 #, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "Uzunluğu %zu karakterden fazla olan URL'ler desteklenmiyor\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "Arama yapılıyor...\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "Dosya bulunamadı\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "Hızlar test ediliyor. Bu biraz zaman alabilir...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "Hız testi başarısız\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "%i adet kullanılabilir sunucu bulundu. Bu URL'ler kullanılacak:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "Hız" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "İndirme başlatılıyor: %s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "Dosya ismi çok uzun!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "Durum dosyası yok, devam edilemiyor!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "" "Durum dosyası bulundu, ancak indirilmiş veri yok. İndirmeye baştan " "başlanıyor.\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -360,48 +512,120 @@ msgstr "" "\n" "İndirilen: %s / %s. (%.2f KB/s)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "Kilo" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "Mega" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "Giga" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "Tera" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %sbayt" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i:%02i:%02i saat" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i:%02i dakika" -#: src/text.c:686 +#: src/text.c:1777 #, c-format msgid "%i second(s)" msgstr "%i saniye" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "Alternatif çıktı kurulamadı. Devre dışı bırakılıyor.\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -425,7 +649,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "Kullanım: axel [seçenekler] url1 [url2] [url...]\n" "\n" @@ -450,7 +674,7 @@ msgstr "" "Hata bildirmek için https://github.com/axel-download-accelerator/axel/issues " "ziyaret edin.\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -474,10 +698,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "Kullanım: axel [seçenekler] url1 [url2] [url...]\n" "\n" @@ -503,16 +739,16 @@ msgstr "" "Hata bildirmek için https://github.com/axel-download-accelerator/axel/" "issuesziyaret edin.\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "ve diğerleri." -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/zh_CN.po b/po/zh_CN.po index df79d0e..6e95624 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -195,7 +195,7 @@ msgstr "不支持的协议\n" msgid "Secure protocol is not supported\n" msgstr "不支持安全协议\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "重定向次数过多。\n" @@ -234,7 +234,7 @@ msgstr "无法打开被动数据连接。\n" msgid "Error writing command %s\n" msgstr "写命令 %s 出错\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "连接已丢失。\n" @@ -244,109 +244,261 @@ msgstr "连接已丢失。\n" msgid "Invalid proxy string: %s\n" msgstr "代理字符串无效:%s\n" -#: src/http.c:240 +#: src/http.c:249 #, c-format msgid "Connection gone while writing.\n" msgstr "写入时连接丢失。\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " "appended.\n" msgstr "自定义请求头数过多 (-H)!当前只有 %u 个请求头可被添加。\n" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "不能将 stdout 重定向到 /dev/null。\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "尝试读取 URL 时出错(URL 太长?)。\n" -#: src/text.c:288 +#: src/text.c:833 #, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "不能处理长度超过 %zu 的 URL\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "正在搜索…\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "找不到文件\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "正在测试速度,可能需要一些时间…\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "速度测试失败\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "找到 %i 个可用的服务器,将使用这些 URL:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "速度" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "正在初始化下载:%s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "文件名太长!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "没有状态文件,无法恢复下载!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "找到状态文件,但未有下载数据。重新开始下载。\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -355,48 +507,120 @@ msgstr "" "\n" "已下载 %s,用时 %s。(%.2f KB/s)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "千" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "兆" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "吉" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "太" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %s字节" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i 时 %02i 分 %02i 秒" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i 分 %02i 秒" -#: src/text.c:686 +#: src/text.c:1777 #, c-format msgid "%i second(s)" msgstr "%i 秒" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "无法设置备用输出,进入挂起状态。\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -420,7 +644,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "用法: axel [选项] 地址 1 [地址 2] [地址…]\n" "\n" @@ -444,7 +668,7 @@ msgstr "" "\n" "请前往 https://github.com/axel-download-accelerator/axel/issues 报告问题\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -468,10 +692,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "用法: axel [选项] 地址 1 [地址 2] [地址…]\n" "\n" @@ -496,16 +732,16 @@ msgstr "" "\n" "请前往 https://github.com/axel-download-accelerator/axel/issues 报告问题\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s (%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "及其他贡献者。" -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/po/zh_TW.po b/po/zh_TW.po index a21dcdc..2012523 100644 --- a/po/zh_TW.po +++ b/po/zh_TW.po @@ -192,7 +192,7 @@ msgstr "不支援的協定\n" msgid "Secure protocol is not supported\n" msgstr "不支援安全協定\n" -#: src/conn.c:477 src/ftp.c:135 +#: src/conn.c:477 src/ftp.c:135 src/http.c:616 #, c-format msgid "Too many redirects.\n" msgstr "重新導向次數過多。\n" @@ -231,7 +231,7 @@ msgstr "開啟被動資料連線時出錯。\n" msgid "Error writing command %s\n" msgstr "寫入指令 %s 時出錯\n" -#: src/ftp.c:322 src/http.c:252 +#: src/ftp.c:322 src/http.c:261 #, c-format msgid "Connection gone.\n" msgstr "連線已中斷。\n" @@ -241,109 +241,261 @@ msgstr "連線已中斷。\n" msgid "Invalid proxy string: %s\n" msgstr "無效的代理伺服器字串:%s\n" -#: src/http.c:240 +#: src/http.c:249 #, c-format msgid "Connection gone while writing.\n" msgstr "寫入時連線已斷。\n" -#: src/text.c:158 +#: src/http.c:480 +#, c-format +msgid "Fetched body too large.\n" +msgstr "" + +#: src/http.c:525 +#, c-format +msgid "Only http(s) is supported for fetch.\n" +msgstr "" + +#: src/http.c:612 src/http.c:620 +#, c-format +msgid "Redirect URL too long.\n" +msgstr "" + +#: src/http.c:628 +#, c-format +msgid "Fetch failed: HTTP %d\n" +msgstr "" + +#: src/text.c:277 +#, c-format +msgid "flux: scan failed: %s\n" +msgstr "" + +#: src/text.c:295 src/text.c:376 src/text.c:478 src/text.c:491 src/text.c:1377 +#: src/text.c:1437 src/text.c:1465 src/text.c:1540 +#, c-format +msgid "flux: out of memory\n" +msgstr "" + +#: src/text.c:325 +msgid "Select the media stream to extract:" +msgstr "" + +#: src/text.c:331 +#, c-format +msgid "flux: scan cancelled.\n" +msgstr "" + +#: src/text.c:348 src/text.c:544 src/text.c:553 +#, c-format +msgid "flux: cannot write config to %s: %s\n" +msgstr "" + +#: src/text.c:359 +#, c-format +msgid "flux: failed to write generated config.\n" +msgstr "" + +#: src/text.c:364 +#, c-format +msgid "flux: wrote extractor config to %s\n" +msgstr "" + +#: src/text.c:385 +#, c-format +msgid "flux: failed to render generated config.\n" +msgstr "" + +#: src/text.c:415 +#, c-format +msgid "flux: If you want to save this, run: flux --save-config %s\n" +msgstr "" + +#: src/text.c:419 +#, c-format +msgid "flux: could not stash the config; copy it from the output above.\n" +msgstr "" + +#: src/text.c:431 +#, c-format +msgid "flux: --save-config needs an id.\n" +msgstr "" + +#: src/text.c:436 +#, c-format +msgid "flux: invalid config id '%s'.\n" +msgstr "" + +#: src/text.c:443 +#, c-format +msgid "flux: cannot determine or create the user extractor directory.\n" +msgstr "" + +#: src/text.c:450 +#, c-format +msgid "flux: config id too long.\n" +msgstr "" + +#: src/text.c:457 +#, c-format +msgid "flux: no pending config with id %s (run --extract-scan first)\n" +msgstr "" + +#: src/text.c:471 +#, c-format +msgid "flux: pending config too large.\n" +msgstr "" + +#: src/text.c:531 +#, c-format +msgid "flux: install path too long.\n" +msgstr "" + +#: src/text.c:541 +#, c-format +msgid "flux: %s already exists; pass a different id or remove it.\n" +msgstr "" + +#: src/text.c:563 +#, c-format +msgid "flux: failed to write %s\n" +msgstr "" + +#: src/text.c:568 +#, c-format +msgid "flux: saved active extractor config to %s\n" +msgstr "" + +#: src/text.c:635 #, c-format msgid "" "Too many custom headers (-H)! Currently only %u custom headers can be " "appended.\n" msgstr "自訂標頭(-H)過多!目前只能附加 %u 個自訂標頭。\n" -#: src/text.c:234 +#: src/text.c:711 #, c-format msgid "Can't redirect stdout to /dev/null.\n" msgstr "無法將 stdout 重新導向 /dev/null。\n" -#: src/text.c:278 +#: src/text.c:742 +#, c-format +msgid "Invalid --extract-scan-depth value '%s' (0-%d).\n" +msgstr "" + +#: src/text.c:748 +#, c-format +msgid "flux: --extract-scan-depth clamped to %d (max).\n" +msgstr "" + +#: src/text.c:765 +#, c-format +msgid "Invalid --mux value '%s' (use mp4 or ts).\n" +msgstr "" + +#: src/text.c:823 #, c-format msgid "Error when trying to read URL (Too long?).\n" msgstr "嘗試讀取 URL 時出錯(URL 太長?)。\n" -#: src/text.c:288 +#: src/text.c:833 #, c-format msgid "Can't handle URLs of length over %zu\n" msgstr "無法處理長度超過 %zu 的 URL\n" -#: src/text.c:303 +#: src/text.c:853 #, c-format msgid "Doing search...\n" msgstr "正在搜尋...\n" -#: src/text.c:306 +#: src/text.c:856 #, c-format msgid "File not found\n" msgstr "找不到檔案\n" -#: src/text.c:311 +#: src/text.c:861 #, c-format msgid "Testing speeds, this can take a while...\n" msgstr "正在測試速度,這可能需要一些時間...\n" -#: src/text.c:314 +#: src/text.c:864 #, c-format msgid "Speed testing failed\n" msgstr "速度測試失敗\n" -#: src/text.c:322 +#: src/text.c:872 #, c-format msgid "%i usable servers found, will use these URLs:\n" msgstr "找到 %i 個可用的伺服器,將使用這些 URL:\n" -#: src/text.c:325 +#: src/text.c:875 msgid "Speed" msgstr "速度" -#: src/text.c:339 +#: src/text.c:922 src/text.c:945 src/text.c:1588 +#, c-format +msgid "Extracted media URL: %s\n" +msgstr "" + +#: src/text.c:962 #, c-format msgid "Invalid URL pattern, or too many URLs (max %d).\n" msgstr "" -#: src/text.c:348 +#: src/text.c:973 #, c-format msgid "" "Refusing to write %zu downloads to a single file '%s'; use a directory or #N " "in --output.\n" msgstr "" -#: src/text.c:359 +#: src/text.c:992 #, c-format msgid "" "\n" "=== %zu/%zu: %s ===\n" msgstr "" -#: src/text.c:378 +#: src/text.c:1013 #, c-format msgid "" "\n" "%zu of %zu downloads completed.\n" msgstr "" -#: src/text.c:423 +#: src/text.c:1021 +#, c-format +msgid "" +"flux: %s looks like a web page, not a media file; no extractor config " +"matched. Try: flux --extract-scan \"%s\"\n" +msgstr "" + +#: src/text.c:1034 +#, c-format +msgid "Warning: --extract is ignored with multiple URLs.\n" +msgstr "" + +#: src/text.c:1077 #, c-format msgid "Initializing download: %s\n" msgstr "初始化下載:%s\n" -#: src/text.c:446 +#: src/text.c:1100 #, c-format msgid "Filename too long!\n" msgstr "檔名太長!\n" -#: src/text.c:459 +#: src/text.c:1113 #, c-format msgid "No state file, cannot resume!\n" msgstr "沒有狀態檔案,無法恢復下載!\n" -#: src/text.c:463 +#: src/text.c:1117 #, c-format msgid "State file found, but no downloaded data. Starting from scratch.\n" msgstr "找到狀態檔案,但沒有已下載的資料。重新開始下載。\n" -#: src/text.c:551 +#: src/text.c:1209 #, c-format msgid "" "\n" @@ -352,48 +504,120 @@ msgstr "" "\n" "已花費 %s 下載了 %s。 (%.2f KB/s)\n" -#: src/text.c:660 +#: src/text.c:1364 +#, c-format +msgid "Use either --all or --episodes, not both.\n" +msgstr "" + +#: src/text.c:1406 +#, c-format +msgid "Resolving %zu episodes\n" +msgstr "" + +#: src/text.c:1455 +#, c-format +msgid "" +"flux: %zu episodes found; pass --all or --episodes to choose non-" +"interactively.\n" +msgstr "" + +#: src/text.c:1489 +msgid " (unresolved)" +msgstr "" + +#: src/text.c:1490 +msgid " (duplicate)" +msgstr "" + +#: src/text.c:1520 +#, c-format +msgid "%s select episodes to download" +msgstr "" + +#: src/text.c:1529 +#, c-format +msgid "flux: episode selection cancelled.\n" +msgstr "" + +#: src/text.c:1534 +#, c-format +msgid "flux: no episodes selected.\n" +msgstr "" + +#: src/text.c:1557 +#, c-format +msgid "" +"Refusing to write %zu episodes to a single file '%s'; use a directory.\n" +msgstr "" + +#: src/text.c:1570 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: already on disk, skipping (%s) ===\n" +msgstr "" + +#: src/text.c:1577 +#, c-format +msgid "" +"\n" +"=== episode %zu/%zu: %s ===\n" +msgstr "" + +#: src/text.c:1582 +#, c-format +msgid "flux: episode %zu: could not resolve media URL.\n" +msgstr "" + +#: src/text.c:1598 +#, c-format +msgid "" +"\n" +"Episodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n" +msgstr "" + +#: src/text.c:1751 msgid "Kilo" msgstr "K" -#: src/text.c:660 +#: src/text.c:1751 msgid "Mega" msgstr "M" -#: src/text.c:660 +#: src/text.c:1751 msgid "Giga" msgstr "G" -#: src/text.c:660 +#: src/text.c:1751 msgid "Tera" msgstr "T" -#: src/text.c:666 +#: src/text.c:1757 #, c-format msgid "%g %sbyte(s)" msgstr "%g %s 位元組" -#: src/text.c:682 +#: src/text.c:1773 #, c-format msgid "%i:%02i:%02i hour(s)" msgstr "%i 時 %02i 分 %02i 秒" -#: src/text.c:684 +#: src/text.c:1775 #, c-format msgid "%i:%02i minute(s)" msgstr "%i 分 %02i 秒" -#: src/text.c:686 +#: src/text.c:1777 #, c-format msgid "%i second(s)" msgstr "%i 秒" -#: src/text.c:788 +#: src/text.c:1879 #, c-format msgid "Can't setup alternate output. Deactivating.\n" msgstr "無法設定替代輸出。停用。\n" -#: src/text.c:851 +#: src/text.c:1942 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -417,7 +641,7 @@ msgid "" "-T x\tSet I/O and connection timeout\n" "-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues\n" +"Visit https://example.invalid/hyperflux/issues\n" msgstr "" "使用方法:axel [選項] url1 [url2] [url...]\n" "\n" @@ -442,7 +666,7 @@ msgstr "" "\n" "請至 https://github.com/axel-download-accelerator/axel/issues 回報問題\n" -#: src/text.c:874 +#: src/text.c:1965 #, fuzzy, c-format msgid "" "Usage: flux [options] url1 [url2] [url...]\n" @@ -466,10 +690,22 @@ msgid "" "(0-100)\n" "--help\t\t\t-h\tThis information\n" "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" +"--extract=name\t\t\tForce a named extractor config\n" +"--extract-list\t\t\tList discovered extractor configs\n" +"--extract-scan=url\t\tScan a page, print the config, and suggest --save-" +"config (-o FILE writes to a path)\n" +"--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default " +"2, max 3)\n" +"--save-config=id\t\tSave a previously scanned pending config (its id) " +"active\n" +"--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" +"--all\t\t\t\tSeries: download every episode (no prompt)\n" +"--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n" +"--quality=q\t\t\tHLS variant: best|worst| (default best)\n" +"--mux=c\t\t\t\tHLS container: mp4|ts (default mp4 if ffmpeg)\n" "--version\t\t-V\tVersion information\n" "\n" -"Visit https://github.com/flux-download-accelerator/flux/issues to report " -"bugs\n" +"Visit https://example.invalid/hyperflux/issues to report bugs\n" msgstr "" "使用方法:axel [選項] url1 [url2] [url...]\n" "\n" @@ -495,16 +731,16 @@ msgstr "" "\n" "請至 https://github.com/axel-download-accelerator/axel/issues 回報問題\n" -#: src/text.c:903 +#: src/text.c:2004 #, fuzzy, c-format msgid "Hyperflux %s (%s)\n" msgstr "Axel %s(%s)\n" -#: src/text.c:917 +#: src/text.c:2018 msgid "and others." msgstr "及其他貢獻者。" -#: src/text.c:918 +#: src/text.c:2019 msgid "" "Please, see the CREDITS file.\n" "\n" diff --git a/src/scan.c b/src/scan.c index 5d2b8b7..7710c67 100644 --- a/src/scan.c +++ b/src/scan.c @@ -140,6 +140,27 @@ url_host(const char *url) return h; } +/* Return the first non-empty path segment of `url` as a malloc'd string, or + * NULL if none exists (e.g. "https://host/" or no scheme). */ +static char * +url_first_path_segment(const char *url) +{ + if (!url) + return NULL; + const char *sep = strstr(url, "://"); + if (!sep) + return NULL; + const char *path = sep + 3; + path += strcspn(path, "/?#"); /* skip authority */ + if (*path != '/') + return NULL; + path++; /* skip leading slash */ + size_t n = strcspn(path, "/?#"); + if (n == 0) + return NULL; + return scan_strndup(path, n); +} + /* ---- ad-domain blocklist --------------------------------------------- */ /* Small built-in list of ad/tracker hosts; a candidate on (or under) any of @@ -156,6 +177,21 @@ static const char *const scan_ad_hosts[] = { "adsrvr.org", "moatads.com", "scorecardresearch.com", + /* popunder / onclick / adult ad networks */ + "popads.net", + "propellerads.com", + "popcash.net", + "adsterra.com", + "hilltopads.net", + "hilltopads.com", + "exoclick.com", + "clickadu.com", + "juicyads.com", + "adcash.com", + "popmyads.com", + "mgid.com", + "onclickads.net", + "trafficjunky.com", }; int @@ -221,8 +257,32 @@ struct scan_ctx { extractor_fetch_fn fetch; scan_probe_fn probe; void *userdata; + /* Recursion bookkeeping (#scan-recur). The visited set prevents cycles + * and self-links; the fetch counter is a hard DoS guard. */ + char *visited[SCAN_MAX_FETCHES]; + size_t nvisited; + int nfetches; /* total page fetches so far */ + /* page0 body for the filename-as-param heuristic. Points into caller's + * buffer; NOT owned. NULL outside of discover_chain recursion. */ + const char *page0_body; }; +/* Record `url` as visited; returns 1 if already seen, 0 if newly added/unrecordable. + * Visited set is best-effort dedup: SCAN_MAX_FETCHES is the real termination bound. */ +static int +scan_visited(struct scan_ctx *ctx, const char *url) +{ + for (size_t i = 0; i < ctx->nvisited; i++) + if (strcmp(ctx->visited[i], url) == 0) + return 1; + if (ctx->nvisited < SCAN_MAX_FETCHES) { + char *dup = scan_strdup(url); + if (dup) + ctx->visited[ctx->nvisited++] = dup; + } + return 0; +} + /* Add one candidate (resolving `raw_ref` against the page URL). `context` is * the DOM/source signal. Duplicate URLs bump the existing candidate's count * (and upgrade its context toward PLAYER). Returns 0 on success, -1 on a hard @@ -460,6 +520,1245 @@ collect_iframes(struct scan_ctx *ctx, const char *text, int depth) return rc; } +/* ---- recursive watch/play/embed discovery (#scan-recur) --------------- */ + +/* Link-following patterns: group 1 captures the next-page href. POSIX ERE only, + * no {}/{}, no ' #', exactly one capture group. Most-specific first. */ +static const char *const scan_link_eres[] = { + /* anchors to common player/watch paths (one entry per path token so the + * single capture group is the href, not an alternation branch). */ + "]+href=[\"']([^\"'<> ]*/watch[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/play[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/player[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/embed[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/stream[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/video[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/v/[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/e/[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/load[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/iframe[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/serve[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/f/[^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/d/[^\"'<> ]*)[\"']", + /* query-param links carrying a media/player handle. */ + "href=[\"']([^\"'<> ]*[?&]file=[^\"'<> ]+)[\"']", + "href=[\"']([^\"'<> ]*[?&]v=[^\"'<> ]+)[\"']", + "href=[\"']([^\"'<> ]*[?&]id=[^\"'<> ]+)[\"']", + "href=[\"']([^\"'<> ]*[?&]ep=[^\"'<> ]+)[\"']", + "href=[\"']([^\"'<> ]*[?&]url=[^\"'<> ]+)[\"']", + "href=[\"']([^\"'<> ]*[?&]embed=[^\"'<> ]+)[\"']", + "href=[\"']([^\"'<> ]*[?&]hash=[^\"'<> ]+)[\"']", + "href=[\"']([^\"'<> ]*[?&]watch=[^\"'<> ]+)[\"']", + "href=[\"']([^\"'<> ]*[?&]token=[^\"'<> ]+)[\"']", + "href=[\"']([^\"'<> ]*[?&]e=[^\"'<> ]+)[\"']", + /* iframes and embeds (cross-origin allowed here: the point is to reach + * the player host; the ad-host blocklist still applies). */ + "]+src=[\"']([^\"'<> ]+)[\"']", + "]+src=[\"']([^\"'<> ]+)[\"']", + /* data-* attributes pointing at the player/source URL. */ + "data-src=[\"']([^\"'<> ]+)[\"']", + "data-file=[\"']([^\"'<> ]+)[\"']", + "data-url=[\"']([^\"'<> ]+)[\"']", + "data-embed=[\"']([^\"'<> ]+)[\"']", + "data-link=[\"']([^\"'<> ]+)[\"']", + "data-video=[\"']([^\"'<> ]+)[\"']", + "data-player=[\"']([^\"'<> ]+)[\"']", + "data-hash=[\"']([^\"'<> ]+)[\"']", + /* JSON page pointers to an embed/player/server URL. */ + "\"embedurl\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", + "\"embed_url\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", + "\"iframe\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", + "\"player\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", + "\"server\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", + "\"serverurl\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", + "\"source\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", +}; + +/* Episode-index patterns: group 1 = an episode href. If a single pattern + * matches >= SCAN_SERIES_MIN distinct hrefs on the landing page, it is a + * series. Same POSIX-ERE constraints as scan_link_eres. */ +static const char *const scan_episode_eres[] = { + "]+href=[\"']([^\"'<> ]*/ep/[0-9][^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/episode/[0-9][^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*/e/[0-9][^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*-episode-[0-9][^\"'<> ]*)[\"']", + "]+href=[\"']([^\"'<> ]*[?&]ep=[0-9]+[^\"'<> ]*)[\"']", +}; + + + +/* Media-capture patterns for the chain's final step. group 1 = the media URL, + * config-safe (REG_EXTENDED-only, no '{', no ' #', lowercase). */ +static const char *const scan_chain_media_eres[] = { + "]+src=[\"']([^\"'<> ]+)[\"']", + "]+src=[\"']([^\"'<> ]+)[\"']", + "\"file\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", + "\"hls\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", + "\"source\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", + "\"src\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"", + "file[[:space:]]*:[[:space:]]*[\"']([^\"'<> ]+)[\"']", + "(https?://[^\"'<> ]+\\.m3u8[^\"'<> ]*)", + "(https?://[^\"'<> ]+\\.mp4[^\"'<> ]*)", + "(https?://[^\"'<> ]+\\.webm[^\"'<> ]*)", +}; + +/* Capture group 1 of the first match of `ere` in `text` into a malloc'd string + * at *out (REG_EXTENDED|REG_ICASE). Returns 1 on a captured match, 0 on no + * match (out left NULL), -1 on OOM. A bad pattern is treated as no match. */ +static int +scan_first_capture(const char *text, const char *ere, char **out) +{ + *out = NULL; + regex_t re; + if (regcomp(&re, ere, REG_EXTENDED | REG_ICASE) != 0) + return 0; + regmatch_t m[2]; + int rc = 0; + if (regexec(&re, text, 2, m, 0) == 0 && m[1].rm_so >= 0) { + size_t len = (size_t)(m[1].rm_eo - m[1].rm_so); + char *cap = scan_strndup(text + m[1].rm_so, len); + if (!cap) + rc = -1; + else { + *out = cap; + rc = 1; + } + } + regfree(&re); + return rc; +} + +/* True if `ref` is a follow-worthy reference: not empty, not a pure fragment or + * anchor, not a mailto:/javascript:/tel: scheme. */ +static int +scan_followable_ref(const char *ref) +{ + if (!ref || !*ref) + return 0; + if (ref[0] == '#') + return 0; + if (strncasecmp(ref, "mailto:", 7) == 0 || + strncasecmp(ref, "javascript:", 11) == 0 || + strncasecmp(ref, "tel:", 4) == 0) + return 0; + return 1; +} + +/* Follow-link entry: resolved URL, raw ref, and capture ERE (all owned). */ +struct scan_follow { + char *abs; /* resolved absolute URL (owned) */ + char *ref; /* raw captured ref (owned) */ + char *ere; /* capture ERE for this link (owned; strdup'd from table or synthesized) */ +}; + +/* Max hrefs collected for series template grouping (fits episode-10+ comfortably). */ +#define BSF_MAX 128 + +/* Forward declarations — helpers defined after detect_series. */ +static int detect_series_generic(struct scan_ctx *ctx, const char *body, + char **first_ep); +static int build_series_from_hrefs(struct scan_ctx *ctx, + char **hrefs, size_t nhrefs, + char **first_ep); + +/* Synthesize a reusable capturing ERE from a concrete href `ref`. Literal parts + * are ERE-escaped; [0-9]+ replaces numeric runs; [A-Za-z0-9_-]+ replaces id-ish + * runs of 6+ alnum chars. Result: href=["']()["']. Exactly one + * capture group, POSIX ERE, no {}, no ' #'. Returns malloc'd string or NULL. */ +static char * +synthesize_link_ere(const char *ref) +{ + if (!ref || !*ref) + return NULL; + + /* First pass: build the generalized body into a fixed buffer. */ + char body[512]; + size_t bo = 0; + const char *p = ref; + while (*p && bo + 4 < sizeof(body)) { + /* Digit run -> [0-9]+ */ + if (isdigit((unsigned char)*p)) { + if (bo + 7 >= sizeof(body)) + break; + memcpy(body + bo, "[0-9]+", 6); + bo += 6; + while (isdigit((unsigned char)*p)) + p++; + continue; + } + /* Id-ish run: 6+ consecutive [A-Za-z0-9] chars that are all + * alphanumeric (no punctuation), replace with [A-Za-z0-9_-]+. */ + if (isalnum((unsigned char)*p)) { + const char *q = p; + while (isalnum((unsigned char)*q)) + q++; + if ((size_t)(q - p) >= 6) { + if (bo + 14 >= sizeof(body)) + break; + memcpy(body + bo, "[A-Za-z0-9_-]+", 14); + bo += 14; + p = q; + continue; + } + /* Short alnum literal: fall through to char-by-char. */ + } + /* ERE metachar escape. */ + char c = *p++; + if (strchr(".^$*+?()|[]\\", c)) { + body[bo++] = '\\'; + if (bo >= sizeof(body)) + break; + } + body[bo++] = c; + } + if (bo >= sizeof(body)) + bo = sizeof(body) - 1; + body[bo] = '\0'; + + /* Wrap: href=["']()["'] */ + char out[600]; + int n = snprintf(out, sizeof(out), "href=[\"'](%s)[\"']", body); + if (n <= 0 || (size_t)n >= sizeof(out)) + return NULL; + + /* Verify POSIX ERE compiles cleanly. */ + regex_t re; + if (regcomp(&re, out, REG_EXTENDED) != 0) + return NULL; + regfree(&re); + + return scan_strdup(out); +} + +/* Boost/penalty signals for generic link relevance scoring. + * Positive words lift; negative words (effectively) skip. */ +static const char *const scan_boost_tokens[] = { + "episode", "ep", "watch", "play", "player", "stream", "video", "vid", + "guarda", "vedi", "season", "stagione", "puntata", "episodio", + "capitolo", "movie", "film", "serie", "anime", "embed", + NULL +}; +static const char *const scan_penalty_tokens[] = { + "login", "signin", "signup", "register", "account", "cart", + "checkout", "category", "categor", "genre", "gener", "tag", + "search", "cerca", "contact", "about", "privacy", "terms", "faq", + "donat", "page=", "/page/", "rss", "feed", "sitemap", + "facebook", "twitter", "x.com", "instagram", "youtube", "t.me", + "whatsapp", + /* redirect / affiliate / ad traps */ + "/out", "/go/", "/redirect", "redir", "aff=", "affiliate", + "sponsor", "promo", "banner", "popup", "popunder", "/ads", + "adserver", "/click", "utm_", "linkvert", "shorten", "/away", + NULL +}; + +/* Return a relevance score for a candidate link. `href` is the raw ref; + * `ctx_window` is ~80 chars of surrounding HTML (may be NULL). Higher=better; + * negative means skip. */ +static int +score_link(const char *href, const char *ctx_window) +{ + if (!href) + return -9999; + + /* Scratch buffer: lowercased href + window for token search. */ + char buf[256]; + snprintf(buf, sizeof(buf), "%s %s", href, ctx_window ? ctx_window : ""); + for (char *q = buf; *q; q++) + *q = (char)tolower((unsigned char)*q); + + /* Hard penalties first. */ + for (size_t i = 0; scan_penalty_tokens[i]; i++) + if (strstr(buf, scan_penalty_tokens[i])) + return -9999; + + int score = 0; + for (size_t i = 0; scan_boost_tokens[i]; i++) + if (strstr(buf, scan_boost_tokens[i])) + score += 10; + + /* Digit run in the href path boosts (episode numbers). */ + const char *path = strchr(href, '/'); + if (path) { + for (const char *q = path; *q; q++) + if (isdigit((unsigned char)*q)) { + score += 5; + break; + } + /* Id-ish last segment (6+ alnum) also boosts. */ + const char *last = strrchr(path, '/'); + if (last) { + last++; + size_t alen = 0; + for (const char *q = last; isalnum((unsigned char)*q); q++) + alen++; + if (alen >= 6) + score += 3; + } + } + return score; +} + +/* Extract the path portion of an absolute URL (everything from the first '/' + * after the authority up to '?' or '#'), or "" if none. Result is a pointer + * into `url` — not malloc'd, valid for the lifetime of `url`. */ +static const char * +url_path_ptr(const char *url) +{ + if (!url) + return ""; + const char *sep = strstr(url, "://"); + if (!sep) + return ""; + const char *auth = sep + 3; + const char *path = auth + strcspn(auth, "/?#"); + return (*path == '/') ? path : ""; +} + +/* Path portion of a ref that may be absolute, scheme-relative, or relative. + * Returns a pointer into `ref` so a relative template anchors path-to-path. */ +static const char * +ref_path_ptr(const char *ref) +{ + if (!ref) + return ""; + const char *sep = strstr(ref, "://"); + if (sep) { /* absolute: skip scheme://authority */ + const char *auth = sep + 3; + const char *path = auth + strcspn(auth, "/?#"); + return (*path == '/') ? path : ""; + } + if (ref[0] == '/' && ref[1] == '/') { /* scheme-relative //host/path */ + const char *auth = ref + 2; + const char *path = auth + strcspn(auth, "/?#"); + return (*path == '/') ? path : ""; + } + return ref; /* absolute or relative path: as-is */ +} + +/* Compute length of the common prefix between two strings (char count). */ +static size_t +common_prefix_len(const char *a, const char *b) +{ + size_t n = 0; + while (*a && *a == *b) { a++; b++; n++; } + return n; +} + +/* Collect same-site links not already in `out[0..n-1]`, score them, + * and append up to `max - n` of the highest-scoring non-penalized ones. + * At `depth > 0` (already inside a followed page), generic candidates must + * share a path prefix with ctx->page_url to stay within the same content + * subtree. Returns updated count, or -1 on OOM. */ +static int +collect_generic_links(struct scan_ctx *ctx, const char *text, + struct scan_follow *out, size_t n, size_t max, + int depth) +{ + /* Generic anchor harvest ERE: group 1 = href value. */ + static const char generic_ere[] = + "]+href=[\"']([^\"'<> ]+)[\"']"; + + char *page_host = url_host(ctx->page_url); + + /* Scored candidate pool (bounded). */ +#define GLINK_MAX 64 + struct { char *abs; char *ref; int score; } pool[GLINK_MAX]; + size_t npool = 0; + + regex_t re; + if (regcomp(&re, generic_ere, REG_EXTENDED | REG_ICASE) != 0) { + free(page_host); + return (int)n; + } + + const char *p = text; + regmatch_t m[2]; + while (npool < GLINK_MAX && regexec(&re, p, 2, m, 0) == 0) { + if (m[1].rm_so < 0) + break; + + /* Extract surrounding window (~80 chars before the match start). */ + ptrdiff_t wstart = m[0].rm_so > 80 ? m[0].rm_so - 80 : 0; + size_t wlen = (size_t)(m[0].rm_eo - wstart); + if (wlen > 160) + wlen = 160; + char window[161]; + memcpy(window, p + wstart, wlen); + window[wlen] = '\0'; + + size_t rlen = (size_t)(m[1].rm_eo - m[1].rm_so); + char *ref = scan_strndup(p + m[1].rm_so, rlen); + if (!ref) { + regfree(&re); + free(page_host); + for (size_t i = 0; i < npool; i++) { + free(pool[i].abs); + free(pool[i].ref); + } + return -1; + } + + regoff_t adv = m[0].rm_eo > m[0].rm_so ? m[0].rm_eo + : m[0].rm_so + 1; + p += adv; + + if (!scan_followable_ref(ref)) { + free(ref); + continue; + } + + int sc = score_link(ref, window); + if (sc < 0) { + free(ref); + continue; + } + + char *abs = extractor_resolve_url(ctx->page_url, ref); + if (!abs) { + free(ref); + continue; /* resolution failure: tolerate */ + } + + /* Same-site only. */ + char *h = url_host(abs); + int same = (h && page_host && strcmp(h, page_host) == 0); + int is_ad = (h && scan_is_ad_host(h)); + free(h); + if (!same || is_ad) { + free(abs); + free(ref); + continue; + } + + /* At depth>0, generic links must share a path prefix with the + * current page to stay within the same content subtree. */ + if (depth > 0) { + const char *my_path = url_path_ptr(ctx->page_url); + const char *cand_path = url_path_ptr(abs); + /* Require at least one shared path segment (the leading + * slash counts; we need the prefix up to the next '/'). */ + const char *my_seg_end = strchr(my_path + 1, '/'); + size_t my_seg_len = my_seg_end + ? (size_t)(my_seg_end - my_path) + : strlen(my_path); + size_t cp_len = common_prefix_len(my_path, cand_path); + if (cp_len < my_seg_len) { + free(abs); + free(ref); + continue; + } + } + + /* Skip if already in out[] or already queued in pool. */ + int dup = 0; + for (size_t i = 0; i < n && !dup; i++) + if (strcmp(out[i].abs, abs) == 0) + dup = 1; + for (size_t i = 0; i < npool && !dup; i++) + if (strcmp(pool[i].abs, abs) == 0) + dup = 1; + /* Also skip the current page itself. */ + if (!dup && strcmp(abs, ctx->page_url) == 0) + dup = 1; + if (dup) { + free(abs); + free(ref); + continue; + } + + pool[npool].abs = abs; + pool[npool].ref = ref; + pool[npool].score = sc; + npool++; + } + regfree(&re); + free(page_host); + + /* Sort pool descending by score (insertion sort; small pool). */ + for (size_t i = 1; i < npool; i++) { + typeof(pool[0]) key = pool[i]; + size_t j = i; + while (j > 0 && pool[j - 1].score < key.score) { + pool[j] = pool[j - 1]; + j--; + } + pool[j] = key; + } + + /* Emit top candidates up to remaining capacity; synthesize EREs. */ + for (size_t i = 0; i < npool && n < max; i++) { + char *synth = synthesize_link_ere(pool[i].ref); + if (!synth) { + /* fallback: generic href ERE */ + synth = scan_strdup(generic_ere); + } + if (!synth) { + /* OOM: free pool tail and bail. */ + for (size_t k = i; k < npool; k++) { + free(pool[k].abs); + free(pool[k].ref); + } + /* Entries 0..i-1 already consumed (abs/ref transferred). */ + return -1; + } + out[n].abs = pool[i].abs; + out[n].ref = pool[i].ref; + out[n].ere = synth; + n++; + pool[i].abs = NULL; + pool[i].ref = NULL; + } + /* Free any pool entries that didn't fit. */ + for (size_t i = 0; i < npool; i++) { + free(pool[i].abs); + free(pool[i].ref); + } + return (int)n; +#undef GLINK_MAX +} + +/* Collect up to SCAN_MAX_FOLLOW unique, followable "next page" links from + * `text`. Each output entry holds the resolved absolute URL, the raw ref (for a + * provenance comment), and the ERE that captured it. `depth` is the hop count + * at which these links will be followed (0 = landing page). Caller frees + * abs/ref/ere of each filled slot. Returns the number filled, or -1 on OOM. */ +static int +collect_follow_links(struct scan_ctx *ctx, const char *text, + struct scan_follow *out, size_t max, int depth) +{ + size_t n = 0; + for (size_t pi = 0; + pi < sizeof(scan_link_eres) / sizeof(scan_link_eres[0]) && n < max; + pi++) { + const char *ere = scan_link_eres[pi]; + regex_t re; + if (regcomp(&re, ere, REG_EXTENDED | REG_ICASE) != 0) + continue; + const char *p = text; + regmatch_t m[2]; + while (n < max && regexec(&re, p, 2, m, 0) == 0) { + if (m[1].rm_so < 0) + break; + size_t len = (size_t)(m[1].rm_eo - m[1].rm_so); + char *ref = scan_strndup(p + m[1].rm_so, len); + if (!ref) { + regfree(&re); + return -1; + } + if (scan_followable_ref(ref)) { + char *abs = extractor_resolve_url(ctx->page_url, + ref); + if (!abs) { + free(ref); + regfree(&re); + return -1; + } + /* Skip ad hosts and already-followed/queued URLs. */ + char *h = url_host(abs); + int bad = (h && scan_is_ad_host(h)); + free(h); + for (size_t i = 0; i < ctx->nvisited && !bad; i++) + if (strcmp(ctx->visited[i], abs) == 0) + bad = 1; + for (size_t i = 0; i < n && !bad; i++) + if (strcmp(out[i].abs, abs) == 0) + bad = 1; + if (bad) { + free(abs); + free(ref); + } else { + char *ere_owned = scan_strdup(ere); + if (!ere_owned) { + free(abs); + free(ref); + regfree(&re); + return -1; + } + out[n].abs = abs; + out[n].ref = ref; + out[n].ere = ere_owned; + n++; + } + } else { + free(ref); + } + regoff_t adv = m[0].rm_eo > m[0].rm_so ? m[0].rm_eo + : m[0].rm_so + 1; + if (adv <= 0) + break; + p += adv; + } + regfree(&re); + } + /* Fill remaining slots with generic relevance-ranked same-site links. */ + if ((size_t)n < max) { + int gn = collect_generic_links(ctx, text, out, (size_t)n, max, + depth); + if (gn < 0) + return -1; + n = gn; + } + return (int)n; +} + +static void +free_follow_links(struct scan_follow *links, size_t n) +{ + for (size_t i = 0; i < n; i++) { + free(links[i].abs); + free(links[i].ref); + free(links[i].ere); + } +} + +/* Record a media candidate discovered at the end of a chain. `steps`/`nsteps` + * are the accumulated capture steps (link steps + the final media step). The + * candidate's chain is a deep copy of those steps. Returns 0 on success (or a + * benign no-op), -1 on OOM. Deduplicates by URL like add_candidate, keeping the + * first-seen (shortest) chain. */ +static int +add_chain_candidate(struct scan_ctx *ctx, const char *media_url, + scan_kind_t kind, scan_ctx_t context, + const scan_step_t *steps, size_t nsteps, int depth) +{ + scan_result_t *r = ctx->result; + + for (size_t i = 0; i < r->ncands; i++) { + if (strcmp(r->cands[i].url, media_url) == 0) { + r->cands[i].count++; + if (context == SCAN_CTX_PLAYER) + r->cands[i].context = SCAN_CTX_PLAYER; + return 0; + } + } + if (r->ncands >= SCAN_MAX_CANDIDATES) + return 0; + if (nsteps == 0 || nsteps > SCAN_MAX_CHAIN) + return 0; /* defensive: a chain must have at least one step */ + + char *url = scan_strdup(media_url); + if (!url) + return -1; + char *host = url_host(url); + scan_candidate_t *c = &r->cands[r->ncands]; + memset(c, 0, sizeof(*c)); + c->url = url; + c->host = host; + c->kind = kind; + c->context = context; + c->size = -1; + c->count = 1; + c->ad_host = host ? scan_is_ad_host(host) : 0; + c->depth = depth; + for (size_t i = 0; i < nsteps; i++) { + c->chain[i].ere = scan_strdup(steps[i].ere); + c->chain[i].sample = steps[i].sample ? + scan_strdup(steps[i].sample) : NULL; + if (!c->chain[i].ere || + (steps[i].sample && !c->chain[i].sample)) { + /* OOM mid-copy: unwind this partial chain and bail. */ + for (size_t j = 0; j <= i; j++) { + free(c->chain[j].ere); + free(c->chain[j].sample); + } + free(url); + free(host); + return -1; + } + } + c->nchain = nsteps; + r->ncands++; + return 0; +} + +/* Check whether the media URL's basename appears on page0 as = + * where is [A-Za-z_][A-Za-z0-9_]*. If so, set *out_token and + * *out_base (both malloc'd). Returns 1 on match, 0 on no match, -1 on OOM. + * Only fires when the full media URL itself is NOT present on page0 (that's the + * existing depth-0 direct case, handled separately). See #scan-basename-param. */ +static int +check_basename_param(const char *page0, const char *media_url, + char **out_token, char **out_base) +{ + if (!page0 || !media_url) + return 0; + + /* Extract basename: everything after the last '/'. */ + const char *slash = strrchr(media_url, '/'); + if (!slash || slash[1] == '\0') + return 0; + const char *basename = slash + 1; + size_t blen = strlen(basename); + if (blen == 0) + return 0; + + /* Reject if full URL appears literally in page0 (direct case). */ + if (strstr(page0, media_url) != NULL) + return 0; + + /* Search page0 for = where token=[A-Za-z_][A-Za-z0-9_]* */ + const char *p = page0; + while (*p) { + /* Find the basename in the remaining page text. */ + const char *hit = strstr(p, basename); + if (!hit) + break; + + /* Walk back past '=' to find the token. */ + if (hit == page0 || hit[-1] != '=') { + p = hit + 1; + continue; + } + /* Find start of token (the identifier before '='). */ + const char *eq = hit - 1; + const char *tok_end = eq; /* points at '=' */ + const char *tok_start = tok_end - 1; + while (tok_start > page0 && + (isalnum((unsigned char)tok_start[-1]) || + tok_start[-1] == '_')) + tok_start--; + size_t tok_len = (size_t)(tok_end - tok_start); + if (tok_len == 0 || (!isalpha((unsigned char)tok_start[0]) && + tok_start[0] != '_')) { + p = hit + 1; + continue; + } + + /* Verify char after basename is a natural delimiter (not alnum/./%). */ + const char *after = hit + blen; + if (*after != '\0' && *after != '"' && *after != '\'' && + *after != '&' && *after != '<' && *after != '>' && + *after != ' ' && *after != '\t' && *after != '\n' && + *after != ')') { + p = hit + 1; + continue; + } + + /* Found a valid = match. Build media_base. */ + /* media_base = media_url up to and including the last '/'. */ + size_t base_len = (size_t)(slash - media_url) + 1; /* includes '/' */ + char *token = malloc(tok_len + 1); + char *base = malloc(base_len + 1); + if (!token || !base) { + free(token); + free(base); + return -1; + } + memcpy(token, tok_start, tok_len); + token[tok_len] = '\0'; + memcpy(base, media_url, base_len); + base[base_len] = '\0'; + + *out_token = token; + *out_base = base; + return 1; + } + return 0; +} + +/* Recursively follow watch/play/embed links from `body` (the page at + * ctx->page_url) looking for media. `steps`/`nsteps` is the chain accumulated so + * far (link steps). `depth` is the number of hops already taken to reach this + * page. Returns 0 on success (media found or not), -1 on a hard error. */ +static int +discover_chain(struct scan_ctx *ctx, const char *body, scan_step_t *steps, + size_t nsteps, int depth, int max_depth) +{ + /* 1) Media on this page? Record a candidate with the media step appended. */ + for (size_t mi = 0; + mi < sizeof(scan_chain_media_eres) / sizeof(scan_chain_media_eres[0]); + mi++) { + char *cap = NULL; + int r = scan_first_capture(body, scan_chain_media_eres[mi], &cap); + if (r < 0) + return -1; + if (r == 0) + continue; + char *media_url = extractor_resolve_url(ctx->page_url, cap); + if (!media_url) { + free(cap); + return -1; + } + scan_kind_t kind; + if (classify_url(media_url, &kind) && nsteps < SCAN_MAX_CHAIN) { + steps[nsteps].ere = (char *)scan_chain_media_eres[mi]; + steps[nsteps].sample = media_url; + int ar = add_chain_candidate(ctx, media_url, kind, + SCAN_CTX_PLAYER, steps, + nsteps + 1, depth); + /* Filename-as-param heuristic: if page0 body contains + * = pointing at this media, record it so + * emit can produce a 1-hop template instead of the chain. */ + if (ar == 0 && depth > 0 && ctx->page0_body) { + scan_result_t *res = ctx->result; + scan_candidate_t *cand = NULL; + for (size_t ci = 0; ci < res->ncands; ci++) { + if (strcmp(res->cands[ci].url, + media_url) == 0) { + cand = &res->cands[ci]; + break; + } + } + if (cand && !cand->param_token) { + char *tok = NULL, *base = NULL; + int hr = check_basename_param( + ctx->page0_body, media_url, + &tok, &base); + if (hr == 1) { + cand->param_token = tok; + cand->media_base = base; + } else if (hr < 0) { + ar = -1; /* OOM */ + } + } + } + free(media_url); + free(cap); + return ar; /* first media hit on a page is enough */ + } + free(media_url); + free(cap); + } + + /* 2) No media here: follow links one level deeper if budget remains. */ + if (depth >= max_depth || nsteps >= SCAN_MAX_CHAIN - 1) + return 0; + + struct scan_follow links[SCAN_MAX_FOLLOW]; + memset(links, 0, sizeof(links)); + int nl = collect_follow_links(ctx, body, links, SCAN_MAX_FOLLOW, depth); + if (nl < 0) + return -1; + + int rc = 0; + for (int i = 0; i < nl && rc == 0; i++) { + if (ctx->nfetches >= SCAN_MAX_FETCHES) + break; + if (scan_visited(ctx, links[i].abs)) + continue; /* cycle/self-link guard */ + char *child = NULL; + ctx->nfetches++; + if (ctx->fetch(links[i].abs, NULL, 0, &child, ctx->userdata) != 0 + || !child) + continue; /* dead link: tolerate and move on */ + if (strlen(child) > SCAN_MAX_PAGE) { + free(child); + continue; + } + /* Push this link's capture step and recurse against the child. */ + steps[nsteps].ere = links[i].ere; + steps[nsteps].sample = links[i].ref; + const char *saved = ctx->page_url; + ctx->page_url = links[i].abs; + rc = discover_chain(ctx, child, steps, nsteps + 1, depth + 1, + max_depth); + ctx->page_url = saved; + free(child); + } + free_follow_links(links, (size_t)nl); + return rc; +} + +/* Detect a series index on the landing `body`. If a single episode pattern + * matches >= SCAN_SERIES_MIN distinct hrefs, set result->is_series and fill + * list_ere/series_path, and return the FIRST episode href (malloc'd) in + * *first_ep for chain discovery. Returns 0 (sets *first_ep on a series), -1 on + * OOM. *first_ep is NULL when not a series. */ +static int +detect_series(struct scan_ctx *ctx, const char *body, char **first_ep) +{ + *first_ep = NULL; + + /* Try each hardcoded episode pattern. Collect ALL matching hrefs (cap + * BSF_MAX so episode-10+ is not dropped) and pass them to the shared + * anchoring helper so the dominant series wins, not the broadest match. */ + for (size_t pi = 0; + pi < sizeof(scan_episode_eres) / sizeof(scan_episode_eres[0]); + pi++) { + regex_t re; + if (regcomp(&re, scan_episode_eres[pi], + REG_EXTENDED | REG_ICASE) != 0) + continue; + + char *hrefs[BSF_MAX]; + size_t nhrefs = 0; + const char *p = body; + regmatch_t m[2]; + while (nhrefs < BSF_MAX && regexec(&re, p, 2, m, 0) == 0) { + if (m[1].rm_so < 0) + break; + size_t len = (size_t)(m[1].rm_eo - m[1].rm_so); + char *ref = scan_strndup(p + m[1].rm_so, len); + if (!ref) { + for (size_t i = 0; i < nhrefs; i++) + free(hrefs[i]); + regfree(&re); + return -1; + } + int dup = 0; + for (size_t i = 0; i < nhrefs; i++) + if (strcmp(hrefs[i], ref) == 0) { + dup = 1; + break; + } + if (!dup) + hrefs[nhrefs++] = ref; + else + free(ref); + regoff_t adv = m[0].rm_eo > m[0].rm_so ? m[0].rm_eo + : m[0].rm_so + 1; + if (adv <= 0) + break; + p += adv; + } + regfree(&re); + + if (nhrefs < SCAN_SERIES_MIN) { + for (size_t i = 0; i < nhrefs; i++) + free(hrefs[i]); + continue; + } + + /* Ownership transferred; build_series_from_hrefs frees hrefs[]. */ + int rc = build_series_from_hrefs(ctx, hrefs, nhrefs, first_ep); + if (rc != 0 || ctx->result->is_series) + return rc; + /* Helper freed hrefs[] but found no dominant group; try next pattern. */ + } + + if (ctx->result->is_series) + return 0; + + /* Fallback: structural template-based detection for non-standard URLs. */ + return detect_series_generic(ctx, body, first_ep); +} + +/* Shared helper: given an array of `nhrefs` distinct hrefs (all owned, freed + * before return), select the dominant series group by template relatedness to + * ctx->page_url, build an anchored list_ere, and return it + the first episode + * href. Sets r->is_series / r->list_ere / r->series_path on success. + * Returns 0 (with *first_ep set) if a series was found, 0 (NULL) if not, -1 + * on OOM. All hrefs[] entries and templates[] are freed before return. */ +static int +build_series_from_hrefs(struct scan_ctx *ctx, + char **hrefs, size_t nhrefs, + char **first_ep) +{ + *first_ep = NULL; + scan_result_t *r = ctx->result; + + if (nhrefs < SCAN_SERIES_MIN) { + for (size_t i = 0; i < nhrefs; i++) + free(hrefs[i]); + return 0; + } + + /* Build templates: replace maximal [0-9]+ runs with '#'. */ + char *templates[BSF_MAX]; + for (size_t i = 0; i < nhrefs; i++) { + char tmp[512]; + size_t to = 0; + for (const char *q = hrefs[i]; *q && to + 2 < sizeof(tmp); ) { + if (isdigit((unsigned char)*q)) { + tmp[to++] = '#'; + while (isdigit((unsigned char)*q)) + q++; + } else { + tmp[to++] = *q++; + } + } + tmp[to] = '\0'; + templates[i] = scan_strdup(tmp); + if (!templates[i]) { + for (size_t k = 0; k < i; k++) + free(templates[k]); + for (size_t k = 0; k < nhrefs; k++) + free(hrefs[k]); + return -1; + } + } + + /* Select the dominant template: prefer the one most related to the + * landing URL so a multi-series page picks the right one. Tiers: + * 2 = generalized ERE matches landing URL (user is on an episode); + * 1 = longest common path prefix with landing path; + * 0 = no relation (fall back to member count). See #scan-series-anchor. */ + const char *landing_path = url_path_ptr(ctx->page_url); + + char *best_tmpl = NULL; + size_t best_cnt = 0; + int best_rel = -1; + size_t best_pfx = 0; + + for (size_t i = 0; i < nhrefs; i++) { + /* Skip duplicate template strings (score each unique template once). */ + int seen = 0; + for (size_t k = 0; k < i && !seen; k++) + if (strcmp(templates[k], templates[i]) == 0) + seen = 1; + if (seen) + continue; + + size_t cnt = 0; + for (size_t j = 0; j < nhrefs; j++) + if (strcmp(templates[i], templates[j]) == 0) + cnt++; + if (cnt < SCAN_SERIES_MIN) + continue; + + /* Build candidate ERE for tier-2 test (landing matches an episode). */ + char e_body[512]; + size_t eb = 0; + for (const char *q = templates[i]; *q && eb + 10 < sizeof(e_body); ) { + if (*q == '#') { + memcpy(e_body + eb, "[0-9]+", 6); + eb += 6; + q++; + } else { + char c = *q++; + if (strchr(".^$*+?()|[]\\", c)) + e_body[eb++] = '\\'; + e_body[eb++] = c; + } + } + e_body[eb] = '\0'; + char cand_ere[600]; + snprintf(cand_ere, sizeof(cand_ere), "href=[\"'](%s)[\"']", e_body); + + int rel = 0; + size_t pfx = 0; + + regex_t cre; + if (regcomp(&cre, cand_ere, REG_EXTENDED) == 0) { + char hb[1024]; + regmatch_t cm[2]; + snprintf(hb, sizeof(hb), "href=\"%s\"", ctx->page_url); + if (regexec(&cre, hb, 2, cm, 0) == 0) + rel = 2; + if (rel < 2 && *landing_path) { + snprintf(hb, sizeof(hb), "href=\"%s\"", landing_path); + if (regexec(&cre, hb, 2, cm, 0) == 0) + rel = 2; + } + regfree(&cre); + } + if (rel < 2) { + pfx = common_prefix_len(ref_path_ptr(templates[i]), + landing_path); + if (pfx > 1) + rel = 1; + } + + int better = (rel > best_rel) || + (rel == best_rel && pfx > best_pfx) || + (rel == best_rel && pfx == best_pfx && + cnt > best_cnt); + if (!best_tmpl || better) { + best_tmpl = templates[i]; + best_cnt = cnt; + best_rel = rel; + best_pfx = pfx; + } + } + + if (!best_tmpl || best_cnt < SCAN_SERIES_MIN) { + for (size_t i = 0; i < nhrefs; i++) { + free(templates[i]); + free(hrefs[i]); + } + return 0; + } + + /* Build list_ere: template '#' -> [0-9]+, ERE-escape literals. */ + char ere_body[512]; + size_t eo = 0; + for (const char *q = best_tmpl; *q && eo + 10 < sizeof(ere_body); ) { + if (*q == '#') { + memcpy(ere_body + eo, "[0-9]+", 6); + eo += 6; + q++; + } else { + char c = *q++; + if (strchr(".^$*+?()|[]\\", c)) + ere_body[eo++] = '\\'; + ere_body[eo++] = c; + } + } + ere_body[eo] = '\0'; + + char list_ere_buf[600]; + int sn = snprintf(list_ere_buf, sizeof(list_ere_buf), + "href=[\"'](%s)[\"']", ere_body); + if (sn <= 0 || (size_t)sn >= sizeof(list_ere_buf)) { + for (size_t i = 0; i < nhrefs; i++) { + free(templates[i]); + free(hrefs[i]); + } + return 0; + } + + /* Verify the ERE compiles under REG_EXTENDED before committing. */ + regex_t lre; + if (regcomp(&lre, list_ere_buf, REG_EXTENDED) != 0) { + for (size_t i = 0; i < nhrefs; i++) { + free(templates[i]); + free(hrefs[i]); + } + return 0; + } + regfree(&lre); + + /* Capture the template string and first matching href before freeing. */ + char *tmpl_copy = scan_strdup(best_tmpl); + char *first = NULL; + if (!tmpl_copy) { + for (size_t k = 0; k < nhrefs; k++) { + free(templates[k]); + free(hrefs[k]); + } + return -1; + } + for (size_t i = 0; i < nhrefs; i++) { + if (strcmp(templates[i], tmpl_copy) == 0 && !first) { + first = scan_strdup(hrefs[i]); + if (!first) { + free(tmpl_copy); + for (size_t k = 0; k < nhrefs; k++) { + free(templates[k]); + free(hrefs[k]); + } + return -1; + } + } + } + for (size_t i = 0; i < nhrefs; i++) { + free(templates[i]); + free(hrefs[i]); + } + + if (!first) { + free(tmpl_copy); + return 0; + } + + r->is_series = 1; + r->series_path = tmpl_copy; + r->list_ere = scan_strdup(list_ere_buf); + if (!r->list_ere) { + free(first); + return -1; /* series_path freed by scan_result_free */ + } + *first_ep = first; + return 0; +} + +/* Structural (template-based) series detection. Harvest same-site anchor hrefs + * and delegate to build_series_from_hrefs for template selection. */ +static int +detect_series_generic(struct scan_ctx *ctx, const char *body, char **first_ep) +{ + *first_ep = NULL; + + static const char anc_ere[] = "]+href=[\"']([^\"'<> ]+)[\"']"; + regex_t re; + if (regcomp(&re, anc_ere, REG_EXTENDED | REG_ICASE) != 0) + return 0; + + char *page_host = url_host(ctx->page_url); + char *hrefs[BSF_MAX]; + size_t nhrefs = 0; + + const char *p = body; + regmatch_t m[2]; + while (nhrefs < BSF_MAX && regexec(&re, p, 2, m, 0) == 0) { + if (m[1].rm_so < 0) + break; + size_t len = (size_t)(m[1].rm_eo - m[1].rm_so); + char *ref = scan_strndup(p + m[1].rm_so, len); + if (!ref) { + regfree(&re); + free(page_host); + for (size_t i = 0; i < nhrefs; i++) + free(hrefs[i]); + return -1; + } + regoff_t adv = m[0].rm_eo > m[0].rm_so ? m[0].rm_eo + : m[0].rm_so + 1; + p += adv; + + if (!scan_followable_ref(ref)) { free(ref); continue; } + char *abs = extractor_resolve_url(ctx->page_url, ref); + if (!abs) { free(ref); continue; } + char *h = url_host(abs); + int same = (h && page_host && strcmp(h, page_host) == 0); + free(h); + free(abs); + if (!same) { free(ref); continue; } + int dup = 0; + for (size_t i = 0; i < nhrefs && !dup; i++) + if (strcmp(hrefs[i], ref) == 0) + dup = 1; + if (dup) { free(ref); continue; } + hrefs[nhrefs++] = ref; + } + regfree(&re); + free(page_host); + + /* Ownership of hrefs[] transferred to build_series_from_hrefs. */ + return build_series_from_hrefs(ctx, hrefs, nhrefs, first_ep); +} + +/* Drive the recursive discovery once the landing page yielded no direct media. + * Series pages run the chain from the first episode; otherwise from each + * followed landing link. Returns 0 on success, -1 on a hard error. */ +static int +scan_recurse(struct scan_ctx *ctx, const char *body, int max_depth) +{ + scan_step_t steps[SCAN_MAX_CHAIN]; + memset(steps, 0, sizeof(steps)); + + char *first_ep = NULL; + if (detect_series(ctx, body, &first_ep) < 0) + return -1; + + if (ctx->result->is_series && first_ep) { + /* Series: run chain discovery from the sample episode (P0). The + * generated chain becomes the per-episode pipeline. */ + char *ep_url = extractor_resolve_url(ctx->page_url, first_ep); + free(first_ep); + if (!ep_url) + return -1; + int rc = 0; + if (!scan_visited(ctx, ep_url) && + ctx->nfetches < SCAN_MAX_FETCHES) { + char *ep_body = NULL; + ctx->nfetches++; + if (ctx->fetch(ep_url, NULL, 0, &ep_body, + ctx->userdata) == 0 && ep_body && + strlen(ep_body) <= SCAN_MAX_PAGE) { + const char *saved = ctx->page_url; + ctx->page_url = ep_url; + ctx->page0_body = ep_body; /* for basename-param heuristic */ + rc = discover_chain(ctx, ep_body, steps, 0, 0, + max_depth); + ctx->page0_body = NULL; + ctx->page_url = saved; + } + free(ep_body); + } + free(ep_url); + return rc; + } + free(first_ep); + + /* Non-series: discover a chain from the landing page itself. */ + ctx->page0_body = body; + int rc2 = discover_chain(ctx, body, steps, 0, 0, max_depth); + ctx->page0_body = NULL; + return rc2; +} + /* ---- enrichment (duration / size) ------------------------------------- */ /* For an HLS candidate, fetch the playlist and record total duration and (for a @@ -616,7 +1915,7 @@ sort_candidates(scan_result_t *r) scan_result_t * scan_page(const char *page_url, extractor_fetch_fn fetch, scan_probe_fn probe, - void *userdata, char **err) + int max_depth, void *userdata, char **err) { if (err) *err = NULL; @@ -656,18 +1955,43 @@ scan_page(const char *page_url, extractor_fetch_fn fetch, scan_probe_fn probe, return NULL; } - struct scan_ctx ctx = { r, page_url, fetch, probe, userdata }; + struct scan_ctx ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.result = r; + ctx.page_url = page_url; + ctx.fetch = fetch; + ctx.probe = probe; + ctx.userdata = userdata; + scan_visited(&ctx, page_url); /* the landing page counts as visited */ + ctx.nfetches = 1; /* landing page already fetched above */ if (collect_patterns(&ctx, body) < 0 || collect_iframes(&ctx, body, 0) < 0) { free(body); + for (size_t i = 0; i < ctx.nvisited; i++) + free(ctx.visited[i]); scan_set_err(err, "out of memory while scanning page"); scan_result_free(r); return NULL; } + + /* No direct media: follow watch/play/embed links (and detect series) to + * reach the player a bounded number of hops away (#scan-recur). */ + if (r->ncands == 0 && max_depth >= 1) { + if (scan_recurse(&ctx, body, max_depth) < 0) { + free(body); + for (size_t i = 0; i < ctx.nvisited; i++) + free(ctx.visited[i]); + scan_set_err(err, "out of memory while scanning page"); + scan_result_free(r); + return NULL; + } + } free(body); + for (size_t i = 0; i < ctx.nvisited; i++) + free(ctx.visited[i]); - /* Enrich and score. */ + /* Enrich and score (recursive candidates participate in scoring). */ for (size_t i = 0; i < r->ncands; i++) { if (r->cands[i].kind == SCAN_KIND_HLS) enrich_hls(&ctx, &r->cands[i]); @@ -686,9 +2010,17 @@ scan_result_free(scan_result_t *r) if (!r) return; free(r->page_url); + free(r->list_ere); + free(r->series_path); for (size_t i = 0; i < r->ncands; i++) { free(r->cands[i].url); free(r->cands[i].host); + free(r->cands[i].param_token); + free(r->cands[i].media_base); + for (size_t j = 0; j < r->cands[i].nchain; j++) { + free(r->cands[i].chain[j].ere); + free(r->cands[i].chain[j].sample); + } } free(r); } @@ -705,22 +2037,47 @@ derive_name(const char *host, char *dst, size_t len) snprintf(dst, len ? len : 1, "site"); return; } - /* Find the last two dot-separated labels; take the first of those. */ - const char *last = NULL, *prev = NULL; - for (const char *p = host; *p; p++) { - if (*p == '.') { - prev = last; - last = p; - } - } + /* Collect up to 4 dot positions so we can index labels right-to-left. */ + const char *dots[4]; + int ndots = 0; + for (const char *p = host; *p && ndots < 4; p++) + if (*p == '.') + dots[ndots++] = p; + + /* Generic second-level / public-suffix tokens (case-insensitive). When the + * second-to-last label matches one of these and there is a third-to-last + * label, step back one more to get the registrable name. See #derive-name. */ + static const char *const sld_tokens[] = { + "com", "co", "net", "org", "edu", "gov", "mil", "ac", + "gob", "gouv", "ne", "or", "go", "nom", "ltd", "plc", NULL + }; + const char *start; size_t n; - if (last && prev) { - start = prev + 1; - n = (size_t)(last - start); - } else if (last) { + if (ndots >= 2) { + /* last label = dots[ndots-1]+1 .. end + * second-to-last label = dots[ndots-2]+1 .. dots[ndots-1] */ + const char *sld_start = dots[ndots - 2] + 1; + size_t sld_len = (size_t)(dots[ndots - 1] - sld_start); + int is_generic = 0; + for (int ti = 0; sld_tokens[ti] && !is_generic; ti++) { + size_t tl = strlen(sld_tokens[ti]); + if (tl == sld_len && + strncasecmp(sld_start, sld_tokens[ti], tl) == 0) + is_generic = 1; + } + if (is_generic && ndots >= 2) { + /* Step back one more to get the registrable label. + * kissanime.com.cv: ndots=2, step to the label before dots[0]. */ + start = (ndots >= 3) ? dots[ndots - 3] + 1 : host; + n = (size_t)(dots[ndots - 2] - start); + } else { + start = dots[ndots - 2] + 1; + n = (size_t)(dots[ndots - 1] - start); + } + } else if (ndots == 1) { start = host; - n = (size_t)(last - host); + n = (size_t)(dots[0] - host); } else { start = host; n = strlen(host); @@ -789,6 +2146,110 @@ pick_candidate(const scan_result_t *r, int chosen) return &r->cands[idx]; } +/* Append `path` to a 'match' ERE buffer, escaping regex metacharacters so the + * token is matched literally (e.g. "/play/" -> "/play/"). Only '.' needs + * escaping for the path tokens we emit, but escape the common ERE specials to + * be safe. Writes onto the end of dst (already NUL-terminated). */ +static void +match_append_path(char *dst, size_t len, const char *path) +{ + if (!path || !*path) + return; + size_t o = strlen(dst); + for (const char *p = path; *p && o + 2 < len; p++) { + if (strchr(".+*?()[]{}|^$\\", *p)) + dst[o++] = '\\'; + dst[o++] = *p; + } + dst[o] = '\0'; +} + +/* Emit the ranked-candidate comment block (shared by direct and recursive + * modes) so the user sees the signals the scanner weighed. */ +static void +emit_candidate_comments(const scan_result_t *r, FILE *out) +{ + fprintf(out, "# Detected media candidates (best first):\n"); + for (size_t i = 0; i < r->ncands; i++) { + const scan_candidate_t *d = &r->cands[i]; + fprintf(out, "# [%zu] %s%s\n", i, + d->kind == SCAN_KIND_HLS ? "(hls) " : "(file) ", + d->url); + fprintf(out, "# score=%.0f", d->score); + if (d->duration > 0) + fprintf(out, " duration=%.0fs", d->duration); + if (d->size > 0) + fprintf(out, " size=%lldB", d->size); + if (d->height > 0) + fprintf(out, " res=%dx%d", d->width, d->height); + else if (d->bandwidth > 0) + fprintf(out, " bw=%ldbps", d->bandwidth); + if (d->ad_host) + fprintf(out, " [ad-host]"); + if (d->context == SCAN_CTX_PLAYER) + fprintf(out, " [player]"); + if (d->nchain >= 2) + fprintf(out, " [%d hop(s)]", d->depth); + fprintf(out, "\n"); + } + fprintf(out, "\n"); +} + +/* Emit the 1-hop filename-as-param pipeline when c->param_token is set. + * page0 = {url}, capture =(), output {}. */ +static void +emit_param_pipeline(const scan_candidate_t *c, FILE *out) +{ + /* Build the capture ERE: =([^"'&<> ]+\.). + * The extension comes from the last '.' in the media basename. */ + const char *slash = strrchr(c->url, '/'); + const char *basename = slash ? slash + 1 : c->url; + const char *dot = strrchr(basename, '.'); + /* ERE-escape the extension dot -> \. */ + char ext_ere[32] = "mp4"; /* fallback */ + if (dot && dot[1] != '\0') { + size_t ei = 0; + ext_ere[ei++] = '\\'; + ext_ere[ei++] = '.'; + const char *ep = dot + 1; + while (*ep && ei < sizeof(ext_ere) - 1) + ext_ere[ei++] = *ep++; + ext_ere[ei] = '\0'; + } + fprintf(out, "get page0 <- {url}\n"); + fprintf(out, "var %s <- page0 regex %s=([^\"'&<> ]+%s)\n", + c->param_token, c->param_token, ext_ere); + fprintf(out, "output %s{%s}\n", c->media_base, c->param_token); +} + +/* Emit the multi-step get/var pipeline for a chained candidate `c`, with the + * first get reading {url}. Each step inchain; i++) { + const scan_step_t *s = &c->chain[i]; + if (i < c->nchain - 1) { + /* LINK step: capture the next page's ref, then fetch it. */ + if (s->sample) + fprintf(out, "# via %s\n", s->sample); + fprintf(out, "var link%zu <- page%zu regex %s\n", + i, i, s->ere); + fprintf(out, "get page%zu <- {link%zu}\n", i + 1, i); + } else { + /* FINAL step: capture the media URL and output it. */ + if (s->sample) + fprintf(out, "# media: %s\n", s->sample); + fprintf(out, "var media <- page%zu regex %s\n", + i, s->ere); + fprintf(out, "output {media}\n"); + } + } +} + int scan_emit_config(const scan_result_t *r, int chosen, FILE *out) { @@ -800,6 +2261,18 @@ scan_emit_config(const scan_result_t *r, int chosen, FILE *out) char match[256]; derive_name(host, name, sizeof(name)); derive_match(host, match, sizeof(match)); + /* For series, tighten match from the LANDING page path so extractor_matches + * fires on the series index URL, not on episode URLs. See #scan-series-match. */ + if (r->is_series) { + char *seg = url_first_path_segment(r->page_url); + if (seg) { + match_append_path(match, sizeof(match), "/"); + match_append_path(match, sizeof(match), seg); + free(seg); + } + } else if (r->series_path) { + match_append_path(match, sizeof(match), r->series_path); + } const scan_candidate_t *c = pick_candidate(r, chosen); @@ -812,6 +2285,33 @@ scan_emit_config(const scan_result_t *r, int chosen, FILE *out) fprintf(out, "match %s\n", match); fprintf(out, "\n"); + /* SERIES: emit the list setup, then the per-episode pipeline (the chosen + * candidate's chain, with page0 fetched from {url} = the episode). */ + if (r->is_series && r->list_ere) { + if (c) + emit_candidate_comments(r, out); + fprintf(out, "# Series index detected: lists episodes, multi-select on `flux `.\n"); + fprintf(out, "get page <- {url}\n"); + fprintf(out, "list eps <- page regex %s\n", r->list_ere); + fprintf(out, "\n"); + if (c && c->nchain >= 1) { + fprintf(out, "# per-episode pipeline ({url} is each episode):\n"); + if (c->param_token) + emit_param_pipeline(c, out); + else + emit_chain_pipeline(c, out); + } else { + /* Series detected but no episode media reachable within depth. + * Emit a parseable stub; adjust the media regex before use. */ + fprintf(out, "# per-episode pipeline ({url} is each episode); adjust regex:\n"); + fprintf(out, "get page0 <- {url}\n"); + fprintf(out, "var media <- page0 regex src=\"([^\"]+\\.mp4)\"\n"); + fprintf(out, "output {media}\n"); + } + free(host); + return 0; + } + if (!c) { /* No candidate at all: pure skeleton with guided TODOs. The * media URL was not discoverable in the page (JS/API case). */ @@ -838,31 +2338,24 @@ scan_emit_config(const scan_result_t *r, int chosen, FILE *out) /* Show the ranked alternatives as comments so the user sees the signals * the scanner weighed and can switch the output by hand. */ - fprintf(out, "# Detected media candidates (best first):\n"); - for (size_t i = 0; i < r->ncands; i++) { - const scan_candidate_t *d = &r->cands[i]; - fprintf(out, "# [%zu] %s%s\n", i, - d->kind == SCAN_KIND_HLS ? "(hls) " : "(file) ", - d->url); - fprintf(out, "# score=%.0f", d->score); - if (d->duration > 0) - fprintf(out, " duration=%.0fs", d->duration); - if (d->size > 0) - fprintf(out, " size=%lldB", d->size); - if (d->height > 0) - fprintf(out, " res=%dx%d", d->width, d->height); - else if (d->bandwidth > 0) - fprintf(out, " bw=%ldbps", d->bandwidth); - if (d->ad_host) - fprintf(out, " [ad-host]"); - if (d->context == SCAN_CTX_PLAYER) - fprintf(out, " [player]"); - fprintf(out, "\n"); + emit_candidate_comments(r, out); + + /* RECURSIVE non-series: the media is reached via a multi-step chain. */ + if (c->nchain >= 2) { + if (c->param_token) { + fprintf(out, "# 1-hop filename-as-param config (basename on page0):\n"); + emit_param_pipeline(c, out); + } else { + fprintf(out, "# Media reached via %d hop(s); multi-step config:\n", + c->depth); + emit_chain_pipeline(c, out); + } + free(host); + return 0; } - fprintf(out, "\n"); - /* The chosen media URL is present in the page text, so a static output - * line resolves it directly. The user can swap in a different [N]. */ + /* DIRECT: the chosen media URL is present in the page text, so a static + * output line resolves it directly. The user can swap in a different [N]. */ fprintf(out, "# Media URL detected directly in the page.\n"); if (c->kind == SCAN_KIND_HLS) fprintf(out, "# HLS playlist -> downloaded segment-by-segment.\n"); diff --git a/src/scan.h b/src/scan.h index de02307..e51bc16 100644 --- a/src/scan.h +++ b/src/scan.h @@ -57,6 +57,15 @@ #define SCAN_MAX_CANDIDATES 256 #define SCAN_MAX_IFRAME_DEPTH 1 /* follow same-origin iframes one level */ +/* Recursion caps for the multi-hop "watch/play/embed" discovery (#scan-recur). + * They bound the crawl so a hostile or looping site can't fan out without end. */ +#define SCAN_MAX_FOLLOW 5 /* candidate links followed per page during recursion */ +#define SCAN_MAX_CHAIN 4 /* pages in a discovered chain (landing..media): SCAN_MAX_DEPTH+1 */ +#define SCAN_DEFAULT_DEPTH 2 /* default --extract-scan-depth */ +#define SCAN_MAX_DEPTH 3 /* hard cap on --extract-scan-depth */ +#define SCAN_MAX_FETCHES 24 /* hard cap on total page fetches per scan (loop/DoS guard) */ +#define SCAN_SERIES_MIN 3 /* >= this many episode links on a page => series */ + /* What the media URL points at. */ typedef enum { SCAN_KIND_FILE = 0, /* direct .mp4/.webm/... */ @@ -70,6 +79,13 @@ typedef enum { SCAN_CTX_AD, /* inside an ad container/iframe or ad host */ } scan_ctx_t; +/* One capture step of a multi-hop chain: a reusable ERE (group 1) applied to a + * fetched page's body, plus the concrete value it captured at scan time (comment only). */ +typedef struct { + char *ere; /* reusable capturing ERE (owned) */ + char *sample; /* concrete captured value for this scan (owned, may be NULL) */ +} scan_step_t; + /* One scored media candidate. */ typedef struct { char *url; /* resolved absolute media URL (owned) */ @@ -84,6 +100,11 @@ typedef struct { int count; /* how many times this URL appeared on the page */ int ad_host; /* 1 if host is on the ad blocklist */ double score; /* computed rank (higher is better) */ + int depth; /* hops from landing page (0 = direct hit) */ + scan_step_t chain[SCAN_MAX_CHAIN]; + size_t nchain; /* capture steps; last captures media, earlier capture links. 0 = direct. */ + char *param_token; /* e.g. "file" when page0 has file=NAME.mp4 and media is at base/NAME.mp4 */ + char *media_base; /* e.g. "https://cdn/videos/" — prefix before the param value in the media URL */ } scan_candidate_t; /* A scan result: the page URL and the ranked candidate list (best first). */ @@ -91,6 +112,9 @@ typedef struct { char *page_url; /* the scanned page URL (owned) */ scan_candidate_t cands[SCAN_MAX_CANDIDATES]; size_t ncands; + int is_series; /* landing page looks like an episode index */ + char *list_ere; /* reusable ERE capturing episode hrefs for `list` (owned), or NULL */ + char *series_path; /* a stable path token for the match line, e.g. "/play/" (owned), or NULL */ } scan_result_t; /* Probe a direct file URL for its size. Store the byte length in *out_size and @@ -103,12 +127,19 @@ typedef int (*scan_probe_fn)(const char *url, long long *out_size, * `fetch`, parses HLS candidate durations via `fetch`, and probes direct-file * sizes via `probe` (may be NULL). Both callbacks receive `userdata`. * + * When the landing page yields no direct media and `max_depth >= 1`, the + * scanner follows internal watch/play/embed links up to `max_depth` hops and + * re-scans each page, recording any media reached at depth>0 with the capture + * chain that leads to it (so a multi-step config can be generated). It also + * detects series index pages. `max_depth == 0` disables recursion. + * * On success returns a malloc'd scan_result_t (free with scan_result_free) with * a scored, ranked candidate list and *err left NULL. On failure returns NULL * and, if err is non-NULL, sets *err to a malloc'd message the caller frees. * A page that yields no candidates is a success with ncands == 0. */ scan_result_t *scan_page(const char *page_url, extractor_fetch_fn fetch, - scan_probe_fn probe, void *userdata, char **err); + scan_probe_fn probe, int max_depth, + void *userdata, char **err); void scan_result_free(scan_result_t *r); diff --git a/src/test_scan.c b/src/test_scan.c index 41ef935..f9bb07f 100644 --- a/src/test_scan.c +++ b/src/test_scan.c @@ -122,7 +122,7 @@ test_direct_source(void) char *err = NULL; scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, - &site, &err); + SCAN_DEFAULT_DEPTH, &site, &err); CHECK(r != NULL, "(a) direct-source page scans"); if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } @@ -177,7 +177,7 @@ test_hls_in_json(void) char *err = NULL; scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, - &site, &err); + SCAN_DEFAULT_DEPTH, &site, &err); CHECK(r != NULL, "(b) HLS-in-JSON page scans"); if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } @@ -240,7 +240,7 @@ test_ad_vs_content(void) char *err = NULL; scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, - &site, &err); + SCAN_DEFAULT_DEPTH, &site, &err); CHECK(r != NULL, "(c) ad-vs-content page scans"); if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } @@ -296,7 +296,7 @@ test_skeleton_when_no_media(void) char *err = NULL; scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, - &site, &err); + SCAN_DEFAULT_DEPTH, &site, &err); CHECK(r != NULL, "(d) animeworld-like page scans"); if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } @@ -318,6 +318,923 @@ test_skeleton_when_no_media(void) scan_result_free(r); } +/* ---- (e) media behind ONE hop -> multi-step config + roundtrip --------- */ + +static void +test_one_hop(void) +{ + /* Landing page has no media, only an . The + * /watch page carries the player JSON with the real mp4. */ + static const struct kv pages[] = { + { "https://watch.example.com/show", + "" + "Watch now" + "" }, + { "https://watch.example.com/watch?file=XYZ", + "
" + "" + "" }, + }; + struct fake_site site = { pages, 2, NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(e) one-hop page scans"); + if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } + + CHECK(r->ncands >= 1, "(e) media found behind one hop"); + if (r->ncands >= 1) { + CHECK(strcmp(r->cands[0].url, + "https://cdn.example.com/v/clip.mp4") == 0, + "(e) top candidate is the mp4"); + CHECK(r->cands[0].nchain == 2, "(e) chain has two steps"); + CHECK(r->cands[0].depth == 1, "(e) depth is one hop"); + } + + char *cfg = NULL; + CHECK(emit_to_string(r, -1, &cfg) == 0 && cfg, "(e) config emits"); + if (cfg) { + CHECK(strstr(cfg, "get page0 <- {url}") != NULL, + "(e) first get reads {url}"); + CHECK(strstr(cfg, "var link0 <- page0 regex") != NULL, + "(e) link0 captured from page0"); + CHECK(strstr(cfg, "get page1 <- {link0}") != NULL, + "(e) page1 fetched from link0"); + CHECK(strstr(cfg, "var media <- page1 regex") != NULL, + "(e) media captured from page1"); + CHECK(strstr(cfg, "output {media}") != NULL, + "(e) outputs {media}"); + + /* Roundtrip: the generated config must parse and run. */ + char *perr = NULL; + extractor_t *ex = extractor_parse(cfg, "(gen)", &perr); + CHECK(ex != NULL, "(e) generated config parses"); + if (!ex) { printf(" parse err: %s\n", perr ? perr : "(none)"); } + free(perr); + if (ex) { + char *media = NULL, *rerr = NULL; + int rc = extractor_run(ex, pages[0].url, fake_fetch, + &site, &media, &rerr); + CHECK(rc == 0, "(e) generated config runs"); + if (rc != 0) + printf(" run err: %s\n", rerr ? rerr : "(none)"); + CHECK(media && strcmp(media, + "https://cdn.example.com/v/clip.mp4") == 0, + "(e) run resolves the mp4"); + free(media); + free(rerr); + extractor_free(ex); + } + free(cfg); + } + scan_result_free(r); +} + +/* ---- (f) media behind TWO hops -> multi-step config + roundtrip -------- */ + +static void +test_two_hops(void) +{ + /* landing -> /embed/123 -> /player/abc with the media. */ + static const struct kv pages[] = { + { "https://watch.example.org/show", + "" + "go" + "" }, + { "https://watch.example.org/embed/123", + "" + "play" + "" }, + { "https://watch.example.org/player/abc", + "" }, + }; + struct fake_site site = { pages, 3, NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(f) two-hop page scans"); + if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } + + CHECK(r->ncands >= 1, "(f) media found behind two hops"); + if (r->ncands >= 1) { + CHECK(strcmp(r->cands[0].url, + "https://cdn.example.org/m/final.mp4") == 0, + "(f) top candidate is the final mp4"); + CHECK(r->cands[0].nchain == 3, "(f) chain has three steps"); + CHECK(r->cands[0].depth == 2, "(f) depth is two hops"); + } + + char *cfg = NULL; + CHECK(emit_to_string(r, -1, &cfg) == 0 && cfg, "(f) config emits"); + if (cfg) { + CHECK(strstr(cfg, "get page0 <- {url}") != NULL, + "(f) first get reads {url}"); + CHECK(strstr(cfg, "get page1 <- {link0}") != NULL, + "(f) page1 from link0"); + CHECK(strstr(cfg, "get page2 <- {link1}") != NULL, + "(f) page2 from link1"); + CHECK(strstr(cfg, "var media <- page2 regex") != NULL, + "(f) media captured from page2"); + + char *perr = NULL; + extractor_t *ex = extractor_parse(cfg, "(gen)", &perr); + CHECK(ex != NULL, "(f) generated config parses"); + if (!ex) { printf(" parse err: %s\n", perr ? perr : "(none)"); } + free(perr); + if (ex) { + char *media = NULL, *rerr = NULL; + int rc = extractor_run(ex, pages[0].url, fake_fetch, + &site, &media, &rerr); + CHECK(rc == 0, "(f) generated config runs"); + if (rc != 0) + printf(" run err: %s\n", rerr ? rerr : "(none)"); + CHECK(media && strcmp(media, + "https://cdn.example.org/m/final.mp4") == 0, + "(f) run resolves the final mp4"); + free(media); + free(rerr); + extractor_free(ex); + } + free(cfg); + } + scan_result_free(r); +} + +/* ---- (g) series index -> list + per-episode pipeline + roundtrip ------- */ + +static void +test_series(void) +{ + /* Landing page lists three /ep/N episodes; each episode page has a + * direct mp4. */ + static const struct kv pages[] = { + { "https://anime.example.net/series/foo", + "" + "Episode 1" + "Episode 2" + "Episode 3" + "" }, + { "https://anime.example.net/ep/1", + "" }, + { "https://anime.example.net/ep/2", + "" }, + { "https://anime.example.net/ep/3", + "" }, + }; + struct fake_site site = { pages, 4, NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(g) series page scans"); + if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } + + CHECK(r->is_series == 1, "(g) series detected"); + CHECK(r->list_ere != NULL, "(g) list ERE set"); + CHECK(r->ncands >= 1, "(g) sample episode media found"); + + char *cfg = NULL; + CHECK(emit_to_string(r, -1, &cfg) == 0 && cfg, "(g) config emits"); + if (cfg) { + CHECK(strstr(cfg, "list eps <- page regex") != NULL, + "(g) list directive present"); + CHECK(strstr(cfg, "output {media}") != NULL, + "(g) per-episode output present"); + + char *perr = NULL; + extractor_t *ex = extractor_parse(cfg, "(gen)", &perr); + CHECK(ex != NULL, "(g) generated config parses"); + if (!ex) { printf(" parse err: %s\n", perr ? perr : "(none)"); } + free(perr); + if (ex) { + /* FIX 2: match must fire on the series LANDING page. */ + CHECK(extractor_matches(ex, pages[0].url) == 1, + "(g) config matches series landing URL"); + + CHECK(extractor_has_list(ex), "(g) config has a list"); + char **urls = NULL; + size_t n = 0; + char *lerr = NULL; + int rc = extractor_list_episodes(ex, pages[0].url, + fake_fetch, &site, &urls, &n, &lerr); + CHECK(rc == 0 && n >= 3, + "(g) list_episodes returns >= 3 episodes"); + if (rc != 0) + printf(" list err: %s\n", lerr ? lerr : "(none)"); + if (n >= 1) + CHECK(strcmp(urls[0], + "https://anime.example.net/ep/1") == 0, + "(g) first episode URL resolved absolute"); + free(lerr); + + /* Per-episode run resolves epN.mp4 for episode 1. */ + char *media = NULL, *rerr = NULL; + int rr = extractor_run(ex, "https://anime.example.net/ep/1", + fake_fetch, &site, &media, &rerr); + CHECK(rr == 0 && media && strcmp(media, + "https://cdn.example.net/e/ep1.mp4") == 0, + "(g) per-episode run resolves ep1.mp4"); + if (rr != 0) + printf(" run err: %s\n", rerr ? rerr : "(none)"); + free(media); + free(rerr); + extractor_free_urls(urls, n); + extractor_free(ex); + } + free(cfg); + } + scan_result_free(r); +} + +/* ---- series with no reachable episode media (FIX 3: parseable skeleton) -- */ + +static void +test_series_no_media(void) +{ + /* Landing lists episodes but the episode pages are unreachable (fetch + * fails), so no candidate chain is built. The emitted config must still + * parse cleanly (FIX 3). */ + static const struct kv pages[] = { + { "https://nomedia.example.org/show/bar", + "" + "Episode 1" + "Episode 2" + "Episode 3" + "" }, + /* episode pages intentionally absent: fake_fetch returns -1 */ + }; + struct fake_site site = { pages, 1, NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(g2) series-no-media page scans"); + if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } + + CHECK(r->is_series == 1, "(g2) series detected even without reachable media"); + + char *cfg = NULL; + CHECK(emit_to_string(r, -1, &cfg) == 0 && cfg, + "(g2) skeleton config emits"); + if (cfg) { + char *perr = NULL; + extractor_t *ex = extractor_parse(cfg, "(gen-nomedia)", &perr); + CHECK(ex != NULL, "(g2) skeleton config parses (FIX 3)"); + if (!ex) + printf(" parse err: %s\n", perr ? perr : "(none)"); + free(perr); + if (ex) + extractor_free(ex); + free(cfg); + } + scan_result_free(r); +} + +/* ---- (h) media behind a non-hardcoded link (generic harvest) ----------- */ + +static void +test_generic_link_follow(void) +{ + /* Landing has a custom-slug episode link (no /ep/, /watch/, ?file=). + * The linked page has a direct mp4 via . The generic harvester + * must follow it and the generated config must be a reusable generalized + * ERE (not a literal "1" in the var line). */ + static const struct kv pages[] = { + { "https://site.example.com/anime/naruto", + "" + "Episodio 1" + "" }, + { "https://site.example.com/serie/foo/episodio-1-ita", + "" }, + }; + struct fake_site site = { pages, 2, NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(h) generic-link page scans"); + if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } + + CHECK(r->ncands >= 1, "(h) media found via generic link follow"); + if (r->ncands >= 1) { + CHECK(strcmp(r->cands[0].url, + "https://cdn.example.com/v/ep1.mp4") == 0, + "(h) top candidate is the mp4"); + CHECK(r->cands[0].nchain >= 2, "(h) chain has at least two steps"); + } + + char *cfg = NULL; + CHECK(emit_to_string(r, -1, &cfg) == 0 && cfg, "(h) config emits"); + if (cfg) { + /* The link var ERE must be generalized (contain a charset or + * quantifier), not the literal episode slug "episodio-1-ita". */ + const char *var_line = strstr(cfg, "var link0"); + CHECK(var_line != NULL, "(h) var link0 line present"); + if (var_line) { + /* A synthesized ERE will contain [0-9]+ or [A-Za-z0-9 */ + int generalized = (strstr(var_line, "[0-9]") != NULL || + strstr(var_line, "[A-Za-z") != NULL); + CHECK(generalized, "(h) link ERE is generalized (not literal slug)"); + } + + char *perr = NULL; + extractor_t *ex = extractor_parse(cfg, "(gen-h)", &perr); + CHECK(ex != NULL, "(h) generated config parses"); + if (!ex) { printf(" parse err: %s\n", perr ? perr : "(none)"); } + free(perr); + if (ex) { + char *media = NULL, *rerr = NULL; + int rc = extractor_run(ex, pages[0].url, fake_fetch, + &site, &media, &rerr); + CHECK(rc == 0, "(h) generated config runs"); + if (rc != 0) + printf(" run err: %s\n", rerr ? rerr : "(none)"); + CHECK(media && strcmp(media, + "https://cdn.example.com/v/ep1.mp4") == 0, + "(h) run resolves ep1.mp4"); + free(media); + free(rerr); + extractor_free(ex); + } + free(cfg); + } + scan_result_free(r); +} + +/* ---- (i) structural series: no /ep/ URLs, generic template detection ---- */ + +static void +test_structural_series(void) +{ + /* Landing lists /anime/foo/1, /anime/foo/2, /anime/foo/3 — no /ep/. + * Each episode page has a direct mp4. Structural detection must fire. */ + static const struct kv pages[] = { + { "https://stream.example.io/show/myshow", + "" + "Episode 1" + "Episode 2" + "Episode 3" + "" }, + { "https://stream.example.io/anime/foo/1", + "" }, + { "https://stream.example.io/anime/foo/2", + "" }, + { "https://stream.example.io/anime/foo/3", + "" }, + }; + struct fake_site site = { pages, 4, NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(i) structural series page scans"); + if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } + + CHECK(r->is_series == 1, "(i) structural series detected"); + CHECK(r->list_ere != NULL, "(i) list ERE set"); + if (r->list_ere) { + /* list_ere must contain [0-9]+ (generalized). */ + CHECK(strstr(r->list_ere, "[0-9]") != NULL, + "(i) list ERE is generalized"); + } + + char *cfg = NULL; + CHECK(emit_to_string(r, -1, &cfg) == 0 && cfg, "(i) config emits"); + if (cfg) { + char *perr = NULL; + extractor_t *ex = extractor_parse(cfg, "(gen-i)", &perr); + CHECK(ex != NULL, "(i) generated config parses"); + if (!ex) { printf(" parse err: %s\n", perr ? perr : "(none)"); } + free(perr); + if (ex) { + /* FIX 2: match must fire on the series landing URL. */ + CHECK(extractor_matches(ex, pages[0].url) == 1, + "(i) config matches series landing URL"); + + CHECK(extractor_has_list(ex), "(i) config has list directive"); + char **urls = NULL; + size_t n = 0; + char *lerr = NULL; + int rc = extractor_list_episodes(ex, pages[0].url, + fake_fetch, &site, &urls, &n, &lerr); + CHECK(rc == 0 && n >= 3, + "(i) list_episodes returns >= 3 episodes"); + if (rc != 0) + printf(" list err: %s\n", lerr ? lerr : "(none)"); + if (n >= 1) { + /* URLs must be absolute. */ + CHECK(strncmp(urls[0], "https://", 8) == 0, + "(i) first episode URL is absolute"); + } + free(lerr); + + /* Per-episode run resolves the mp4. */ + char *media = NULL, *rerr = NULL; + int rr = extractor_run(ex, + "https://stream.example.io/anime/foo/1", + fake_fetch, &site, &media, &rerr); + CHECK(rr == 0 && media && strcmp(media, + "https://cdn.example.io/v/ep1.mp4") == 0, + "(i) per-episode run resolves ep1.mp4"); + if (rr != 0) + printf(" run err: %s\n", rerr ? rerr : "(none)"); + free(media); + free(rerr); + extractor_free_urls(urls, n); + extractor_free(ex); + } + free(cfg); + } + scan_result_free(r); +} + +/* ---- (j) multi-series containment: picks the user's series, not the biggest */ + +static void +test_multi_series_containment(void) +{ + /* Landing is the foo series index. Page also lists a larger bar series + * (5 entries) and a stray baz link. Scanner must anchor to foo. */ + static const struct kv pages[] = { + { "https://site.test/anime/foo", + "" + /* foo series: 3 episodes */ + "Foo Ep 1" + "Foo Ep 2" + "Foo Ep 3" + /* bar series: 5 episodes (would win on count alone) */ + "Bar Ep 1" + "Bar Ep 2" + "Bar Ep 3" + "Bar Ep 4" + "Bar Ep 5" + /* unrelated link */ + "Baz Ep 9" + "" }, + { "https://site.test/anime/foo/1", + "" }, + { "https://site.test/anime/foo/2", + "" }, + { "https://site.test/anime/foo/3", + "" }, + }; + struct fake_site site = { pages, 4, NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(j) multi-series page scans"); + if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } + + CHECK(r->is_series == 1, "(j) series detected"); + if (r->list_ere) { + /* list_ere must match foo URLs, not bar. */ + CHECK(strstr(r->list_ere, "/anime/foo/") != NULL || + strstr(r->list_ere, "foo") != NULL, + "(j) list_ere anchored to foo series"); + CHECK(strstr(r->list_ere, "/anime/bar/") == NULL, + "(j) list_ere does NOT match bar series"); + } + + char *cfg = NULL; + CHECK(emit_to_string(r, -1, &cfg) == 0 && cfg, "(j) config emits"); + if (cfg) { + char *perr = NULL; + extractor_t *ex = extractor_parse(cfg, "(gen-j)", &perr); + CHECK(ex != NULL, "(j) generated config parses"); + if (!ex) { printf(" parse err: %s\n", perr ? perr : "(none)"); } + free(perr); + if (ex) { + CHECK(extractor_matches(ex, pages[0].url) == 1, + "(j) config matches series landing URL"); + + CHECK(extractor_has_list(ex), "(j) config has list directive"); + char **urls = NULL; + size_t n = 0; + char *lerr = NULL; + int rc = extractor_list_episodes(ex, pages[0].url, + fake_fetch, &site, &urls, &n, &lerr); + CHECK(rc == 0, "(j) list_episodes succeeds"); + if (rc != 0) + printf(" list err: %s\n", lerr ? lerr : "(none)"); + free(lerr); + + /* All returned URLs must be foo episodes. */ + CHECK(n == 3, "(j) exactly 3 foo episodes returned"); + for (size_t i = 0; i < n; i++) { + CHECK(strstr(urls[i], "/anime/foo/") != NULL, + "(j) episode URL is a foo URL"); + CHECK(strstr(urls[i], "/anime/bar/") == NULL, + "(j) no bar URL in episode list"); + CHECK(strstr(urls[i], "/anime/baz/") == NULL, + "(j) no baz URL in episode list"); + } + extractor_free_urls(urls, n); + extractor_free(ex); + } + free(cfg); + } + scan_result_free(r); +} + +/* ---- (l) multi-series containment with ABSOLUTE hrefs: anchors path-to-path */ + +static void +test_multi_series_absolute(void) +{ + /* Same as (j) but every episode href is absolute. Before the path-to-path + * relatedness fix the scanner mis-tiered and could pick the larger bar + * series; it must still anchor to foo (the landing). */ + static const struct kv pages[] = { + { "https://site.test/anime/foo", + "" + "Foo 1" + "Foo 2" + "Foo 3" + "Bar 1" + "Bar 2" + "Bar 3" + "Bar 4" + "Bar 5" + "" }, + { "https://site.test/anime/foo/1", + "" }, + { "https://site.test/anime/foo/2", + "" }, + { "https://site.test/anime/foo/3", + "" }, + }; + struct fake_site site = { pages, 4, NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(l) absolute-href multi-series scans"); + if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } + + CHECK(r->is_series == 1, "(l) series detected"); + if (r->list_ere) { + CHECK(strstr(r->list_ere, "/anime/foo/") != NULL, + "(l) list_ere anchored to foo, not the larger bar series"); + CHECK(strstr(r->list_ere, "/anime/bar/") == NULL, + "(l) list_ere does NOT match bar"); + } + + char *cfg = NULL; + if (emit_to_string(r, -1, &cfg) == 0 && cfg) { + char *perr = NULL; + extractor_t *ex = extractor_parse(cfg, "(gen-l)", &perr); + CHECK(ex != NULL, "(l) generated config parses"); + free(perr); + if (ex) { + char **urls = NULL; + size_t n = 0; + char *lerr = NULL; + int rc = extractor_list_episodes(ex, pages[0].url, + fake_fetch, &site, &urls, &n, &lerr); + CHECK(rc == 0 && n == 3, "(l) exactly 3 foo episodes returned"); + free(lerr); + for (size_t i = 0; i < n; i++) + CHECK(strstr(urls[i], "/anime/bar/") == NULL, + "(l) no bar URL in episode list"); + extractor_free_urls(urls, n); + extractor_free(ex); + } + free(cfg); + } + scan_result_free(r); +} + +/* ---- (k) ad / onclick / popunder trap: real media found, traps skipped ---- */ + +static void +test_ad_trap(void) +{ + /* Landing has multiple traps but also the real same-site player link. */ + static const struct kv pages[] = { + { "https://site.test/watch/show", + "" + /* onclick popunder: href='#' -> followable_ref rejects it */ + "PLAY" + /* cross-origin ad anchor: ad-host blocklist catches it */ + "Watch HD" + /* same-site affiliate redirect: penalty token '/out' catches it */ + "mirror" + /* the real player link */ + "player" + "" }, + { "https://site.test/embed/show-1", + "" }, + }; + struct fake_site site = { pages, 2, NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(k) ad-trap page scans"); + if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } + + CHECK(r->ncands >= 1, "(k) real media found despite traps"); + for (size_t i = 0; i < r->ncands; i++) { + CHECK(strstr(r->cands[i].url, "popads") == NULL, + "(k) no popads candidate"); + CHECK(strstr(r->cands[i].url, "hilltopads") == NULL, + "(k) no hilltopads candidate"); + CHECK(strstr(r->cands[i].url, "/out?") == NULL, + "(k) no affiliate /out candidate"); + } + if (r->ncands >= 1) { + CHECK(strcmp(r->cands[0].url, "https://cdn.test/v/real.mp4") == 0, + "(k) top candidate is the real mp4"); + } + scan_result_free(r); +} + +/* ---- (m) kissanime-shaped: anchored list_ere + multi-suffix name ---------- */ + +static void +test_kissanime_shape(void) +{ + /* Series page for foo-bar-season-2 listing 10 own episodes AND 9 sidebar + * "latest" links from other series — mirrors the kissanime.com.cv shape. + * Each own episode page has a direct mp4. */ + static const struct kv pages[] = { + /* Series index */ + { "https://kissanime.com.cv/anime/foo-bar-season-2/", + "" + /* 10 own episodes (episode-10 is two digits) */ + "Ep 1" + "Ep 2" + "Ep 3" + "Ep 4" + "Ep 5" + "Ep 6" + "Ep 7" + "Ep 8" + "Ep 9" + "Ep 10" + /* 9 sidebar "latest" from other series */ + "latest" + "latest" + "latest" + "latest" + "latest" + "latest" + "latest" + "latest" + "latest" + "" }, + /* Episode pages — only ep1 needed for chain discovery */ + { "https://kissanime.com.cv/foo-bar-season-2-episode-1/", + "" }, + { "https://kissanime.com.cv/foo-bar-season-2-episode-2/", + "" }, + { "https://kissanime.com.cv/foo-bar-season-2-episode-10/", + "" }, + }; + struct fake_site site = { pages, 4, NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(m) kissanime page scans"); + if (!r) { printf(" err: %s\n", err ? err : "(none)"); free(err); return; } + + CHECK(r->is_series == 1, "(m) series detected"); + + /* list_ere must be anchored to the foo-bar-season family. The digit + * '2' in 'season-2' is generalized to [0-9]+ (correct), so check for + * the literal prefix 'foo-bar-season-' plus a digit placeholder. */ + if (r->list_ere) { + CHECK(strstr(r->list_ere, "foo-bar-season-") != NULL, + "(m) list_ere contains foo-bar-season- prefix"); + CHECK(strstr(r->list_ere, "episode-") != NULL, + "(m) list_ere contains episode- token"); + CHECK(strstr(r->list_ere, "rent-a-thing") == NULL, + "(m) list_ere does NOT match rent-a-thing"); + CHECK(strstr(r->list_ere, "beyond-x") == NULL, + "(m) list_ere does NOT match beyond-x"); + CHECK(strstr(r->list_ere, "zzz-show") == NULL, + "(m) list_ere does NOT match zzz-show"); + } + + char *cfg = NULL; + CHECK(emit_to_string(r, -1, &cfg) == 0 && cfg, "(m) config emits"); + if (cfg) { + /* FIX A: name must be 'kissanime', not 'com'. */ + CHECK(strstr(cfg, "name kissanime") != NULL, + "(m) name is kissanime (not com)"); + + char *perr = NULL; + extractor_t *ex = extractor_parse(cfg, "(gen-m)", &perr); + CHECK(ex != NULL, "(m) generated config parses"); + if (!ex) { printf(" parse err: %s\n", perr ? perr : "(none)"); } + free(perr); + if (ex) { + CHECK(extractor_matches(ex, pages[0].url) == 1, + "(m) config matches series landing URL"); + + CHECK(extractor_has_list(ex), "(m) config has list directive"); + char **urls = NULL; + size_t n = 0; + char *lerr = NULL; + int rc = extractor_list_episodes(ex, pages[0].url, + fake_fetch, &site, &urls, &n, &lerr); + CHECK(rc == 0, "(m) list_episodes succeeds"); + if (rc != 0) + printf(" list err: %s\n", lerr ? lerr : "(none)"); + free(lerr); + + /* Must return exactly 10 foo-bar episodes. */ + CHECK(n == 10, "(m) exactly 10 episodes listed"); + int has_ep10 = 0; + for (size_t i = 0; i < n; i++) { + CHECK(strstr(urls[i], "foo-bar-season-2-episode-") != NULL, + "(m) episode URL is a foo-bar-season-2 URL"); + CHECK(strstr(urls[i], "rent-a-thing") == NULL, + "(m) no rent-a-thing in list"); + CHECK(strstr(urls[i], "beyond-x") == NULL, + "(m) no beyond-x in list"); + if (strstr(urls[i], "episode-10")) + has_ep10 = 1; + } + CHECK(has_ep10, "(m) episode-10 (two digits) included"); + + /* Per-episode run resolves the mp4 for episode 1. */ + char *media = NULL, *rerr = NULL; + int rr = extractor_run(ex, + "https://kissanime.com.cv/foo-bar-season-2-episode-1/", + fake_fetch, &site, &media, &rerr); + CHECK(rr == 0 && media && + strcmp(media, "https://cdn.1a.test/v/foo-bar-2nd-season-episode-1.mp4") == 0, + "(m) extractor_run resolves episode-1 mp4"); + if (rr != 0) + printf(" run err: %s\n", rerr ? rerr : "(none)"); + free(media); + free(rerr); + extractor_free_urls(urls, n); + extractor_free(ex); + } + free(cfg); + } + scan_result_free(r); +} + +/* ---- (n) filename-as-param 1-hop template (kissanime-iframe shape) ------- */ + +static void +test_filename_as_param(void) +{ + /* Series landing lists 3 episode URLs. Each episode page carries an iframe + * whose src has file= as a query param; the cdn iframe page + * exposes the real mp4 via . The basename appears on the + * episode page (page0 for chain discovery), so the scanner must emit the + * 1-hop param template instead of a multi-step get chain. */ + static const struct kv pages[] = { + { "https://site.test/anime/foo-s2/", + "" + "ep1" + "ep2" + "ep3" + "" }, + /* episode pages: each has an iframe with file= */ + { "https://site.test/anime/foo-s2/episode-1/", + "" + "" }, + { "https://site.test/anime/foo-s2/episode-2/", + "" + "" }, + { "https://site.test/anime/foo-s2/episode-3/", + "" + "" }, + /* cdn iframe pages: each exposes the real mp4 via */ + { "https://cdn.v.test/index.php?action=play&file=foo-2nd-episode-1.mp4", + "" }, + { "https://cdn.v.test/index.php?action=play&file=foo-2nd-episode-2.mp4", + "" }, + { "https://cdn.v.test/index.php?action=play&file=foo-2nd-episode-3.mp4", + "" }, + }; + struct fake_site site = { pages, sizeof(pages) / sizeof(pages[0]), NULL, 0 }; + + char *err = NULL; + scan_result_t *r = scan_page(pages[0].url, fake_fetch, fake_probe, + SCAN_DEFAULT_DEPTH, &site, &err); + CHECK(r != NULL, "(n) filename-as-param page scans"); + if (!r) { + printf(" err: %s\n", err ? err : "(none)"); + free(err); + return; + } + + CHECK(r->is_series, "(n) detected as series"); + CHECK(r->ncands >= 1, "(n) candidate found"); + + char *cfg = NULL; + CHECK(emit_to_string(r, -1, &cfg) == 0 && cfg, "(n) config emits"); + if (cfg) { + /* Must use the 1-hop param template, not a multi-get chain. */ + CHECK(strstr(cfg, "var file <- page0 regex") != NULL, + "(n) config has 1-hop var line"); + CHECK(strstr(cfg, "file=([^\"'&<> ]+") != NULL, + "(n) capture regex uses file= param"); + CHECK(strstr(cfg, "output https://cdn.v.test/videos/{file}") != NULL, + "(n) output uses media_base + {file}"); + /* Must NOT contain a multi-hop get page1 or get page2. */ + CHECK(strstr(cfg, "get page1") == NULL, + "(n) no page1 hop (1-hop template only)"); + CHECK(strstr(cfg, "get page2") == NULL, + "(n) no page2 hop"); + + /* Roundtrip: config must parse and run. */ + char *perr = NULL; + extractor_t *ex = extractor_parse(cfg, "(gen-n)", &perr); + CHECK(ex != NULL, "(n) generated config parses"); + if (!ex) { + printf(" parse err: %s\n", perr ? perr : "(none)"); + } + free(perr); + + if (ex) { + /* Anti-collapse: episode-1 and episode-3 must resolve to + * DISTINCT per-episode mp4 paths (not the same latest one). */ + char *media1 = NULL, *rerr1 = NULL; + int rc1 = extractor_run(ex, + "https://site.test/anime/foo-s2/episode-1/", + fake_fetch, &site, &media1, &rerr1); + CHECK(rc1 == 0, "(n) run resolves episode-1"); + if (rc1 != 0) + printf(" run ep1 err: %s\n", rerr1 ? rerr1 : "(none)"); + CHECK(media1 != NULL && + strcmp(media1, + "https://cdn.v.test/videos/foo-2nd-episode-1.mp4") == 0, + "(n) episode-1 resolves to correct mp4"); + free(media1); + free(rerr1); + + char *media3 = NULL, *rerr3 = NULL; + int rc3 = extractor_run(ex, + "https://site.test/anime/foo-s2/episode-3/", + fake_fetch, &site, &media3, &rerr3); + CHECK(rc3 == 0, "(n) run resolves episode-3"); + if (rc3 != 0) + printf(" run ep3 err: %s\n", rerr3 ? rerr3 : "(none)"); + CHECK(media3 != NULL && + strcmp(media3, + "https://cdn.v.test/videos/foo-2nd-episode-3.mp4") == 0, + "(n) episode-3 resolves to correct mp4 (anti-collapse)"); + free(media3); + free(rerr3); + + extractor_free(ex); + } + free(cfg); + } + scan_result_free(r); +} + /* ---- ad-host blocklist unit ------------------------------------------- */ static void @@ -343,6 +1260,17 @@ main(void) test_hls_in_json(); test_ad_vs_content(); test_skeleton_when_no_media(); + test_one_hop(); + test_two_hops(); + test_series(); + test_series_no_media(); + test_generic_link_follow(); + test_structural_series(); + test_multi_series_containment(); + test_multi_series_absolute(); + test_ad_trap(); + test_kissanime_shape(); + test_filename_as_param(); test_ad_host_blocklist(); if (failures == 0) diff --git a/src/text.c b/src/text.c index 97c59fd..f455455 100644 --- a/src/text.c +++ b/src/text.c @@ -97,6 +97,8 @@ int run = 1; #define YES_OPT 262 #define ALL_OPT 263 #define EPISODES_OPT 264 +#define EXTRACT_SCAN_DEPTH_OPT 265 +#define SAVE_CONFIG_OPT 266 #ifdef NOGETOPTLONG #define getopt_long(a, b, c, d, e) getopt(a, b, c) @@ -130,6 +132,8 @@ static struct option flux_options[] = { {"mux", 1, NULL, MUX_OPT}, {"all", 0, NULL, ALL_OPT}, {"episodes", 1, NULL, EPISODES_OPT}, + {"extract-scan-depth", 1, NULL, EXTRACT_SCAN_DEPTH_OPT}, + {"save-config", 1, NULL, SAVE_CONFIG_OPT}, {NULL, 0, NULL, 0} }; #endif @@ -209,22 +213,66 @@ extract_size_probe(const char *url, long long *out_size, void *userdata) return 0; } +/* FNV-1a 32-bit hash, lowercased input. Used to derive a stable pending-config + * id from the source URL so re-scanning the same site yields the same id. */ +static unsigned long +fnv1a_url(const char *s) +{ + unsigned long h = 2166136261UL; + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + h ^= (unsigned long)tolower(*p); + h *= 16777619UL; + h &= 0xffffffffUL; + } + return h; +} + +/* Build the pending-config id "-<5 hex>" (lowercase, filesystem-safe) + * from the scan result and its source URL. Returns 0 on success, -1 on error. */ +static int +scan_pending_id(const scan_result_t *r, const char *src_url, char *dst, + size_t len) +{ + char name[128]; + if (scan_config_name(r, name, sizeof(name)) != 0) + return -1; + unsigned long h = fnv1a_url(src_url) & 0xfffffUL; /* 20 bits -> 5 hex */ + int n = snprintf(dst, len, "%s-%05lx", name, h); + return (n > 0 && (size_t)n < len) ? 0 : -1; +} + +/* Build /.pending into dst and create it (0700). Returns 1 + * on success, 0 on failure. The dir is hidden so config discovery skips it. */ +static int +scan_pending_dir(char *dst, size_t len) +{ + char base[4096]; + if (!extractor_user_dir(base, sizeof(base), 1)) + return 0; + int n = snprintf(dst, len, "%s/.pending", base); + if (n <= 0 || (size_t)n >= len) + return 0; + if (mkdir(dst, 0700) != 0 && errno != EEXIST) + return 0; + return 1; +} + /* Run --extract-scan: scan `page_url`, optionally disambiguate via the TUI, and * emit a generated config. Output destination: - * - no -o (out_file NULL/empty): SAVE active into the user extractor dir as - * /.conf (the dir is created). Refuses to overwrite an - * existing auto-path so a hand-tuned config is never clobbered. - * - '-o -' : write to stdout (the legacy default). - * - '-o FILE': write to FILE (explicit override, may overwrite). + * - no -o (out_file NULL/empty) or '-o -': PRINT the config to stdout, stash + * it to /.pending/.conf, and hint how to save it active with + * `flux --save-config `. + * - '-o FILE': write to FILE (explicit override, may overwrite; no stash/hint). + * `max_depth` bounds the recursive watch/play/embed discovery (#scan-recur). * `auto_yes` skips the TUI and picks the top-ranked candidate. Returns 0 on * success, 1 on any error (matching the caller's exit-code convention). */ static int run_extract_scan(conf_t *conf, const char *page_url, const char *out_file, - int auto_yes) + int max_depth, int auto_yes) { char *err = NULL; scan_result_t *r = scan_page(page_url, extract_http_fetch, - extract_size_probe, conf, &err); + extract_size_probe, max_depth, conf, &err); if (!r) { fprintf(stderr, _("flux: scan failed: %s\n"), err ? err : "unknown error"); @@ -289,82 +337,235 @@ run_extract_scan(conf_t *conf, const char *page_url, const char *out_file, * silently guess. tui_select_one's fallback already handled the prompt * above; if it returned a valid index we proceed. */ - /* Resolve the output destination. The auto-save path (no -o) lands in the - * active user dir; '-o -' is stdout; '-o FILE' is an explicit override. */ + /* '-o FILE' (not '-') writes the config to FILE explicitly; anything else + * (no -o, or '-o -') prints to stdout and stashes a pending copy. */ int to_stdout = (out_file && strcmp(out_file, "-") == 0); int explicit_file = (out_file && *out_file && !to_stdout); - char autopath[MAX_STRING]; - const char *dest = out_file; /* path used for messages (explicit case) */ - - if (!to_stdout && !explicit_file) { - /* Default: save active into /.conf, creating it. */ - char dir[4096]; - if (!extractor_user_dir(dir, sizeof(dir), 1)) { - fprintf(stderr, - _("flux: cannot determine or create the user extractor directory.\n")); - scan_result_free(r); - return 1; - } - char name[128]; - if (scan_config_name(r, name, sizeof(name)) != 0) { - fprintf(stderr, _("flux: cannot derive a config name.\n")); - scan_result_free(r); - return 1; - } - int n = snprintf(autopath, sizeof(autopath), "%s/%s.conf", - dir, name); - if (n <= 0 || (size_t)n >= sizeof(autopath)) { - fprintf(stderr, _("flux: extractor config path too long.\n")); + if (explicit_file) { + FILE *out = fopen(out_file, "w"); + if (!out) { + fprintf(stderr, _("flux: cannot write config to %s: %s\n"), + out_file, strerror(errno)); scan_result_free(r); return 1; } - /* Never silently clobber a hand-tuned config on the auto-path. */ - if (access(autopath, F_OK) == 0) { + int er = scan_emit_config(r, chosen, out); + if (fclose(out) != 0) + er = -1; + scan_result_free(r); + if (er != 0) { fprintf(stderr, - _("flux: %s already exists; pass -o to overwrite or choose a path.\n"), - autopath); - scan_result_free(r); + _("flux: failed to write generated config.\n")); return 1; } - dest = autopath; + if (conf->verbose >= 0) + fprintf(stderr, + _("flux: wrote extractor config to %s\n"), + out_file); + return 0; } - FILE *out = stdout; - int close_out = 0; - if (!to_stdout) { - out = fopen(dest, "w"); - if (!out) { - fprintf(stderr, _("flux: cannot write config to %s: %s\n"), - dest, strerror(errno)); - scan_result_free(r); - return 1; - } - close_out = 1; + /* Default / '-o -': render to a heap buffer, print to stdout, stash to + * the pending store, and hint how to save it active by id (#scan-recur). */ + char *cfg = NULL; + size_t clen = 0; + FILE *mp = open_memstream(&cfg, &clen); + if (!mp) { + scan_result_free(r); + fprintf(stderr, _("flux: out of memory\n")); + return 1; + } + int er = scan_emit_config(r, chosen, mp); + if (fclose(mp) != 0) + er = -1; + if (er != 0) { + free(cfg); + scan_result_free(r); + fprintf(stderr, _("flux: failed to render generated config.\n")); + return 1; } - int er = scan_emit_config(r, chosen, out); - if (close_out) { - if (fclose(out) != 0) - er = -1; - } else { - fflush(out); + fputs(cfg, stdout); + fflush(stdout); + + char id[160]; + char pdir[4096]; + char ppath[MAX_STRING]; + int stashed = 0; + if (scan_pending_id(r, page_url, id, sizeof(id)) == 0 && + scan_pending_dir(pdir, sizeof(pdir))) { + int n = snprintf(ppath, sizeof(ppath), "%s/%s.conf", pdir, id); + if (n > 0 && (size_t)n < sizeof(ppath)) { + FILE *pf = fopen(ppath, "w"); + if (pf) { + if (fputs(cfg, pf) >= 0 && fclose(pf) == 0) + stashed = 1; + else + remove(ppath); /* drop a partial stash */ + } + } } + free(cfg); scan_result_free(r); - if (er != 0) { - fprintf(stderr, _("flux: failed to write generated config.\n")); - return 1; - } - if (close_out && conf->verbose >= 0) { - if (explicit_file) + if (conf->verbose >= 0) { + if (stashed) fprintf(stderr, - _("flux: wrote extractor config to %s\n"), dest); + _("flux: If you want to save this, run: flux --save-config %s\n"), + id); else fprintf(stderr, - _("flux: saved active extractor config to %s\n"), + _("flux: could not stash the config; copy it from the output above.\n")); + } + return 0; +} + +/* Run --save-config : install the pending config .conf into the active + * user extractor dir under its declared `name`. Refuses to clobber an existing + * active file. Returns 0 on success, 1 on any error. */ +static int +run_save_config(conf_t *conf, const char *id) +{ + if (!id || !*id) { + fprintf(stderr, _("flux: --save-config needs an id.\n")); + return 1; + } + /* Reject path separators so an id can't escape the pending dir. */ + if (strchr(id, '/') || strstr(id, "..")) { + fprintf(stderr, _("flux: invalid config id '%s'.\n"), id); + return 1; + } + + char dir[4096]; + if (!extractor_user_dir(dir, sizeof(dir), 1)) { + fprintf(stderr, + _("flux: cannot determine or create the user extractor directory.\n")); + return 1; + } + + char ppath[MAX_STRING]; + int n = snprintf(ppath, sizeof(ppath), "%s/.pending/%s.conf", dir, id); + if (n <= 0 || (size_t)n >= sizeof(ppath)) { + fprintf(stderr, _("flux: config id too long.\n")); + return 1; + } + + FILE *pf = fopen(ppath, "rb"); + if (!pf) { + fprintf(stderr, + _("flux: no pending config with id %s (run --extract-scan first)\n"), + id); + return 1; + } + + /* Read the pending config and parse out its `name` for the filename. */ + char *buf = NULL; + size_t cap = 0, len = 0; + for (;;) { + if (len + 4096 + 1 > cap) { + size_t ncap = cap ? cap * 2 : 8192; + if (ncap > (1u << 20)) { /* configs are tiny */ + free(buf); + fclose(pf); + fprintf(stderr, _("flux: pending config too large.\n")); + return 1; + } + char *nb = realloc(buf, ncap); + if (!nb) { + free(buf); + fclose(pf); + fprintf(stderr, _("flux: out of memory\n")); + return 1; + } + buf = nb; + cap = ncap; + } + size_t got = fread(buf + len, 1, 4096, pf); + len += got; + if (got < 4096) + break; + } + fclose(pf); + if (!buf) { + fprintf(stderr, _("flux: out of memory\n")); + return 1; + } + buf[len] = '\0'; + + /* Find the first `name ` line to choose the install filename. */ + char cfgname[128] = ""; + for (char *line = buf; line && *line; ) { + char *eol = strchr(line, '\n'); + while (*line == ' ' || *line == '\t') + line++; + if (strncmp(line, "name", 4) == 0 && + (line[4] == ' ' || line[4] == '\t')) { + const char *v = line + 4; + while (*v == ' ' || *v == '\t') + v++; + size_t vn = strcspn(v, " \t\r\n"); + if (vn > 0 && vn < sizeof(cfgname)) { + memcpy(cfgname, v, vn); + cfgname[vn] = '\0'; + } + break; + } + line = eol ? eol + 1 : NULL; + } + if (!cfgname[0]) + snprintf(cfgname, sizeof(cfgname), "%s", id); /* fallback */ + + /* Validate cfgname: reject path traversal / shell-hostile chars. See #scan-save. */ + int cfgname_ok = cfgname[0] != '\0'; + for (const char *cp = cfgname; *cp && cfgname_ok; cp++) + if (!isalnum((unsigned char)*cp) && *cp != '.' && *cp != '_' && *cp != '-') + cfgname_ok = 0; + if (!cfgname_ok || strstr(cfgname, "..") || strchr(cfgname, '/')) + snprintf(cfgname, sizeof(cfgname), "%s", id); + + char dest[MAX_STRING]; + n = snprintf(dest, sizeof(dest), "%s/%s.conf", dir, cfgname); + if (n <= 0 || (size_t)n >= sizeof(dest)) { + free(buf); + fprintf(stderr, _("flux: install path too long.\n")); + return 1; + } + /* Create atomically: O_EXCL refuses to clobber an existing active config + * without a separate access() check, so there is no TOCTOU race. See #scan-save. */ + int fd = open(dest, O_WRONLY | O_CREAT | O_EXCL, 0644); + if (fd < 0) { + free(buf); + if (errno == EEXIST) + fprintf(stderr, + _("flux: %s already exists; pass a different id or remove it.\n"), dest); + else + fprintf(stderr, _("flux: cannot write config to %s: %s\n"), + dest, strerror(errno)); + return 1; + } + FILE *out = fdopen(fd, "w"); + if (!out) { + close(fd); + remove(dest); + free(buf); + fprintf(stderr, _("flux: cannot write config to %s: %s\n"), + dest, strerror(errno)); + return 1; + } + int ok = (fwrite(buf, 1, len, out) == len); + if (fclose(out) != 0) + ok = 0; + free(buf); + if (!ok) { + remove(dest); + fprintf(stderr, _("flux: failed to write %s\n"), dest); + return 1; } + if (conf->verbose >= 0) + fprintf(stderr, + _("flux: saved active extractor config to %s\n"), dest); return 0; } @@ -395,6 +596,8 @@ main(int argc, char *argv[]) const char *hls_mux = NULL; /* --mux mp4|ts */ int select_all = 0; /* --all: every episode */ const char *episodes_spec = NULL; /* --episodes 1,3-5,8 */ + int scan_depth = SCAN_DEFAULT_DEPTH; /* --extract-scan-depth (0..MAX) */ + const char *save_config_id = NULL; /* --save-config */ fn[0] = 0; @@ -531,6 +734,27 @@ main(int argc, char *argv[]) case EPISODES_OPT: episodes_spec = optarg; break; + case EXTRACT_SCAN_DEPTH_OPT: { + char *end = NULL; + long d = strtol(optarg, &end, 10); + if (!end || *end != '\0' || d < 0) { + fprintf(stderr, + _("Invalid --extract-scan-depth value '%s' (0-%d).\n"), + optarg, SCAN_MAX_DEPTH); + goto free_conf; + } + if (d > SCAN_MAX_DEPTH) { + fprintf(stderr, + _("flux: --extract-scan-depth clamped to %d (max).\n"), + SCAN_MAX_DEPTH); + d = SCAN_MAX_DEPTH; + } + scan_depth = (int)d; + break; + } + case SAVE_CONFIG_OPT: + save_config_id = optarg; + break; case QUALITY_OPT: hls_quality = optarg; break; @@ -569,11 +793,18 @@ main(int argc, char *argv[]) ssl_init(conf); #endif /* HAVE_SSL */ + /* --save-config installs a pending config by id and exits; handle it + * before the positional-URL dispatch (like --extract-scan). */ + if (save_config_id) { + ret = run_save_config(conf, save_config_id); + goto free_conf; + } + /* --extract-scan carries its own URL and writes a config (to -o or * stdout) instead of downloading; handle it before the positional-URL * dispatch and exit. */ if (scan_url) { - ret = run_extract_scan(conf, scan_url, fn, auto_yes); + ret = run_extract_scan(conf, scan_url, fn, scan_depth, auto_yes); goto free_conf; } @@ -1095,6 +1326,26 @@ episode_outpath(char *dst, size_t dlen, const char *out_name, int out_is_file, * directory (or empty), receives per-episode files; a plain -o file name is only * honoured for a single selected episode (else it would clobber). Returns 0 if * all attempted succeeded, 2 on interrupt, 1 if any failed or on a setup error. */ + +/* Episode number from a page URL (its last digit run), to order the list 1..N + * even when the site lists newest first; -1 if the URL carries no number. */ +static long +episode_number(const char *url) +{ + long n = -1; + const char *p = url; + while (p && *p) { + if (isdigit((unsigned char)*p)) { + char *end; + n = strtol(p, &end, 10); + p = end; + } else { + p++; + } + } + return n; +} + static int run_series(conf_t *conf, const char *page_url, const char *extract_name, char **episode_urls, size_t nepisodes, int select_all, @@ -1127,6 +1378,30 @@ run_series(conf_t *conf, const char *page_url, const char *extract_name, goto cleanup; } + /* Order episodes 1..N by episode number so the list does not read newest + * first; URLs with no number keep their original order (stable). */ + { + long *epnum = malloc(nepisodes * sizeof(*epnum)); + if (epnum) { + for (size_t i = 0; i < nepisodes; i++) + epnum[i] = episode_number(episode_urls[i]); + for (size_t i = 1; i < nepisodes; i++) { + char *ku = episode_urls[i]; + long kn = epnum[i]; + size_t j = i; + while (j > 0 && epnum[j - 1] > kn && + kn >= 0) { + episode_urls[j] = episode_urls[j - 1]; + epnum[j] = epnum[j - 1]; + j--; + } + episode_urls[j] = ku; + epnum[j] = kn; + } + free(epnum); + } + } + if (conf->verbose >= 0) printf(_("Resolving %zu episodes\xE2\x80\xA6\n"), nepisodes); @@ -1193,15 +1468,32 @@ run_series(conf_t *conf, const char *page_url, const char *extract_name, /* Label = episode number + remote filename (basename of the * resolved media URL); missing pre-selected, on-disk unselected. */ for (size_t i = 0; i < nepisodes; i++) { - const char *leaf = outpaths[i][0] - ? (strrchr(outpaths[i], '/') - ? strrchr(outpaths[i], '/') + 1 : outpaths[i]) - : "(unresolved)"; - snprintf(labels[i], sizeof(labels[i]), "%2zu %s", - i + 1, leaf); + /* Label by the episode's own URL identity (last path segment), + * so the list reads as real episodes even with placeholder dups. */ + const char *eu = episode_urls[i]; + size_t eul = strlen(eu); + while (eul > 0 && eu[eul - 1] == '/') + eul--; + const char *slug = eu; + for (size_t k = 0; k < eul; k++) + if (eu[k] == '/') + slug = eu + k + 1; + int slen = (int)(eu + eul - slug); + /* A later episode resolving to the same media URL is a + * placeholder duplicate (unaired episode served the latest file). */ + int dup = 0; + for (size_t j = 0; j < i && !dup; j++) + if (media_urls[i] && media_urls[j] && + strcmp(media_urls[i], media_urls[j]) == 0) + dup = 1; + const char *mark = !media_urls[i] ? _(" (unresolved)") + : dup ? _(" (duplicate)") : ""; + snprintf(labels[i], sizeof(labels[i]), "%2zu %.*s%s", + i + 1, slen, slug, mark); items[i].label = labels[i]; items[i].on_disk = on_disk[i]; - items[i].selected = on_disk[i] ? 0 : 1; + items[i].selected = + (on_disk[i] || dup || !media_urls[i]) ? 0 : 1; } /* Header name: a forced config name, else the /play/ slug with its * trailing "." dropped (e.g. dr-stone-4-part-3-ita). */ @@ -1271,7 +1563,10 @@ run_series(conf_t *conf, const char *page_url, const char *extract_name, for (size_t i = 0; i < nepisodes && run; i++) { if (!sel[i]) continue; - if (on_disk[i]) { /* already present: skip, never re-download */ + /* Skip if already present, including a file written earlier in this + * run (placeholder episodes share one file). Re-check at download time. */ + if (on_disk[i] || + (outpaths[i][0] && access(outpaths[i], F_OK) == 0)) { printf(_("\n=== episode %zu/%zu: already on disk, skipping (%s) ===\n"), i + 1, nepisodes, outpaths[i]); skipped++; @@ -1689,7 +1984,9 @@ print_help(void) "--timeout=x\t\t-T x\tSet I/O and connection timeout\n" "--extract=name\t\t\tForce a named extractor config\n" "--extract-list\t\t\tList discovered extractor configs\n" - "--extract-scan=url\t\tScan a page and save an active config (-o FILE to a path, -o - to stdout)\n" + "--extract-scan=url\t\tScan a page, print the config, and suggest --save-config (-o FILE writes to a path)\n" + "--extract-scan-depth=N\t\tHops to follow for watch/play/embed pages (default 2, max 3)\n" + "--save-config=id\t\tSave a previously scanned pending config (its id) active\n" "--yes\t\t\t\tNon-interactive: auto-pick the top candidate\n" "--all\t\t\t\tSeries: download every episode (no prompt)\n" "--episodes=spec\t\t\tSeries: pick episodes, e.g. 1,3-5,8 (1-based)\n"