Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ env:
PYTHON_VERSION: '3.12'
APP_NAME: 'YoutubeWeekly'
BINARIES_TAG: 'v0.0.0-binaries'
# yt-dlp runs YouTube's own JavaScript to solve the signature / "n"
# challenge. Without an engine, every format above 360p is dropped on the
# videos that demand it. quickjs-ng ships static single-file builds ~2MB.
QUICKJS_TAG: 'v0.16.2'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
Expand Down Expand Up @@ -216,6 +220,13 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Download bundled QuickJS
run: |
New-Item -ItemType Directory -Force -Path app/tools/quickjs_win64 | Out-Null
curl.exe -L --fail --retry 5 --retry-delay 10 --retry-all-errors --connect-timeout 30 --max-time 300 `
"https://github.com/quickjs-ng/quickjs/releases/download/${{ env.QUICKJS_TAG }}/qjs-windows-x86_64.exe" `
-o app/tools/quickjs_win64/qjs.exe

- name: Inject version
run: |
$version = "${{ needs.create-release.outputs.version }}"
Expand Down Expand Up @@ -284,6 +295,14 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Download bundled QuickJS
run: |
mkdir -p app/tools/quickjs_macOS/arm64
curl -L --fail --retry 5 --retry-delay 10 --retry-all-errors --connect-timeout 30 --max-time 300 \
https://github.com/quickjs-ng/quickjs/releases/download/${{ env.QUICKJS_TAG }}/qjs-darwin-arm64 \
-o app/tools/quickjs_macOS/arm64/qjs
chmod +x app/tools/quickjs_macOS/arm64/qjs

- name: Inject version
run: |
sed -i '' "s/__version__ = \".*\"/__version__ = \"${{ needs.create-release.outputs.version }}\"/" app/backend/config.py
Expand Down Expand Up @@ -353,6 +372,14 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Download bundled QuickJS
run: |
mkdir -p app/tools/quickjs_macOS/intel
curl -L --fail --retry 5 --retry-delay 10 --retry-all-errors --connect-timeout 30 --max-time 300 \
https://github.com/quickjs-ng/quickjs/releases/download/${{ env.QUICKJS_TAG }}/qjs-darwin-x86_64 \
-o app/tools/quickjs_macOS/intel/qjs
chmod +x app/tools/quickjs_macOS/intel/qjs

- name: Inject version
run: |
sed -i '' "s/__version__ = \".*\"/__version__ = \"${{ needs.create-release.outputs.version }}\"/" app/backend/config.py
Expand Down Expand Up @@ -426,6 +453,14 @@ jobs:
tar -xf ffmpeg-linux.tar.xz --strip-components=1 -C app/tools/ffmpeg_linux/ffmpeg-7.0.2-amd64-static
chmod +x app/tools/ffmpeg_linux/ffmpeg-7.0.2-amd64-static/ffmpeg

- name: Download bundled QuickJS
run: |
mkdir -p app/tools/quickjs_linux
curl -L --fail --retry 5 --retry-delay 10 --retry-all-errors --connect-timeout 30 --max-time 300 \
https://github.com/quickjs-ng/quickjs/releases/download/${{ env.QUICKJS_TAG }}/qjs-linux-x86_64 \
-o app/tools/quickjs_linux/qjs
chmod +x app/tools/quickjs_linux/qjs

- name: Inject version
run: |
sed -i "s/__version__ = \".*\"/__version__ = \"${{ needs.create-release.outputs.version }}\"/" app/backend/config.py
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
- **Children's Videos Download Again, in Full Quality**: Videos marked "Made for Kids" on YouTube — children's songs, Bible stories and the like — all failed with "This video is not available", even though they play perfectly in a browser. YouTube holds those back unless the app can run a piece of its code, so the app now carries the small engine needed. They download at full 1080p again, and any link that still refuses is retried a second way before the app gives up
- **Sharper App Icon**: The icon shipped at a single small size, so Windows stretched it for shortcuts, folder views and notifications — it looked fuzzy nearly everywhere. It now includes every size Windows asks for, up to 256px

## v1.6.0
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG_ro.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Istoric versiuni

## Unreleased
- **Videoclipurile pentru copii se descarcă din nou, la calitate maximă**: Videoclipurile marcate „Made for Kids” pe YouTube — cântece pentru copii, istorioare biblice și altele — eșuau toate cu „Acest videoclip nu este disponibil”, deși se redau perfect în browser. YouTube le reține dacă aplicația nu poate rula o bucată din codul lui, așa că aplicația include acum micul motor necesar. Se descarcă din nou la 1080p complet, iar un link care tot refuză este reîncercat pe a doua cale înainte ca aplicația să renunțe
- **Pictogramă mai clară**: Pictograma era livrată într-o singură dimensiune mică, așa că Windows o întindea pentru scurtături, foldere și notificări — arăta neclară aproape peste tot. Acum include toate dimensiunile cerute de Windows, până la 256px

## v1.6.0
Expand Down
73 changes: 72 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The build bundles config defaults from `config/`, platform binaries for mpv/ffmp

- **Backend (`app/backend/`)**: Core business logic
- `config.py`: Configuration management, platform-specific executable paths, `__version__` constant, app data dir (`CONFIG_DIR`); copies default configs from `config/` into the OS-specific app data dir on first run. `load_settings()` returns `(settings, warnings)`.
- `downloader.py`: yt-dlp-based downloads (find videos, quality/format selection, delete old videos, protected videos, recent Sabbath dates). Includes fuzzy date matching — handles off-by-one-day titles and formatting variants, and surfaces a confirmation prompt to the user.
- `downloader.py`: yt-dlp-based downloads (find videos, quality/format selection, delete old videos, protected videos, recent Sabbath dates). Includes fuzzy date matching — handles off-by-one-day titles and formatting variants, and surfaces a confirmation prompt to the user. Hands yt-dlp the bundled JS engine, and retries a failed download once across `FALLBACK_PLAYER_CLIENTS` — see "JavaScript engine (QuickJS)" and "Player-client fallback" below.
- `auto_downloader.py`: Automatic download scheduling for upcoming Sabbath (Fri/Sat).
- `updater.py`: Talks to the GitHub releases API (`ThorSPB/YoutubeWeekly`). Provides current-release check, paginated listing of available versions for rollback (`MIN_ROLLBACK_VERSION = 1.1.0`), and asset downloader with progress callback. Per-platform asset selection (`win64`, `macos-arm64`, `macos-intel`, `linux-x64`).
- `update_bootstrap.py`: External process that swaps a downloaded ZIP over the running install. Uses `WaitForSingleObject` on Windows for reliable exit detection; renames the running bootstrap before extraction so it can update itself.
Expand Down Expand Up @@ -146,6 +146,77 @@ Videos with "diaspora" in the title are excluded. The fuzzy matcher (since v1.1.
- Date selector: "automat" (next Saturday) or a specific past Sabbath date (last 30 Saturdays).
- "Others" section: paste any YouTube URL to download into the `other/` folder. Tracked separately in telemetry (since v1.2.0) so quality selection there is metered independently.

### JavaScript engine (QuickJS)

**Downloads need a JavaScript engine.** yt-dlp solves YouTube's signature /
`n` challenge by *running YouTube's own JavaScript*, and some videos release no
adaptive-format URL until that is solved. Adaptive formats are the only route
above 360p (the sole surviving muxed format is 360p), so with no engine those
videos fail outright — with YouTube's flatly wrong "This video is not
available", because yt-dlp then falls through its clients until `visionos`
refuses.

**The discriminator is YouTube's "Made for Kids" flag.** Measured 2026-09-07
across 22 videos, no engine vs `quickjs`: **8/8 kids videos failed, 0/10
non-kids failed**; every failure returned 1080p with an engine. The kids set
spanned five unrelated channels plus Cocomelon and Baby Shark, 2021 and 2026
uploads, all `People & Blogs`, `isFamilySafe: true` in *both* groups. That is
why nothing else had ever failed: an ordinary video is served by `visionos`
with clean URLs and never needs JavaScript at all.

- **`app/tools/quickjs_<platform>/qjs[.exe]`**, fetched per platform in
`release.yml` (pinned by `QUICKJS_TAG`) from **quickjs-ng**, which publishes
static single-file builds of ~2 MB. `app/tools` is bundled wholesale by the
spec, so no spec change is needed for a new binary under it.
- `config.py::get_default_executable_paths()` resolves it into
**`js_runtime_path`**, recomputed on every `load_settings()` like
`mpv_path`/`ffmpeg_path` — a saved path breaks when the app is moved or
updated.
- A missing engine adds **no user-facing warning**, unlike mpv and ffmpeg: it
is not exposed in Settings so there is nothing to configure, a source
checkout legitimately has none, and an empty path just means "let yt-dlp
auto-detect". `download_video` logs which engine it used instead, so a build
that shipped without the binary — or lost its executable bit in packaging —
is visible in the log rather than silently back to 360p-or-nothing.
- ⚠ **`js_runtimes` must be a dict** of `{runtime: {config}}`. A list raises a
bare `ValueError` from `YoutubeDL.__init__` that names no option.
- ⚠ **yt-dlp auto-enables only `deno`.** Node can sit on `PATH` completely
unused, which is what makes "no JS runtime" so easy to misdiagnose.

Dead ends, all measured — don't re-walk them:
- **PO tokens are irrelevant here.** `bgutil-ytdlp-pot-provider` minted a real
GVS token and nothing changed.
- **Forcing `player_client=web` is SABR-only regardless**, so every web-client
probe looks like a dead end and sends you chasing SABR (yt-dlp #12482).
- Upgrading yt-dlp changes nothing (stable 2026.08.19 == nightly 2026.08.30).
- The `android` client alone "works" but serves only muxed **360p** — a trap
that looks like a fix.

### Player-client fallback

A safety net *behind* the JavaScript engine above, not a substitute for it: it
rescues a challenge-blocked video at 360p, where the engine gets 1080p. It
earns its place for the case where the bundled binary is missing or broken, and
for whatever YouTube does next.

`download_video` treats a failure as inconclusive: it retries **once** with
`extractor_args={"youtube": {"player_client": FALLBACK_PLAYER_CLIENTS}}`.
Notes on that list and the retry:
- `default` stays **first** in it. Without it the retry would trade a perfectly
good 1080p stream for whatever the older clients happen to offer.
- The retry is deliberately **not** gated on the error text. YouTube's messages
churn, and a fix that pattern-matches them is a fix that quietly stops
working. The cost of an unnecessary retry is a few HTTP requests on a path
that was already failing.
- The error handed back to the caller is the **first** attempt's, not the
retry's: the retry ran against clients the user never chose, and its message
would only mislead.
- A retry restarts from zero bytes, so it emits `PROGRESS_RESET_STATUS` first.
`DownloadProgress` only ever moves forward, so without that the bar would sit
at the abandoned attempt's high-water mark.
- Every download in the app (channels, "Others", link overrides) goes through
`download_video`, so all of them get this.

### Video Overrides
A safety valve for the case the date matcher structurally cannot handle: the
uploader typed the wrong date. Real example (2026-08-15): ScoalaDeSabat titled
Expand Down
24 changes: 21 additions & 3 deletions app/backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,24 +65,29 @@ def get_default_executable_paths():
base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) # This should be YoutubeWeekly/app
mpv_path = ""
ffmpeg_path = ""
js_runtime_path = ""
warnings = []

system = platform.system()
if system == "Windows":
mpv_candidate = os.path.join(base_path, "player", "win64", "mpv-x86_64-20250715-git-fdbea0f", "mpv.exe")
ffmpeg_candidate = os.path.join(base_path, "tools", "ffmpeg_win64", "ffmpeg-7.1.1-essentials_build", "bin", "ffmpeg.exe")
qjs_candidate = os.path.join(base_path, "tools", "quickjs_win64", "qjs.exe")
elif system == "Darwin": # macOS
if platform.machine() == "arm64":
mpv_candidate = os.path.join(base_path, "player", "macOS", "arm64", "mpv-arm64-0.40.0", "mpv.app", "Contents", "MacOS", "mpv")
ffmpeg_candidate = os.path.join(base_path, "tools", "ffmpeg_macOS", "ffmpeg711arm", "ffmpeg")
qjs_candidate = os.path.join(base_path, "tools", "quickjs_macOS", "arm64", "qjs")
else: # Intel
mpv_candidate = os.path.join(base_path, "player", "macOS", "intel", "mpv-0.39.0", "mpv.app", "Contents", "MacOS", "mpv")
ffmpeg_candidate = os.path.join(base_path, "tools", "ffmpeg_macOS", "ffmpeg71intel", "ffmpeg")
qjs_candidate = os.path.join(base_path, "tools", "quickjs_macOS", "intel", "qjs")
elif system == "Linux":
# Assuming a 64-bit Linux for now, adjust if 32-bit is needed
mpv_candidate = "/usr/bin/mpv" # Placeholder, as you didn't provide a bundled Linux mpv
ffmpeg_candidate = os.path.join(base_path, "tools", "ffmpeg_linux", "ffmpeg-7.0.2-amd64-static", "ffmpeg")

qjs_candidate = os.path.join(base_path, "tools", "quickjs_linux", "qjs")

# Validate mpv path
if os.path.exists(mpv_candidate) and os.access(mpv_candidate, os.X_OK):
mpv_path = mpv_candidate
Expand All @@ -95,7 +100,19 @@ def get_default_executable_paths():
else:
warnings.append(f"Warning: Default FFmpeg executable not found or not executable at '{ffmpeg_candidate}'. Please configure FFmpeg path in settings.")

return {"mpv_path": mpv_path, "ffmpeg_path": ffmpeg_path}, warnings
# Validate the bundled JavaScript engine - see "JavaScript engine (QuickJS)"
# in CLAUDE.md for why downloads need one at all.
#
# Deliberately adds NO warning when it is missing, unlike mpv and ffmpeg:
# there is nothing for the user to configure (it is not exposed in
# Settings), a source checkout legitimately has no bundled binary, and
# yt-dlp falls back to auto-detecting a system runtime. An empty path here
# means "let yt-dlp decide", which is exactly the old behaviour.
if qjs_candidate and os.path.exists(qjs_candidate) and os.access(qjs_candidate, os.X_OK):
js_runtime_path = qjs_candidate

return {"mpv_path": mpv_path, "ffmpeg_path": ffmpeg_path,
"js_runtime_path": js_runtime_path}, warnings

def _merge_missing_defaults(settings):
"""Add keys present in bundled defaults but missing from the user's settings.
Expand All @@ -110,7 +127,7 @@ def _merge_missing_defaults(settings):

# Window geometries and bundled exe paths are runtime-only; defaults file
# may carry stale values for them, so skip those keys during migration.
skip_keys = {"mpv_path", "ffmpeg_path"}
skip_keys = {"mpv_path", "ffmpeg_path", "js_runtime_path"}

added = False
for key, default_value in defaults.items():
Expand Down Expand Up @@ -142,6 +159,7 @@ def load_settings():
# Always use the bundled executables
settings["mpv_path"] = default_paths["mpv_path"]
settings["ffmpeg_path"] = default_paths["ffmpeg_path"]
settings["js_runtime_path"] = default_paths["js_runtime_path"]

# Ensure video_folder is an absolute path relative to the executable
video_folder = settings.get("video_folder", "data/videos")
Expand Down
Loading