diff --git a/AGENTS.md b/AGENTS.md index 39e5c65..6ec2596 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,24 @@ uv run pytest app/tests/ All of these run in CI (`.github/workflows/main.yml`) on every push to master and must pass. +Gotchas: + +- Backend tests must run **from the repo root**: `main.py` resolves the static-assets + path relative to the cwd, and several test modules import `main`. Running from + `app/` makes five test modules fail to import. +- The frontend must be **built before** running backend tests (same reason — the + assets at `ui/dist/metube/browser` must exist). The command order above is + load-bearing. +- `app/tests/test_ytdl_utils.py` stubs `yt_dlp` at import time. Run standalone, + two tests fail with `AttributeError: does not have the + attribute 'YoutubeDL'`; under the full suite the real module is imported first + and they pass. This is a known quirk, not a bug to fix in the code under test. + +Every non-markdown push to master builds multi-arch Docker images and cuts a dated +release the same day. **Master is continuously released** — a PR must be +release-ready exactly as merged; there is no stabilization window for follow-up +fixes. + ## Code style Follow `.editorconfig`: @@ -104,5 +122,36 @@ ui/src/app/ — Angular standalone components (no NgModules) - Backend configuration lives in the `Config` class in `app/main.py` with env-var defaults in `_DEFAULTS`. New env vars go there. - Real-time communication uses Socket.IO events, not REST polling. - Frontend uses standalone Angular components with `inject()` for DI, RxJS Subjects for state, and `takeUntilDestroyed()` for cleanup. +- Frontend components use OnPush change detection: subscribe callbacks must call `cdr.markForCheck()`. - State is persisted as JSON files via `AtomicJsonStore` in `app/state_store.py`. +- Persisted state stays compact: the completed queue deliberately drops bulky entry data (see `_compact_persisted_entry` in `app/ytdl.py`). Don't expand what gets persisted without discussion. +- Custom yt-dlp postprocessors added to `ytdl_params['postprocessors']` run in **list order** within a stage. When combining postprocessors, mirror the ordering the yt-dlp CLI would produce (e.g. sponsor-segment removal before chapter splitting). - No pre-commit hooks — linting and tests are enforced in CI only. + +## Checklist: adding a per-download option + +New options on the download form (the `split_by_chapters` pattern) need **all** of +these pieces — the last three are the ones commonly missed: + +1. `parse_download_options` in `app/main.py`. +2. A field on `DownloadInfo` in `app/ytdl.py`. +3. A `hasattr` backfill in `DownloadInfo.__setstate__` for old persisted records. +4. The safe-deserialization field list in `app/ytdl.py`. +5. UI form control + cookie persistence in `ui/src/app/app.ts` / `app.html`, and + the payload in `downloads.service.ts` (plus its spec). +6. The redownload path in `app.ts`, so retries carry the option. +7. If the option makes sense for unattended downloads: threading through + `app/subscriptions.py` (`SubscriptionInfo` field, serializer, add/update + routes, the enqueue call) — or a note in the PR that it's deliberately + direct-downloads-only. + +## Security invariants + +User input and extractor-provided metadata (titles, playlist names, URLs) are +untrusted. Use the existing guards instead of hand-rolling: + +- User-submitted URLs go through the SSRF guard (see `test_url_guard.py` for the + expected behavior). +- Anything that becomes a filesystem path goes through `_is_within_directory` and + `_sanitize_path_component` in `app/ytdl.py` — including values that arrive via + yt-dlp metadata, which sites can influence.