{"owner":"alexta69","repo":"metube","hasSkills":true,"totalSkillsCount":1,"totalTokensCount":2255,"categories":["root-instruction"],"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md"],"skills":{"AGENTS.md":"# Agent Guidelines\n\n## Project scope — read this before planning a feature\n\nMeTube's contract is: give it a URL, it runs yt-dlp well, and correct files appear.\nThe maintainer holds a deliberate line on what belongs inside that contract, and PRs\non the wrong side of it are declined **regardless of code quality**. Check your plan\nagainst this line before writing any code.\n\n**In scope — improving the write itself:**\n\n- Features that make the file yt-dlp writes at download time come out more correct,\n  using only data the extractor already provides (e.g. filling a missing album-artist\n  tag from the extractor's own metadata).\n- Surfacing functionality yt-dlp itself owns and maintains as first-class UI options\n  (e.g. a SponsorBlock toggle that just passes yt-dlp postprocessor params).\n- Download queue, subscriptions, output templates, and UI improvements to the\n  download workflow.\n\n**Out of scope — managing files after they exist:**\n\n- Tag editors, metadata dialogs, or any workflow that rewrites files after the\n  download has finished. This holds even for slimmed-down versions.\n- Lookups against external metadata services (iTunes, Deezer, MusicBrainz, etc.).\n  More broadly: any new dependency on a third-party API, or new network egress from\n  self-hosted instances, beyond what yt-dlp itself performs.\n- Library organization: moving/renaming existing files into Artist/Album layouts,\n  watch-folder processing, and similar media-manager features. Dedicated tools\n  (beets, MusicBrainz Picard, Lidarr) do this properly; the README points users\n  to them.\n\n**Corollaries that shape borderline PRs:**\n\n- Site-specific intelligence (parsing playlist-ID prefixes, URL path conventions,\n  and other platform internals) is extractor work and belongs upstream in yt-dlp,\n  not re-implemented here — it silently breaks when the platform changes and\n  MeTube would own the breakage.\n- Prefer enriching yt-dlp's info dict and letting its existing pipeline\n  (FFmpegMetadata etc.) do the writing, over adding custom per-format tag-writing\n  code to MeTube.\n- Supplemental processing must never fail a download that otherwise succeeded:\n  warn and continue, don't raise.\n- Keep feature scope minimal on first submission. A hardcoded sensible default\n  beats a configuration surface; follow-ups can add options when users actually\n  ask. PRs that bundle several \"reasonable next steps\" invite rejection of the\n  whole.\n\nIf a feature idea fails this test, the accepted alternative is usually documentation\non pairing MeTube with the right dedicated tool — a wiki guide, or a pointer in the\nREADME.\n\n## Documentation: where content goes\n\nThe README is synced verbatim to Docker Hub, which rejects descriptions over\n**25,000 bytes** (CI checks `wc -c README.md`). Rather than trimming prose to fit,\neach kind of content has one home:\n\n- **README.md** — what MeTube is, the quick start, and the environment variable\n  reference: **one table row per variable**, a sentence or two at most. A variable\n  that needs more explanation gets a section in a wiki guide, linked from its row.\n  No free-text paragraphs in the configuration section. A PR that adds an env var\n  adds its row in the same diff.\n- **Wiki** (<https://github.com/alexta69/metube/wiki>, a separate git repository:\n  `https://github.com/alexta69/metube.wiki.git`) — guides, concepts, recipes,\n  integrations, and troubleshooting. Explanations of behavior go here.\n- **CONTRIBUTING.md** — the scope summary for humans, the feature request policy,\n  and building and testing locally.\n\nLinks in README.md must be absolute URLs: on Docker Hub, relative links break. If the\nREADME passes 15,000 bytes, something in it belongs in the wiki.\n\n## Tech stack\n\n- **Backend:** Python 3.13+, aiohttp, python-socketio 5.x, yt-dlp\n- **Frontend:** Angular 22, TypeScript, Bootstrap 5, SASS, ngx-socket-io\n- **Package managers:** uv (Python), pnpm (frontend)\n- **Container:** Multi-stage Docker (Node builder + Python runtime), multi-arch (amd64/arm64)\n\n## Build & test commands\n\n```bash\n# Frontend (run from ui/)\npnpm install --frozen-lockfile\npnpm run lint\npnpm run build\npnpm exec ng test --watch=false\n\n# Backend (run from repo root)\nuv sync --frozen --group dev\nuv run python -m compileall -q app\nuv run pytest app/tests/\n```\n\nAll of these run in CI (`.github/workflows/main.yml`) on every push to master and must pass.\n\nThe backend tests need no frontend build and can run from the repo root or\nfrom `app/`: `app/tests/conftest.py` points `BASE_DIR` at a stand-in UI\ndirectory before `main` is imported.\n\nEvery non-markdown push to master builds multi-arch Docker images and cuts a dated\nrelease the same day. **Master is continuously released** — a PR must be\nrelease-ready exactly as merged; there is no stabilization window for follow-up\nfixes.\n\n## Commit messages\n\nA commit that resolves an issue must close it, with a GitHub closing keyword in\nparentheses at the end of the subject line:\n\n```\nfix: stop metadata probes from writing playlist sidecar files (closes #1040)\n```\n\nBecause master is the default branch and is released on every push, the issue\ncloses at the moment the fix ships, and keeps a permanent link to the commit that\nfixed it. A bare `(#1040)` is only a reference — and reads as a pull-request\nnumber — so it does not count; the keyword is what closes the issue.\n\nAuto-closing leaves only a commit stub on the issue, which is not an answer to\nwhoever reported it. Post an explanatory comment as well: what the cause was, what\nchanged, and anything the reporter needs to do differently.\n\nFollow `.editorconfig`:\n- Python: 4-space indent\n- Everything else (TypeScript, YAML, JSON, HTML): 2-space indent\n- UTF-8, LF line endings, trim trailing whitespace, final newline\n\nFrontend additionally uses ESLint (`ui/eslint.config.js`) and Prettier (config in `ui/package.json`: `printWidth=100`, `singleQuote=true`).\n\n## Project structure\n\n```\napp/main.py          — HTTP server, Socket.IO events, REST API routes, Config class\napp/ytdl.py          — Download queue logic, yt-dlp integration\napp/download_options.py — DownloadOptions: one request's options, passed whole through the queue\napp/subscriptions.py — Channel/playlist subscription manager\napp/state_store.py   — JSON-based persistent storage with atomic writes\napp/format_catalog.py — The download options (types, codecs, formats, qualities, labels, defaults)\napp/dl_formats.py    — Maps those options onto yt-dlp format selectors and options\napp/tests/           — pytest tests (asyncio_mode=auto)\nui/src/app/          — Angular standalone components (no NgModules)\n```\n\n## Key conventions\n\n- Backend configuration lives in the `Config` class in `app/main.py` with env-var defaults in `_DEFAULTS`. New env vars go there.\n- Download options are defined only in `app/format_catalog.py`. Request validation, `dl_formats`, the UI's form (sent over Socket.IO on connect) and `GET /formats` all read it; never hardcode an option list or id elsewhere. A new codec, format or quality also needs its yt-dlp mapping in `dl_formats`, which `test_format_catalog.py` enforces.\n- Real-time communication uses Socket.IO events, not REST polling.\n- Frontend uses standalone Angular components with `inject()` for DI, RxJS Subjects for state, and `takeUntilDestroyed()` for cleanup.\n- Frontend components use OnPush change detection: subscribe callbacks must call `cdr.markForCheck()`.\n- State is persisted as JSON files via `AtomicJsonStore` in `app/state_store.py`.\n- 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.\n- 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).\n- No pre-commit hooks — linting and tests are enforced in CI only.\n\n## Checklist: adding a per-download option\n\nNew options on the download form (the `split_by_chapters` pattern) need **all** of\nthese pieces — the last three are the ones commonly missed:\n\n1. A field on `DownloadOptions` in `app/download_options.py`, set by\n   `parse_download_options` in `app/main.py`. The queue's add, playlist,\n   failed-add and retry paths all carry that one value, so nothing else in\n   them needs touching.\n2. A field on `DownloadInfo` in `app/ytdl.py`.\n3. A default in `_DOWNLOAD_INFO_DEFAULTS` in `app/ytdl.py`, which old persisted\n   records take (a test fails if a `DownloadInfo` field has none).\n4. The safe-deserialization field list in `app/ytdl.py`.\n5. UI form control + `SettingsStore` persistence in `ui/src/app/app.ts` / `app.html`, and\n   the payload in `downloads.service.ts` (plus its spec).\n6. The redownload path in `app.ts`, so retries carry the option.\n7. If the option makes sense for unattended downloads: threading through\n   `app/subscriptions.py` (`SubscriptionInfo` field, serializer, add/update\n   routes, `SubscriptionInfo.download_options()`) — or a note in the PR that it's deliberately\n   direct-downloads-only.\n\n## Security invariants\n\nUser input and extractor-provided metadata (titles, playlist names, URLs) are\nuntrusted. Use the existing guards instead of hand-rolling:\n\n- User-submitted URLs go through the SSRF guard (see `test_url_guard.py` for the\n  expected behavior).\n- Anything that becomes a filesystem path goes through `_is_within_directory` and\n  `_sanitize_path_component` in `app/ytdl.py` — including values that arrive via\n  yt-dlp metadata, which sites can influence.\n"},"files":{"AGENTS.md":"# Agent Guidelines\n\n## Project scope — read this before planning a feature\n\nMeTube's contract is: give it a URL, it runs yt-dlp well, and correct files appear.\nThe maintainer holds a deliberate line on what belongs inside that contract, and PRs\non the wrong side of it are declined **regardless of code quality**. Check your plan\nagainst this line before writing any code.\n\n**In scope — improving the write itself:**\n\n- Features that make the file yt-dlp writes at download time come out more correct,\n  using only data the extractor already provides (e.g. filling a missing album-artist\n  tag from the extractor's own metadata).\n- Surfacing functionality yt-dlp itself owns and maintains as first-class UI options\n  (e.g. a SponsorBlock toggle that just passes yt-dlp postprocessor params).\n- Download queue, subscriptions, output templates, and UI improvements to the\n  download workflow.\n\n**Out of scope — managing files after they exist:**\n\n- Tag editors, metadata dialogs, or any workflow that rewrites files after the\n  download has finished. This holds even for slimmed-down versions.\n- Lookups against external metadata services (iTunes, Deezer, MusicBrainz, etc.).\n  More broadly: any new dependency on a third-party API, or new network egress from\n  self-hosted instances, beyond what yt-dlp itself performs.\n- Library organization: moving/renaming existing files into Artist/Album layouts,\n  watch-folder processing, and similar media-manager features. Dedicated tools\n  (beets, MusicBrainz Picard, Lidarr) do this properly; the README points users\n  to them.\n\n**Corollaries that shape borderline PRs:**\n\n- Site-specific intelligence (parsing playlist-ID prefixes, URL path conventions,\n  and other platform internals) is extractor work and belongs upstream in yt-dlp,\n  not re-implemented here — it silently breaks when the platform changes and\n  MeTube would own the breakage.\n- Prefer enriching yt-dlp's info dict and letting its existing pipeline\n  (FFmpegMetadata etc.) do the writing, over adding custom per-format tag-writing\n  code to MeTube.\n- Supplemental processing must never fail a download that otherwise succeeded:\n  warn and continue, don't raise.\n- Keep feature scope minimal on first submission. A hardcoded sensible default\n  beats a configuration surface; follow-ups can add options when users actually\n  ask. PRs that bundle several \"reasonable next steps\" invite rejection of the\n  whole.\n\nIf a feature idea fails this test, the accepted alternative is usually documentation\non pairing MeTube with the right dedicated tool — a wiki guide, or a pointer in the\nREADME.\n\n## Documentation: where content goes\n\nThe README is synced verbatim to Docker Hub, which rejects descriptions over\n**25,000 bytes** (CI checks `wc -c README.md`). Rather than trimming prose to fit,\neach kind of content has one home:\n\n- **README.md** — what MeTube is, the quick start, and the environment variable\n  reference: **one table row per variable**, a sentence or two at most. A variable\n  that needs more explanation gets a section in a wiki guide, linked from its row.\n  No free-text paragraphs in the configuration section. A PR that adds an env var\n  adds its row in the same diff.\n- **Wiki** (<https://github.com/alexta69/metube/wiki>, a separate git repository:\n  `https://github.com/alexta69/metube.wiki.git`) — guides, concepts, recipes,\n  integrations, and troubleshooting. Explanations of behavior go here.\n- **CONTRIBUTING.md** — the scope summary for humans, the feature request policy,\n  and building and testing locally.\n\nLinks in README.md must be absolute URLs: on Docker Hub, relative links break. If the\nREADME passes 15,000 bytes, something in it belongs in the wiki.\n\n## Tech stack\n\n- **Backend:** Python 3.13+, aiohttp, python-socketio 5.x, yt-dlp\n- **Frontend:** Angular 22, TypeScript, Bootstrap 5, SASS, ngx-socket-io\n- **Package managers:** uv (Python), pnpm (frontend)\n- **Container:** Multi-stage Docker (Node builder + Python runtime), multi-arch (amd64/arm64)\n\n## Build & test commands\n\n```bash\n# Frontend (run from ui/)\npnpm install --frozen-lockfile\npnpm run lint\npnpm run build\npnpm exec ng test --watch=false\n\n# Backend (run from repo root)\nuv sync --frozen --group dev\nuv run python -m compileall -q app\nuv run pytest app/tests/\n```\n\nAll of these run in CI (`.github/workflows/main.yml`) on every push to master and must pass.\n\nThe backend tests need no frontend build and can run from the repo root or\nfrom `app/`: `app/tests/conftest.py` points `BASE_DIR` at a stand-in UI\ndirectory before `main` is imported.\n\nEvery non-markdown push to master builds multi-arch Docker images and cuts a dated\nrelease the same day. **Master is continuously released** — a PR must be\nrelease-ready exactly as merged; there is no stabilization window for follow-up\nfixes.\n\n## Commit messages\n\nA commit that resolves an issue must close it, with a GitHub closing keyword in\nparentheses at the end of the subject line:\n\n```\nfix: stop metadata probes from writing playlist sidecar files (closes #1040)\n```\n\nBecause master is the default branch and is released on every push, the issue\ncloses at the moment the fix ships, and keeps a permanent link to the commit that\nfixed it. A bare `(#1040)` is only a reference — and reads as a pull-request\nnumber — so it does not count; the keyword is what closes the issue.\n\nAuto-closing leaves only a commit stub on the issue, which is not an answer to\nwhoever reported it. Post an explanatory comment as well: what the cause was, what\nchanged, and anything the reporter needs to do differently.\n\nFollow `.editorconfig`:\n- Python: 4-space indent\n- Everything else (TypeScript, YAML, JSON, HTML): 2-space indent\n- UTF-8, LF line endings, trim trailing whitespace, final newline\n\nFrontend additionally uses ESLint (`ui/eslint.config.js`) and Prettier (config in `ui/package.json`: `printWidth=100`, `singleQuote=true`).\n\n## Project structure\n\n```\napp/main.py          — HTTP server, Socket.IO events, REST API routes, Config class\napp/ytdl.py          — Download queue logic, yt-dlp integration\napp/download_options.py — DownloadOptions: one request's options, passed whole through the queue\napp/subscriptions.py — Channel/playlist subscription manager\napp/state_store.py   — JSON-based persistent storage with atomic writes\napp/format_catalog.py — The download options (types, codecs, formats, qualities, labels, defaults)\napp/dl_formats.py    — Maps those options onto yt-dlp format selectors and options\napp/tests/           — pytest tests (asyncio_mode=auto)\nui/src/app/          — Angular standalone components (no NgModules)\n```\n\n## Key conventions\n\n- Backend configuration lives in the `Config` class in `app/main.py` with env-var defaults in `_DEFAULTS`. New env vars go there.\n- Download options are defined only in `app/format_catalog.py`. Request validation, `dl_formats`, the UI's form (sent over Socket.IO on connect) and `GET /formats` all read it; never hardcode an option list or id elsewhere. A new codec, format or quality also needs its yt-dlp mapping in `dl_formats`, which `test_format_catalog.py` enforces.\n- Real-time communication uses Socket.IO events, not REST polling.\n- Frontend uses standalone Angular components with `inject()` for DI, RxJS Subjects for state, and `takeUntilDestroyed()` for cleanup.\n- Frontend components use OnPush change detection: subscribe callbacks must call `cdr.markForCheck()`.\n- State is persisted as JSON files via `AtomicJsonStore` in `app/state_store.py`.\n- 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.\n- 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).\n- No pre-commit hooks — linting and tests are enforced in CI only.\n\n## Checklist: adding a per-download option\n\nNew options on the download form (the `split_by_chapters` pattern) need **all** of\nthese pieces — the last three are the ones commonly missed:\n\n1. A field on `DownloadOptions` in `app/download_options.py`, set by\n   `parse_download_options` in `app/main.py`. The queue's add, playlist,\n   failed-add and retry paths all carry that one value, so nothing else in\n   them needs touching.\n2. A field on `DownloadInfo` in `app/ytdl.py`.\n3. A default in `_DOWNLOAD_INFO_DEFAULTS` in `app/ytdl.py`, which old persisted\n   records take (a test fails if a `DownloadInfo` field has none).\n4. The safe-deserialization field list in `app/ytdl.py`.\n5. UI form control + `SettingsStore` persistence in `ui/src/app/app.ts` / `app.html`, and\n   the payload in `downloads.service.ts` (plus its spec).\n6. The redownload path in `app.ts`, so retries carry the option.\n7. If the option makes sense for unattended downloads: threading through\n   `app/subscriptions.py` (`SubscriptionInfo` field, serializer, add/update\n   routes, `SubscriptionInfo.download_options()`) — or a note in the PR that it's deliberately\n   direct-downloads-only.\n\n## Security invariants\n\nUser input and extractor-provided metadata (titles, playlist names, URLs) are\nuntrusted. Use the existing guards instead of hand-rolling:\n\n- User-submitted URLs go through the SSRF guard (see `test_url_guard.py` for the\n  expected behavior).\n- Anything that becomes a filesystem path goes through `_is_within_directory` and\n  `_sanitize_path_component` in `app/ytdl.py` — including values that arrive via\n  yt-dlp metadata, which sites can influence.\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/alexta69/metube/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# Agent Guidelines\n\n## Project scope — read this before planning a feature\n\nMeTube's contract is: give it a URL, it runs yt-dlp well, and correct files appear.\nThe maintainer holds a deliberate line on what belongs inside that contract, and PRs\non the wrong side of it are declined **regardless of code quality**. Check your plan\nagainst this line before writing any code.\n\n**In scope — improving the write itself:**\n\n- Features that make the file yt-dlp writes at download time come out more correct,\n  using only data the extractor already provides (e.g. filling a missing album-artist\n  tag from the extractor's own metadata).\n- Surfacing functionality yt-dlp itself owns and maintains as first-class UI options\n  (e.g. a SponsorBlock toggle that just passes yt-dlp postprocessor params).\n- Download queue, subscriptions, output templates, and UI improvements to the\n  download workflow.\n\n**Out of scope — managing files after they exist:**\n\n- Tag editors, metadata dialogs, or any workflow that rewrites files after the\n  download has finished. This holds even for slimmed-down versions.\n- Lookups against external metadata services (iTunes, Deezer, MusicBrainz, etc.).\n  More broadly: any new dependency on a third-party API, or new network egress from\n  self-hosted instances, beyond what yt-dlp itself performs.\n- Library organization: moving/renaming existing files into Artist/Album layouts,\n  watch-folder processing, and similar media-manager features. Dedicated tools\n  (beets, MusicBrainz Picard, Lidarr) do this properly; the README points users\n  to them.\n\n**Corollaries that shape borderline PRs:**\n\n- Site-specific intelligence (parsing playlist-ID prefixes, URL path conventions,\n  and other platform internals) is extractor work and belongs upstream in yt-dlp,\n  not re-implemented here — it silently breaks when the platform changes and\n  MeTube would own the breakage.\n- Prefer enriching yt-dlp's info dict and letting its existing pipeline\n  (FFmpegMetadata etc.) do the writing, over adding custom per-format tag-writing\n  code to MeTube.\n- Supplemental processing must never fail a download that otherwise succeeded:\n  warn and continue, don't raise.\n- Keep feature scope minimal on first submission. A hardcoded sensible default\n  beats a configuration surface; follow-ups can add options when users actually\n  ask. PRs that bundle several \"reasonable next steps\" invite rejection of the\n  whole.\n\nIf a feature idea fails this test, the accepted alternative is usually documentation\non pairing MeTube with the right dedicated tool — a wiki guide, or a pointer in the\nREADME.\n\n## Documentation: where content goes\n\nThe README is synced verbatim to Docker Hub, which rejects descriptions over\n**25,000 bytes** (CI checks `wc -c README.md`). Rather than trimming prose to fit,\neach kind of content has one home:\n\n- **README.md** — what MeTube is, the quick start, and the environment variable\n  reference: **one table row per variable**, a sentence or two at most. A variable\n  that needs more explanation gets a section in a wiki guide, linked from its row.\n  No free-text paragraphs in the configuration section. A PR that adds an env var\n  adds its row in the same diff.\n- **Wiki** (<https://github.com/alexta69/metube/wiki>, a separate git repository:\n  `https://github.com/alexta69/metube.wiki.git`) — guides, concepts, recipes,\n  integrations, and troubleshooting. Explanations of behavior go here.\n- **CONTRIBUTING.md** — the scope summary for humans, the feature request policy,\n  and building and testing locally.\n\nLinks in README.md must be absolute URLs: on Docker Hub, relative links break. If the\nREADME passes 15,000 bytes, something in it belongs in the wiki.\n\n## Tech stack\n\n- **Backend:** Python 3.13+, aiohttp, python-socketio 5.x, yt-dlp\n- **Frontend:** Angular 22, TypeScript, Bootstrap 5, SASS, ngx-socket-io\n- **Package managers:** uv (Python), pnpm (frontend)\n- **Container:** Multi-stage Docker (Node builder + Python runtime), multi-arch (amd64/arm64)\n\n## Build & test commands\n\n```bash\n# Frontend (run from ui/)\npnpm install --frozen-lockfile\npnpm run lint\npnpm run build\npnpm exec ng test --watch=false\n\n# Backend (run from repo root)\nuv sync --frozen --group dev\nuv run python -m compileall -q app\nuv run pytest app/tests/\n```\n\nAll of these run in CI (`.github/workflows/main.yml`) on every push to master and must pass.\n\nThe backend tests need no frontend build and can run from the repo root or\nfrom `app/`: `app/tests/conftest.py` points `BASE_DIR` at a stand-in UI\ndirectory before `main` is imported.\n\nEvery non-markdown push to master builds multi-arch Docker images and cuts a dated\nrelease the same day. **Master is continuously released** — a PR must be\nrelease-ready exactly as merged; there is no stabilization window for follow-up\nfixes.\n\n## Commit messages\n\nA commit that resolves an issue must close it, with a GitHub closing keyword in\nparentheses at the end of the subject line:\n\n```\nfix: stop metadata probes from writing playlist sidecar files (closes #1040)\n```\n\nBecause master is the default branch and is released on every push, the issue\ncloses at the moment the fix ships, and keeps a permanent link to the commit that\nfixed it. A bare `(#1040)` is only a reference — and reads as a pull-request\nnumber — so it does not count; the keyword is what closes the issue.\n\nAuto-closing leaves only a commit stub on the issue, which is not an answer to\nwhoever reported it. Post an explanatory comment as well: what the cause was, what\nchanged, and anything the reporter needs to do differently.\n\nFollow `.editorconfig`:\n- Python: 4-space indent\n- Everything else (TypeScript, YAML, JSON, HTML): 2-space indent\n- UTF-8, LF line endings, trim trailing whitespace, final newline\n\nFrontend additionally uses ESLint (`ui/eslint.config.js`) and Prettier (config in `ui/package.json`: `printWidth=100`, `singleQuote=true`).\n\n## Project structure\n\n```\napp/main.py          — HTTP server, Socket.IO events, REST API routes, Config class\napp/ytdl.py          — Download queue logic, yt-dlp integration\napp/download_options.py — DownloadOptions: one request's options, passed whole through the queue\napp/subscriptions.py — Channel/playlist subscription manager\napp/state_store.py   — JSON-based persistent storage with atomic writes\napp/format_catalog.py — The download options (types, codecs, formats, qualities, labels, defaults)\napp/dl_formats.py    — Maps those options onto yt-dlp format selectors and options\napp/tests/           — pytest tests (asyncio_mode=auto)\nui/src/app/          — Angular standalone components (no NgModules)\n```\n\n## Key conventions\n\n- Backend configuration lives in the `Config` class in `app/main.py` with env-var defaults in `_DEFAULTS`. New env vars go there.\n- Download options are defined only in `app/format_catalog.py`. Request validation, `dl_formats`, the UI's form (sent over Socket.IO on connect) and `GET /formats` all read it; never hardcode an option list or id elsewhere. A new codec, format or quality also needs its yt-dlp mapping in `dl_formats`, which `test_format_catalog.py` enforces.\n- Real-time communication uses Socket.IO events, not REST polling.\n- Frontend uses standalone Angular components with `inject()` for DI, RxJS Subjects for state, and `takeUntilDestroyed()` for cleanup.\n- Frontend components use OnPush change detection: subscribe callbacks must call `cdr.markForCheck()`.\n- State is persisted as JSON files via `AtomicJsonStore` in `app/state_store.py`.\n- 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.\n- 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).\n- No pre-commit hooks — linting and tests are enforced in CI only.\n\n## Checklist: adding a per-download option\n\nNew options on the download form (the `split_by_chapters` pattern) need **all** of\nthese pieces — the last three are the ones commonly missed:\n\n1. A field on `DownloadOptions` in `app/download_options.py`, set by\n   `parse_download_options` in `app/main.py`. The queue's add, playlist,\n   failed-add and retry paths all carry that one value, so nothing else in\n   them needs touching.\n2. A field on `DownloadInfo` in `app/ytdl.py`.\n3. A default in `_DOWNLOAD_INFO_DEFAULTS` in `app/ytdl.py`, which old persisted\n   records take (a test fails if a `DownloadInfo` field has none).\n4. The safe-deserialization field list in `app/ytdl.py`.\n5. UI form control + `SettingsStore` persistence in `ui/src/app/app.ts` / `app.html`, and\n   the payload in `downloads.service.ts` (plus its spec).\n6. The redownload path in `app.ts`, so retries carry the option.\n7. If the option makes sense for unattended downloads: threading through\n   `app/subscriptions.py` (`SubscriptionInfo` field, serializer, add/update\n   routes, `SubscriptionInfo.download_options()`) — or a note in the PR that it's deliberately\n   direct-downloads-only.\n\n## Security invariants\n\nUser input and extractor-provided metadata (titles, playlist names, URLs) are\nuntrusted. Use the existing guards instead of hand-rolling:\n\n- User-submitted URLs go through the SSRF guard (see `test_url_guard.py` for the\n  expected behavior).\n- Anything that becomes a filesystem path goes through `_is_within_directory` and\n  `_sanitize_path_component` in `app/ytdl.py` — including values that arrive via\n  yt-dlp metadata, which sites can influence.\n","isInternal":false,"tokens":2255,"sizeBytes":9674}],"systemPromptSnippet":"<yakaai_skills repo=\"alexta69/metube\">\n<!-- File: AGENTS.md (Tokens: ~2255 | Category: root-instruction) -->\n# Agent Guidelines\n\n## Project scope — read this before planning a feature\n\nMeTube's contract is: give it a URL, it runs yt-dlp well, and correct files appear.\nThe maintainer holds a deliberate line on what belongs inside that contract, and PRs\non the wrong side of it are declined **regardless of code quality**. Check your plan\nagainst this line before writing any code.\n\n**In scope — improving the write itself:**\n\n- Features that make the file yt-dlp writes at download time come out more correct,\n  using only data the extractor already provides (e.g. filling a missing album-artist\n  tag from the extractor's own metadata).\n- Surfacing functionality yt-dlp itself owns and maintains as first-class UI options\n  (e.g. a SponsorBlock toggle that just passes yt-dlp postprocessor params).\n- Download queue, subscriptions, output templates, and UI improvements to the\n  download workflow.\n\n**Out of scope — managing files after they exist:**\n\n- Tag editors, metadata dialogs, or any workflow that rewrites files after the\n  download has finished. This holds even for slimmed-down versions.\n- Lookups against external metadata services (iTunes, Deezer, MusicBrainz, etc.).\n  More broadly: any new dependency on a third-party API, or new network egress from\n  self-hosted instances, beyond what yt-dlp itself performs.\n- Library organization: moving/renaming existing files into Artist/Album layouts,\n  watch-folder processing, and similar media-manager features. Dedicated tools\n  (beets, MusicBrainz Picard, Lidarr) do this properly; the README points users\n  to them.\n\n**Corollaries that shape borderline PRs:**\n\n- Site-specific intelligence (parsing playlist-ID prefixes, URL path conventions,\n  and other platform internals) is extractor work and belongs upstream in yt-dlp,\n  not re-implemented here — it silently breaks when the platform changes and\n  MeTube would own the breakage.\n- Prefer enriching yt-dlp's info dict and letting its existing pipeline\n  (FFmpegMetadata etc.) do the writing, over adding custom per-format tag-writing\n  code to MeTube.\n- Supplemental processing must never fail a download that otherwise succeeded:\n  warn and continue, don't raise.\n- Keep feature scope minimal on first submission. A hardcoded sensible default\n  beats a configuration surface; follow-ups can add options when users actually\n  ask. PRs that bundle several \"reasonable next steps\" invite rejection of the\n  whole.\n\nIf a feature idea fails this test, the accepted alternative is usually documentation\non pairing MeTube with the right dedicated tool — a wiki guide, or a pointer in the\nREADME.\n\n## Documentation: where content goes\n\nThe README is synced verbatim to Docker Hub, which rejects descriptions over\n**25,000 bytes** (CI checks `wc -c README.md`). Rather than trimming prose to fit,\neach kind of content has one home:\n\n- **README.md** — what MeTube is, the quick start, and the environment variable\n  reference: **one table row per variable**, a sentence or two at most. A variable\n  that needs more explanation gets a section in a wiki guide, linked from its row.\n  No free-text paragraphs in the configuration section. A PR that adds an env var\n  adds its row in the same diff.\n- **Wiki** (<https://github.com/alexta69/metube/wiki>, a separate git repository:\n  `https://github.com/alexta69/metube.wiki.git`) — guides, concepts, recipes,\n  integrations, and troubleshooting. Explanations of behavior go here.\n- **CONTRIBUTING.md** — the scope summary for humans, the feature request policy,\n  and building and testing locally.\n\nLinks in README.md must be absolute URLs: on Docker Hub, relative links break. If the\nREADME passes 15,000 bytes, something in it belongs in the wiki.\n\n## Tech stack\n\n- **Backend:** Python 3.13+, aiohttp, python-socketio 5.x, yt-dlp\n- **Frontend:** Angular 22, TypeScript, Bootstrap 5, SASS, ngx-socket-io\n- **Package managers:** uv (Python), pnpm (frontend)\n- **Container:** Multi-stage Docker (Node builder + Python runtime), multi-arch (amd64/arm64)\n\n## Build & test commands\n\n```bash\n# Frontend (run from ui/)\npnpm install --frozen-lockfile\npnpm run lint\npnpm run build\npnpm exec ng test --watch=false\n\n# Backend (run from repo root)\nuv sync --frozen --group dev\nuv run python -m compileall -q app\nuv run pytest app/tests/\n```\n\nAll of these run in CI (`.github/workflows/main.yml`) on every push to master and must pass.\n\nThe backend tests need no frontend build and can run from the repo root or\nfrom `app/`: `app/tests/conftest.py` points `BASE_DIR` at a stand-in UI\ndirectory before `main` is imported.\n\nEvery non-markdown push to master builds multi-arch Docker images and cuts a dated\nrelease the same day. **Master is continuously released** — a PR must be\nrelease-ready exactly as merged; there is no stabilization window for follow-up\nfixes.\n\n## Commit messages\n\nA commit that resolves an issue must close it, with a GitHub closing keyword in\nparentheses at the end of the subject line:\n\n```\nfix: stop metadata probes from writing playlist sidecar files (closes #1040)\n```\n\nBecause master is the default branch and is released on every push, the issue\ncloses at the moment the fix ships, and keeps a permanent link to the commit that\nfixed it. A bare `(#1040)` is only a reference — and reads as a pull-request\nnumber — so it does not count; the keyword is what closes the issue.\n\nAuto-closing leaves only a commit stub on the issue, which is not an answer to\nwhoever reported it. Post an explanatory comment as well: what the cause was, what\nchanged, and anything the reporter needs to do differently.\n\nFollow `.editorconfig`:\n- Python: 4-space indent\n- Everything else (TypeScript, YAML, JSON, HTML): 2-space indent\n- UTF-8, LF line endings, trim trailing whitespace, final newline\n\nFrontend additionally uses ESLint (`ui/eslint.config.js`) and Prettier (config in `ui/package.json`: `printWidth=100`, `singleQuote=true`).\n\n## Project structure\n\n```\napp/main.py          — HTTP server, Socket.IO events, REST API routes, Config class\napp/ytdl.py          — Download queue logic, yt-dlp integration\napp/download_options.py — DownloadOptions: one request's options, passed whole through the queue\napp/subscriptions.py — Channel/playlist subscription manager\napp/state_store.py   — JSON-based persistent storage with atomic writes\napp/format_catalog.py — The download options (types, codecs, formats, qualities, labels, defaults)\napp/dl_formats.py    — Maps those options onto yt-dlp format selectors and options\napp/tests/           — pytest tests (asyncio_mode=auto)\nui/src/app/          — Angular standalone components (no NgModules)\n```\n\n## Key conventions\n\n- Backend configuration lives in the `Config` class in `app/main.py` with env-var defaults in `_DEFAULTS`. New env vars go there.\n- Download options are defined only in `app/format_catalog.py`. Request validation, `dl_formats`, the UI's form (sent over Socket.IO on connect) and `GET /formats` all read it; never hardcode an option list or id elsewhere. A new codec, format or quality also needs its yt-dlp mapping in `dl_formats`, which `test_format_catalog.py` enforces.\n- Real-time communication uses Socket.IO events, not REST polling.\n- Frontend uses standalone Angular components with `inject()` for DI, RxJS Subjects for state, and `takeUntilDestroyed()` for cleanup.\n- Frontend components use OnPush change detection: subscribe callbacks must call `cdr.markForCheck()`.\n- State is persisted as JSON files via `AtomicJsonStore` in `app/state_store.py`.\n- 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.\n- 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).\n- No pre-commit hooks — linting and tests are enforced in CI only.\n\n## Checklist: adding a per-download option\n\nNew options on the download form (the `split_by_chapters` pattern) need **all** of\nthese pieces — the last three are the ones commonly missed:\n\n1. A field on `DownloadOptions` in `app/download_options.py`, set by\n   `parse_download_options` in `app/main.py`. The queue's add, playlist,\n   failed-add and retry paths all carry that one value, so nothing else in\n   them needs touching.\n2. A field on `DownloadInfo` in `app/ytdl.py`.\n3. A default in `_DOWNLOAD_INFO_DEFAULTS` in `app/ytdl.py`, which old persisted\n   records take (a test fails if a `DownloadInfo` field has none).\n4. The safe-deserialization field list in `app/ytdl.py`.\n5. UI form control + `SettingsStore` persistence in `ui/src/app/app.ts` / `app.html`, and\n   the payload in `downloads.service.ts` (plus its spec).\n6. The redownload path in `app.ts`, so retries carry the option.\n7. If the option makes sense for unattended downloads: threading through\n   `app/subscriptions.py` (`SubscriptionInfo` field, serializer, add/update\n   routes, `SubscriptionInfo.download_options()`) — or a note in the PR that it's deliberately\n   direct-downloads-only.\n\n## Security invariants\n\nUser input and extractor-provided metadata (titles, playlist names, URLs) are\nuntrusted. Use the existing guards instead of hand-rolling:\n\n- User-submitted URLs go through the SSRF guard (see `test_url_guard.py` for the\n  expected behavior).\n- Anything that becomes a filesystem path goes through `_is_within_directory` and\n  `_sanitize_path_component` in `app/ytdl.py` — including values that arrive via\n  yt-dlp metadata, which sites can influence.\n</yakaai_skills>"}