Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
102 commits
Select commit Hold shift + click to select a range
5889df2
feat: prepare external API persistence foundation
bballdavis Jul 31, 2026
fb1cf01
fix: restore external API foundation CI
bballdavis Jul 31, 2026
8731466
fix: reissue external API migrations after schema normalization
bballdavis Sep 8, 2026
67c4c9a
fix: close external API compatibility migration markers
bballdavis Sep 8, 2026
3da0a68
fix: clear dependency audit findings
bballdavis Sep 8, 2026
52f5ab4
feat: add external API control plane
bballdavis Sep 8, 2026
23a3680
fix: make external API opt-in and verify MariaDB migrations
bballdavis Sep 8, 2026
0b43baa
fix: close config watcher after fixture validation
bballdavis Sep 8, 2026
074f14c
fix: keep contract pagination tests watcher-free
bballdavis Sep 9, 2026
f186253
fix: keep external request tests watcher-free
bballdavis Sep 9, 2026
c36ce89
test: keep auto-removal route mocks isolated
bballdavis Sep 9, 2026
bf14e3f
feat: add external API persistence foundation
bballdavis Sep 15, 2026
122fd47
test: keep channel model mocks association-compatible
bballdavis Sep 15, 2026
d10af4a
fix: use portable JSON literal in API key migration
bballdavis Sep 20, 2026
699a621
feat: add external API control plane
bballdavis Sep 8, 2026
bddf26a
fix: make external API opt-in and verify MariaDB migrations
bballdavis Sep 8, 2026
11e6149
fix: close config watcher after fixture validation
bballdavis Sep 8, 2026
45b7dd4
fix: keep contract pagination tests watcher-free
bballdavis Sep 9, 2026
8cc2b8e
feat: add external API administrator UI
bballdavis Sep 8, 2026
3d689ca
test: cover external request review states
bballdavis Sep 8, 2026
ebc698b
fix: keep external request tests watcher-free
bballdavis Sep 9, 2026
90c578c
test: keep auto-removal route mocks isolated
bballdavis Sep 9, 2026
927396a
feat: expose external API runtime capability
bballdavis Sep 18, 2026
74f5c04
docs: describe external API config capability
bballdavis Sep 18, 2026
1d5cab5
feat: type external API runtime capability
bballdavis Sep 18, 2026
c11f1b8
feat: default external API capability off
bballdavis Sep 18, 2026
fb4c93f
feat: gate external requests by runtime capability
bballdavis Sep 18, 2026
e3652d2
feat: pass external API capability to key settings
bballdavis Sep 18, 2026
c0b3fbe
feat: hide external key controls when API is disabled
bballdavis Sep 18, 2026
78a9a81
fix: keep select content in its portal
bballdavis Sep 18, 2026
8e953c6
docs: document external API request navigation
bballdavis Sep 18, 2026
6693dfe
docs: describe ExternalRequests architecture
bballdavis Sep 18, 2026
74e8837
fix: preserve API key section test defaults
bballdavis Sep 18, 2026
8157650
fix: normalize gated key section formatting
bballdavis Sep 18, 2026
87971a7
test: cover external API runtime capability
bballdavis Sep 18, 2026
f15e49a
test: cover enabled Requests navigation
bballdavis Sep 18, 2026
feac0a0
test: preserve trusted select pointer guard
bballdavis Sep 18, 2026
26fe94c
test: preserve trusted select pointer guard
bballdavis Sep 18, 2026
c425e7e
test: cover disabled direct Requests route
bballdavis Sep 18, 2026
8961bd6
refactor: add API key helper component
bballdavis Sep 18, 2026
6be3136
refactor: add API key helper component
bballdavis Sep 18, 2026
f60b574
refactor: add API key helper component
bballdavis Sep 18, 2026
2b3a2ed
refactor: export channel list response type
bballdavis Sep 18, 2026
5840ec6
fix: include channel database ids in list responses
bballdavis Sep 18, 2026
4615528
refactor external requests review flow
bballdavis Sep 18, 2026
b5f7b3e
refactor: extract api key channel grant helpers
bballdavis Sep 18, 2026
8c1e58d
wire API key policy and channel picker components
bballdavis Sep 18, 2026
b9acaad
Wire API key operations through typed hook
bballdavis Sep 18, 2026
81a9db9
Use app dialog for external access confirmation
bballdavis Sep 18, 2026
0df067b
Fix mapper regression test scope
bballdavis Sep 18, 2026
bc9ae6c
Update API key tests for typed hook
bballdavis Sep 18, 2026
df8ba11
test: close external API review gaps
bballdavis Sep 19, 2026
97bcf3b
style: clean extracted API key helpers
bballdavis Sep 19, 2026
52b0844
fix: align external API capability gate
bballdavis Sep 19, 2026
b27a25e
test: align logger expectations with current dev
bballdavis Sep 19, 2026
fa22465
test: align PR validation with current dev
bballdavis Sep 22, 2026
2dff745
fix: keep API key policy edits retryable
bballdavis Sep 22, 2026
7b4be9c
Merge PR #750 foundation and upstream dev into external API GUI
bballdavis Sep 29, 2026
e825d1f
Merge current external API runtime into GUI branch
bballdavis Sep 29, 2026
9d9cec4
fix: reconcile runtime dependency lockfile
bballdavis Sep 29, 2026
8c67673
feat: add external API control plane
bballdavis Sep 8, 2026
0082558
fix: make external API opt-in and verify MariaDB migrations
bballdavis Sep 8, 2026
89a8564
fix: close config watcher after fixture validation
bballdavis Sep 8, 2026
f5e8340
fix: keep contract pagination tests watcher-free
bballdavis Sep 9, 2026
36f5f5d
fix: keep external request tests watcher-free
bballdavis Sep 9, 2026
9d78863
test: keep auto-removal route mocks isolated
bballdavis Sep 9, 2026
1882438
fix: preserve external request retries and completed results
bballdavis Oct 7, 2026
10a9b86
fix: serialize external approvals and recover durable download jobs
bballdavis Oct 7, 2026
5595104
fix: harden external API inputs and expose effective availability
bballdavis Oct 7, 2026
b2213f5
perf: batch external request history and verify runtime indexes
bballdavis Oct 7, 2026
62eaf05
refactor: separate external request lifecycle and preserve approval r…
bballdavis Oct 7, 2026
c3de9c3
fix: bound channel metadata work without limiting catalog refreshes
bballdavis Oct 7, 2026
9140548
test: align external request model indexes with runtime migration
bballdavis Oct 7, 2026
e35e430
fix: patch proxy address trust validation for external ingress
bballdavis Oct 7, 2026
4aaaaa2
merge: align external API UI with reviewed runtime
bballdavis Oct 7, 2026
f49effb
fix: make external request review retries reliable
bballdavis Oct 7, 2026
4d811e3
fix: isolate external API key dialogs and preserve grants
bballdavis Oct 7, 2026
ac3ad33
fix: return validation errors for unavailable channel grants
bballdavis Oct 7, 2026
ac89554
fix: keep external request actions visible on mobile
bballdavis Oct 7, 2026
8ca3178
merge: include verified channel grant validation fix
bballdavis Oct 7, 2026
ff475a7
Merge upstream dev into external API runtime
bballdavis Oct 22, 2026
c0242cc
fix: keep external API changes out of shared logging and ratings
bballdavis Oct 22, 2026
0468713
Merge external API runtime and current dev into GUI branch
bballdavis Oct 22, 2026
9ee25db
fix: limit the select change to the trusted-event guard
bballdavis Oct 22, 2026
927640a
refactor: name the external key workload limits once
bballdavis Oct 22, 2026
144251b
refactor: tidy the external request queue components
bballdavis Oct 22, 2026
1231cca
docs: describe the API keys page and Requests queue as built
bballdavis Oct 22, 2026
e98ff1d
fix: allow external request review with auth disabled and reject non-…
bballdavis Oct 22, 2026
9380164
refactor: group external API modules under modules/externalApi
bballdavis Oct 22, 2026
8e5bcc9
fix: match external API paths case-insensitively at the app level
bballdavis Oct 22, 2026
e2f8fac
feat: run external requests in a background executor
bballdavis Oct 22, 2026
c95f8fa
Merge external API runtime review fixes into GUI branch
bballdavis Oct 22, 2026
0f9d9ec
feat: follow background request execution in the Requests queue
bballdavis Oct 22, 2026
540260a
fix: load external request dependencies lazily at startup
bballdavis Oct 22, 2026
9b87150
fix: keep external catalog reads cached-only and portable
bballdavis Oct 22, 2026
5f48d9c
docs: describe the external request lifecycle and executor
bballdavis Oct 22, 2026
62c1ae7
Merge remaining external API runtime fixes into GUI branch
bballdavis Oct 22, 2026
ab709a9
test: point capability check at the moved external API config module
bballdavis Oct 22, 2026
60e5a65
fix: point the external fixture check at the moved test
bballdavis Oct 22, 2026
8af0111
test: run the request executor only when database tests ask
bballdavis Oct 22, 2026
d698fb3
fix(deps): update Storybook to 10.6.2 for GHSA-f2ph-cmg5-wxc8
bballdavis Oct 22, 2026
446058c
Merge external API runtime CI fixes into GUI branch
bballdavis Oct 22, 2026
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ YOUTUBE_OUTPUT_DIR=./downloads
# Default: true (authentication enabled)
AUTH_ENABLED=true

# Versioned external API. This API always requires an external API key even
# when AUTH_ENABLED=false. It is disabled by default; set to true to enable it.
EXTERNAL_API_ENABLED=false

# Initial Admin Credentials (for headless/automated deployments)
# If not set, you'll create credentials through the web UI on first access
# And they will be saved to config/config.json (password hashed)
Expand Down
20 changes: 18 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ jobs:
- name: Install dependencies
run: npm ci --ignore-scripts

- name: Validate external API fixture and checksum
run: npm run test:external-fixture

- name: Run backend tests with coverage
run: npm run test:backend -- --coverage --coverageReporters=json-summary --coverageReporters=lcov --coverageReporters=text
env:
Expand Down Expand Up @@ -161,7 +164,7 @@ jobs:
coverage/lcov.info

test-external-api-database:
name: External API DB migrations (${{ matrix.engine }})
name: External API DB runtime and migrations (${{ matrix.engine }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
Expand Down Expand Up @@ -197,7 +200,7 @@ jobs:
run: npm install -g npm@11.15.0 --ignore-scripts
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Run external API migration lifecycle
- name: Run external API runtime and migration lifecycle
run: |
npm run test:external-api-database -- --json --outputFile=external-api-database-results.json
node -e "const result = require('./external-api-database-results.json'); if (result.numPassedTests < 1 || result.numPendingTestSuites !== 0 || result.numPendingTests !== 0 || result.numTodoTests !== 0) process.exit(1);"
Expand All @@ -208,6 +211,19 @@ jobs:
DB_PASSWORD: rootpass
DB_ADMIN_USER: root
DB_ADMIN_PASSWORD: rootpass
- name: Verify runtime query plans on synthetic data
run: npm run test:external-index-plans
env:
DB_HOST: 127.0.0.1
DB_PORT: 3321
DB_USER: root
DB_PASSWORD: rootpass
EXTERNAL_API_INDEX_REPORT: external-api-index-plans.json
- name: Upload runtime query plans
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: external-api-index-plans-${{ matrix.engine }}
path: external-api-index-plans.json

test-frontend:
name: Frontend Tests
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"),
## Architecture

### Backend (server/)
- `routes/externalApi.js` and `middleware/externalApiAuth.js`: opt-in `/external-api/v1` routes with mandatory external-key authentication, bounded UTF-8 JSON bodies, private responses, and per-key limits. All External API business logic lives in `modules/externalApi/`; `externalApiConfig.js` (feature availability and `isExternalApiPath`), `externalApiResponse.js`, `externalPermissions.js`, and `externalEligibility.js` (media/rating policy; maps TV-Y7 to level 2 locally since `ratingMapper` has no numeric TV-Y7) centralize the shared rules, and `apiKeyChannelGrantModule.js` manages per-key channel grants.
- `modules/externalApi/externalCatalogService.js`: cached, grant-filtered catalog and asset lookup. Reads never spawn yt-dlp or write: video detail uses `videoMetadataModule.getCachedVideoMetadata` (cached `.info.json` only). Raw SQL stays portable (snake_case aliases, `POSITION(... IN ...)`), keyset cursors (v3) tie-break on `cv.id`, and single-channel lookups go through `Channel.findOne` with the `apiKeyGrants` include. `externalCatalogRequests.js` batches current-page request status, `externalPagination.js` shares bounded page cursors, and `externalThumbnailProxy.js` enforces upstream image URL, size, and timeout limits.
- `modules/externalApi/externalRequestService.js`: request creation (auto-approved requests are stored `approved` with `approval_source='auto'`) and owner history; reads never change request state. `externalRequestExecutor.js` is the background executor (started in `server.js` only when the External API is enabled; sweeps every minute, wakes on create/approve, and on `jobModule.onJobEnded`): it rechecks authority, claims `approved` -> `processing`, queues video downloads through `downloadModule.doSpecificDownloads` with the request id as the job id, provisions/enables/grants channels, and deletes videos through `videoDeletionModule`, holding requests with a message while downloads are paused or the video is busy, and recovering interrupted rows. `externalRequestTargets.js` validates grant/channel/video/policy targets, `externalRequestReconciliation.js` closes processing video requests whose job ended, `externalRequestReviewService.js` owns idempotent administrator approve/reject (approve only marks `approved` and wakes the executor), `externalRequestPrimitives.js` shares validation/DTOs, and `externalQuotaService.js` counts `approved` + `processing` as active jobs. Key-before-request locks serialize decisions; `externalWorkLimiter.js` bounds expensive work. `routes/externalRequests.js` exposes admin management endpoints that reject every API key; their UI is a separate follow-up.
- `modules/externalCookies.js`: validates external cookie snapshots through the installed yt-dlp parser in `utils/validate-cookies.py`, memoizes unchanged results, and reports unusable files for omission; `modules/uploadedCookies.js` gives the uploaded `config/cookies.user.txt` a disposable private copy per run (yt-dlp rewrites its cookie file in place on exit, so concurrent runs sharing it could read a half-written jar or undo a Settings upload; nothing is written back, and Settings uploads are written to a temp file and renamed into place; if the copy fails, the run goes without cookies (error-level log; info when the upload was just deleted) and yt-dlp is never handed the uploaded file itself, whose exit-time jar save could overwrite a newer upload or recreate deleted cookies); `modules/ytdlpProcess.js` owns both kinds of copy's lifetime around yt-dlp processes. No persistent cookie fallback.
- `modules/rescanRowUpdate.js` + `modules/videoRowGuard.js`: the filesystem rescan (`videosModule.backfillVideoMetadata`) walks the disk once and reads rows in chunks, so downloads and deletions in between make the walk stale. The walk only nominates rows (`isRescanCandidate`); `resolveRescanUpdate` recomputes each nominated row's write from a fresh per-format stat (present only for a regular file / missing on `ENOENT` or `ENOTDIR` or a non-file / unknown on any other error; both formats missing marks the row missing and keeps its stored paths; a missing format is cleared only when the other is present; any present format means `removed: false` even if the other is unknown; an unknown format is never cleared, and with nothing present an unknown writes nothing), and the write only lands if the row is unchanged since read (`videoRowGuard.unchangedSinceRead`: removed, both paths and sizes, `video_resolution`, `last_downloaded_at`; zero affected rows means a conflict only when the write includes a column that really differs, because Sequelize's MySQL dialect connects with -FOUND_ROWS and mysql2 then counts changed rows, not matched rows). Nominated rows left as recorded (files still at a stored path, e.g. outside the downloads folder, or files that could not be checked; re-checked every run) are counted as `kept` (`videosKept` in the run details, not the message). The walk's count of folders and files it could not read is `unreadableOnDisk` in the run details and log (not the message: those rows are re-checked by path). Rows another writer changed are counted as `skippedChanged` (`videosSkipped` in the run record) and failed writes as `failed` (`videosFailed`); both appear in the run message, and a finished scan with failed writes records as `error`/`partial` (a timed-out scan stays `timed-out` so Maintenance can continue it); the completion `rescanStatus` broadcast is built through the same `rescanRunSummary` record so the live Maintenance view matches the run history. Library repair (`jobModule.backfillFromCompleteList`) uses the same guard: it reads the row before searching the disk, writes file details and `removed: false` only for a file it actually found, and looks up a lost row's files by YouTube id with one `videosModule.scanForVideoFiles` walk per run (only when a lost row exists; video and audio, any folder layout), recreating it as missing when the walk finds nothing. The walk skips and counts (`unreadable`) folders and files it cannot read rather than stopping, logging unreadable files once per folder; repair still recreates unmatched rows as missing after a partial walk, because deferring would block recovery wherever a folder is never readable (lost+found, NAS recycle bins), and the next rescan restores a row whose file it finds. Such a run records as `error`/`partial`, naming the count in its message (`recreatedUnverified` and `unreadableOnDisk` in the details); a partial walk that affected no row stays `completed`.
- `modules/cookieDetails.js`: summarizes the YouTube login cookies (names, counts, earliest expiry; never values) in the active cookie file as `details` on `GET /api/cookies/status` and the upload/delete responses, reading external files through `externalCookies.readExternalCookies`. `modules/cookieTest.js`: `POST /api/cookies/test`, one yt-dlp request to the subscriptions feed with `buildCommonArgs` (same proxy/IP family/cookies/cache as downloads) through `ytDlpRunner`, against a private temp copy of uploaded cookies (yt-dlp writes its jar back on exit, which would otherwise undo a replace/delete made mid-test; external files get spawnYtDlp's snapshot) and never logs stderr (yt-dlp echoes malformed cookie lines with their values); any exit 0 with JSON is signed in (even zero subscriptions), and the feed's "Incomplete yt initial data" warnings mean signed out (EXPIRED_COOKIES), a meaning specific to this feed, so it is not in the shared subscription-import classifier. One test at a time (409), rate-limited by `cookieTestRateLimiter`.
Expand Down Expand Up @@ -66,6 +69,8 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"),
- `components/Configuration/sections/LoggingSection.tsx` (`/settings/logging`): log level select ('' = LOG_LEVEL), the current level's source from the saved setting plus `/getconfig`'s `logging.envLevel` (via `useConfig().loggingStatus`), log file limits or an unwritable-folder warning, and Download logs through `Configuration/hooks/useLogDownload.ts` (axios blob, since a link cannot send `x-access-token`).
- `components/FindChannels/`: channel search page at `/subscriptions/find`; each result card has an Open in YouTube link below its clickable area; clicking an unsubscribed card opens the shared `AddChannelDialog`, whose confirm hands off to `Subscriptions` via `location.state.addChannelUrl` and auto-runs the add flow.
- `components/FindVideos/`: video search page at `/videos/find`. The server returns YouTube's relevance order; `sortResults.ts` sorts the loaded set client-side (relevance, published, views, duration, title, channel; nulls last either way), through the desktop table's headers (natural direction, reversed, then back to relevance) or the shared `VideoList/VideoListSortButton` on phones and in grid view (its `SortOption.undirected` marks relevance, which has no arrow). `hooks/useFindVideosPreferences.ts` remembers view (table by default), sort, and minimum duration in localStorage; `hooks/useVideoSearch.ts` keeps the last search in sessionStorage and restores it when the page opens with the same `?q=`, which the page writes after every search (a `?q=` with nothing remembered runs the search). `components/resultParts.tsx` holds the pieces the table, phone list, and cards share: relative published date with the exact date as tooltip (yt-dlp dates are approximate), compact view count, a Short badge at 60 s or less, a status chip only for states other than never-downloaded, the per-row download button (`hooks/useResultDownload.ts` owns the dialog for either one row or the selection, never touching the selection for a single download), and the channel name through the shared `ChannelNameDisplay` (link when `channelSubscribed`, plus a Subscribed chip; otherwise the add-channel affordance opening `AddChannelDialog`).
- `components/ExternalRequests/`: administrator review queue for external API requests at `/requests` (routed and linked in the nav only when `/getconfig`'s `isPlatformManaged.externalApiEnabled` is true; the `showRequestsNavLink` setting can additionally hide the link). `RequestsPage.tsx` renders filters, a table on desktop and cards on phones, and the shared `PageControls`; `hooks/useExternalRequests.ts` loads the page (stale responses discarded, page clamped when a review empties the last page, and a silent refetch every 5 s while any visible request is `approved`/`processing`, since the server's background executor advances them; paused while the tab is hidden) and `hooks/useRequestReview.ts` owns details (kept in step with those refetches) and approve/reject confirmation for pending requests (cancelling an action started from the details dialog returns to it; an inline one returns to the list); `components/` holds `RequestActions`, `RequestTarget`, `RequestDetailsDialog`, and `RequestActionDialog`.
- `components/Configuration/sections/ApiKeysSection.tsx` (`/settings/api-keys`): bookmarklet keys always come first (`ApiKeysSection/BookmarkletKeysSection.tsx`, and `CreatedKeyDialog.tsx` shows the one-time key, the draggable Send to Youtarr bookmarklet, and phone shortcut details); the external access keys section (`ExternalKeysSection`, `ExternalKeyCard`, `CreateKeyDialog`, `EditKeyDialog` + `useExternalAccessEditor`, `PolicyEditor`, `ChannelGrantPicker`) appears only when the external API is enabled. Requests go through `ApiKeysSection/useApiKeys.ts` (axios); `WORKLOAD_LIMITS` there mirrors the server's per-key ceilings.
- `themes/`: theme definitions (`playful`, `linear`, `flat`), shared layout policy (`layoutPolicy.ts`), and the `ALL_THEMES` registry. New themes add an entry here and implement the required token surface.
- `hooks/`: app-wide custom hooks for data fetching and state.
- `contexts/` and `providers/`: React Context for cross-cutting concerns (auth token, WebSocket, theme). `contexts/ThemeEngineContext.tsx` owns the active theme mode, resolves the layout policy for the current viewport, and injects theme CSS variables onto the document root.
Expand Down Expand Up @@ -232,6 +237,8 @@ npm run test:frontend # all frontend test
npm run test:frontend -- client/src/hooks/__tests__/foo.ts # one frontend test
npm run test:backend # all backend tests
npm run test:backend -- server/modules/__tests__/foo.test.js # one backend test
npm run test:external-api-database # real database migration/runtime regressions
npm run test:external-index-plans # local synthetic query/EXPLAIN report
npm run lint # lint front + back
npm run lint:ts # TypeScript typecheck
```
Expand Down
Loading
Loading