From 08a97e51931715ea157271e3d984512710a0a0ad Mon Sep 17 00:00:00 2001 From: ryzenpay Date: Fri, 24 Jul 2026 22:54:15 -0400 Subject: [PATCH 1/4] youtube cookie through extension, channel sync, playlist sync, UI fixes, oidc fixes, prettier --- .claude/settings.local.json | 20 +- .github/dependabot.yml | 60 +- .github/workflows/docker-publish.yml | 6 +- .github/workflows/extension-publish.yml | 4 +- .github/workflows/helm-publish.yml | 2 +- .prettierignore | 22 + .prettierrc | 7 + Dockerfile | 5 +- PRIVACY.md | 1 + README.md | 26 +- charts/wytui/Chart.yaml | 2 +- charts/wytui/templates/service/wytui.yaml | 4 +- charts/wytui/values.yaml | 28 +- docker-compose.dev.yml | 8 +- docker-compose.yml | 6 +- docs/index.html | 1720 +++--- .../plans/2026-07-24-youtube-search.md | 2784 +++++++++ .../specs/2026-07-24-youtube-search-design.md | 412 ++ extension/background.js | 429 +- extension/content.css | 96 +- extension/content.js | 70 +- extension/manifest.json | 92 +- extension/popup.html | 1186 ++-- extension/popup.js | 643 ++- package-lock.json | 32 + package.json | 4 + playwright.config.ts | 12 +- prisma.config.ts | 8 +- .../migration.sql | 5 + .../migration.sql | 27 + .../migration.sql | 18 + .../migration.sql | 9 + prisma/schema.prisma | 62 +- prisma/seed.ts | 301 +- src/app.css | 29 +- src/app.html | 5 +- src/hooks.server.ts | 88 +- .../components/download/DownloadCard.svelte | 307 +- .../components/download/DownloadForm.svelte | 4976 +++++++++-------- .../download/DownloadListRow.svelte | 17 +- .../download/DownloadVersionPicker.svelte | 33 +- .../download/SubscriptionConfig.svelte | 29 +- src/lib/components/icons/BellIcon.svelte | 17 +- src/lib/components/icons/CheckIcon.svelte | 15 +- .../components/icons/CheckSquareIcon.svelte | 19 +- src/lib/components/icons/DownloadIcon.svelte | 8 +- .../components/icons/ExternalLinkIcon.svelte | 19 +- .../components/icons/FolderDownIcon.svelte | 19 +- src/lib/components/icons/ListPlusIcon.svelte | 11 +- src/lib/components/icons/LockIcon.svelte | 17 +- src/lib/components/icons/PlayIcon.svelte | 15 +- src/lib/components/icons/RefreshIcon.svelte | 5 +- src/lib/components/icons/ShieldIcon.svelte | 15 +- src/lib/components/icons/TrashIcon.svelte | 17 +- src/lib/components/icons/UsersIcon.svelte | 21 +- src/lib/components/icons/XIcon.svelte | 17 +- src/lib/components/icons/ZapIcon.svelte | 15 +- src/lib/components/player/VideoPlayer.svelte | 343 +- .../playlist/AddToPlaylistMenu.svelte | 58 +- src/lib/components/ui/Dropdown.svelte | 14 +- src/lib/components/ui/ErrorMessage.svelte | 39 +- src/lib/components/ui/EyeToggleButton.svelte | 87 + src/lib/components/ui/HealthPanel.svelte | 98 +- src/lib/components/ui/Input.svelte | 41 +- .../components/ui/KeyboardShortcutHelp.svelte | 26 +- src/lib/components/ui/Modal.svelte | 4 +- src/lib/components/ui/PasswordInput.svelte | 42 + src/lib/components/ui/PathBrowser.svelte | 5 +- .../components/ui/ProgressIndicator.svelte | 31 +- src/lib/components/ui/Sidebar.svelte | 325 +- src/lib/components/ui/Skeleton.svelte | 12 +- src/lib/components/ui/SuccessIcon.svelte | 3 +- src/lib/components/ui/TagEditor.svelte | 26 +- src/lib/components/ui/Toast.svelte | 56 +- src/lib/components/ui/ViewToggle.svelte | 10 +- .../youtube/ImportSubscriptionsModal.svelte | 805 +++ .../youtube/SyncPlaylistsModal.svelte | 823 +++ src/lib/extension-links.ts | 17 + src/lib/server/auth.ts | 17 +- src/lib/server/csrf.ts | 3 +- src/lib/server/guards.ts | 2 +- src/lib/server/jobs/scheduler.ts | 25 + src/lib/server/ldap.test.ts | 106 + src/lib/server/ldap.ts | 149 +- src/lib/server/oidc.test.ts | 95 + src/lib/server/oidc.ts | 135 +- src/lib/server/openapi.ts | 2 +- src/lib/server/permissions.ts | 4 +- src/lib/server/rate-limit.ts | 11 +- src/lib/server/services/artwork.test.ts | 17 + src/lib/server/services/artwork.ts | 109 + .../server/services/auto-delete.service.ts | 11 +- src/lib/server/services/backup.service.ts | 21 +- .../services/channel-override.service.ts | 4 +- src/lib/server/services/cleanup.service.ts | 32 +- .../services/download-cancel.service.test.ts | 97 + .../server/services/download-progress.test.ts | 13 + src/lib/server/services/download-progress.ts | 8 + src/lib/server/services/download.service.ts | 300 +- src/lib/server/services/import.service.ts | 17 +- src/lib/server/services/library.service.ts | 179 +- src/lib/server/services/monitor.service.ts | 17 +- .../server/services/music-metadata.service.ts | 13 +- .../server/services/notification.service.ts | 6 +- src/lib/server/services/playlist.service.ts | 132 +- src/lib/server/services/plex.service.ts | 12 +- src/lib/server/services/rescan.service.ts | 2 +- src/lib/server/services/search.service.ts | 154 +- .../server/services/subscription.service.ts | 100 +- src/lib/server/services/thumbnail.test.ts | 39 + src/lib/server/services/thumbnail.ts | 50 + .../server/services/watch-progress.service.ts | 6 +- .../services/youtube-link.service.test.ts | 88 + .../server/services/youtube-link.service.ts | 97 + .../services/youtube-sync.service.test.ts | 56 + .../server/services/youtube-sync.service.ts | 94 + .../server/services/youtube.service.test.ts | 33 + src/lib/server/services/youtube.service.ts | 320 ++ src/lib/server/services/ytdlp.service.test.ts | 41 + src/lib/server/services/ytdlp.service.ts | 94 +- src/lib/server/sse/emitter.ts | 4 +- src/lib/server/utils/crypto-box.test.ts | 25 + src/lib/server/utils/crypto-box.ts | 28 + src/lib/server/utils/feed-diff.test.ts | 12 + src/lib/server/utils/feed-diff.ts | 11 + src/lib/server/utils/fetch.ts | 4 +- src/lib/server/utils/netscape-cookies.test.ts | 42 + src/lib/server/utils/netscape-cookies.ts | 30 + src/lib/server/utils/subscriptions-io.test.ts | 26 + src/lib/server/utils/subscriptions-io.ts | 78 + src/lib/stores/modal.svelte.ts | 2 +- src/lib/stores/sse.svelte.ts | 22 +- src/lib/stores/toast.svelte.ts | 6 +- src/lib/styles/tokens.css | 24 +- src/lib/types/index.ts | 10 +- src/lib/utils/a11y.ts | 4 +- src/lib/utils/download.test.ts | 12 +- src/lib/utils/download.ts | 4 +- src/lib/utils/fetch.ts | 39 +- src/lib/utils/format.ts | 28 +- src/routes/+layout.svelte | 49 +- src/routes/analytics/+page.svelte | 66 +- .../api/admin/downloads/clear/+server.ts | 49 + src/routes/api/analytics/+server.ts | 480 +- src/routes/api/auth/me/+server.ts | 69 +- src/routes/api/backup/+server.ts | 116 +- src/routes/api/backup/[id]/+server.ts | 55 +- .../api/backup/[id]/download/+server.ts | 67 +- src/routes/api/backup/restore/+server.ts | 65 +- src/routes/api/browse/+server.ts | 121 +- src/routes/api/channel-overrides/+server.ts | 172 +- .../api/channel-overrides/[id]/+server.ts | 189 +- src/routes/api/channels/+server.ts | 2 +- src/routes/api/downloads/+server.ts | 375 +- src/routes/api/downloads/[id]/+server.ts | 262 +- .../api/downloads/[id]/cancel/+server.ts | 71 +- .../api/downloads/[id]/playlists/+server.ts | 2 +- .../api/downloads/[id]/promote/+server.ts | 156 +- .../api/downloads/[id]/refresh/+server.ts | 116 +- .../api/downloads/[id]/tasks/+server.ts | 77 +- .../api/downloads/[id]/versions/+server.ts | 91 +- src/routes/api/downloads/batch/+server.ts | 176 +- src/routes/api/downloads/quick/+server.ts | 208 +- src/routes/api/downloads/refresh/+server.ts | 107 +- src/routes/api/files/[id]/+server.ts | 245 +- src/routes/api/health/+server.ts | 295 +- src/routes/api/import/+server.ts | 83 +- src/routes/api/import/scan/+server.ts | 73 +- src/routes/api/keys/+server.ts | 160 +- src/routes/api/keys/[id]/+server.ts | 63 +- src/routes/api/library-requests/+server.ts | 52 +- .../api/library-requests/[id]/+server.ts | 87 +- src/routes/api/library/clear/+server.ts | 59 +- src/routes/api/library/usage/+server.ts | 61 +- src/routes/api/monitors/+server.ts | 245 +- src/routes/api/monitors/[id]/+server.ts | 371 +- src/routes/api/notifications/test/+server.ts | 41 +- src/routes/api/playlists/+server.ts | 150 +- src/routes/api/playlists/[id]/+server.ts | 217 +- .../api/playlists/[id]/download/+server.ts | 38 + .../api/playlists/[id]/items/+server.ts | 243 +- src/routes/api/playlists/import/+server.ts | 329 +- src/routes/api/preferences/+server.ts | 110 +- src/routes/api/profiles/+server.ts | 286 +- src/routes/api/rescan/+server.ts | 132 +- src/routes/api/scheduler/+server.ts | 99 +- .../api/scheduler/[jobName]/run/+server.ts | 61 +- src/routes/api/search/+server.ts | 101 +- src/routes/api/settings/+server.ts | 777 ++- src/routes/api/settings/cookies/+server.ts | 4 +- src/routes/api/settings/disk/+server.ts | 65 +- .../api/settings/jellyfin-test/+server.ts | 109 +- .../api/settings/jellyfin-users/+server.ts | 113 +- src/routes/api/settings/plex/test/+server.ts | 61 +- src/routes/api/setup/+server.ts | 147 +- src/routes/api/sse/+server.ts | 47 +- src/routes/api/subscriptions/+server.ts | 355 +- src/routes/api/subscriptions/[id]/+server.ts | 408 +- .../subscriptions/[id]/backfill/+server.ts | 107 +- .../api/subscriptions/[id]/check/+server.ts | 75 +- src/routes/api/tags/+server.ts | 53 +- src/routes/api/users/+server.ts | 293 +- src/routes/api/users/[id]/+server.ts | 236 +- src/routes/api/users/[id]/password/+server.ts | 145 +- src/routes/api/version/+server.ts | 129 +- .../watch-progress/[downloadId]/+server.ts | 91 +- .../[downloadId]/watched/+server.ts | 87 +- .../api/watch-progress/continue/+server.ts | 72 +- src/routes/api/youtube/history/+server.ts | 32 + src/routes/api/youtube/link/+server.ts | 34 + src/routes/api/youtube/playlists/+server.ts | 26 + .../api/youtube/playlists/stats/+server.ts | 19 + .../api/youtube/playlists/sync/+server.ts | 34 + .../api/youtube/subscriptions/+server.ts | 77 + .../youtube/subscriptions/export/+server.ts | 20 + src/routes/api/youtube/watch-later/+server.ts | 11 + src/routes/auth/oidc/+server.ts | 2 +- src/routes/auth/oidc/callback/+server.ts | 12 +- src/routes/auth/signin/+page.server.ts | 34 +- src/routes/auth/signin/+page.svelte | 35 +- src/routes/channels/+page.svelte | 24 +- src/routes/channels/[name]/+page.svelte | 33 +- src/routes/docs/+page.svelte | 14 +- src/routes/downloads/+page.svelte | 1210 ++-- src/routes/downloads/[id]/+page.server.ts | 24 +- src/routes/downloads/[id]/+page.svelte | 285 +- src/routes/llms-full.txt/+server.ts | 4 +- src/routes/monitors/+page.svelte | 166 +- src/routes/playlists/+page.svelte | 157 +- src/routes/playlists/[id]/+page.svelte | 458 +- src/routes/scheduler/+page.svelte | 1 - src/routes/search/+page.svelte | 79 +- src/routes/settings/+page.svelte | 2238 ++++---- src/routes/setup/+page.server.ts | 5 +- src/routes/setup/+page.svelte | 22 +- src/routes/subscriptions/+page.svelte | 1171 ++-- svelte.config.js | 6 +- tests/integration/share-api.test.ts | 21 +- vite.config.ts | 2 +- wytui-extension.zip | Bin 15469 -> 0 bytes 240 files changed, 23830 insertions(+), 12354 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 docs/superpowers/plans/2026-07-24-youtube-search.md create mode 100644 docs/superpowers/specs/2026-07-24-youtube-search-design.md create mode 100644 prisma/migrations/20260724010617_add_download_tuning_and_poster_settings/migration.sql create mode 100644 prisma/migrations/20260724015314_add_youtube_link/migration.sql create mode 100644 prisma/migrations/20260724042501_add_oidc_db_config_and_username/migration.sql create mode 100644 prisma/migrations/20260724224055_add_pending_playlist_items/migration.sql create mode 100644 src/lib/components/ui/EyeToggleButton.svelte create mode 100644 src/lib/components/ui/PasswordInput.svelte create mode 100644 src/lib/components/youtube/ImportSubscriptionsModal.svelte create mode 100644 src/lib/components/youtube/SyncPlaylistsModal.svelte create mode 100644 src/lib/extension-links.ts create mode 100644 src/lib/server/ldap.test.ts create mode 100644 src/lib/server/oidc.test.ts create mode 100644 src/lib/server/services/artwork.test.ts create mode 100644 src/lib/server/services/artwork.ts create mode 100644 src/lib/server/services/download-cancel.service.test.ts create mode 100644 src/lib/server/services/download-progress.test.ts create mode 100644 src/lib/server/services/download-progress.ts create mode 100644 src/lib/server/services/thumbnail.test.ts create mode 100644 src/lib/server/services/thumbnail.ts create mode 100644 src/lib/server/services/youtube-link.service.test.ts create mode 100644 src/lib/server/services/youtube-link.service.ts create mode 100644 src/lib/server/services/youtube-sync.service.test.ts create mode 100644 src/lib/server/services/youtube-sync.service.ts create mode 100644 src/lib/server/services/youtube.service.test.ts create mode 100644 src/lib/server/services/youtube.service.ts create mode 100644 src/lib/server/services/ytdlp.service.test.ts create mode 100644 src/lib/server/utils/crypto-box.test.ts create mode 100644 src/lib/server/utils/crypto-box.ts create mode 100644 src/lib/server/utils/feed-diff.test.ts create mode 100644 src/lib/server/utils/feed-diff.ts create mode 100644 src/lib/server/utils/netscape-cookies.test.ts create mode 100644 src/lib/server/utils/netscape-cookies.ts create mode 100644 src/lib/server/utils/subscriptions-io.test.ts create mode 100644 src/lib/server/utils/subscriptions-io.ts create mode 100644 src/routes/api/admin/downloads/clear/+server.ts create mode 100644 src/routes/api/playlists/[id]/download/+server.ts create mode 100644 src/routes/api/youtube/history/+server.ts create mode 100644 src/routes/api/youtube/link/+server.ts create mode 100644 src/routes/api/youtube/playlists/+server.ts create mode 100644 src/routes/api/youtube/playlists/stats/+server.ts create mode 100644 src/routes/api/youtube/playlists/sync/+server.ts create mode 100644 src/routes/api/youtube/subscriptions/+server.ts create mode 100644 src/routes/api/youtube/subscriptions/export/+server.ts create mode 100644 src/routes/api/youtube/watch-later/+server.ts delete mode 100644 wytui-extension.zip diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bbba024..f159444 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,12 +1,12 @@ { - "permissions": { - "allow": [ - "Bash(npx svelte-check *)", - "Bash(npm install *)", - "Bash(npx tsc *)", - "Bash(docker compose *)", - "Bash(npx prisma *)", - "Bash(npm run *)" - ] - } + "permissions": { + "allow": [ + "Bash(npx svelte-check *)", + "Bash(npm install *)", + "Bash(npx tsc *)", + "Bash(docker compose *)", + "Bash(npx prisma *)", + "Bash(npm run *)" + ] + } } diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d7d27d3..dccb3be 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,27 +1,27 @@ version: 2 updates: # Enable version updates for npm - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "weekly" - day: "monday" - time: "09:00" + interval: 'weekly' + day: 'monday' + time: '09:00' open-pull-requests-limit: 10 groups: # Group all non-major updates together minor-and-patch: patterns: - - "*" + - '*' update-types: - - "minor" - - "patch" + - 'minor' + - 'patch' # Group development dependencies dev-dependencies: - dependency-type: "development" + dependency-type: 'development' update-types: - - "minor" - - "patch" + - 'minor' + - 'patch' # Specific package configurations ignore: # Ignore major version updates for stable dependencies @@ -29,34 +29,34 @@ updates: # - dependency-name: "svelte" # update-types: ["version-update:semver-major"] labels: - - "dependencies" - - "automated" + - 'dependencies' + - 'automated' reviewers: - - "willuhmjs" + - 'willuhmjs' commit-message: - prefix: "chore" - include: "scope" + prefix: 'chore' + include: 'scope' # Enable version updates for Docker - - package-ecosystem: "docker" - directory: "/" + - package-ecosystem: 'docker' + directory: '/' schedule: - interval: "weekly" - day: "monday" + interval: 'weekly' + day: 'monday' labels: - - "dependencies" - - "docker" + - 'dependencies' + - 'docker' commit-message: - prefix: "chore" + prefix: 'chore' # Enable version updates for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "weekly" - day: "monday" + interval: 'weekly' + day: 'monday' labels: - - "dependencies" - - "ci" + - 'dependencies' + - 'ci' commit-message: - prefix: "chore" + prefix: 'chore' diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 69addb8..658b35b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,10 +2,10 @@ name: Build and Push Docker Image on: push: - branches: [ main, master ] - tags: [ 'v*.*.*' ] + branches: [main, master] + tags: ['v*.*.*'] pull_request: - branches: [ main, master ] + branches: [main, master] env: REGISTRY: ghcr.io diff --git a/.github/workflows/extension-publish.yml b/.github/workflows/extension-publish.yml index 1d80b5c..9ff56e7 100644 --- a/.github/workflows/extension-publish.yml +++ b/.github/workflows/extension-publish.yml @@ -8,7 +8,7 @@ name: Publish Extension (Firefox AMO) on: push: - branches: [ main, master ] + branches: [main, master] paths: - 'extension/**' workflow_dispatch: {} @@ -17,7 +17,7 @@ jobs: publish: runs-on: ubuntu-latest permissions: - contents: write # to push the ext-v tag + contents: write # to push the ext-v tag steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/helm-publish.yml b/.github/workflows/helm-publish.yml index d39b475..a6c5909 100644 --- a/.github/workflows/helm-publish.yml +++ b/.github/workflows/helm-publish.yml @@ -2,7 +2,7 @@ name: Package and Publish Helm Chart on: push: - branches: [ main, master ] + branches: [main, master] paths: - 'charts/**' workflow_dispatch: diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..a39fd1e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,22 @@ +# Package managers +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Build output & generated +.svelte-kit/ +build/ +dist/ +node_modules/ +extension-artifacts/ + +# Prisma generated client +prisma/generated/ +src/lib/prisma/ + +# Misc +*.min.js +*.min.css + +# Helm charts (Go templates break under Prettier) +charts/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..d51e0ec --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "useTabs": true, + "singleQuote": true, + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/Dockerfile b/Dockerfile index 943cd2d..c8293cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,8 +21,9 @@ RUN npm prune --production FROM node:24-alpine3.23 -RUN apk add --no-cache ffmpeg curl python3 \ - && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ +ARG YTDLP_VERSION=2025.06.09 +RUN apk add --no-cache ffmpeg curl python3 aria2 \ + && curl -L https://github.com/yt-dlp/yt-dlp/releases/download/${YTDLP_VERSION}/yt-dlp -o /usr/local/bin/yt-dlp \ && chmod a+rx /usr/local/bin/yt-dlp RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 diff --git a/PRIVACY.md b/PRIVACY.md index 86aa0a6..59e4c8a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -3,6 +3,7 @@ wytui does not collect, store, or transmit any personal data to any third party. The extension stores the following data locally in your browser using chrome.storage.local: + - Your wytui server URL - Your optional API key diff --git a/README.md b/README.md index 5abaac1..4f59325 100644 --- a/README.md +++ b/README.md @@ -60,23 +60,23 @@ The chart includes a bundled PostgreSQL by default. To use an external database: postgresql: enabled: false secret: - url: "postgresql://user:pass@host:5432/wytui?schema=public" + url: 'postgresql://user:pass@host:5432/wytui?schema=public' ``` ### Environment Variables -| Variable | Description | -|---|---| -| `DATABASE_URL` | PostgreSQL connection string | -| `AUTH_SECRET` | Session signing secret | -| `AUTH_TRUST_HOST` | Set `true` behind a reverse proxy (optional) | -| `ORIGIN` | Public URL of the app (optional, defaults to `http://localhost:3000`) | -| `ADMIN_USERNAME` | Auto-create admin user, skipping the setup wizard (optional) | -| `ADMIN_PASSWORD` | Password for the auto-created admin user (optional) | -| `OIDC_ISSUER_URL` | OIDC issuer URL (optional) | -| `OIDC_CLIENT_ID` | OIDC client ID (optional) | -| `OIDC_CLIENT_SECRET` | OIDC client secret (optional) | -| `OIDC_DISPLAY_NAME` | OIDC provider display name (optional, defaults to "SSO") | +| Variable | Description | +| -------------------- | --------------------------------------------------------------------- | +| `DATABASE_URL` | PostgreSQL connection string | +| `AUTH_SECRET` | Session signing secret | +| `AUTH_TRUST_HOST` | Set `true` behind a reverse proxy (optional) | +| `ORIGIN` | Public URL of the app (optional, defaults to `http://localhost:3000`) | +| `ADMIN_USERNAME` | Auto-create admin user, skipping the setup wizard (optional) | +| `ADMIN_PASSWORD` | Password for the auto-created admin user (optional) | +| `OIDC_ISSUER_URL` | OIDC issuer URL (optional) | +| `OIDC_CLIENT_ID` | OIDC client ID (optional) | +| `OIDC_CLIENT_SECRET` | OIDC client secret (optional) | +| `OIDC_DISPLAY_NAME` | OIDC provider display name (optional, defaults to "SSO") | ## OIDC Authentication diff --git a/charts/wytui/Chart.yaml b/charts/wytui/Chart.yaml index 686f89a..65d82e8 100644 --- a/charts/wytui/Chart.yaml +++ b/charts/wytui/Chart.yaml @@ -3,4 +3,4 @@ name: wytui description: Self-hosted web UI for yt-dlp type: application version: 0.1.0 -appVersion: "latest" +appVersion: 'latest' diff --git a/charts/wytui/templates/service/wytui.yaml b/charts/wytui/templates/service/wytui.yaml index fb450da..4cd40a7 100644 --- a/charts/wytui/templates/service/wytui.yaml +++ b/charts/wytui/templates/service/wytui.yaml @@ -7,5 +7,5 @@ spec: selector: app: wytui ports: - - port: 3000 - targetPort: 3000 + - port: 3000 + targetPort: 3000 diff --git a/charts/wytui/values.yaml b/charts/wytui/values.yaml index 50de4c7..3dc3551 100644 --- a/charts/wytui/values.yaml +++ b/charts/wytui/values.yaml @@ -18,7 +18,7 @@ ingress: secret: existing: false name: wytui-secret - authSecret: "" + authSecret: '' authSecretKey: AUTH_SECRET oidc: @@ -29,9 +29,9 @@ oidc: name: wytui-oidc-secret clientId: wytui clientIdKey: OIDC_CLIENT_ID - clientSecret: "" + clientSecret: '' clientSecretKey: OIDC_CLIENT_SECRET - issuer: "" + issuer: '' issuerKey: OIDC_ISSUER_URL postgresql: @@ -41,35 +41,35 @@ postgresql: secret: existing: false name: wytui-postgresql-secret - url: "" + url: '' urlKey: DATABASE_URL username: wytui usernameKey: POSTGRES_USER - password: "" + password: '' passwordKey: POSTGRES_PASSWORD persistent: - existingClaim: "" - subPath: "" + existingClaim: '' + subPath: '' size: 5Gi - storageClass: "" + storageClass: '' accessModes: - ReadWriteOnce downloads: persistent: - existingClaim: "" - subPath: "" + existingClaim: '' + subPath: '' size: 5Gi - storageClass: "" + storageClass: '' accessModes: - ReadWriteOnce library: persistent: - existingClaim: "" - subPath: "" + existingClaim: '' + subPath: '' size: 10Gi - storageClass: "" + storageClass: '' accessModes: - ReadWriteOnce diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3a51c2f..98c9ad5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -6,11 +6,11 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-devpassword} POSTGRES_DB: ${POSTGRES_DB:-wytui} ports: - - "5432:5432" + - '5432:5432' volumes: - pgdata:/var/lib/postgresql healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ['CMD-SHELL', 'pg_isready -U postgres'] interval: 5s timeout: 5s retries: 5 @@ -25,8 +25,8 @@ services: - downloads:/downloads - library:/media ports: - - "5173:5173" - - "5555:5555" + - '5173:5173' + - '5555:5555' environment: - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-devpassword}@db:5432/${POSTGRES_DB:-wytui}?schema=public - AUTH_SECRET=${AUTH_SECRET:-dev-secret-please-change-in-production} diff --git a/docker-compose.yml b/docker-compose.yml index acb2434..91ce03b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,11 +6,11 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set. Run ./docker-init.sh to auto-generate secure credentials.} POSTGRES_DB: ${POSTGRES_DB:-wytui} ports: - - "5432:5432" + - '5432:5432' volumes: - pgdata:/var/lib/postgresql healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ['CMD-SHELL', 'pg_isready -U postgres'] interval: 5s timeout: 5s retries: 5 @@ -27,7 +27,7 @@ services: app: build: . ports: - - "3000:3000" + - '3000:3000' environment: - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-wytui}?schema=public - AUTH_SECRET=${AUTH_SECRET:?AUTH_SECRET must be set. Run ./docker-init.sh to auto-generate secure credentials.} diff --git a/docs/index.html b/docs/index.html index 0984205..710cc99 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,407 +1,509 @@ - + - - - - wytui - Self-Hosted yt-dlp Web UI | Video Downloader Interface - - - - - - - - - - - - - - - - - - - - - - - -
- -
- -
-
-
- - - - - - -
-

wytui

-

A self-hosted web UI for yt-dlp. Download videos from YouTube and 1000+ sites with a beautiful dark interface.

- -
-
- -
-
-

Quick Start

-

Get running in minutes with Docker Compose or Helm.

-
-
-

- - Docker Compose -

-
# Copy .env.example to .env and fill in your values
+	
+		
+		
+		wytui - Self-Hosted yt-dlp Web UI | Video Downloader Interface
+		
+		
+		
+		
+
+		
+		
+		
+		
+		
+		
+
+		
+		
+		
+		
+
+		
+		
+
+		
+	
+	
+		
+ +
+ +
+
+
+ + + + + + + + + + + +
+

wytui

+

+ A self-hosted web UI for yt-dlp. Download videos from YouTube and 1000+ sites with a + beautiful dark interface. +

+ +
+
+ +
+
+

Quick Start

+

Get running in minutes with Docker Compose or Helm.

+
+
+

+ + + + Docker Compose +

+
# Copy .env.example to .env and fill in your values
 cp .env.example .env
 
 # Start the stack
 docker compose up -d
-
-
-

- - Helm -

-
# Install with Helm
+					
+
+

+ + + + Helm +

+
# Install with Helm
 helm install wytui \
   oci://ghcr.io/willuhmjs/wytui
 
@@ -409,229 +511,599 @@ 

helm install wytui \ oci://ghcr.io/willuhmjs/wytui \ -f values.yaml

-
-
-
-
- -
-
-

Features

-

A complete media management platform — download, watch, organize, and automate.

-
- -
-
- -
-

In-Browser Video Player

-

Watch downloads directly in the browser. Variable speed, keyboard shortcuts, HTTP Range seeking, timestamp deep links (?t=), similar videos sidebar, and a context menu for loop, speed, and SponsorBlock controls.

-
-
-
- -
-

SponsorBlock

-

Automatic sponsor segment skipping with colored seek bar overlays. Powered by the community SponsorBlock database.

-
-
-
- -
-

Watch Progress

-

Per-user playback position tracking. Resume where you left off and see which videos you've watched. Continue Watching section on the homepage.

-
-
-
- -
-

Chromecast

-

Cast videos to any Chromecast device. Play, pause, and seek from the browser with full Cast SDK integration.

-
- - -
-
- -
-

Download Profiles

-

Pre-configured presets for 4K, 1080p, 720p, 480p video and MP3, AAC, FLAC audio. Save custom profiles with any yt-dlp flags.

-
-
-
- -
-

Two-Tier Storage

-

Temporary cache with configurable quota plus a permanent library organized by uploader. Promote downloads to your library with one click.

-
-
-
- -
-

Subscriptions & Playlist Import

-

Monitor channels and playlists. Auto-download new content on a schedule. Backfill by date, download entire channels, or import a YouTube playlist with live progress tracking.

-
-
-
- -
-

Livestream Monitors

-

Watch livestreams and auto-download when they go live. Never miss a stream from your favorite creators.

-
-
-
- -
-

Real-Time Progress

-

Live download status via Server-Sent Events. Granular step-by-step tracking through fetch, download, and post-processing stages — speed, ETA, and ffmpeg progress all update live.

-
-
-
- -
-

Browser Extension

-

Companion extension for Chrome and Firefox. Works on YouTube and 1000+ supported sites — select quality, profile, and playlist, then send to wytui in one click. Detects already-downloaded URLs automatically.

-
-
-
- -
-

Channel Overrides

-

Per-channel download settings. Override format, quality, SponsorBlock, and auto-delete on a per-creator basis.

-
- -
-
- -
-

Cookie Import

-

Import browser cookies to download member-only, age-restricted, or paywalled content. Paste a Netscape-format cookie file directly in the settings.

-
-
-
- -
-

Speed Throttling

-

Cap download bandwidth with a configurable speed limit and optional sleep interval between downloads. Avoid hammering your network or CDN rate limits.

-
- - -
-
- -
-

Full-Text Search

-

Instant search across titles, descriptions, uploaders, and subtitles. Powered by PostgreSQL full-text search with ranked results — find the exact moment someone says something in any video.

-
-
-
- -
-

Custom Playlists

-

Organize downloads into playlists with drag-to-reorder. Create, edit, and manage playlists with a dedicated UI.

-
-
-
- -
-

Tags & Categories

-

YouTube categories from metadata plus user-defined tags. Filter and organize your library with flexible tagging.

-
-
-
- -
-

Video Type Classification

-

Automatically classifies videos as regular, short, or livestream. Filter your library by content type.

-
- - -
-
- -
-

Jellyfin & Plex Integration

-

Auto library scan on download completion for both Jellyfin and Plex. Jellyfin also gets thumbnail artwork and deep-link search. Your downloads flow seamlessly into your media server.

-
-
-
- -
-

Return YouTube Dislike

-

See dislike counts on videos via the Return YouTube Dislike API. Metadata is fetched and stored alongside your downloads.

-
-
-
- -
-

Metadata Refresh

-

Re-fetch metadata for individual videos or in bulk. Update titles, descriptions, thumbnails, and dislike counts on demand.

-
- - -
-
- -
-

Auto-Delete Watched

-

Automatically delete watched videos after a configurable number of days. Per-channel overrides for fine-grained control.

-
-
-
- -
-

Notifications (Apprise)

-

Get notified on download completion or failure via 80+ services. Powered by Apprise — supports Discord, Slack, email, and more.

-
-
-
- -
-

Backup & Restore

-

Full database backup and restore with scheduled backups via cron. Download backups as JSON, restore with one click.

-
-
-
- -
-

Filesystem Import

-

Import existing video files from your filesystem. Scans directories, extracts YouTube IDs, and creates library entries automatically.

-
-
-
- -
-

Scheduled Tasks

-

Dashboard for all background jobs — subscriptions, monitors, auto-delete, backups, cache cleanup. View history and trigger runs manually.

-
- - -
-
- -
-

OIDC, LDAP & Reverse-Proxy Auth

-

OpenID Connect SSO, LDAP, and reverse-proxy header auth (Authelia, Authentik). Admin and user roles with secure multi-user access via your existing identity provider.

-
-
-
- -
-

Mobile-Friendly

-

Responsive sidebar navigation with bottom tab bar on mobile. Web Share API on iOS, keyboard shortcuts on desktop.

-
-
-
-
- - - +
+
+
+
+ +
+
+

Features

+

A complete media management platform — download, watch, organize, and automate.

+
+ +
+
+ + + + +
+

In-Browser Video Player

+

+ Watch downloads directly in the browser. Variable speed, keyboard shortcuts, HTTP + Range seeking, timestamp deep links (?t=), similar videos sidebar, and a + context menu for loop, speed, and SponsorBlock controls. +

+
+
+
+ + + + + +
+

SponsorBlock

+

+ Automatic sponsor segment skipping with colored seek bar overlays. Powered by the + community SponsorBlock database. +

+
+
+
+ + + + +
+

Watch Progress

+

+ Per-user playback position tracking. Resume where you left off and see which videos + you've watched. Continue Watching section on the homepage. +

+
+
+
+ + + + +
+

Chromecast

+

+ Cast videos to any Chromecast device. Play, pause, and seek from the browser with full + Cast SDK integration. +

+
+ + +
+
+ + + + +
+

Download Profiles

+

+ Pre-configured presets for 4K, 1080p, 720p, 480p video and MP3, AAC, FLAC audio. Save + custom profiles with any yt-dlp flags. +

+
+
+
+ + + + +
+

Two-Tier Storage

+

+ Temporary cache with configurable quota plus a permanent library organized by + uploader. Promote downloads to your library with one click. +

+
+
+
+ + + + +
+

Subscriptions & Playlist Import

+

+ Monitor channels and playlists. Auto-download new content on a schedule. Backfill by + date, download entire channels, or import a YouTube playlist with live progress + tracking. +

+
+
+
+ + + + +
+

Livestream Monitors

+

+ Watch livestreams and auto-download when they go live. Never miss a stream from your + favorite creators. +

+
+
+
+ + + +
+

Real-Time Progress

+

+ Live download status via Server-Sent Events. Granular step-by-step tracking through + fetch, download, and post-processing stages — speed, ETA, and ffmpeg progress all + update live. +

+
+
+
+ + + +
+

Browser Extension

+

+ Companion extension for Chrome and Firefox. Works on YouTube and 1000+ supported sites + — select quality, profile, and playlist, then send to wytui in one click. Detects + already-downloaded URLs automatically. +

+
+
+
+ + + + +
+

Channel Overrides

+

+ Per-channel download settings. Override format, quality, SponsorBlock, and auto-delete + on a per-creator basis. +

+
+ +
+
+ + + +
+

Cookie Import

+

+ Import browser cookies to download member-only, age-restricted, or paywalled content. + Paste a Netscape-format cookie file directly in the settings. +

+
+
+
+ + + + +
+

Speed Throttling

+

+ Cap download bandwidth with a configurable speed limit and optional sleep interval + between downloads. Avoid hammering your network or CDN rate limits. +

+
+ + +
+
+ + + + +
+

Full-Text Search

+

+ Instant search across titles, descriptions, uploaders, and subtitles. Powered by + PostgreSQL full-text search with ranked results — find the exact moment someone says + something in any video. +

+
+
+
+ + + + + + + + +
+

Custom Playlists

+

+ Organize downloads into playlists with drag-to-reorder. Create, edit, and manage + playlists with a dedicated UI. +

+
+
+
+ + + + +
+

Tags & Categories

+

+ YouTube categories from metadata plus user-defined tags. Filter and organize your + library with flexible tagging. +

+
+
+
+ + + + + + +
+

Video Type Classification

+

+ Automatically classifies videos as regular, short, or livestream. Filter your library + by content type. +

+
+ + +
+
+ + + + +
+

Jellyfin & Plex Integration

+

+ Auto library scan on download completion for both Jellyfin and Plex. Jellyfin also + gets thumbnail artwork and deep-link search. Your downloads flow seamlessly into your + media server. +

+
+
+
+ + + +
+

Return YouTube Dislike

+

+ See dislike counts on videos via the Return YouTube Dislike API. Metadata is fetched + and stored alongside your downloads. +

+
+
+
+ + + + +
+

Metadata Refresh

+

+ Re-fetch metadata for individual videos or in bulk. Update titles, descriptions, + thumbnails, and dislike counts on demand. +

+
+ + +
+
+ + + +
+

Auto-Delete Watched

+

+ Automatically delete watched videos after a configurable number of days. Per-channel + overrides for fine-grained control. +

+
+
+
+ + + + +
+

Notifications (Apprise)

+

+ Get notified on download completion or failure via 80+ services. Powered by Apprise — + supports Discord, Slack, email, and more. +

+
+
+
+ + + + + +
+

Backup & Restore

+

+ Full database backup and restore with scheduled backups via cron. Download backups as + JSON, restore with one click. +

+
+
+
+ + + + +
+

Filesystem Import

+

+ Import existing video files from your filesystem. Scans directories, extracts YouTube + IDs, and creates library entries automatically. +

+
+
+
+ + + + +
+

Scheduled Tasks

+

+ Dashboard for all background jobs — subscriptions, monitors, auto-delete, backups, + cache cleanup. View history and trigger runs manually. +

+
+ + +
+
+ + + + +
+

OIDC, LDAP & Reverse-Proxy Auth

+

+ OpenID Connect SSO, LDAP, and reverse-proxy header auth (Authelia, Authentik). Admin + and user roles with secure multi-user access via your existing identity provider. +

+
+
+
+ + + + +
+

Mobile-Friendly

+

+ Responsive sidebar navigation with bottom tab bar on mobile. Web Share API on iOS, + keyboard shortcuts on desktop. +

+
+
+
+
+ + + diff --git a/docs/superpowers/plans/2026-07-24-youtube-search.md b/docs/superpowers/plans/2026-07-24-youtube-search.md new file mode 100644 index 0000000..f8e9fd7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-youtube-search.md @@ -0,0 +1,2784 @@ +# YouTube Search Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users search YouTube from inside wytui and act on results directly — download videos, subscribe to channels, import playlists. + +**Architecture:** A new anonymous (cookie-less) search service shells out to yt-dlp against `youtube.com/results?search_query=…&sp=…`, where `sp` is a base64 protobuf filter blob built by a pure encoder. Results are parsed into a discriminated union, cached process-wide with a bounded TTL map, and served by a new `GET /api/youtube/search`. The existing `/search` page gains a Library/YouTube tab split, with each mode extracted into its own component. + +**Tech Stack:** SvelteKit 2 (Svelte 5 runes), TypeScript, Prisma, vitest, yt-dlp 2026.07.04 + +**Spec:** `docs/superpowers/specs/2026-07-24-youtube-search-design.md` + +## Global Constraints + +- **Do not run `git commit` without the user's explicit go-ahead.** The user manages the git workflow. Commit steps below are checkpoints — stage the files, show the message, and ask. +- Search is **always anonymous**. Never pass `--cookies` on any code path added by this plan. +- yt-dlp binary path comes from `process.env.YTDLP_PATH || '/usr/local/bin/yt-dlp'` (existing convention in `youtube.service.ts:39`). +- yt-dlp playlist indices are **1-based and inclusive**: `--playlist-start (offset + 1)`, `--playlist-end (offset + limit)`. +- Search timeout is **45s**; the shared runner default stays **120s**. +- Cache: **15 min TTL, 200-entry cap, insertion-order eviction**, shared across all users. +- Rate limit: `youtubeSearch: { windowMs: 60 * 1000, maxRequests: 120 }`. +- Svelte 5 runes (`$state`, `$derived`, `$props`, `$effect`) — match existing components, not Svelte 4 syntax. +- Tabs for indentation, single quotes, semicolons (Prettier config is in the repo; run `npm run format` before committing). +- Unit tests: `npm run test` (vitest). `tests/integration/` is Playwright and excluded from vitest — do not add to it. + +## File Structure + +| File | Responsibility | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `src/lib/server/utils/ytdlp-json.ts` | **New.** Spawn yt-dlp, return stdout, with timeout + settled guard. Extracted from `YouTubeService.runYtdlpJson`. | +| `src/lib/server/utils/ytdlp-json.test.ts` | **New.** Success / non-zero exit / timeout, with `spawn` mocked. | +| `src/lib/server/services/youtube.service.ts` | **Modify.** Delete private `runYtdlpJson`, import the shared one. | +| `src/lib/server/services/youtube-search.service.ts` | **New.** Types, `buildSearchParam`, `parseSearchEntries`, bounded cache, `search()`. | +| `src/lib/server/services/youtube-search.service.test.ts` | **New.** Encoder vectors, parser fixtures, cache behaviour, offset mapping. | +| `src/lib/server/rate-limit.ts` | **Modify.** Add `youtubeSearch` bucket. | +| `src/hooks.server.ts` | **Modify.** Route `/api/youtube/search` to that bucket. | +| `src/routes/api/youtube/search/+server.ts` | **New.** `GET` handler: validate params, call service, attach `existingDownload`. | +| `src/lib/utils/format.ts` | **Modify.** Add `formatCount` (1234567 → "1.2M"). | +| `src/lib/utils/format.test.ts` | **New or modify.** Tests for `formatCount`. | +| `src/routes/search/+page.svelte` | **Modify.** Becomes a thin tab shell. | +| `src/lib/components/search/LibrarySearch.svelte` | **New.** Existing library-search body, moved verbatim. | +| `src/lib/components/search/YouTubeSearch.svelte` | **New.** Search box, filters, URL state, fetch, result list, Load more, selection bar. | +| `src/lib/components/search/YouTubeVideoResult.svelte` | **New.** One video row. | +| `src/lib/components/search/YouTubeChannelResult.svelte` | **New.** One channel row. | +| `src/lib/components/search/YouTubePlaylistResult.svelte` | **New.** One playlist row. | +| `src/lib/components/ui/Sidebar.svelte` | **Modify.** Add the Search nav item. | + +Tasks 1–5 are server-side and independently testable. Task 6 is a no-behaviour-change refactor. Tasks 7–8 build the UI on top. + +--- + +### Task 1: Extract the yt-dlp JSON runner + +`YouTubeService.runYtdlpJson` is private, hardcodes a 120s timeout, and takes a cookie path. Search needs it cookie-less with a 45s timeout, so it moves to a shared util with an options bag. + +**Files:** + +- Create: `src/lib/server/utils/ytdlp-json.ts` +- Create: `src/lib/server/utils/ytdlp-json.test.ts` +- Modify: `src/lib/server/services/youtube.service.ts` (remove lines 119-157, the private `runYtdlpJson`; update its 2 call sites and imports) + +**Interfaces:** + +- Consumes: nothing +- Produces: `runYtdlpJson(target: string, opts?: { cookiePath?: string | null; timeoutMs?: number; extraArgs?: string[] }): Promise` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/utils/ytdlp-json.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; + +const spawnMock = vi.fn(); +vi.mock('child_process', () => ({ spawn: (...a: any[]) => spawnMock(...a) })); + +/** Minimal stand-in for a ChildProcess: emits on stdout/stderr, then closes. */ +function fakeProc() { + const p: any = new EventEmitter(); + p.stdout = new EventEmitter(); + p.stderr = new EventEmitter(); + p.kill = vi.fn(); + return p; +} + +describe('runYtdlpJson', () => { + beforeEach(() => { + spawnMock.mockReset(); + vi.useRealTimers(); + }); + + it('resolves stdout and passes the expected base args', async () => { + const p = fakeProc(); + spawnMock.mockReturnValue(p); + const { runYtdlpJson } = await import('./ytdlp-json'); + const promise = runYtdlpJson('ytsearch1:hi'); + + p.stdout.emit('data', '{"a":'); + p.stdout.emit('data', '1}'); + p.emit('close', 0); + + await expect(promise).resolves.toBe('{"a":1}'); + const args = spawnMock.mock.calls[0][1]; + expect(args).toEqual([ + '--flat-playlist', + '--dump-single-json', + '--no-warnings', + 'ytsearch1:hi', + ]); + }); + + it('inserts --cookies and extraArgs before the target', async () => { + const p = fakeProc(); + spawnMock.mockReturnValue(p); + const { runYtdlpJson } = await import('./ytdlp-json'); + const promise = runYtdlpJson('TARGET', { + cookiePath: '/tmp/c.txt', + extraArgs: ['--playlist-start', '1'], + }); + p.emit('close', 0); + await promise; + + expect(spawnMock.mock.calls[0][1]).toEqual([ + '--flat-playlist', + '--dump-single-json', + '--no-warnings', + '--cookies', + '/tmp/c.txt', + '--playlist-start', + '1', + 'TARGET', + ]); + }); + + it('rejects with stderr text on non-zero exit', async () => { + const p = fakeProc(); + spawnMock.mockReturnValue(p); + const { runYtdlpJson } = await import('./ytdlp-json'); + const promise = runYtdlpJson('TARGET'); + p.stderr.emit('data', 'ERROR: boom'); + p.emit('close', 1); + await expect(promise).rejects.toThrow('ERROR: boom'); + }); + + it('kills the process and rejects when the timeout elapses', async () => { + vi.useFakeTimers(); + const p = fakeProc(); + spawnMock.mockReturnValue(p); + const { runYtdlpJson } = await import('./ytdlp-json'); + const promise = runYtdlpJson('TARGET', { timeoutMs: 1000 }); + vi.advanceTimersByTime(1001); + await expect(promise).rejects.toThrow('yt-dlp timed out'); + expect(p.kill).toHaveBeenCalledWith('SIGKILL'); + }); + + it('ignores a late close after the timeout already rejected', async () => { + vi.useFakeTimers(); + const p = fakeProc(); + spawnMock.mockReturnValue(p); + const { runYtdlpJson } = await import('./ytdlp-json'); + const promise = runYtdlpJson('TARGET', { timeoutMs: 1000 }); + vi.advanceTimersByTime(1001); + await expect(promise).rejects.toThrow('yt-dlp timed out'); + expect(() => p.emit('close', 0)).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/utils/ytdlp-json.test.ts` +Expected: FAIL — `Cannot find module './ytdlp-json'` + +- [ ] **Step 3: Write minimal implementation** + +Create `src/lib/server/utils/ytdlp-json.ts`: + +```ts +import { spawn } from 'child_process'; + +const YTDLP = process.env.YTDLP_PATH || '/usr/local/bin/yt-dlp'; + +export interface RunYtdlpJsonOptions { + /** Netscape cookie file path. Omit for anonymous requests. */ + cookiePath?: string | null; + /** Hard kill after this many ms. Defaults to 120s. */ + timeoutMs?: number; + /** Extra flags inserted after the base args, before the target. */ + extraArgs?: string[]; +} + +/** + * Run yt-dlp in flat-JSON mode and resolve its stdout. + * + * The `settled` guard matters: without it a process that both times out and + * later closes would settle the promise twice and leave a dangling timer. + */ +export function runYtdlpJson(target: string, opts: RunYtdlpJsonOptions = {}): Promise { + const { cookiePath = null, timeoutMs = 120000, extraArgs = [] } = opts; + + return new Promise((resolve, reject) => { + const args = [ + '--flat-playlist', + '--dump-single-json', + '--no-warnings', + ...(cookiePath ? ['--cookies', cookiePath] : []), + ...extraArgs, + target, + ]; + const p = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + let err = ''; + let settled = false; + + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + try { + p.kill('SIGKILL'); + } catch {} + reject(new Error('yt-dlp timed out')); + }, timeoutMs); + + p.stdout.on('data', (c) => (out += c.toString())); + p.stderr.on('data', (c) => (err += c.toString())); + p.on('error', (e) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(e); + }); + p.on('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + code === 0 ? resolve(out) : reject(new Error(err || `yt-dlp exit ${code}`)); + }); + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/utils/ytdlp-json.test.ts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Switch `youtube.service.ts` to the shared runner** + +In `src/lib/server/services/youtube.service.ts`: + +1. Delete the entire private `runYtdlpJson` method (the block starting `/** Run yt-dlp and return stdout (flat JSON). Cookies are optional. */` through its closing `}`). +2. Delete the now-unused `const YTDLP = …` line and the `import { spawn } from 'child_process';` line. +3. Add to the imports: `import { runYtdlpJson } from '../utils/ytdlp-json';` +4. In `fetchList`, change `await this.runYtdlpJson(cookiePath, target)` to `await runYtdlpJson(target, { cookiePath })`. +5. In `fetchPlaylistFlat`, change `await this.runYtdlpJson(null, url)` to `await runYtdlpJson(url)`. + +- [ ] **Step 6: Verify nothing regressed** + +Run: `npx vitest run src/lib/server/services/ && npm run check` +Expected: existing `youtube.service.test.ts`, `youtube-sync.service.test.ts`, `youtube-link.service.test.ts` all PASS; `svelte-check` reports no new errors. + +- [ ] **Step 7: Commit (ask first)** + +```bash +npm run format +git add src/lib/server/utils/ytdlp-json.ts src/lib/server/utils/ytdlp-json.test.ts src/lib/server/services/youtube.service.ts +git commit -m "refactor: extract yt-dlp JSON runner into a shared util" +``` + +--- + +### Task 2: The `sp` filter encoder + +A pure function, no I/O. The expected outputs below were verified against live YouTube on 2026-07-24 — treat them as fixed contract, not guesses. + +**Files:** + +- Create: `src/lib/server/services/youtube-search.service.ts` +- Create: `src/lib/server/services/youtube-search.service.test.ts` + +**Interfaces:** + +- Consumes: nothing +- Produces: + - `type SearchResultType = 'video' | 'channel' | 'playlist'` + - `type SearchSort = 'relevance' | 'date' | 'views' | 'rating'` + - `type SearchUploadDate = 'any' | 'hour' | 'today' | 'week' | 'month' | 'year'` + - `type SearchDuration = 'any' | 'short' | 'medium' | 'long'` + - `buildSearchParam(opts: { type: SearchResultType; sort?: SearchSort; uploadDate?: SearchUploadDate; duration?: SearchDuration }): string` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/services/youtube-search.service.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { buildSearchParam } from './youtube-search.service'; + +describe('buildSearchParam', () => { + // These four were verified against live YouTube on 2026-07-24. + it('encodes type-only filters with all other fields defaulted', () => { + expect(buildSearchParam({ type: 'video' })).toBe('EgIQAQ=='); + expect(buildSearchParam({ type: 'channel' })).toBe('EgIQAg=='); + expect(buildSearchParam({ type: 'playlist' })).toBe('EgIQAw=='); + }); + + it('encodes sort + uploadDate + duration together', () => { + expect( + buildSearchParam({ type: 'video', sort: 'date', uploadDate: 'week', duration: 'long' }), + ).toBe('CAESBggDEAEYAg=='); + }); + + it('omits fields at their default value', () => { + // relevance is sort=0 and is omitted entirely rather than encoded as 08 00 + expect(buildSearchParam({ type: 'video', sort: 'relevance' })).toBe('EgIQAQ=='); + expect(buildSearchParam({ type: 'video', uploadDate: 'any', duration: 'any' })).toBe( + 'EgIQAQ==', + ); + }); + + it('encodes each sort value', () => { + expect(buildSearchParam({ type: 'video', sort: 'date' })).toBe('CAESAhAB'); + expect(buildSearchParam({ type: 'video', sort: 'views' })).toBe('CAISAhAB'); + expect(buildSearchParam({ type: 'video', sort: 'rating' })).toBe('CAMSAhAB'); + }); + + it('uses YouTube duration ordinals, where long=2 and medium=3', () => { + // short -> 18 01, long -> 18 02, medium -> 18 03 + expect(buildSearchParam({ type: 'video', duration: 'short' })).toBe('EgQQARgB'); + expect(buildSearchParam({ type: 'video', duration: 'long' })).toBe('EgQQARgC'); + expect(buildSearchParam({ type: 'video', duration: 'medium' })).toBe('EgQQARgD'); + }); + + it('encodes each uploadDate value', () => { + expect(buildSearchParam({ type: 'video', uploadDate: 'hour' })).toBe('EgQIARAB'); + expect(buildSearchParam({ type: 'video', uploadDate: 'today' })).toBe('EgQIAhAB'); + expect(buildSearchParam({ type: 'video', uploadDate: 'week' })).toBe('EgQIAxAB'); + expect(buildSearchParam({ type: 'video', uploadDate: 'month' })).toBe('EgQIBBAB'); + expect(buildSearchParam({ type: 'video', uploadDate: 'year' })).toBe('EgQIBRAB'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/services/youtube-search.service.test.ts` +Expected: FAIL — `Cannot find module './youtube-search.service'` + +- [ ] **Step 3: Write minimal implementation** + +Create `src/lib/server/services/youtube-search.service.ts`: + +```ts +export type SearchResultType = 'video' | 'channel' | 'playlist'; +export type SearchSort = 'relevance' | 'date' | 'views' | 'rating'; +export type SearchUploadDate = 'any' | 'hour' | 'today' | 'week' | 'month' | 'year'; +export type SearchDuration = 'any' | 'short' | 'medium' | 'long'; + +// YouTube's `sp` query param is base64 of a small protobuf message: +// +// field 1 (varint) sort +// field 2 (message) filters { 1: uploadDate, 2: type, 3: duration } +// +// Fields at their default value are omitted, per standard protobuf encoding. +// Both the omitting form and an explicit `sort=0` were verified to work; the +// omitting form is used here because it is what YouTube's own UI emits. +const SORT_VALUES: Record = { + relevance: 0, + date: 1, + views: 2, + rating: 3, +}; + +const UPLOAD_DATE_VALUES: Record = { + any: 0, + hour: 1, + today: 2, + week: 3, + month: 4, + year: 5, +}; + +const TYPE_VALUES: Record = { + video: 1, + channel: 2, + playlist: 3, +}; + +// Not in size order — this is YouTube's numbering, not a mistake. +const DURATION_VALUES: Record = { + any: 0, + short: 1, // under 4 minutes + long: 2, // over 20 minutes + medium: 3, // 4 to 20 minutes +}; + +/** Encode a protobuf varint. Values here are all small, but this is general. */ +function varint(value: number): number[] { + const bytes: number[] = []; + let v = value; + while (v > 0x7f) { + bytes.push((v & 0x7f) | 0x80); + v >>>= 7; + } + bytes.push(v); + return bytes; +} + +/** Encode `field N, wire type 0 (varint)`, or nothing when value is 0. */ +function varintField(fieldNumber: number, value: number): number[] { + if (value === 0) return []; + return [(fieldNumber << 3) | 0, ...varint(value)]; +} + +export function buildSearchParam(opts: { + type: SearchResultType; + sort?: SearchSort; + uploadDate?: SearchUploadDate; + duration?: SearchDuration; +}): string { + const { type, sort = 'relevance', uploadDate = 'any', duration = 'any' } = opts; + + const filters = [ + ...varintField(1, UPLOAD_DATE_VALUES[uploadDate]), + ...varintField(2, TYPE_VALUES[type]), + ...varintField(3, DURATION_VALUES[duration]), + ]; + + const message = [ + ...varintField(1, SORT_VALUES[sort]), + // field 2, wire type 2 (length-delimited) + ...(filters.length ? [(2 << 3) | 2, ...varint(filters.length), ...filters] : []), + ]; + + return Buffer.from(message).toString('base64'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/services/youtube-search.service.test.ts` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit (ask first)** + +```bash +npm run format +git add src/lib/server/services/youtube-search.service.ts src/lib/server/services/youtube-search.service.test.ts +git commit -m "feat: add YouTube search sp filter encoder" +``` + +--- + +### Task 3: Result parsers + +yt-dlp returns every search hit as `_type: "url"`. Videos carry `ie_key: "Youtube"`; channels and playlists both carry `ie_key: "YoutubeTab"` and are told apart by URL shape. The fixtures below mirror real responses captured on 2026-07-24. + +**Files:** + +- Modify: `src/lib/server/services/youtube-search.service.ts` (append) +- Modify: `src/lib/server/services/youtube-search.service.test.ts` (append) + +**Interfaces:** + +- Consumes: `SearchResultType` from Task 2 +- Produces: + - `interface VideoResult`, `ChannelResult`, `PlaylistResult`, `type SearchResult` + - `parseSearchEntries(json: string, type: SearchResultType): { results: SearchResult[]; rawCount: number }` + +- [ ] **Step 1: Write the failing test** + +Append to `src/lib/server/services/youtube-search.service.test.ts`: + +```ts +import { parseSearchEntries } from './youtube-search.service'; + +const videoJson = JSON.stringify({ + entries: [ + { + _type: 'url', + ie_key: 'Youtube', + id: '7W3TwaIAlLo', + url: 'https://www.youtube.com/watch?v=7W3TwaIAlLo', + title: "TWO reasons your sourdough doesn't SPRING", + description: 'Having issues with your sourdough starter?', + duration: 1289, + channel_id: 'UCzH5n3Ih5kgQoiDAQt2FwLw', + channel: 'LifebyMikeG', + channel_url: 'https://www.youtube.com/channel/UCzH5n3Ih5kgQoiDAQt2FwLw', + uploader: 'LifebyMikeG', + thumbnails: [ + { url: 'https://i.ytimg.com/vi/7W3TwaIAlLo/small.jpg', height: 202, width: 360 }, + { url: 'https://i.ytimg.com/vi/7W3TwaIAlLo/hq720.jpg', height: 404, width: 720 }, + ], + view_count: 2761758, + channel_is_verified: true, + }, + { _type: 'url', ie_key: 'Youtube', id: 'bare', title: 'Bare minimum' }, + { nope: true }, + ], +}); + +const channelJson = JSON.stringify({ + entries: [ + { + _type: 'url', + ie_key: 'YoutubeTab', + id: 'UCMeIfTykbcnPvbPyX7Kqr0Q', + url: 'https://www.youtube.com/channel/UCMeIfTykbcnPvbPyX7Kqr0Q', + title: 'Oh my Bread Sourdough Baking School ', + channel: 'Oh my Bread Sourdough Baking School ', + channel_follower_count: 17500, + description: 'Hello there, Sourdough Enthusiasts!', + channel_is_verified: null, + thumbnails: [ + { url: '//yt3.ggpht.com/abc=s88-c-k-c0x00ffffff-no-rj-mo', height: 88, width: 88 }, + { url: '//yt3.ggpht.com/abc=s176-c-k-c0x00ffffff-no-rj-mo', height: 176, width: 176 }, + ], + }, + ], +}); + +const playlistJson = JSON.stringify({ + entries: [ + { + _type: 'url', + ie_key: 'YoutubeTab', + id: 'PLt_lOWx8jR_PQQqNquacTdaUuGWCD-V1S', + url: 'https://www.youtube.com/playlist?list=PLt_lOWx8jR_PQQqNquacTdaUuGWCD-V1S', + title: 'Your Beginners Guide to Making Sourdough Bread', + channel: 'LifebyMikeG', + channel_id: 'UCzH5n3Ih5kgQoiDAQt2FwLw', + uploader: 'LifebyMikeG', + duration: null, + view_count: null, + thumbnails: [ + { url: 'https://i.ytimg.com/vi/BJEHsvW2J6M/hq720.jpg', height: 404, width: 720 }, + ], + }, + ], +}); + +describe('parseSearchEntries', () => { + it('maps video entries and picks the largest thumbnail', () => { + const { results, rawCount } = parseSearchEntries(videoJson, 'video'); + expect(rawCount).toBe(3); + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + type: 'video', + id: '7W3TwaIAlLo', + title: "TWO reasons your sourdough doesn't SPRING", + url: 'https://www.youtube.com/watch?v=7W3TwaIAlLo', + uploader: 'LifebyMikeG', + channelId: 'UCzH5n3Ih5kgQoiDAQt2FwLw', + channelUrl: 'https://www.youtube.com/channel/UCzH5n3Ih5kgQoiDAQt2FwLw', + thumbnail: 'https://i.ytimg.com/vi/7W3TwaIAlLo/hq720.jpg', + duration: 1289, + viewCount: 2761758, + verified: true, + description: 'Having issues with your sourdough starter?', + existingDownload: null, + }); + }); + + it('tolerates missing fields and synthesizes a watch URL from the id', () => { + const { results } = parseSearchEntries(videoJson, 'video'); + expect(results[1]).toMatchObject({ + type: 'video', + id: 'bare', + title: 'Bare minimum', + url: 'https://www.youtube.com/watch?v=bare', + verified: false, + existingDownload: null, + }); + expect(results[1]).not.toHaveProperty('duration', expect.anything()); + }); + + it('maps channel entries and fixes protocol-relative thumbnails', () => { + const { results } = parseSearchEntries(channelJson, 'channel'); + expect(results[0]).toEqual({ + type: 'channel', + id: 'UCMeIfTykbcnPvbPyX7Kqr0Q', + title: 'Oh my Bread Sourdough Baking School ', + url: 'https://www.youtube.com/channel/UCMeIfTykbcnPvbPyX7Kqr0Q', + thumbnail: 'https://yt3.ggpht.com/abc=s176-c-k-c0x00ffffff-no-rj-mo', + subscriberCount: 17500, + description: 'Hello there, Sourdough Enthusiasts!', + }); + }); + + it('maps playlist entries without inventing a video count', () => { + const { results } = parseSearchEntries(playlistJson, 'playlist'); + expect(results[0]).toEqual({ + type: 'playlist', + id: 'PLt_lOWx8jR_PQQqNquacTdaUuGWCD-V1S', + title: 'Your Beginners Guide to Making Sourdough Bread', + url: 'https://www.youtube.com/playlist?list=PLt_lOWx8jR_PQQqNquacTdaUuGWCD-V1S', + thumbnail: 'https://i.ytimg.com/vi/BJEHsvW2J6M/hq720.jpg', + uploader: 'LifebyMikeG', + channelId: 'UCzH5n3Ih5kgQoiDAQt2FwLw', + }); + expect(results[0]).not.toHaveProperty('videoCount'); + }); + + it('skips entries whose shape does not match the requested type', () => { + // asking for channels but getting video entries yields nothing + const { results, rawCount } = parseSearchEntries(videoJson, 'channel'); + expect(results).toHaveLength(0); + expect(rawCount).toBe(3); + }); + + it('returns empty on malformed JSON', () => { + expect(parseSearchEntries('not json', 'video')).toEqual({ results: [], rawCount: 0 }); + }); + + it('returns empty when entries is absent', () => { + expect(parseSearchEntries('{}', 'video')).toEqual({ results: [], rawCount: 0 }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/services/youtube-search.service.test.ts` +Expected: FAIL — `parseSearchEntries is not a function` + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/lib/server/services/youtube-search.service.ts`: + +```ts +export interface VideoResult { + type: 'video'; + id: string; + title: string; + url: string; + uploader?: string; + channelId?: string; + channelUrl?: string; + thumbnail?: string; + duration?: number; + viewCount?: number; + verified: boolean; + description?: string; + /** Attached by the route handler, never by the service — see cache note. */ + existingDownload: { id: string; status: string } | null; +} + +export interface ChannelResult { + type: 'channel'; + id: string; + title: string; + url: string; + thumbnail?: string; + subscriberCount?: number; + description?: string; +} + +export interface PlaylistResult { + type: 'playlist'; + id: string; + title: string; + url: string; + thumbnail?: string; + uploader?: string; + channelId?: string; +} + +export type SearchResult = VideoResult | ChannelResult | PlaylistResult; + +/** + * Largest available thumbnail, normalized to an absolute https URL. + * Channel avatars come back protocol-relative (`//yt3.ggpht.com/…`). + */ +function pickThumbnail(entry: any): string | undefined { + let url: unknown = entry?.thumbnail; + if (typeof url !== 'string' || !url) { + const list = Array.isArray(entry?.thumbnails) ? entry.thumbnails : []; + url = list.at(-1)?.url; + } + if (typeof url !== 'string' || !url) return undefined; + return url.startsWith('//') ? `https:${url}` : url; +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v ? v : undefined; +} + +function num(v: unknown): number | undefined { + return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined; +} + +function toVideo(e: any): VideoResult | null { + if (e?.ie_key !== 'Youtube' || typeof e?.id !== 'string') return null; + return { + type: 'video', + id: e.id, + title: str(e.title) ?? e.id, + url: str(e.url) ?? `https://www.youtube.com/watch?v=${e.id}`, + uploader: str(e.uploader) ?? str(e.channel), + channelId: str(e.channel_id), + channelUrl: str(e.channel_url), + thumbnail: pickThumbnail(e), + duration: num(e.duration), + viewCount: num(e.view_count), + verified: e.channel_is_verified === true, + description: str(e.description), + existingDownload: null, + }; +} + +function toChannel(e: any): ChannelResult | null { + if (e?.ie_key !== 'YoutubeTab' || typeof e?.id !== 'string') return null; + const url = str(e.url); + if (!url?.includes('/channel/')) return null; + return { + type: 'channel', + id: e.id, + title: str(e.title) ?? str(e.channel) ?? e.id, + url, + thumbnail: pickThumbnail(e), + subscriberCount: num(e.channel_follower_count), + description: str(e.description), + }; +} + +function toPlaylist(e: any): PlaylistResult | null { + if (e?.ie_key !== 'YoutubeTab' || typeof e?.id !== 'string') return null; + const url = str(e.url); + if (!url?.includes('/playlist?list=')) return null; + return { + type: 'playlist', + id: e.id, + title: str(e.title) ?? e.id, + url, + thumbnail: pickThumbnail(e), + uploader: str(e.uploader) ?? str(e.channel), + channelId: str(e.channel_id), + }; +} + +const MAPPERS = { + video: toVideo, + channel: toChannel, + playlist: toPlaylist, +} as const; + +/** + * Parse a `--dump-single-json` search response. + * + * `rawCount` is the number of entries yt-dlp returned, before any were skipped + * for shape mismatch. Callers use it for `hasMore` — deriving that from + * `results.length` would end pagination early whenever an entry is dropped. + */ +export function parseSearchEntries( + json: string, + type: SearchResultType, +): { results: SearchResult[]; rawCount: number } { + let data: any; + try { + data = JSON.parse(json); + } catch { + return { results: [], rawCount: 0 }; + } + const entries = Array.isArray(data?.entries) ? data.entries : []; + const map = MAPPERS[type]; + const results: SearchResult[] = []; + for (const e of entries) { + const mapped = map(e); + if (mapped) results.push(mapped); + } + return { results, rawCount: entries.length }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/services/youtube-search.service.test.ts` +Expected: PASS (13 tests total across Tasks 2 and 3) + +- [ ] **Step 5: Commit (ask first)** + +```bash +npm run format +git add src/lib/server/services/youtube-search.service.ts src/lib/server/services/youtube-search.service.test.ts +git commit -m "feat: parse YouTube search results into a typed union" +``` + +--- + +### Task 4: Cache and the `search()` entry point + +**Files:** + +- Modify: `src/lib/server/services/youtube-search.service.ts` (append) +- Modify: `src/lib/server/services/youtube-search.service.test.ts` (append) + +**Interfaces:** + +- Consumes: `runYtdlpJson` (Task 1), `buildSearchParam` (Task 2), `parseSearchEntries` (Task 3) +- Produces: + - `interface SearchOptions { query, type, sort, uploadDate, duration, offset, limit }` + - `youtubeSearchService.search(opts: SearchOptions): Promise<{ results: SearchResult[]; hasMore: boolean }>` + - `youtubeSearchService.clearCache(): void` (test seam) + +- [ ] **Step 1: Write the failing test** + +Append to `src/lib/server/services/youtube-search.service.test.ts`. Note the mock is declared at module scope with `vi.mock` hoisting in mind: + +```ts +import { vi, beforeEach, afterEach } from 'vitest'; +import { youtubeSearchService } from './youtube-search.service'; + +// vi.mock is hoisted above every import, so the service module below is loaded +// with this mock already in place. The factory returns a closure over +// runYtdlpJsonMock rather than the fn itself — the closure body is not +// evaluated at hoist time, which is what keeps this out of the TDZ. +const runYtdlpJsonMock = vi.fn(); +vi.mock('../utils/ytdlp-json', () => ({ + runYtdlpJson: (...a: any[]) => runYtdlpJsonMock(...a), +})); + +const oneVideo = JSON.stringify({ + entries: [{ _type: 'url', ie_key: 'Youtube', id: 'abc', title: 'A' }], +}); + +function nVideos(n: number) { + return JSON.stringify({ + entries: Array.from({ length: n }, (_, i) => ({ + _type: 'url', + ie_key: 'Youtube', + id: `v${i}`, + title: `V${i}`, + })), + }); +} + +const base = { + query: 'sourdough', + type: 'video' as const, + sort: 'relevance' as const, + uploadDate: 'any' as const, + duration: 'any' as const, + offset: 0, + limit: 20, +}; + +describe('youtubeSearchService.search', () => { + beforeEach(() => { + runYtdlpJsonMock.mockReset(); + youtubeSearchService.clearCache(); + vi.useRealTimers(); + }); + afterEach(() => vi.useRealTimers()); + + it('builds the results URL with an encoded query and sp param', async () => { + runYtdlpJsonMock.mockResolvedValue(oneVideo); + await youtubeSearchService.search({ ...base, query: 'sourdough bread' }); + + const [target] = runYtdlpJsonMock.mock.calls[0]; + expect(target).toBe( + 'https://www.youtube.com/results?search_query=sourdough%20bread&sp=EgIQAQ%3D%3D', + ); + }); + + it('never passes cookies', async () => { + runYtdlpJsonMock.mockResolvedValue(oneVideo); + await youtubeSearchService.search(base); + const [, opts] = runYtdlpJsonMock.mock.calls[0]; + expect(opts.cookiePath).toBeUndefined(); + }); + + it('maps offset and limit onto 1-based inclusive playlist indices', async () => { + runYtdlpJsonMock.mockResolvedValue(oneVideo); + await youtubeSearchService.search(base); + expect(runYtdlpJsonMock.mock.calls[0][1].extraArgs).toEqual([ + '--playlist-start', + '1', + '--playlist-end', + '20', + ]); + + youtubeSearchService.clearCache(); + await youtubeSearchService.search({ ...base, offset: 20 }); + expect(runYtdlpJsonMock.mock.calls[1][1].extraArgs).toEqual([ + '--playlist-start', + '21', + '--playlist-end', + '40', + ]); + }); + + it('uses a 45s timeout', async () => { + runYtdlpJsonMock.mockResolvedValue(oneVideo); + await youtubeSearchService.search(base); + expect(runYtdlpJsonMock.mock.calls[0][1].timeoutMs).toBe(45000); + }); + + it('derives hasMore from the raw entry count, not the parsed length', async () => { + // 20 raw entries, but 2 are channels so only 18 parse as videos + const mixed = JSON.parse(nVideos(20)); + mixed.entries[0] = { _type: 'url', ie_key: 'YoutubeTab', id: 'UC1', url: '/channel/UC1' }; + mixed.entries[1] = { _type: 'url', ie_key: 'YoutubeTab', id: 'UC2', url: '/channel/UC2' }; + runYtdlpJsonMock.mockResolvedValue(JSON.stringify(mixed)); + + const out = await youtubeSearchService.search(base); + expect(out.results).toHaveLength(18); + expect(out.hasMore).toBe(true); + }); + + it('reports hasMore false on a short page', async () => { + runYtdlpJsonMock.mockResolvedValue(nVideos(5)); + const out = await youtubeSearchService.search(base); + expect(out.hasMore).toBe(false); + }); + + it('serves a repeat query from cache without re-spawning yt-dlp', async () => { + runYtdlpJsonMock.mockResolvedValue(oneVideo); + await youtubeSearchService.search(base); + await youtubeSearchService.search(base); + expect(runYtdlpJsonMock).toHaveBeenCalledTimes(1); + }); + + it('treats differing filters as distinct cache keys', async () => { + runYtdlpJsonMock.mockResolvedValue(oneVideo); + await youtubeSearchService.search(base); + await youtubeSearchService.search({ ...base, duration: 'long' }); + await youtubeSearchService.search({ ...base, offset: 20 }); + await youtubeSearchService.search({ ...base, type: 'channel' }); + expect(runYtdlpJsonMock).toHaveBeenCalledTimes(4); + }); + + it('re-fetches once the TTL has elapsed', async () => { + vi.useFakeTimers(); + runYtdlpJsonMock.mockResolvedValue(oneVideo); + await youtubeSearchService.search(base); + vi.advanceTimersByTime(15 * 60 * 1000 + 1); + await youtubeSearchService.search(base); + expect(runYtdlpJsonMock).toHaveBeenCalledTimes(2); + }); + + it('evicts the oldest entry once the cap is reached', async () => { + runYtdlpJsonMock.mockResolvedValue(oneVideo); + // Fill the cache past its 200-entry cap. + for (let i = 0; i < 201; i++) { + await youtubeSearchService.search({ ...base, query: `q${i}` }); + } + expect(runYtdlpJsonMock).toHaveBeenCalledTimes(201); + + // q0 was evicted, so it re-fetches; q200 is still cached. + await youtubeSearchService.search({ ...base, query: 'q0' }); + expect(runYtdlpJsonMock).toHaveBeenCalledTimes(202); + await youtubeSearchService.search({ ...base, query: 'q200' }); + expect(runYtdlpJsonMock).toHaveBeenCalledTimes(202); + }); + + it('propagates yt-dlp failures to the caller', async () => { + runYtdlpJsonMock.mockRejectedValue(new Error('ERROR: Sign in to confirm')); + await expect(youtubeSearchService.search(base)).rejects.toThrow('Sign in to confirm'); + }); + + it('does not cache a failed search', async () => { + runYtdlpJsonMock.mockRejectedValueOnce(new Error('boom')); + await expect(youtubeSearchService.search(base)).rejects.toThrow('boom'); + runYtdlpJsonMock.mockResolvedValue(oneVideo); + const out = await youtubeSearchService.search(base); + expect(out.results).toHaveLength(1); + expect(runYtdlpJsonMock).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/services/youtube-search.service.test.ts` +Expected: FAIL — `youtubeSearchService is undefined` + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/lib/server/services/youtube-search.service.ts`: + +```ts +import { runYtdlpJson } from '../utils/ytdlp-json'; + +export interface SearchOptions { + query: string; + type: SearchResultType; + sort: SearchSort; + uploadDate: SearchUploadDate; + duration: SearchDuration; + offset: number; + limit: number; +} + +export interface SearchResponse { + results: SearchResult[]; + hasMore: boolean; +} + +const SEARCH_TIMEOUT_MS = 45_000; +const CACHE_TTL_MS = 15 * 60 * 1000; +// The playlist caches in youtube.service.ts are keyed by userId and so are +// naturally bounded. Search query space is not, hence a hard cap. +const CACHE_MAX_ENTRIES = 200; + +type CacheEntry = { data: SearchResponse; expires: number }; + +class YouTubeSearchService { + private cache = new Map(); + + /** Test seam — also useful if we ever expose a manual refresh. */ + clearCache(): void { + this.cache.clear(); + } + + private cacheKey(o: SearchOptions): string { + return [o.type, o.sort, o.uploadDate, o.duration, o.offset, o.limit, o.query].join('|'); + } + + private readCache(key: string): SearchResponse | undefined { + const entry = this.cache.get(key); + if (!entry) return undefined; + if (entry.expires <= Date.now()) { + this.cache.delete(key); + return undefined; + } + return entry.data; + } + + private writeCache(key: string, data: SearchResponse): void { + // A Map iterates in insertion order, so the first key is the oldest. + while (this.cache.size >= CACHE_MAX_ENTRIES) { + const oldest = this.cache.keys().next(); + if (oldest.done) break; + this.cache.delete(oldest.value); + } + this.cache.set(key, { data, expires: Date.now() + CACHE_TTL_MS }); + } + + /** + * Search YouTube. Always anonymous — no cookies on this path, so one cache + * serves every user. `existingDownload` stays null here and is attached + * per-user by the route handler; caching it would leak library state + * between users. + */ + async search(opts: SearchOptions): Promise { + const key = this.cacheKey(opts); + const cached = this.readCache(key); + if (cached) return cached; + + const sp = buildSearchParam({ + type: opts.type, + sort: opts.sort, + uploadDate: opts.uploadDate, + duration: opts.duration, + }); + const target = + `https://www.youtube.com/results` + + `?search_query=${encodeURIComponent(opts.query)}` + + `&sp=${encodeURIComponent(sp)}`; + + const json = await runYtdlpJson(target, { + timeoutMs: SEARCH_TIMEOUT_MS, + extraArgs: [ + '--playlist-start', + String(opts.offset + 1), + '--playlist-end', + String(opts.offset + opts.limit), + ], + }); + + const { results, rawCount } = parseSearchEntries(json, opts.type); + const response: SearchResponse = { results, hasMore: rawCount >= opts.limit }; + this.writeCache(key, response); + return response; + } +} + +export const youtubeSearchService = new YouTubeSearchService(); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/services/youtube-search.service.test.ts` +Expected: PASS (25 tests total) + +- [ ] **Step 5: Commit (ask first)** + +```bash +npm run format +git add src/lib/server/services/youtube-search.service.ts src/lib/server/services/youtube-search.service.test.ts +git commit -m "feat: add cached anonymous YouTube search service" +``` + +--- + +### Task 5: API route and rate limiting + +**Files:** + +- Create: `src/routes/api/youtube/search/+server.ts` +- Modify: `src/lib/server/rate-limit.ts` (add to `RATE_LIMITS`, after the `settings` entry) +- Modify: `src/hooks.server.ts:33-40` (add a branch to the existing chain) +- Modify: `src/lib/server/services/youtube-search.service.ts` (append `parseSearchParams`) +- Modify: `src/lib/server/services/youtube-search.service.test.ts` (append) + +**Interfaces:** + +- Consumes: `youtubeSearchService.search` (Task 4), `requireAuth` from `$lib/server/guards`, `apiRoute` from `$lib/server/openapi`, `prisma` from `$lib/server/db` +- Produces: `GET /api/youtube/search` → `{ results: SearchResult[], hasMore: boolean }`; `parseSearchParams(sp: URLSearchParams): SearchOptions` (throws `Error` with a message on invalid input) + +- [ ] **Step 1: Write the failing test for param validation** + +Append to `src/lib/server/services/youtube-search.service.test.ts`: + +```ts +import { parseSearchParams } from './youtube-search.service'; + +describe('parseSearchParams', () => { + const p = (s: string) => new URLSearchParams(s); + + it('applies defaults when only q is given', () => { + expect(parseSearchParams(p('q=sourdough'))).toEqual({ + query: 'sourdough', + type: 'video', + sort: 'relevance', + uploadDate: 'any', + duration: 'any', + offset: 0, + limit: 20, + }); + }); + + it('trims the query', () => { + expect(parseSearchParams(p('q=%20%20hi%20%20')).query).toBe('hi'); + }); + + it('rejects a missing or blank query', () => { + expect(() => parseSearchParams(p(''))).toThrow('Query is required'); + expect(() => parseSearchParams(p('q=%20%20'))).toThrow('Query is required'); + }); + + it('rejects a query over 200 characters', () => { + expect(() => parseSearchParams(p(`q=${'a'.repeat(201)}`))).toThrow('Query is too long'); + }); + + it('accepts a query of exactly 200 characters', () => { + expect(parseSearchParams(p(`q=${'a'.repeat(200)}`)).query).toHaveLength(200); + }); + + it('rejects out-of-enum values rather than passing them to the encoder', () => { + expect(() => parseSearchParams(p('q=x&type=movie'))).toThrow('Invalid type'); + expect(() => parseSearchParams(p('q=x&sort=oldest'))).toThrow('Invalid sort'); + expect(() => parseSearchParams(p('q=x&uploadDate=decade'))).toThrow('Invalid uploadDate'); + expect(() => parseSearchParams(p('q=x&duration=epic'))).toThrow('Invalid duration'); + }); + + it('accepts every valid enum value', () => { + expect(parseSearchParams(p('q=x&type=channel')).type).toBe('channel'); + expect(parseSearchParams(p('q=x&sort=views')).sort).toBe('views'); + expect(parseSearchParams(p('q=x&uploadDate=month')).uploadDate).toBe('month'); + expect(parseSearchParams(p('q=x&duration=medium')).duration).toBe('medium'); + }); + + it('clamps limit to 50 and floors it at 1', () => { + expect(parseSearchParams(p('q=x&limit=999')).limit).toBe(50); + expect(parseSearchParams(p('q=x&limit=0')).limit).toBe(1); + expect(parseSearchParams(p('q=x&limit=-5')).limit).toBe(1); + }); + + it('floors offset at 0 and ignores non-numeric input', () => { + expect(parseSearchParams(p('q=x&offset=-10')).offset).toBe(0); + expect(parseSearchParams(p('q=x&offset=abc')).offset).toBe(0); + expect(parseSearchParams(p('q=x&limit=abc')).limit).toBe(20); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/services/youtube-search.service.test.ts` +Expected: FAIL — `parseSearchParams is not a function` + +- [ ] **Step 3: Implement `parseSearchParams`** + +Append to `src/lib/server/services/youtube-search.service.ts`: + +```ts +const MAX_QUERY_LENGTH = 200; +const MAX_LIMIT = 50; +const DEFAULT_LIMIT = 20; + +const VALID_TYPES: SearchResultType[] = ['video', 'channel', 'playlist']; +const VALID_SORTS: SearchSort[] = ['relevance', 'date', 'views', 'rating']; +const VALID_UPLOAD_DATES: SearchUploadDate[] = ['any', 'hour', 'today', 'week', 'month', 'year']; +const VALID_DURATIONS: SearchDuration[] = ['any', 'short', 'medium', 'long']; + +function pickEnum(raw: string | null, valid: T[], fallback: T, label: string): T { + if (raw === null || raw === '') return fallback; + if (!valid.includes(raw as T)) throw new Error(`Invalid ${label}: ${raw}`); + return raw as T; +} + +function pickInt(raw: string | null, fallback: number, min: number, max: number): number { + const n = Number.parseInt(raw ?? '', 10); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); +} + +/** + * Validate and normalize query params into SearchOptions. + * Throws a plain Error whose message is safe to return to the client as a 400. + */ +export function parseSearchParams(sp: URLSearchParams): SearchOptions { + const query = (sp.get('q') ?? '').trim(); + if (!query) throw new Error('Query is required'); + if (query.length > MAX_QUERY_LENGTH) { + throw new Error(`Query is too long (max ${MAX_QUERY_LENGTH} characters)`); + } + + return { + query, + type: pickEnum(sp.get('type'), VALID_TYPES, 'video', 'type'), + sort: pickEnum(sp.get('sort'), VALID_SORTS, 'relevance', 'sort'), + uploadDate: pickEnum(sp.get('uploadDate'), VALID_UPLOAD_DATES, 'any', 'uploadDate'), + duration: pickEnum(sp.get('duration'), VALID_DURATIONS, 'any', 'duration'), + offset: pickInt(sp.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER), + limit: pickInt(sp.get('limit'), DEFAULT_LIMIT, 1, MAX_LIMIT), + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/services/youtube-search.service.test.ts` +Expected: PASS (35 tests total) + +- [ ] **Step 5: Add the rate limit bucket** + +In `src/lib/server/rate-limit.ts`, inside `RATE_LIMITS`, after the `settings` entry: + +```ts + // Each uncached search spawns a yt-dlp process. Cache hits count against + // this too (the hook runs before the handler and cannot tell), so the + // budget has to accommodate filter-fiddling, which is mostly cache hits. + youtubeSearch: { + windowMs: 60 * 1000, + maxRequests: 120, + }, +``` + +In `src/hooks.server.ts`, extend the existing chain (currently ending with the `/api/settings` branch around line 38): + +```ts + } else if (event.url.pathname.startsWith('/api/settings')) { + rateLimitConfig = RATE_LIMITS.settings; + } else if (event.url.pathname.startsWith('/api/youtube/search')) { + rateLimitConfig = RATE_LIMITS.youtubeSearch; + } +``` + +- [ ] **Step 6: Create the route** + +Create `src/routes/api/youtube/search/+server.ts`: + +```ts +import { json, error } from '@sveltejs/kit'; +import { requireAuth } from '$lib/server/guards'; +import { prisma } from '$lib/server/db'; +import { apiRoute } from '$lib/server/openapi'; +import { + youtubeSearchService, + parseSearchParams, + type SearchResult, +} from '$lib/server/services/youtube-search.service'; +import type { RequestHandler } from './$types'; + +/** + * Attach the caller's existing downloads to video results. + * + * Done here rather than in the service because the service's cache is shared + * across all users — baking per-user state into it would leak one user's + * library into another's results. + */ +async function attachExistingDownloads( + results: SearchResult[], + userId: string, +): Promise { + const ids = results.filter((r) => r.type === 'video').map((r) => r.id); + if (ids.length === 0) return results; + + const existing = await prisma.download.findMany({ + where: { userId, videoId: { in: ids }, status: { notIn: ['DELETED'] } }, + select: { id: true, videoId: true, status: true }, + orderBy: { createdAt: 'desc' }, + }); + + // Most recent wins — findMany is ordered desc, so only set the first hit. + const byVideoId = new Map(); + for (const d of existing) { + if (d.videoId && !byVideoId.has(d.videoId)) { + byVideoId.set(d.videoId, { id: d.id, status: d.status }); + } + } + + return results.map((r) => + r.type === 'video' ? { ...r, existingDownload: byVideoId.get(r.id) ?? null } : r, + ); +} + +export const GET = apiRoute( + '/api/youtube/search', + 'GET', + { + summary: 'Search YouTube', + description: + 'Search YouTube for videos, channels or playlists. Runs anonymously — the ' + + "caller's linked YouTube cookies are never used. Results are cached server-side " + + 'for 15 minutes.', + tags: ['YouTube'], + auth: true, + query: { + q: { type: 'string', required: true, description: 'Search query (max 200 chars)' }, + type: { + type: 'string', + description: 'Result type', + enum: ['video', 'channel', 'playlist'], + default: 'video', + }, + sort: { + type: 'string', + description: 'Sort order', + enum: ['relevance', 'date', 'views', 'rating'], + default: 'relevance', + }, + uploadDate: { + type: 'string', + description: 'Upload recency filter (videos only)', + enum: ['any', 'hour', 'today', 'week', 'month', 'year'], + default: 'any', + }, + duration: { + type: 'string', + description: 'Length filter (videos only)', + enum: ['any', 'short', 'medium', 'long'], + default: 'any', + }, + offset: { type: 'integer', default: 0 }, + limit: { type: 'integer', default: 20, description: 'Max 50' }, + }, + responses: { + 200: { + description: 'Search results', + schema: { + type: 'object', + properties: { + results: { type: 'array', items: { type: 'object' } }, + hasMore: { type: 'boolean' }, + }, + }, + }, + }, + }, + async ({ locals, url }) => { + const userId = requireAuth(locals); + + let opts; + try { + opts = parseSearchParams(url.searchParams); + } catch (e: any) { + throw error(400, e.message); + } + + let response; + try { + response = await youtubeSearchService.search(opts); + } catch (e: any) { + // stderr can carry URLs and internals — log it, never return it. + console.error('YouTube search failed:', e?.message ?? e); + throw error(502, 'YouTube search is unavailable right now. Please try again.'); + } + + return json({ + results: await attachExistingDownloads(response.results, userId), + hasMore: response.hasMore, + }); + }, +) satisfies RequestHandler; +``` + +- [ ] **Step 7: Verify the route compiles and works end to end** + +Run: `npm run check` +Expected: no new errors. + +Then, with the dev stack up (`docker compose -f docker-compose.dev.yml up -d`), sign in through the UI and hit the endpoint in the browser console or via curl with a session cookie: + +``` +/api/youtube/search?q=sourdough&type=video&duration=long&sort=date +``` + +Expected: JSON with ~20 results, every `duration` over 1200, `hasMore: true`, and each video carrying `existingDownload: null` (or a real id if you already have it). A second identical request should return noticeably faster — that is the cache. + +Also confirm the failure path: `?q=` returns **400 "Query is required"**, and `?q=x&type=movie` returns **400 "Invalid type: movie"**. + +- [ ] **Step 8: Commit (ask first)** + +```bash +npm run format +git add src/routes/api/youtube/search/+server.ts src/lib/server/rate-limit.ts src/hooks.server.ts src/lib/server/services/youtube-search.service.ts src/lib/server/services/youtube-search.service.test.ts +git commit -m "feat: add GET /api/youtube/search" +``` + +--- + +### Task 6: Split the search page into tabs + +Pure refactor plus the sidebar link. No change to library-search behaviour — that is the acceptance criterion. + +**Files:** + +- Create: `src/lib/components/search/LibrarySearch.svelte` +- Modify: `src/routes/search/+page.svelte` (currently 590 lines → ~120) +- Modify: `src/lib/components/ui/Sidebar.svelte:32-38` (`libraryItems`) + +**Interfaces:** + +- Consumes: nothing from earlier tasks +- Produces: `LibrarySearch.svelte` (no props); `/search?tab=library|youtube` URL contract that Task 7 plugs into + +- [ ] **Step 1: Move the library search body into its own component** + +Create `src/lib/components/search/LibrarySearch.svelte` containing **everything** currently in `src/routes/search/+page.svelte` except: + +- the `` block (stays on the page) +- the outer `
` wrapper and the `

Search

` heading (both move to the page shell) + +Move the ` + + + Search - wytui + + +
+

Search

+ +
+ + +
+ + {#if tab === 'youtube'} + + {:else} + + {/if} +
+ + +``` + +Check the `.page` and `h1` rules against what the original file used and keep the original values if they differ — the goal is a visually identical Library tab. Also confirm the CSS variable names (`--border`, `--text`, `--text-secondary`, `--accent`) against `src/app.css` and substitute the real ones. + +- [ ] **Step 3: Create a placeholder `YouTubeSearch.svelte` so the page compiles** + +Create `src/lib/components/search/YouTubeSearch.svelte`: + +```svelte +

YouTube search coming in the next task.

+``` + +Task 7 replaces this wholesale. + +- [ ] **Step 4: Add the sidebar entry** + +In `src/lib/components/ui/Sidebar.svelte`, add to `libraryItems` (the `search` icon case already exists at ~line 191, but nothing currently renders it): + +```ts +const libraryItems: NavItem[] = [ + { label: 'Downloads', href: '/downloads', icon: 'download' }, + { label: 'Search', href: '/search', icon: 'search' }, + { label: 'Channels', href: '/channels', icon: 'channel' }, + { label: 'Subscriptions', href: '/subscriptions', icon: 'broadcast' }, + { label: 'Monitors', href: '/monitors', icon: 'eye' }, + { label: 'Playlists', href: '/playlists', icon: 'playlist' }, +]; +``` + +- [ ] **Step 5: Verify the refactor changed nothing** + +Run: `npm run check && npm run test` +Expected: no new errors, all tests pass. + +Then in the browser: open `/search`, confirm the Library tab looks and behaves exactly as before (type a query, results appear after ~300ms, the type/storage/uploader filters still work, the clear button still works). Confirm the sidebar now shows Search and highlights when active. Confirm clicking YouTube puts `?tab=youtube` in the URL and that a reload keeps you on that tab. + +- [ ] **Step 6: Commit (ask first)** + +```bash +npm run format +git add src/routes/search/+page.svelte src/lib/components/search/ src/lib/components/ui/Sidebar.svelte +git commit -m "refactor: split search page into Library/YouTube tabs" +``` + +--- + +### Task 7: The YouTube search UI (read-only) + +Search box, filters, URL state, fetching, and rendering all three result types. Actions come in Task 8. + +**Files:** + +- Modify: `src/lib/utils/format.ts` (add `formatCount`) +- Create: `src/lib/utils/format.test.ts` +- Modify: `src/lib/components/search/YouTubeSearch.svelte` (replace the placeholder) +- Create: `src/lib/components/search/YouTubeVideoResult.svelte` +- Create: `src/lib/components/search/YouTubeChannelResult.svelte` +- Create: `src/lib/components/search/YouTubePlaylistResult.svelte` + +**Interfaces:** + +- Consumes: `GET /api/youtube/search` (Task 5), the tab shell (Task 6) +- Produces: row components taking `{ result, selected?, onToggle? }`; Task 8 adds action props to these same components + +- [ ] **Step 1: Write the failing test for `formatCount`** + +Create `src/lib/utils/format.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { formatCount } from './format'; + +describe('formatCount', () => { + it('leaves values under 1000 alone', () => { + expect(formatCount(0)).toBe('0'); + expect(formatCount(999)).toBe('999'); + }); + + it('abbreviates thousands', () => { + expect(formatCount(1000)).toBe('1K'); + expect(formatCount(1200)).toBe('1.2K'); + expect(formatCount(17500)).toBe('17.5K'); + expect(formatCount(999_000)).toBe('999K'); + }); + + it('abbreviates millions', () => { + expect(formatCount(1_000_000)).toBe('1M'); + expect(formatCount(2_761_758)).toBe('2.8M'); + expect(formatCount(3_542_314)).toBe('3.5M'); + }); + + it('abbreviates billions', () => { + expect(formatCount(1_500_000_000)).toBe('1.5B'); + }); + + it('drops a trailing .0', () => { + expect(formatCount(2_000_000)).toBe('2M'); + expect(formatCount(15_000)).toBe('15K'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/utils/format.test.ts` +Expected: FAIL — `formatCount is not a function` + +- [ ] **Step 3: Implement `formatCount`** + +Append to `src/lib/utils/format.ts`: + +```ts +/** Abbreviate a count the way YouTube does: 2761758 -> "2.8M". */ +export function formatCount(n: number): string { + const units: [number, string][] = [ + [1_000_000_000, 'B'], + [1_000_000, 'M'], + [1_000, 'K'], + ]; + for (const [size, suffix] of units) { + if (n >= size) { + const scaled = n / size; + // One decimal below 100, none above — "2.8M" but "999K". + const text = scaled < 100 ? scaled.toFixed(1) : String(Math.round(scaled)); + return `${text.replace(/\.0$/, '')}${suffix}`; + } + } + return String(n); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/utils/format.test.ts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Create the three row components** + +Create `src/lib/components/search/YouTubeVideoResult.svelte`: + +```svelte + + +
+ onToggle(result.id)} + aria-label="Select {result.title}" + /> + +
+ {#if result.thumbnail} + + {:else} +
+ {/if} + {#if result.duration} + {formatDuration(result.duration)} + {/if} +
+ +
+ + {result.title} + +
+ {#if result.uploader} + + {result.uploader}{#if result.verified}{/if} + + {/if} + {#if result.viewCount} + ·{formatCount(result.viewCount)} views + {/if} +
+ {#if result.description} +

{result.description}

+ {/if} +
+ +
+ {#if result.existingDownload} + ✓ Downloaded + {/if} + +
+
+ + +``` + +Create `src/lib/components/search/YouTubeChannelResult.svelte`: + +```svelte + + +
+ {#if result.thumbnail} + + {:else} +
+ {/if} + +
+ + {result.title.trim()} + + {#if result.subscriberCount} +
{formatCount(result.subscriberCount)} subscribers
+ {/if} + {#if result.description} +

{result.description}

+ {/if} +
+ +
+ +
+
+ + +``` + +Create `src/lib/components/search/YouTubePlaylistResult.svelte`: + +```svelte + + +
+ {#if result.thumbnail} + + {:else} +
+ {/if} + +
+ + {result.title} + + {#if result.uploader} +
{result.uploader}
+ {/if} + +
+ +
+ +
+
+ + +``` + +- [ ] **Step 6: Build the container component** + +Replace `src/lib/components/search/YouTubeSearch.svelte`: + +```svelte + + + + +
+
+ + +
+ +
+ + +
+ + {#if type === 'video'} +
+ + +
+ +
+ + +
+ {/if} +
+ +{#if loading} + +{:else if errorMessage} + +{:else if searched && results.length === 0} + +{:else if results.length > 0} +
+ {#each results as result (result.id)} + {#if result.type === 'video'} + + {:else if result.type === 'channel'} + + {:else} + + {/if} + {/each} +
+ + {#if hasMore} +
+ +
+ {/if} +{:else if query} + + +{:else} + +{/if} + + +``` + +`Skeleton` takes `count` and `variant` (`'card' | 'row' | 'text' | 'list' | 'grid' | 'table-row'`) and renders its own repetition — do not wrap it in an `{#each}`. `EmptyState` takes `title` and optional `description`. Both are confirmed against the current components. + +- [ ] **Step 7: Verify** + +Run: `npm run check && npm run test` +Expected: no new errors, all tests pass. + +In the browser at `/search?tab=youtube`: + +- Typing does **not** fire a request (watch the network tab) — only Enter or the Search button does. +- Searching "sourdough" returns ~20 video rows with thumbnails, durations, view counts and verified ticks. +- Switching Type to Channels shows round avatars and subscriber counts, and hides the Uploaded/Length filters. +- Switching Type to Playlists shows playlist rows with no video count. +- Changing Sort/Uploaded/Length re-searches immediately. +- The URL tracks every filter; reloading restores the query in the box (unsubmitted) and the filters. +- Load more appends a second page. +- Checkboxes toggle and highlight rows. +- A video you have already downloaded shows the "✓ Downloaded" link. + +- [ ] **Step 8: Commit (ask first)** + +```bash +npm run format +git add src/lib/utils/format.ts src/lib/utils/format.test.ts src/lib/components/search/ +git commit -m "feat: add YouTube search UI with filters and pagination" +``` + +--- + +### Task 8: Wire up the actions + +Adds the profile picker, per-row download, the batch selection bar, Subscribe, and Import. + +**Files:** + +- Modify: `src/lib/components/search/YouTubeSearch.svelte` +- Modify: `src/lib/components/search/YouTubeVideoResult.svelte` +- Modify: `src/lib/components/search/YouTubeChannelResult.svelte` +- Modify: `src/lib/components/search/YouTubePlaylistResult.svelte` + +**Interfaces:** + +- Consumes: everything from Task 7; `GET /api/profiles`, `POST /api/downloads/quick`, `POST /api/downloads/batch`, `POST /api/subscriptions`, `POST /api/playlists/import` +- Produces: nothing downstream — this is the last task + +- [ ] **Step 1: Add the profile picker and action handlers to the container** + +In `src/lib/components/search/YouTubeSearch.svelte`, add to the imports: + +```ts +import { csrfFetch } from '$lib/utils/fetch'; +``` + +Add state next to the existing declarations: + +```ts + interface Profile { + id: string; + name: string; + isDefault?: boolean; + } + + let profiles = $state([]); + let profileId = $state(''); + let saveToLibrary = $state(false); + let busyIds = $state(new Set()); + let batchBusy = $state(false); + + // Loaded once, when the YouTube tab first mounts. + $effect(() => { + void loadProfiles(); + }); + + async function loadProfiles() { + try { + const res = await fetch('/api/profiles'); + if (!res.ok) return; + profiles = await res.json(); + profileId = (profiles.find((p) => p.isDefault) ?? profiles[0])?.id ?? ''; + } catch { + // Non-fatal: the action buttons stay disabled and explain why. + } + } + + function setBusy(id: string, on: boolean) { + const next = new Set(busyIds); + on ? next.add(id) : next.delete(id); + busyIds = next; + } + + async function downloadOne(result: any) { + if (!profileId) return; + setBusy(result.id, true); + try { + const res = await csrfFetch('/api/downloads/quick', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: result.url, profileId, saveToLibrary }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error || body?.message || 'Download failed'); + } + const download = await res.json(); + addToast('success', `Queued "${result.title}"`); + // Reflect it immediately so the row flips to the Downloaded link. + results = results.map((r) => + r.id === result.id + ? { ...r, existingDownload: { id: download.id, status: download.status } } + : r, + ); + } catch (e: any) { + addToast('error', e.message || 'Download failed'); + } finally { + setBusy(result.id, false); + } + } + + async function downloadSelected() { + if (!profileId || selected.size === 0) return; + const chosen = results.filter((r) => selected.has(r.id)); + batchBusy = true; + try { + const res = await csrfFetch('/api/downloads/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + urls: chosen.map((r) => r.url), + profileId, + saveToLibrary, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.message || 'Batch download failed'); + } + const out = await res.json(); + // addToast only accepts 'success' | 'error' | 'info' — no 'warning'. + if (out.failed > 0) { + addToast('info', `Queued ${out.succeeded}, ${out.failed} failed`); + } else { + addToast('success', `Queued ${out.succeeded} video${out.succeeded === 1 ? '' : 's'}`); + } + selected = new Set(); + } catch (e: any) { + addToast('error', e.message || 'Batch download failed'); + } finally { + batchBusy = false; + } + } + + async function subscribe(result: any) { + if (!profileId) return; + setBusy(result.id, true); + try { + const res = await csrfFetch('/api/subscriptions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: result.url, + name: result.title.trim(), + profileId, + type: 'CHANNEL', + saveToLibrary, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.message || 'Failed to subscribe'); + } + addToast('success', `Subscribed to ${result.title.trim()}`); + } catch (e: any) { + addToast('error', e.message || 'Failed to subscribe'); + } finally { + setBusy(result.id, false); + } + } + + async function importPlaylist(result: any) { + if (!profileId) return; + setBusy(result.id, true); + try { + const res = await csrfFetch('/api/playlists/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: result.url, profileId, saveToLibrary }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.message || 'Failed to import playlist'); + } + addToast('success', `Importing "${result.title}"`); + } catch (e: any) { + addToast('error', e.message || 'Failed to import playlist'); + } finally { + setBusy(result.id, false); + } + } + +``` + +- [ ] **Step 2: Add the profile picker to the filter bar** + +Inside `.filter-bar` in `YouTubeSearch.svelte`, after the last filter group: + +```svelte +
+ + +
+``` + +And add the style rule: + +```css +.filter-group-end { + margin-left: auto; +} +``` + +Below the `.filter-bar` div, add the no-profile warning: + +```svelte +{#if profiles.length === 0} +

+ No download profile found — create one in Settings before downloading. +

+{/if} +``` + +```css +.no-profile { + margin: -0.5rem 0 1rem; + font-size: 0.83rem; + color: var(--warning, #b45309); +} +``` + +- [ ] **Step 3: Pass the handlers into the row components** + +Update the `{#each}` block in `YouTubeSearch.svelte`: + +```svelte +{#each results as result (result.id)} + {#if result.type === 'video'} + + {:else if result.type === 'channel'} + + {:else} + + {/if} +{/each} +``` + +- [ ] **Step 4: Add the sticky selection bar** + +At the very end of the markup in `YouTubeSearch.svelte`, after the results block: + +```svelte +{#if selected.size > 0} +
+ {selected.size} selected + + + +
+{/if} +``` + +```css +.selection-bar { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + margin-top: 0.5rem; + border-top: 1px solid var(--border); + background: var(--bg); + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.08); +} +.selection-count { + font-weight: 500; +} +.btn-link { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font-size: 0.85rem; +} +.library-toggle { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + color: var(--text-secondary); +} +.btn-primary { + padding: 0.5rem 1.1rem; + border: none; + border-radius: 8px; + background: var(--accent); + color: #fff; + font-weight: 500; + cursor: pointer; +} +.btn-primary:disabled { + opacity: 0.55; + cursor: not-allowed; +} +``` + +- [ ] **Step 5: Add the buttons to the row components** + +In `YouTubeVideoResult.svelte`, extend `Props` and the actions block: + +```ts +interface Props { + result: VideoResult; + selected: boolean; + onToggle: (id: string) => void; + onDownload: (result: VideoResult) => void; + busy: boolean; + disabled: boolean; +} + +let { result, selected, onToggle, onDownload, busy, disabled }: Props = $props(); +``` + +```svelte +
+ {#if result.existingDownload} + ✓ Downloaded + {:else} + + {/if} +
+``` + +```css +.btn-row { + padding: 0.35rem 0.7rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text); + font-size: 0.82rem; + white-space: nowrap; + cursor: pointer; +} +.btn-row:hover:not(:disabled) { + background: var(--bg-hover); +} +.btn-row:disabled { + opacity: 0.55; + cursor: not-allowed; +} +``` + +In `YouTubeChannelResult.svelte`, the same pattern with `onSubscribe` and label `+ Subscribe`. In `YouTubePlaylistResult.svelte`, `onImport` and label `+ Import`. Copy the `.btn-row` CSS into both — Svelte scopes styles per component, so it does not carry over. + +Note the deliberate asymmetry: an already-downloaded **video** hides its button, but channels and playlists always show theirs. wytui has no cheap way to know whether you are already subscribed to a channel from within the search response, and re-importing a playlist is a valid way to pick up new entries. + +- [ ] **Step 6: Verify** + +Run: `npm run check && npm run test` +Expected: no new errors, all tests pass. + +In the browser at `/search?tab=youtube`: + +- The Profile dropdown is populated and preselects your default profile. +- Clicking Download on one row queues it, toasts, and flips the row to "✓ Downloaded" — check `/downloads` to confirm the job appears. +- Checking three rows shows the sticky bar; "Download 3" queues all three and toasts with the count. +- The Save to library toggle actually routes to the library pool — verify `storagePool` on the created download. +- Switch to Channels, click Subscribe, then check `/subscriptions` for the new entry. +- Switch to Playlists, click Import, then check `/playlists`. +- Temporarily rename your only profile away / test with no profiles to confirm the warning shows and the buttons disable rather than erroring. + +- [ ] **Step 7: Commit (ask first)** + +```bash +npm run format +git add src/lib/components/search/ +git commit -m "feat: download, subscribe and import from YouTube search results" +``` + +--- + +## Verification Checklist + +Run once the whole plan is done: + +- [ ] `npm run test` — all vitest suites pass +- [ ] `npm run check` — no new svelte-check errors +- [ ] `npm run format:check` — clean +- [ ] `/search` Library tab behaves exactly as it did before Task 6 +- [ ] `/api/youtube/search` appears in the OpenAPI docs at `/docs` +- [ ] Searching does not send `--cookies`: `docker exec wytui-app-1 ps aux | grep yt-dlp` during a search shows no cookie flag +- [ ] A repeat search is served from cache (visibly faster, no new yt-dlp process) diff --git a/docs/superpowers/specs/2026-07-24-youtube-search-design.md b/docs/superpowers/specs/2026-07-24-youtube-search-design.md new file mode 100644 index 0000000..06ab701 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-youtube-search-design.md @@ -0,0 +1,412 @@ +# YouTube Search — Design + +**Date:** 2026-07-24 +**Status:** Approved, ready for implementation planning + +## Problem + +wytui can download any YouTube URL you paste, but you have to find that URL +elsewhere first. Users should be able to search YouTube from inside wytui and +act on the results directly — download videos, subscribe to channels, import +playlists. + +An existing `/search` page already exists, but it searches the _local library_ +(the `Download` table via `searchService`). It is reachable only by keyboard +shortcut (`src/lib/stores/keyboard.svelte.ts:38`) and is absent from the +sidebar. + +## Decisions + +| Decision | Choice | Rationale | +| ------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Placement | Tabs on `/search`: **Library** \| **YouTube** | One mental model for "search"; reuses the page shell | +| Result types | Videos, channels, playlists (separate tabs) | An "All" tab is not viable — see Constraints | +| Auth | **Always anonymous** — never pass cookies | Verified unnecessary; keeps the linked session unspent and lets one cache serve all users | +| Filters | Type + sort + upload date + duration | Built with a protobuf `sp=` encoder | +| Download UX | Per-row one-click **and** multi-select batch bar | Covers the impulse case and the bulk case | +| Also in scope | "Already downloaded" badge, Load more, server-side cache, rate limiting | All four confirmed in scope | + +## Verified Constraints + +All of the following were confirmed against **yt-dlp 2026.07.04** running in the +`wytui-app-1` container, cookie-less, on 2026-07-24. + +| Behaviour | Result | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `ytsearch3:query` | Works; returns `view_count`, `description`, `duration`, `channel_is_verified` | +| `results?search_query=X&sp=EgIQAg==` (channels) | Works; `ie_key: "YoutubeTab"`, `channel_follower_count` present | +| `results?search_query=X&sp=EgIQAw==` (playlists) | Works; `ie_key: "YoutubeTab"`, id prefixed `PL`, url is `playlist?list=…` | +| Unfiltered `results?search_query=X` | Returned **20/20 videos** — no channels or playlists interleaved | +| `--playlist-start 21 --playlist-end 26` | Works; took **7.4s** (re-walks continuations each call) | +| `sp=CAESBggDEAEYAg==` (date sort + this week + video + >20min) | Works; all 5 results >20 min | +| `sp=CAASAhAB` (explicit `sort=0`) vs `sp=EgIQAQ==` (omitted) | Both accepted, near-identical results | + +Three consequences that shape the design: + +1. **No "All" tab.** An unfiltered search returns only videos, so an "All" tab + would silently be a Videos tab. Type is a three-way choice defaulting to + Videos. +2. **No playlist video count.** Flat output does not carry it. Showing "24 + videos" would require a second yt-dlp invocation per playlist result. The + field is omitted rather than faked. +3. **Deep pagination gets slower.** Each page re-walks continuations + server-side. Acceptable for a "Load more" button; the cache mitigates repeat + views of the same page. + +Additionally, channel thumbnails come back **protocol-relative** +(`//yt3.ggpht.com/…`) and must be prefixed with `https:` in the parser. + +## Architecture + +Three new server files, one extraction: + +| File | Purpose | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/lib/server/utils/ytdlp-json.ts` | **Extracted** from `YouTubeService`'s private `runYtdlpJson`: spawn + timeout + settled-guard. Gains a `timeoutMs` option (default `120_000`). | +| `src/lib/server/services/youtube-search.service.ts` | `buildSearchParam`, `search`, three parsers, bounded cache | +| `src/routes/api/youtube/search/+server.ts` | `GET` handler, auth-required, registered via `apiRoute()` | + +Search lives in its own service rather than inside `youtube.service.ts` because +that file is entirely concerned with _the linked user's account_ — cookies, +subscriptions, watch-later, history, mark-watched. Search is anonymous and +shares nothing with it except the process runner, which is exactly what gets +extracted. `youtube.service.ts` is already 305 lines; the extraction leaves it +smaller and more focused, and is scoped strictly to code the new feature needs. + +### Data flow + +``` +filters ──► buildSearchParam() ──► sp=… + │ +query ──────────────────────────────┤ + ▼ + https://www.youtube.com/results?search_query=…&sp=… + │ + cache lookup ──hit──► results + │ miss + ▼ + yt-dlp --flat-playlist --dump-single-json --no-warnings + --playlist-start N --playlist-end M (no --cookies) + │ + ▼ + parser (by requested type) + │ + (videos only) ▼ + prisma.download.findMany({ videoId: { in: [...] } }) + │ + ▼ + { results, hasMore } ──► cache write +``` + +### Service interface + +```ts +interface SearchOptions { + query: string; + type: SearchResultType; // default 'video' + sort: 'relevance' | 'date' | 'views' | 'rating'; + uploadDate: 'any' | 'hour' | 'today' | 'week' | 'month' | 'year'; + duration: 'any' | 'short' | 'medium' | 'long'; + offset: number; + limit: number; +} + +search(opts: SearchOptions): Promise<{ results: SearchResult[]; hasMore: boolean }> +``` + +`existingDownload` is **not** attached by the service — it is per-user, and the +cache is shared across users. The route handler attaches it after the service +returns, so cached entries stay user-neutral. + +**yt-dlp playlist indices are 1-based and inclusive.** The mapping is +`--playlist-start (offset + 1)` and `--playlist-end (offset + limit)`. So +`offset=0, limit=20` → `--playlist-start 1 --playlist-end 20`; `offset=20` → +`--playlist-start 21 --playlist-end 40`. + +### The `sp` parameter builder + +`buildSearchParam(opts)` is a pure function returning a base64 string. It +encodes a small protobuf message with a ~30-line varint encoder: + +``` +field 1 (varint) sort: 0 relevance | 1 date | 2 views | 3 rating +field 2 (message) filters: + field 1 (varint) uploadDate: 1 hour | 2 today | 3 week | 4 month | 5 year + field 2 (varint) type: 1 video | 2 channel | 3 playlist + field 3 (varint) duration: 1 short (<4m) | 2 long (>20m) | 3 medium (4-20m) +``` + +Fields at their default value (`sort = relevance`, `uploadDate = any`, +`duration = any`) are **omitted**, per standard protobuf encoding. `type` is +always emitted. Both encodings of the relevance case were verified to work; the +omitting form is chosen for simplicity. + +Note the duration ordinals are not in size order: `2` is _long_ and `3` is +_medium_. This is YouTube's numbering, not a typo. + +### Cache + +Keyed by `type|sort|uploadDate|duration|offset|query`, TTL **15 minutes**. + +Unlike the existing playlist cache in `youtube.service.ts` — which is keyed by +`userId` and therefore naturally bounded — the search query space is unbounded. +This cache must have a **hard entry cap of 200 entries, evicting in insertion +order** (a JS `Map` preserves insertion order, so evicting `keys().next()` is +sufficient — no LRU bookkeeping needed) to avoid unbounded memory growth. +Because search is anonymous and `existingDownload` is attached downstream, one +cache serves every user. + +## Result Types + +```ts +type SearchResultType = 'video' | 'channel' | 'playlist'; + +interface VideoResult { + type: 'video'; + id: string; // 11-char video id + title: string; + url: string; + uploader?: string; + channelId?: string; + channelUrl?: string; + thumbnail?: string; + duration?: number; // seconds + viewCount?: number; + verified?: boolean; // channel_is_verified + description?: string; // truncated snippet from YouTube + existingDownload: { id: string; status: string } | null; +} + +interface ChannelResult { + type: 'channel'; + id: string; // UC… channel id + title: string; + url: string; + thumbnail?: string; // https: prefix applied + subscriberCount?: number; // channel_follower_count + description?: string; +} + +interface PlaylistResult { + type: 'playlist'; + id: string; // PL… playlist id + title: string; + url: string; + thumbnail?: string; + uploader?: string; + channelId?: string; +} +``` + +Entries are discriminated by `ie_key` plus URL shape: `Youtube` → video; +`YoutubeTab` with `/channel/` → channel; `YoutubeTab` with `/playlist?list=` → +playlist. Entries that match none are skipped. + +## API + +``` +GET /api/youtube/search + ?q= (required) + &type=video|channel|playlist (default video) + &sort=relevance|date|views|rating (default relevance) + &uploadDate=any|hour|today|week|month|year (default any) + &duration=any|short|medium|long (default any) + &offset= (default 0) + &limit= (default 20, max 50) + +200 → { results: SearchResult[], hasMore: boolean } +``` + +Auth required via `requireAuth(locals)`. Registered through `apiRoute()` so it +appears in the OpenAPI docs alongside the rest of the API. + +For `type=video`, `existingDownload` is populated by a **single** batched query +per page: + +```ts +prisma.download.findMany({ + where: { + userId, + videoId: { in: ids }, + status: { notIn: ['DELETED'] }, + }, + select: { id: true, videoId: true, status: true }, +}); +``` + +`videoId` is already indexed. Channel and playlist results carry no +`existingDownload`. + +`hasMore` is computed from the **raw entry count** yt-dlp returned, not from +`results.length` — parsers skip unrecognised entries, so a page that yielded 18 +parsed results from 20 raw entries still has more available. YouTube gives no +total count, so `rawEntryCount === limit` is the only signal. + +### Rate limiting + +Add to `RATE_LIMITS` in `src/lib/server/rate-limit.ts`: + +```ts +youtubeSearch: { windowMs: 60 * 1000, maxRequests: 120 }, +``` + +and an `else if` branch for `/api/youtube/search` in the existing chain in +`src/hooks.server.ts` (~line 33). + +120/min fits the existing buckets (`downloads` 200, `settings` 100, `general` 500) rather than sitting far below them. It matters that **cache hits still +count against the budget** — `handle` runs before the route handler and cannot +know whether a request will reach yt-dlp — so the limit has to accommodate +filter-fiddling and tab-switching, which are mostly cache hits. The purpose here +is to stop a runaway client, not to meter normal use. + +## UI + +### Page structure + +`src/routes/search/+page.svelte` is 590 lines today. Adding a second search mode +inline would push it past 1200. Instead the page becomes a thin shell (tab +switcher + shared header), with bodies extracted to: + +- `src/lib/components/search/LibrarySearch.svelte` — existing code, moved as-is +- `src/lib/components/search/YouTubeSearch.svelte` — new +- `src/lib/components/search/YouTubeVideoResult.svelte` +- `src/lib/components/search/YouTubeChannelResult.svelte` +- `src/lib/components/search/YouTubePlaylistResult.svelte` + +State lives in the URL (`?tab=youtube&q=…&type=…&sort=…&uploadDate=…&duration=…`) +so searches are linkable and survive reload. + +``` +┌───────────────────────────────────────────────────────────┐ +│ Search │ +│ ┌─────────┬─────────┐ │ +│ │ Library │ YouTube │ │ +│ └─────────┴─────────┘ │ +│ 🔍 [ sourdough ] [Search] │ +│ │ +│ Type[Videos▾] Sort[Relevance▾] Uploaded[Any▾] │ +│ Length[Any▾] Profile[Best▾] │ +├───────────────────────────────────────────────────────────┤ +│ ☐ ┌────┐ A Very Boring Tutorial on Sourdough │ +│ │thmb│ Serena Neel ✓ · 36:03 · 1.2M views [↓ Download] │ +│ └────┘ │ +│ ☑ ┌────┐ Amazing Sourdough Bread Recipe │ +│ │thmb│ Preppy Kitchen ✓ · 16:50 · 3.5M [↓ Download] │ +│ └────┘ │ +│ ┌────┐ Easy Sourdough for Beginners │ +│ │thmb│ Bread Guy · 21:12 ✓ Downloaded → view │ +│ └────┘ │ +│ [ Load more ] │ +╞═══════════════════════════════════════════════════════════╡ +│ 1 selected ☐ Save to library [Download 1] │ +└───────────────────────────────────────────────────────────┘ +``` + +### Interaction rules + +**Explicit submit, not debounce.** The Library tab debounces at 300ms because it +queries Postgres. Each YouTube search spawns a yt-dlp process taking 3–7s, so +the YouTube tab searches on Enter or button click, and on filter change when a +query is already present — never per keystroke. + +**One page-level profile picker.** Per-row download, batch download, Subscribe +and Import all need a `profileId`. A single ` + + +
- -
- - -
+ +
+
+ Save to Library + Skip cache and save permanently +
+
+
- -
-
- Save to Library - Skip cache and save permanently -
-
-
+
+ +
-
- -
+
+ + -
- - + +
+
+ + +
+
+ + +
+
+ +
- -
-
- - -
-
- - -
-
- -
-
+
- - +
+ + +
+
+
+ + + diff --git a/extension/popup.js b/extension/popup.js index 42a64f3..136f9ac 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -40,387 +40,430 @@ let deleteArmed = false; let deleteArmTimer = null; // Show the running extension version (read from manifest so it can't drift) -try { versionText.textContent = 'v' + chrome.runtime.getManifest().version; } catch {} +try { + versionText.textContent = 'v' + chrome.runtime.getManifest().version; +} catch {} const downloadTabBtn = document.querySelector('.tab-btn[data-tab="download"]'); function setConfigured(configured) { - downloadTabBtn.disabled = !configured; - downloadTabBtn.title = configured ? '' : 'Configure wytui first'; + downloadTabBtn.disabled = !configured; + downloadTabBtn.title = configured ? '' : 'Configure wytui first'; } // Tab switching document.querySelectorAll('.tab-btn').forEach((btn) => { - btn.addEventListener('click', () => { - if (btn.disabled) return; - document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active')); - document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active')); - btn.classList.add('active'); - document.getElementById('panel-' + btn.dataset.tab).classList.add('active'); - }); + btn.addEventListener('click', () => { + if (btn.disabled) return; + document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active')); + document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active')); + btn.classList.add('active'); + document.getElementById('panel-' + btn.dataset.tab).classList.add('active'); + }); }); // Library toggle libraryToggleRow.addEventListener('click', () => { - saveToLibrary = !saveToLibrary; - libraryToggle.classList.toggle('on', saveToLibrary); + saveToLibrary = !saveToLibrary; + libraryToggle.classList.toggle('on', saveToLibrary); }); // Init: load settings, current tab, profiles chrome.storage.local.get(['serverUrl', 'apiKey'], async (data) => { - if (data.serverUrl) serverUrlInput.value = data.serverUrl; - if (data.apiKey) apiKeyInput.value = data.apiKey; - - serverUrl = data.serverUrl || ''; - - // If no server URL at all, go straight to settings - if (!data.serverUrl) { - updateStatus('unconfigured'); - setConfigured(false); - switchToSettings(); - return; - } - - // Get current tab (do this while checking auth) - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tab?.url) { - currentTabUrl = tab.url; - urlText.textContent = formatUrl(tab.url); - if (tab.favIconUrl) { - urlFavicon.src = tab.favIconUrl; - urlFavicon.style.display = ''; - } else { - urlFavicon.style.display = 'none'; - } - } - - // Verify auth (API key or cookie session) and load profiles - updateStatus('checking'); - const profileResult = await chrome.runtime.sendMessage({ action: 'fetchProfiles' }); - if (!profileResult?.success) { - updateStatus(data.apiKey ? 'key-invalid' : 'error'); - setConfigured(false); - switchToSettings(); - return; - } - - updateStatus(statusFromResult(profileResult), profileResult.email); - setConfigured(true); - applyLibraryVisibility(profileResult); - populateProfiles(profileResult); - if (currentTabUrl) lookupExisting(currentTabUrl); + if (data.serverUrl) serverUrlInput.value = data.serverUrl; + if (data.apiKey) apiKeyInput.value = data.apiKey; + + serverUrl = data.serverUrl || ''; + + // If no server URL at all, go straight to settings + if (!data.serverUrl) { + updateStatus('unconfigured'); + setConfigured(false); + switchToSettings(); + return; + } + + // Get current tab (do this while checking auth) + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab?.url) { + currentTabUrl = tab.url; + urlText.textContent = formatUrl(tab.url); + if (tab.favIconUrl) { + urlFavicon.src = tab.favIconUrl; + urlFavicon.style.display = ''; + } else { + urlFavicon.style.display = 'none'; + } + } + + // Verify auth (API key or cookie session) and load profiles + updateStatus('checking'); + const profileResult = await chrome.runtime.sendMessage({ action: 'fetchProfiles' }); + if (!profileResult?.success) { + updateStatus(data.apiKey ? 'key-invalid' : 'error'); + setConfigured(false); + switchToSettings(); + return; + } + + updateStatus(statusFromResult(profileResult), profileResult.email); + setConfigured(true); + applyLibraryVisibility(profileResult); + populateProfiles(profileResult); + if (currentTabUrl) lookupExisting(currentTabUrl); }); function switchToSettings() { - document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active')); - document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active')); - document.querySelector('.tab-btn[data-tab="settings"]').classList.add('active'); - document.getElementById('panel-settings').classList.add('active'); + document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active')); + document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active')); + document.querySelector('.tab-btn[data-tab="settings"]').classList.add('active'); + document.getElementById('panel-settings').classList.add('active'); } // Open in wytui (opens downloads page) openWytuiBtn.addEventListener('click', () => { - if (serverUrl) chrome.tabs.create({ url: serverUrl.replace(/\/+$/, '') + '/downloads' }); + if (serverUrl) chrome.tabs.create({ url: serverUrl.replace(/\/+$/, '') + '/downloads' }); }); function formatUrl(url) { - try { - const u = new URL(url); - const path = u.pathname === '/' ? '' : u.pathname; - const query = u.search; - const full = u.hostname + path + query; - return full.length > 50 ? full.slice(0, 48) + '…' : full; - } catch { - return url; - } + try { + const u = new URL(url); + const path = u.pathname === '/' ? '' : u.pathname; + const query = u.search; + const full = u.hostname + path + query; + return full.length > 50 ? full.slice(0, 48) + '…' : full; + } catch { + return url; + } } function updateStatus(state, detail) { - const states = { - unconfigured: ['status-dot', 'Not configured'], - checking: ['status-dot checking', 'Checking…'], - 'connected-key': ['status-dot connected', 'Connected · API key'], - 'connected-session': ['status-dot connected', 'Connected · Session'], - 'connected-proxy': ['status-dot connected', 'Connected · Proxy'], - 'key-invalid': ['status-dot error', 'API key invalid'], - error: ['status-dot error', 'Auth failed'], - }; - const [cls, label] = states[state] || states.unconfigured; - statusDot.className = cls; - statusText.textContent = detail ? `${label} · ${detail}` : label; - const isConnected = state.startsWith('connected-'); - downloadBtn.disabled = !isConnected; + const states = { + unconfigured: ['status-dot', 'Not configured'], + checking: ['status-dot checking', 'Checking…'], + 'connected-key': ['status-dot connected', 'Connected · API key'], + 'connected-session': ['status-dot connected', 'Connected · Session'], + 'connected-proxy': ['status-dot connected', 'Connected · Proxy'], + 'key-invalid': ['status-dot error', 'API key invalid'], + error: ['status-dot error', 'Auth failed'], + }; + const [cls, label] = states[state] || states.unconfigured; + statusDot.className = cls; + statusText.textContent = detail ? `${label} · ${detail}` : label; + const isConnected = state.startsWith('connected-'); + downloadBtn.disabled = !isConnected; } // Map a successful fetchProfiles result to the status the server actually used. // If an API key is stored but the server did NOT authenticate via it, the key is // invalid — surface that instead of a misleading "connected" state. function statusFromResult(result) { - if (result.hasApiKey && result.authMethod !== 'apikey') return 'key-invalid'; - if (result.authMethod === 'apikey') return 'connected-key'; - if (result.authMethod === 'proxy') return 'connected-proxy'; - return 'connected-session'; + if (result.hasApiKey && result.authMethod !== 'apikey') return 'key-invalid'; + if (result.authMethod === 'apikey') return 'connected-key'; + if (result.authMethod === 'proxy') return 'connected-proxy'; + return 'connected-session'; } const STATUS_MAP = { - COMPLETED: ['completed', 'Completed'], - PENDING: ['pending', 'Pending'], - DOWNLOADING: ['downloading', 'Downloading'], - FETCHING_INFO: ['downloading', 'Fetching info'], - PROCESSING: ['downloading', 'Processing'], - FAILED: ['failed', 'Failed'], - CANCELLED: ['failed', 'Cancelled'], + COMPLETED: ['completed', 'Completed'], + PENDING: ['pending', 'Pending'], + DOWNLOADING: ['downloading', 'Downloading'], + FETCHING_INFO: ['downloading', 'Fetching info'], + PROCESSING: ['downloading', 'Processing'], + FAILED: ['failed', 'Failed'], + CANCELLED: ['failed', 'Cancelled'], }; // Human label for a copy's format/profile (e.g. "1080p · MP4", "Audio · MP3") function copyFormatLabel(c) { - const p = c.profile; - let detail = ''; - if (p?.audioOnly) { - detail = p.audioFormat ? p.audioFormat.toUpperCase() : 'Audio'; - } else { - detail = p?.quality || (c.height ? c.height + 'p' : (c.format || '')); - } - const pool = c.storagePool === 'library' ? ' · Library' : ''; - const name = p?.name || ''; - if (name && detail) return `${name} · ${detail}${pool}`; - return (name || detail || '—') + pool; + const p = c.profile; + let detail = ''; + if (p?.audioOnly) { + detail = p.audioFormat ? p.audioFormat.toUpperCase() : 'Audio'; + } else { + detail = p?.quality || (c.height ? c.height + 'p' : c.format || ''); + } + const pool = c.storagePool === 'library' ? ' · Library' : ''; + const name = p?.name || ''; + if (name && detail) return `${name} · ${detail}${pool}`; + return (name || detail || '—') + pool; } async function lookupExisting(url) { - let result; - try { - result = await chrome.runtime.sendMessage({ action: 'lookupUrl', url }); - } catch (err) { - console.error('[wytui] sendMessage failed:', err); - return; - } - - existingCopies = (result?.success && Array.isArray(result.downloads)) ? result.downloads : []; - copyIndex = 0; - disarmDelete(); - - if (!existingCopies.length) { - existingWrap.classList.remove('visible'); - setDownloadButtonLabel('Download'); - return; - } - - // Header: title + channel shown once (first copy with a title wins) - const named = existingCopies.find((d) => d.title) || existingCopies[0]; - existingTitle.textContent = named.title || url; - if (named.uploader) { - existingChannel.textContent = named.uploader; - existingChannel.style.display = ''; - } else { - existingChannel.style.display = 'none'; - } - - renderCopy(); - existingWrap.classList.add('visible'); - setDownloadButtonLabel('Download another format'); + let result; + try { + result = await chrome.runtime.sendMessage({ action: 'lookupUrl', url }); + } catch (err) { + console.error('[wytui] sendMessage failed:', err); + return; + } + + existingCopies = result?.success && Array.isArray(result.downloads) ? result.downloads : []; + copyIndex = 0; + disarmDelete(); + + if (!existingCopies.length) { + existingWrap.classList.remove('visible'); + setDownloadButtonLabel('Download'); + return; + } + + // Header: title + channel shown once (first copy with a title wins) + const named = existingCopies.find((d) => d.title) || existingCopies[0]; + existingTitle.textContent = named.title || url; + if (named.uploader) { + existingChannel.textContent = named.uploader; + existingChannel.style.display = ''; + } else { + existingChannel.style.display = 'none'; + } + + renderCopy(); + existingWrap.classList.add('visible'); + setDownloadButtonLabel('Download another format'); } function renderCopy() { - if (!existingCopies.length) return; - if (copyIndex >= existingCopies.length) copyIndex = existingCopies.length - 1; - if (copyIndex < 0) copyIndex = 0; - disarmDelete(); - - const c = existingCopies[copyIndex]; - const [cls, label] = STATUS_MAP[c.status] || ['other', c.status]; - existingStatus.className = 'existing-status ' + cls; - existingStatus.textContent = label; - existingFormat.textContent = copyFormatLabel(c); - existingViewLink.href = serverUrl.replace(/\/+$/, '') + '/downloads/' + c.id; - - const multi = existingCopies.length > 1; - copyCounter.textContent = multi ? `${copyIndex + 1} / ${existingCopies.length}` : ''; - copyPrev.classList.toggle('hidden', !multi); - copyNext.classList.toggle('hidden', !multi); - copyPrev.disabled = copyIndex === 0; - copyNext.disabled = copyIndex === existingCopies.length - 1; + if (!existingCopies.length) return; + if (copyIndex >= existingCopies.length) copyIndex = existingCopies.length - 1; + if (copyIndex < 0) copyIndex = 0; + disarmDelete(); + + const c = existingCopies[copyIndex]; + const [cls, label] = STATUS_MAP[c.status] || ['other', c.status]; + existingStatus.className = 'existing-status ' + cls; + existingStatus.textContent = label; + existingFormat.textContent = copyFormatLabel(c); + existingViewLink.href = serverUrl.replace(/\/+$/, '') + '/downloads/' + c.id; + + const multi = existingCopies.length > 1; + copyCounter.textContent = multi ? `${copyIndex + 1} / ${existingCopies.length}` : ''; + copyPrev.classList.toggle('hidden', !multi); + copyNext.classList.toggle('hidden', !multi); + copyPrev.disabled = copyIndex === 0; + copyNext.disabled = copyIndex === existingCopies.length - 1; } -copyPrev.addEventListener('click', () => { if (copyIndex > 0) { copyIndex--; renderCopy(); } }); -copyNext.addEventListener('click', () => { if (copyIndex < existingCopies.length - 1) { copyIndex++; renderCopy(); } }); +copyPrev.addEventListener('click', () => { + if (copyIndex > 0) { + copyIndex--; + renderCopy(); + } +}); +copyNext.addEventListener('click', () => { + if (copyIndex < existingCopies.length - 1) { + copyIndex++; + renderCopy(); + } +}); function disarmDelete() { - deleteArmed = false; - if (deleteArmTimer) { clearTimeout(deleteArmTimer); deleteArmTimer = null; } - copyDeleteBtn.textContent = 'Delete'; - copyDeleteBtn.disabled = false; + deleteArmed = false; + if (deleteArmTimer) { + clearTimeout(deleteArmTimer); + deleteArmTimer = null; + } + copyDeleteBtn.textContent = 'Delete'; + copyDeleteBtn.disabled = false; } // Two-step delete (avoids confirm() dismissing the popup) copyDeleteBtn.addEventListener('click', async () => { - if (!existingCopies.length) return; - - if (!deleteArmed) { - deleteArmed = true; - copyDeleteBtn.textContent = 'Confirm delete?'; - deleteArmTimer = setTimeout(disarmDelete, 3000); - return; - } - - if (deleteArmTimer) { clearTimeout(deleteArmTimer); deleteArmTimer = null; } - deleteArmed = false; - const target = existingCopies[copyIndex]; - copyDeleteBtn.disabled = true; - copyDeleteBtn.textContent = 'Deleting…'; - - const res = await chrome.runtime.sendMessage({ action: 'deleteDownload', id: target.id }); - if (!res?.success) { - showMessage(res?.error || 'Failed to delete.', 'error'); - disarmDelete(); - return; - } - - existingCopies.splice(copyIndex, 1); - if (!existingCopies.length) { - existingWrap.classList.remove('visible'); - setDownloadButtonLabel('Download'); - showMessage('Deleted.', 'success'); - return; - } - renderCopy(); - showMessage('Deleted.', 'success'); + if (!existingCopies.length) return; + + if (!deleteArmed) { + deleteArmed = true; + copyDeleteBtn.textContent = 'Confirm delete?'; + deleteArmTimer = setTimeout(disarmDelete, 3000); + return; + } + + if (deleteArmTimer) { + clearTimeout(deleteArmTimer); + deleteArmTimer = null; + } + deleteArmed = false; + const target = existingCopies[copyIndex]; + copyDeleteBtn.disabled = true; + copyDeleteBtn.textContent = 'Deleting…'; + + const res = await chrome.runtime.sendMessage({ action: 'deleteDownload', id: target.id }); + if (!res?.success) { + showMessage(res?.error || 'Failed to delete.', 'error'); + disarmDelete(); + return; + } + + existingCopies.splice(copyIndex, 1); + if (!existingCopies.length) { + existingWrap.classList.remove('visible'); + setDownloadButtonLabel('Download'); + showMessage('Deleted.', 'success'); + return; + } + renderCopy(); + showMessage('Deleted.', 'success'); }); function applyLibraryVisibility(result) { - const mode = result?.libraryMode || 'none'; - if (mode === 'none') { - libraryToggleRow.style.display = 'none'; - saveToLibrary = false; - libraryToggle.classList.remove('on'); - return; - } - libraryToggleRow.style.display = ''; - if (libraryToggleTitle && libraryToggleSub) { - if (mode === 'request') { - libraryToggleTitle.textContent = 'Request Library Save'; - libraryToggleSub.textContent = 'Needs admin approval'; - } else { - libraryToggleTitle.textContent = 'Save to Library'; - libraryToggleSub.textContent = 'Skip cache and save permanently'; - } - } + const mode = result?.libraryMode || 'none'; + if (mode === 'none') { + libraryToggleRow.style.display = 'none'; + saveToLibrary = false; + libraryToggle.classList.remove('on'); + return; + } + libraryToggleRow.style.display = ''; + if (libraryToggleTitle && libraryToggleSub) { + if (mode === 'request') { + libraryToggleTitle.textContent = 'Request Library Save'; + libraryToggleSub.textContent = 'Needs admin approval'; + } else { + libraryToggleTitle.textContent = 'Save to Library'; + libraryToggleSub.textContent = 'Skip cache and save permanently'; + } + } } function populateProfiles(result) { - profileSelect.innerHTML = ''; - - if (!result?.success || !result.profiles?.length) { - profileSelect.innerHTML = ''; - profileSelect.disabled = false; - return; - } - - const defaultOpt = document.createElement('option'); - defaultOpt.value = ''; - defaultOpt.textContent = 'Default profile'; - profileSelect.appendChild(defaultOpt); - - result.profiles.forEach((p) => { - const opt = document.createElement('option'); - opt.value = p.id; - const badge = p.isDefault ? ' ★' : p.isSystem ? ' (system)' : ''; - const detail = p.audioOnly ? ' · audio' : p.quality ? ` · ${p.quality}` : ''; - opt.textContent = p.name + badge + detail; - profileSelect.appendChild(opt); - }); - - profileSelect.disabled = false; - - const defaultProfile = result.profiles.find((p) => p.isDefault); - if (defaultProfile) profileSelect.value = defaultProfile.id; + profileSelect.innerHTML = ''; + + if (!result?.success || !result.profiles?.length) { + profileSelect.innerHTML = ''; + profileSelect.disabled = false; + return; + } + + const defaultOpt = document.createElement('option'); + defaultOpt.value = ''; + defaultOpt.textContent = 'Default profile'; + profileSelect.appendChild(defaultOpt); + + result.profiles.forEach((p) => { + const opt = document.createElement('option'); + opt.value = p.id; + const badge = p.isDefault ? ' ★' : p.isSystem ? ' (system)' : ''; + const detail = p.audioOnly ? ' · audio' : p.quality ? ` · ${p.quality}` : ''; + opt.textContent = p.name + badge + detail; + profileSelect.appendChild(opt); + }); + + profileSelect.disabled = false; + + const defaultProfile = result.profiles.find((p) => p.isDefault); + if (defaultProfile) profileSelect.value = defaultProfile.id; } // Save settings saveBtn.addEventListener('click', () => { - const url = serverUrlInput.value.trim().replace(/\/+$/, ''); - const key = apiKeyInput.value.trim(); - - if (!url) { - showSettingsError('Server URL is required.'); - return; - } - - chrome.storage.local.set({ serverUrl: url, apiKey: key }, async () => { - serverUrl = url; - saveBtn.textContent = 'Saved!'; - setTimeout(() => { saveBtn.textContent = 'Save'; }, 2000); - updateStatus('checking'); - const profileResult = await chrome.runtime.sendMessage({ action: 'fetchProfiles' }); - if (!profileResult?.success) { - updateStatus(key ? 'key-invalid' : 'error'); - setConfigured(false); - return; - } - updateStatus(statusFromResult(profileResult), profileResult.email); - setConfigured(true); - applyLibraryVisibility(profileResult); - populateProfiles(profileResult); - if (currentTabUrl) lookupExisting(currentTabUrl); - }); + const url = serverUrlInput.value.trim().replace(/\/+$/, ''); + const key = apiKeyInput.value.trim(); + + if (!url) { + showSettingsError('Server URL is required.'); + return; + } + + chrome.storage.local.set({ serverUrl: url, apiKey: key }, async () => { + serverUrl = url; + saveBtn.textContent = 'Saved!'; + setTimeout(() => { + saveBtn.textContent = 'Save'; + }, 2000); + updateStatus('checking'); + const profileResult = await chrome.runtime.sendMessage({ action: 'fetchProfiles' }); + if (!profileResult?.success) { + updateStatus(key ? 'key-invalid' : 'error'); + setConfigured(false); + return; + } + updateStatus(statusFromResult(profileResult), profileResult.email); + setConfigured(true); + applyLibraryVisibility(profileResult); + populateProfiles(profileResult); + if (currentTabUrl) lookupExisting(currentTabUrl); + }); }); function showSettingsError(msg) { - saveBtn.textContent = msg; - saveBtn.style.background = '#ef4444'; - setTimeout(() => { - saveBtn.textContent = 'Save'; - saveBtn.style.background = ''; - }, 2500); + saveBtn.textContent = msg; + saveBtn.style.background = '#ef4444'; + setTimeout(() => { + saveBtn.textContent = 'Save'; + saveBtn.style.background = ''; + }, 2500); } function setDownloadButtonLabel(text) { - if (downloadBtnLabel) downloadBtnLabel.textContent = text; + if (downloadBtnLabel) downloadBtnLabel.textContent = text; } // Download (also used to add another format when the video already exists) downloadBtn.addEventListener('click', async () => { - if (!currentTabUrl) { - showMessage('No page URL found.', 'error'); - return; - } - - const wasExisting = existingCopies.length > 0; - downloadBtn.disabled = true; - setDownloadButtonLabel('Sending…'); - viewLink.style.display = 'none'; - - const profileId = profileSelect.value || undefined; - - const response = await chrome.runtime.sendMessage({ - action: 'quickDownload', - url: currentTabUrl, - profileId, - saveToLibrary, - }); - - downloadBtn.disabled = false; - setDownloadButtonLabel(wasExisting ? 'Download another format' : 'Download'); - - if (response?.success) { - showMessage('Download started!', 'success'); - if (response.downloadId && serverUrl) { - viewLink.href = serverUrl.replace(/\/+$/, '') + '/downloads/' + response.downloadId; - viewLink.style.display = 'inline-flex'; - } - // Refresh the card so the new copy shows up in the pager - lookupExisting(currentTabUrl); - } else { - showMessage(response?.error || 'Failed to send download.', 'error'); - } + if (!currentTabUrl) { + showMessage('No page URL found.', 'error'); + return; + } + + const wasExisting = existingCopies.length > 0; + downloadBtn.disabled = true; + setDownloadButtonLabel('Sending…'); + viewLink.style.display = 'none'; + + const profileId = profileSelect.value || undefined; + + const response = await chrome.runtime.sendMessage({ + action: 'quickDownload', + url: currentTabUrl, + profileId, + saveToLibrary, + }); + + downloadBtn.disabled = false; + setDownloadButtonLabel(wasExisting ? 'Download another format' : 'Download'); + + if (response?.success) { + showMessage('Download started!', 'success'); + if (response.downloadId && serverUrl) { + viewLink.href = serverUrl.replace(/\/+$/, '') + '/downloads/' + response.downloadId; + viewLink.style.display = 'inline-flex'; + } + // Refresh the card so the new copy shows up in the pager + lookupExisting(currentTabUrl); + } else { + showMessage(response?.error || 'Failed to send download.', 'error'); + } }); function showMessage(text, type) { - messageEl.textContent = text; - messageEl.className = 'message ' + type; - if (type === 'error') viewLink.style.display = 'none'; + messageEl.textContent = text; + messageEl.className = 'message ' + type; + if (type === 'error') viewLink.style.display = 'none'; } +// Link YouTube +document.getElementById('link-youtube').addEventListener('click', async () => { + const status = document.getElementById('yt-status'); + status.textContent = 'Linking…'; + status.className = 'message'; + status.style.display = 'block'; + const res = await chrome.runtime.sendMessage({ action: 'linkYouTube' }); + if (res?.success) { + status.textContent = 'Linked as ' + (res.channelName || 'YouTube user'); + status.className = 'message success'; + } else { + status.className = 'message error'; + status.textContent = (res?.error || 'Linking failed.') + ' '; + // Offer a quick way to sign in to YouTube (common cause: not logged in / expired cookies). + const a = document.createElement('a'); + a.href = 'https://youtube.com'; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = 'Log in to YouTube'; + status.appendChild(a); + } +}); + // Inject spin keyframe into popup const style = document.createElement('style'); style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }'; diff --git a/package-lock.json b/package-lock.json index 2172161..8823913 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,8 @@ "@vitest/ui": "^4.1.8", "happy-dom": "^20.9.0", "jsdom": "^29.1.1", + "prettier": "^3.9.6", + "prettier-plugin-svelte": "^4.1.1", "prisma": "^7.4.2", "svelte": "^5.55.4", "svelte-check": "^4.4.6", @@ -5496,6 +5498,36 @@ "preact": ">=10" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.1.tgz", + "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^5.0.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/package.json b/package.json index 058d2e7..420df5d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "format": "prettier --write .", + "format:check": "prettier --check .", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test": "vitest run", "test:watch": "vitest", @@ -59,6 +61,8 @@ "@vitest/ui": "^4.1.8", "happy-dom": "^20.9.0", "jsdom": "^29.1.1", + "prettier": "^3.9.6", + "prettier-plugin-svelte": "^4.1.1", "prisma": "^7.4.2", "svelte": "^5.55.4", "svelte-check": "^4.4.6", diff --git a/playwright.config.ts b/playwright.config.ts index 09dbcd4..de682a8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -35,9 +35,11 @@ export default defineConfig({ use: { ...devices['iPhone 13'] }, }, ], - webServer: process.env.CI ? undefined : { - command: 'npm run preview', - url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, - }, + webServer: process.env.CI + ? undefined + : { + command: 'npm run preview', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, }); diff --git a/prisma.config.ts b/prisma.config.ts index eb41011..14161ef 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -3,12 +3,12 @@ import { defineConfig, env } from 'prisma/config'; export default defineConfig({ schema: 'prisma/schema.prisma', migrations: { - path: 'prisma/migrations' + path: 'prisma/migrations', }, datasource: { - url: env('DATABASE_URL') + url: env('DATABASE_URL'), }, seed: { - command: 'tsx prisma/seed.ts' - } + command: 'tsx prisma/seed.ts', + }, }); diff --git a/prisma/migrations/20260724010617_add_download_tuning_and_poster_settings/migration.sql b/prisma/migrations/20260724010617_add_download_tuning_and_poster_settings/migration.sql new file mode 100644 index 0000000..47e7d0d --- /dev/null +++ b/prisma/migrations/20260724010617_add_download_tuning_and_poster_settings/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "settings" ADD COLUMN "concurrentFragments" INTEGER NOT NULL DEFAULT 4, +ADD COLUMN "generateJellyfinPosters" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "httpChunkSize" TEXT, +ADD COLUMN "useAria2c" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260724015314_add_youtube_link/migration.sql b/prisma/migrations/20260724015314_add_youtube_link/migration.sql new file mode 100644 index 0000000..0404e6d --- /dev/null +++ b/prisma/migrations/20260724015314_add_youtube_link/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "youtube_links" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "cookiesEnc" TEXT NOT NULL, + "cookieUpdatedAt" TIMESTAMP(3) NOT NULL, + "channelName" TEXT, + "channelHandle" TEXT, + "channelId" TEXT, + "syncWatchedToYouTube" BOOLEAN NOT NULL DEFAULT false, + "syncHistoryToWytui" BOOLEAN NOT NULL DEFAULT false, + "syncWatchLater" BOOLEAN NOT NULL DEFAULT false, + "useFeedForNewVideos" BOOLEAN NOT NULL DEFAULT false, + "lastFeedCheck" TIMESTAMP(3), + "lastHistorySync" TIMESTAMP(3), + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "youtube_links_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "youtube_links_userId_key" ON "youtube_links"("userId"); + +-- AddForeignKey +ALTER TABLE "youtube_links" ADD CONSTRAINT "youtube_links_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260724042501_add_oidc_db_config_and_username/migration.sql b/prisma/migrations/20260724042501_add_oidc_db_config_and_username/migration.sql new file mode 100644 index 0000000..62487b2 --- /dev/null +++ b/prisma/migrations/20260724042501_add_oidc_db_config_and_username/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - A unique constraint covering the columns `[username]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "settings" ADD COLUMN "oidcClientId" TEXT, +ADD COLUMN "oidcClientSecret" TEXT, +ADD COLUMN "oidcDisplayName" TEXT, +ADD COLUMN "oidcEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "oidcIssuerUrl" TEXT; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "username" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); diff --git a/prisma/migrations/20260724224055_add_pending_playlist_items/migration.sql b/prisma/migrations/20260724224055_add_pending_playlist_items/migration.sql new file mode 100644 index 0000000..fcd6ee0 --- /dev/null +++ b/prisma/migrations/20260724224055_add_pending_playlist_items/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "playlist_items" ADD COLUMN "sourceUrl" TEXT, +ADD COLUMN "thumbnail" TEXT, +ADD COLUMN "title" TEXT, +ADD COLUMN "videoId" TEXT, +ALTER COLUMN "downloadId" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "playlist_items_playlistId_videoId_idx" ON "playlist_items"("playlistId", "videoId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da3f6e3..7568d46 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,6 +10,7 @@ datasource db { model User { id String @id @default(cuid()) email String @unique + username String? @unique // optional login handle; login accepts email OR username password String? name String? emailVerified DateTime? @@ -32,6 +33,7 @@ model User { watchProgress WatchProgress[] playlists Playlist[] libraryRequests LibraryRequest[] + youtubeLink YouTubeLink? @@map("users") } @@ -340,6 +342,14 @@ model Settings { // Kill a download if it makes no progress for this many seconds (0 = disabled) downloadStallTimeoutSeconds Int @default(600) + // Download speed tuning + concurrentFragments Int @default(4) // yt-dlp --concurrent-fragments (0/1 = off) + useAria2c Boolean @default(false) // use aria2c external downloader when present + httpChunkSize String? // e.g. "10M" -> --http-chunk-size (null = omit) + + // Jellyfin artwork + generateJellyfinPosters Boolean @default(true) // build 2:3 poster.jpg + 16:9 backdrop.jpg + // yt-dlp settings ytdlpPath String @default("/usr/local/bin/yt-dlp") ytdlpVersion String? @@ -357,6 +367,13 @@ model Settings { // Authentication authMode String @default("password") // "password" | "oidc" | "both" + // OIDC (SSO). May instead be configured via env vars, which take precedence. + oidcEnabled Boolean @default(false) + oidcIssuerUrl String? + oidcClientId String? + oidcClientSecret String? // encrypted at rest via crypto-box + oidcDisplayName String? // button label, e.g. "Company SSO" (defaults to "SSO") + // Library & cache libraryPath String? musicLibraryPath String? @@ -514,14 +531,24 @@ model Playlist { model PlaylistItem { id String @id @default(cuid()) playlistId String - downloadId String + // Null until the item is actually downloaded. Items synced from YouTube start + // as "pending" — the video reference lives in the snapshot fields below, and + // downloadId is filled in once a Download is created for it. + downloadId String? position Int - playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade) - download Download @relation(fields: [downloadId], references: [id], onDelete: Cascade) - addedAt DateTime @default(now()) + playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade) + download Download? @relation(fields: [downloadId], references: [id], onDelete: Cascade) + addedAt DateTime @default(now()) + + // Snapshot of the source video for pending (not-yet-downloaded) items. + videoId String? + title String? + thumbnail String? + sourceUrl String? @@unique([playlistId, downloadId]) @@index([playlistId]) + @@index([playlistId, videoId]) @@map("playlist_items") } @@ -567,3 +594,30 @@ model ScheduledJobRun { @@index([startedAt]) @@map("scheduled_job_runs") } + +model YouTubeLink { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + cookiesEnc String @db.Text // AES-256-GCM, format iv:tag:ciphertext (base64) + cookieUpdatedAt DateTime + + channelName String? + channelHandle String? + channelId String? + + syncWatchedToYouTube Boolean @default(false) + syncHistoryToWytui Boolean @default(false) + syncWatchLater Boolean @default(false) + useFeedForNewVideos Boolean @default(false) + + lastFeedCheck DateTime? + lastHistorySync DateTime? + lastError String? @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("youtube_links") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index bd86c2f..582f633 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -4,147 +4,182 @@ import { execSync } from 'child_process'; const prisma = new PrismaClient(); async function main() { - console.log('🌱 Seeding database...'); + console.log('🌱 Seeding database...'); - // Get yt-dlp version - let ytdlpVersion = null; - try { - ytdlpVersion = execSync('yt-dlp --version').toString().trim(); - console.log(`✅ Detected yt-dlp version: ${ytdlpVersion}`); - } catch (e) { - console.warn('⚠️ yt-dlp not found, skipping version detection'); - } + // Get yt-dlp version + let ytdlpVersion = null; + try { + ytdlpVersion = execSync('yt-dlp --version').toString().trim(); + console.log(`✅ Detected yt-dlp version: ${ytdlpVersion}`); + } catch (e) { + console.warn('⚠️ yt-dlp not found, skipping version detection'); + } - // Create singleton settings - const settings = await prisma.settings.upsert({ - where: { id: 'singleton' }, - update: {}, - create: { - id: 'singleton', - maxConcurrentDownloads: 3, - downloadPath: '/downloads', - ytdlpPath: '/usr/local/bin/yt-dlp', - ytdlpVersion, - autoUpdateYtdlp: true, - updateCheckInterval: 86400, - enableArchive: true, - }, - }); - console.log('✅ Created settings:', settings.id); + // Create singleton settings + const settings = await prisma.settings.upsert({ + where: { id: 'singleton' }, + update: {}, + create: { + id: 'singleton', + maxConcurrentDownloads: 3, + downloadPath: '/downloads', + ytdlpPath: '/usr/local/bin/yt-dlp', + ytdlpVersion, + autoUpdateYtdlp: true, + updateCheckInterval: 86400, + enableArchive: true, + }, + }); + console.log('✅ Created settings:', settings.id); - // Create system download profiles - const profiles = [ - // Video Quality Presets (using H.264 for compatibility) - { - name: '4K (Best Quality)', - description: '2160p H.264 video with AAC audio', - isSystem: true, - isDefault: false, - quality: '2160', - format: 'mp4', - customFlags: ['-f', 'bestvideo[vcodec^=avc][height<=2160]+bestaudio/best', '--merge-output-format', 'mp4', '--postprocessor-args', 'ffmpeg:-c:a aac -b:a 192k'], - }, - { - name: '1080p', - description: 'Full HD H.264 video with AAC audio', - isSystem: true, - isDefault: true, - quality: '1080', - format: 'mp4', - customFlags: ['-f', 'bestvideo[vcodec^=avc]+bestaudio/best', '--merge-output-format', 'mp4', '--postprocessor-args', 'ffmpeg:-c:a aac -b:a 192k'], - }, - { - name: '720p', - description: 'HD H.264 video with AAC audio', - isSystem: true, - isDefault: false, - quality: '720', - format: 'mp4', - customFlags: ['-f', 'bestvideo[vcodec^=avc][height<=720]+bestaudio/best', '--merge-output-format', 'mp4', '--postprocessor-args', 'ffmpeg:-c:a aac -b:a 192k'], - }, - { - name: '480p (Mobile)', - description: 'SD H.264 video optimized for mobile', - isSystem: true, - isDefault: false, - quality: '480', - format: 'mp4', - customFlags: ['-f', 'bestvideo[vcodec^=avc][height<=480]+bestaudio/best', '--merge-output-format', 'mp4', '--postprocessor-args', 'ffmpeg:-c:a aac -b:a 192k'], - }, + // Create system download profiles + const profiles = [ + // Video Quality Presets (using H.264 for compatibility) + { + name: '4K (Best Quality)', + description: '2160p H.264 video with AAC audio', + isSystem: true, + isDefault: false, + quality: '2160', + format: 'mp4', + customFlags: [ + '-f', + 'bestvideo[vcodec^=avc][height<=2160]+bestaudio/best', + '--merge-output-format', + 'mp4', + '--postprocessor-args', + 'ffmpeg:-c:a aac -b:a 192k', + ], + }, + { + name: '1080p', + description: 'Full HD H.264 video with AAC audio', + isSystem: true, + isDefault: true, + quality: '1080', + format: 'mp4', + customFlags: [ + '-f', + 'bestvideo[vcodec^=avc]+bestaudio/best', + '--merge-output-format', + 'mp4', + '--postprocessor-args', + 'ffmpeg:-c:a aac -b:a 192k', + ], + }, + { + name: '720p', + description: 'HD H.264 video with AAC audio', + isSystem: true, + isDefault: false, + quality: '720', + format: 'mp4', + customFlags: [ + '-f', + 'bestvideo[vcodec^=avc][height<=720]+bestaudio/best', + '--merge-output-format', + 'mp4', + '--postprocessor-args', + 'ffmpeg:-c:a aac -b:a 192k', + ], + }, + { + name: '480p (Mobile)', + description: 'SD H.264 video optimized for mobile', + isSystem: true, + isDefault: false, + quality: '480', + format: 'mp4', + customFlags: [ + '-f', + 'bestvideo[vcodec^=avc][height<=480]+bestaudio/best', + '--merge-output-format', + 'mp4', + '--postprocessor-args', + 'ffmpeg:-c:a aac -b:a 192k', + ], + }, - // Audio Extraction - { - name: 'MP3 (320kbps)', - description: 'High quality MP3 audio', - isSystem: true, - isDefault: false, - audioOnly: true, - audioFormat: 'mp3', - audioBitrate: '320k', - customFlags: ['-x', '--audio-format', 'mp3', '--audio-quality', '0'], - }, - { - name: 'AAC (High Quality)', - description: 'High quality AAC audio', - isSystem: true, - isDefault: false, - audioOnly: true, - audioFormat: 'aac', - audioBitrate: '256k', - customFlags: ['-x', '--audio-format', 'aac', '--audio-quality', '0'], - }, - { - name: 'FLAC (Lossless)', - description: 'Lossless FLAC audio', - isSystem: true, - isDefault: false, - audioOnly: true, - audioFormat: 'flac', - customFlags: ['-x', '--audio-format', 'flac'], - }, + // Audio Extraction + { + name: 'MP3 (320kbps)', + description: 'High quality MP3 audio', + isSystem: true, + isDefault: false, + audioOnly: true, + audioFormat: 'mp3', + audioBitrate: '320k', + customFlags: ['-x', '--audio-format', 'mp3', '--audio-quality', '0'], + }, + { + name: 'AAC (High Quality)', + description: 'High quality AAC audio', + isSystem: true, + isDefault: false, + audioOnly: true, + audioFormat: 'aac', + audioBitrate: '256k', + customFlags: ['-x', '--audio-format', 'aac', '--audio-quality', '0'], + }, + { + name: 'FLAC (Lossless)', + description: 'Lossless FLAC audio', + isSystem: true, + isDefault: false, + audioOnly: true, + audioFormat: 'flac', + customFlags: ['-x', '--audio-format', 'flac'], + }, - // Smart Defaults - { - name: 'Best (Auto)', - description: 'Best H.264 video with AAC audio', - isSystem: true, - isDefault: false, - customFlags: ['-f', 'bestvideo[vcodec^=avc]+bestaudio/best', '--merge-output-format', 'mp4', '--postprocessor-args', 'ffmpeg:-c:a aac -b:a 192k'], - }, - { - name: 'Smallest Size', - description: 'Lowest quality for minimal file size', - isSystem: true, - isDefault: false, - customFlags: ['-f', 'worstvideo+worstaudio/worst'], - }, - ]; + // Smart Defaults + { + name: 'Best (Auto)', + description: 'Best H.264 video with AAC audio', + isSystem: true, + isDefault: false, + customFlags: [ + '-f', + 'bestvideo[vcodec^=avc]+bestaudio/best', + '--merge-output-format', + 'mp4', + '--postprocessor-args', + 'ffmpeg:-c:a aac -b:a 192k', + ], + }, + { + name: 'Smallest Size', + description: 'Lowest quality for minimal file size', + isSystem: true, + isDefault: false, + customFlags: ['-f', 'worstvideo+worstaudio/worst'], + }, + ]; - for (const profile of profiles) { - // Check if profile exists - const existing = await prisma.downloadProfile.findFirst({ - where: { name: profile.name, userId: null }, - }); + for (const profile of profiles) { + // Check if profile exists + const existing = await prisma.downloadProfile.findFirst({ + where: { name: profile.name, userId: null }, + }); - if (!existing) { - const created = await prisma.downloadProfile.create({ - data: profile, - }); - console.log(`✅ Created profile: ${created.name}`); - } else { - console.log(`⏭️ Skipped existing profile: ${existing.name}`); - } - } + if (!existing) { + const created = await prisma.downloadProfile.create({ + data: profile, + }); + console.log(`✅ Created profile: ${created.name}`); + } else { + console.log(`⏭️ Skipped existing profile: ${existing.name}`); + } + } - console.log('🎉 Seeding complete!'); + console.log('🎉 Seeding complete!'); } main() - .then(async () => { - await prisma.$disconnect(); - }) - .catch(async (e) => { - console.error('❌ Seeding failed:', e); - await prisma.$disconnect(); - process.exit(1); - }); + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error('❌ Seeding failed:', e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/src/app.css b/src/app.css index a340539..46e1902 100644 --- a/src/app.css +++ b/src/app.css @@ -80,6 +80,14 @@ button, outline: none; } +/* Fixed height only for the .btn component so every button variant lines up + with other controls (ViewToggle, inputs). Bare {/if} {#if download.thumbnail || isPreviewable} -
+
{#if download.thumbnail && !thumbnailFailed} {download.title thumbnailFailed = true} + onerror={() => (thumbnailFailed = true)} /> {:else if isPreviewable && !showPreview} - {/if} {#if !showPreview && download.status === 'COMPLETED'}
- +
{/if} {#if showPreview} @@ -342,16 +391,51 @@ {/if} -
+
{/if} @@ -369,9 +453,21 @@ title={copied ? 'Copied!' : 'Copy source URL'} > {#if copied} - + {:else} - + {/if} {#if mediaType} @@ -384,21 +480,79 @@ {/if} {#if download.status === 'COMPLETED'} - + {:else if download.status === 'FAILED'} - + {:else if download.status === 'CANCELLED'} - + {:else if download.status === 'DOWNLOADING'} - + {:else if download.status === 'PROCESSING'} - + {:else if download.status === 'FETCHING_INFO'} - + {:else if download.status === 'PENDING'} - + {:else if download.status === 'DELETED'} - + {/if}
@@ -445,10 +599,24 @@ {#if download.status === 'PROCESSING'}
-
+ {#if download.indeterminate} +
+ {:else} +
+ {/if}
- {download.processingStep || 'Processing...'} + {#if download.indeterminate && download.stepStartedAt} + {download.processingStep || 'Processing...'} · {formatElapsed(elapsedTime)} + {:else if download.indeterminate} + {download.processingStep || 'Processing...'} + {:else if download.processingStep && download.processingStep.includes('%')} + + {download.processingStep} + {:else} + + {download.processingStep || 'Processing...'} {progressPercent}% + {/if}
{/if} @@ -463,14 +631,17 @@ {/if} {#if download.status === 'DELETED'} -
- Watched by all users — file removed -
+
Watched by all users — file removed
{/if}
{#if download.status === 'DOWNLOADING' || download.status === 'PENDING' || download.status === 'FETCHING_INFO' || download.status === 'PROCESSING'} - @@ -486,7 +657,13 @@ /> {#if download.storagePool === 'cache' && libraryConfigured} - @@ -507,21 +684,37 @@ {/if} {#if download.status === 'DELETED'} - {/if} {#if download.status === 'FAILED' || download.status === 'CANCELLED'} - {/if} {#if download.status === 'COMPLETED' || download.status === 'FAILED' || download.status === 'CANCELLED' || download.status === 'DELETED'} - @@ -536,7 +729,9 @@ border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); overflow: hidden; - transition: transform var(--transition-normal), box-shadow var(--transition-normal), + transition: + transform var(--transition-normal), + box-shadow var(--transition-normal), border-color var(--transition-normal); flex-shrink: 0; position: relative; @@ -547,7 +742,9 @@ .download-card:hover { border-color: var(--color-border-translucent-hover); transform: translateY(-3px); - box-shadow: var(--shadow-lg), 0 0 0 1px rgba(59, 130, 246, 0.05); + box-shadow: + var(--shadow-lg), + 0 0 0 1px rgba(59, 130, 246, 0.05); } @media (prefers-reduced-motion: reduce) { @@ -690,7 +887,9 @@ justify-content: center; cursor: pointer; opacity: 0.8; - transition: opacity var(--transition-fast), background var(--transition-fast); + transition: + opacity var(--transition-fast), + background var(--transition-fast); } .mute-btn:hover { @@ -898,12 +1097,7 @@ left: 0; bottom: 0; right: 0; - background: linear-gradient( - 90deg, - transparent, - var(--color-overlay-white-30), - transparent - ); + background: linear-gradient(90deg, transparent, var(--color-overlay-white-30), transparent); animation: shimmer 2s infinite; } @@ -933,6 +1127,10 @@ display: none; } + .progress-bar.processing { + background: var(--color-status-warning); + } + .progress-bar.indeterminate.processing { background: linear-gradient( 90deg, @@ -997,5 +1195,4 @@ min-width: 0; } } - diff --git a/src/lib/components/download/DownloadForm.svelte b/src/lib/components/download/DownloadForm.svelte index 3d67636..c2af8c9 100644 --- a/src/lib/components/download/DownloadForm.svelte +++ b/src/lib/components/download/DownloadForm.svelte @@ -1,2466 +1,2526 @@
-
-
- - -
- -
- - {#if url.trim().split('\n').filter(line => line.trim()).length > 1} -

- {url.trim().split('\n').filter(line => line.trim()).length} URLs detected -

- {/if} -
- - {#if !initialized} -
- - - -
- {/if} - - {#if libraryConfigured} - - {/if} - - {#if !advancedMode} -
-
- Video + Audio -
- {#each videoProfiles as profile} - - {/each} -
-
- - {#if selectedVideoProfileId} -
- Audio Quality -
- - - -
-
- {/if} - -
- Audio (only) -
- {#each audioProfiles as profile} - - {/each} -
-
- -
- - Options - {#if optionsNonDefault} - - {/if} - -
- - - -
-
-
- {:else if customProfiles.length > 0} -
-
- Load Profile -
- {#each customProfiles as profile} - - {/each} -
-
-
- {/if} - - {#if advancedMode} -
-
- - {#if enabledFlagCount > 0} - {enabledFlagCount} - {/if} - -
- - -
-
- - {#if showSaveDialog} -
- e.key === "Enter" && handleSaveProfile()} - /> - - -
- {/if} - - - - {#each filteredCategories as category} - {@const isExpanded = effectiveExpandedCategories.has(category.category)} - {@const activeCount = category.flags.filter( - (f) => flags[f.key]?.enabled && !f.defaultValue, - ).length} -
- - {#if isExpanded} -
- {#each category.flags as f} - {@const state = flags[f.key]} - {#if f.defaultValue} -
-
- {f.label} - {f.flag} -
-
- {#if f.type === "select"} - - {:else} - { - flags[f.key] = { - ...flags[f.key], - value: (e.target as HTMLInputElement).value, - }; - }} - /> - {/if} -
-
- {:else} -
- - {#if state?.enabled && f.type !== "bool"} -
- {#if f.type === "select"} - - {:else} - { - flags[f.key] = { - ...flags[f.key], - value: (e.target as HTMLInputElement).value, - }; - }} - /> - {/if} -
- {/if} -
- {/if} - {/each} -
- {/if} -
- {/each} - {#if filteredCategories.length === 0 && flagSearch} -
No flags matching "{flagSearch}"
- {/if} -
- {/if} - - {#if error} -
{error}
- {/if} - - {#if playlistProgress} -
-
- Importing: {playlistProgress.playlistTitle || 'Playlist'} - {#if playlistProgress.total && playlistProgress.current} - {playlistProgress.current} / {playlistProgress.total} - {/if} -
- {#if playlistProgress.total} -
-
-
-
- {playlistProgress.created || 0} created - {#if playlistProgress.skipped} - {playlistProgress.skipped} skipped - {/if} - {#if playlistProgress.errors} - {playlistProgress.errors} failed - {/if} -
- {/if} -
- {/if} - - {#if isPlaylistUrl} -
- - -
- {:else} - - {/if} -
+
+
+ + +
+ +
+ + {#if url + .trim() + .split('\n') + .filter((line) => line.trim()).length > 1} +

+ {url + .trim() + .split('\n') + .filter((line) => line.trim()).length} URLs detected +

+ {/if} +
+ + {#if !initialized} +
+ + + +
+ {/if} + + {#if libraryConfigured} + + {/if} + + {#if !advancedMode} +
+
+ Video + Audio +
+ {#each videoProfiles as profile} + + {/each} +
+
+ + {#if selectedVideoProfileId} +
+ Audio Quality +
+ + + +
+
+ {/if} + +
+ Audio (only) +
+ {#each audioProfiles as profile} + + {/each} +
+
+ +
+ + Options + {#if optionsNonDefault} + + {/if} + +
+ + + +
+
+
+ {:else if customProfiles.length > 0} +
+
+ Load Profile +
+ {#each customProfiles as profile} + + {/each} +
+
+
+ {/if} + + {#if advancedMode} +
+
+ + {#if enabledFlagCount > 0} + {enabledFlagCount} + {/if} + +
+ + +
+
+ + {#if showSaveDialog} +
+ e.key === 'Enter' && handleSaveProfile()} + /> + + +
+ {/if} + + + + {#each filteredCategories as category} + {@const isExpanded = effectiveExpandedCategories.has(category.category)} + {@const activeCount = category.flags.filter( + (f) => flags[f.key]?.enabled && !f.defaultValue, + ).length} +
+ + {#if isExpanded} +
+ {#each category.flags as f} + {@const state = flags[f.key]} + {#if f.defaultValue} +
+
+ {f.label} + {f.flag} +
+
+ {#if f.type === 'select'} + + {:else} + { + flags[f.key] = { + ...flags[f.key], + value: (e.target as HTMLInputElement).value, + }; + }} + /> + {/if} +
+
+ {:else} +
+ + {#if state?.enabled && f.type !== 'bool'} +
+ {#if f.type === 'select'} + + {:else} + { + flags[f.key] = { + ...flags[f.key], + value: (e.target as HTMLInputElement).value, + }; + }} + /> + {/if} +
+ {/if} +
+ {/if} + {/each} +
+ {/if} +
+ {/each} + {#if filteredCategories.length === 0 && flagSearch} +
No flags matching "{flagSearch}"
+ {/if} +
+ {/if} + + {#if error} +
{error}
+ {/if} + + {#if playlistProgress} +
+
+ Importing: {playlistProgress.playlistTitle || 'Playlist'} + {#if playlistProgress.total && playlistProgress.current} + {playlistProgress.current} / {playlistProgress.total} + {/if} +
+ {#if playlistProgress.total} +
+
+
+
+ {playlistProgress.created || 0} created + {#if playlistProgress.skipped} + {playlistProgress.skipped} skipped + {/if} + {#if playlistProgress.errors} + {playlistProgress.errors} failed + {/if} +
+ {/if} +
+ {/if} + + {#if isPlaylistUrl} +
+ + +
+ {:else} + + {/if} +
diff --git a/src/lib/components/download/DownloadListRow.svelte b/src/lib/components/download/DownloadListRow.svelte index 10ef72e..75a69e2 100644 --- a/src/lib/components/download/DownloadListRow.svelte +++ b/src/lib/components/download/DownloadListRow.svelte @@ -21,7 +21,7 @@ const VIDEO_EXTENSIONS = new Set(['MP4', 'WEBM', 'MKV', 'FLV', 'MOV', 'AVI']); let mediaType = $derived(download.filename?.split('.').pop()?.toUpperCase() || null); let isVideoCompleted = $derived( - download.status === 'COMPLETED' && mediaType !== null && VIDEO_EXTENSIONS.has(mediaType) + download.status === 'COMPLETED' && mediaType !== null && VIDEO_EXTENSIONS.has(mediaType), ); let formattedSize = $derived(download.filesize ? formatBytes(download.filesize) : null); @@ -33,16 +33,9 @@ const d = new Date(date); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); }); - -
+
{#if download.thumbnail && !thumbnailFailed} (thumbnailFailed = true)} /> {:else if isVideoCompleted} - + {:else}
{/if} diff --git a/src/lib/components/download/DownloadVersionPicker.svelte b/src/lib/components/download/DownloadVersionPicker.svelte index 2696982..eb8968f 100644 --- a/src/lib/components/download/DownloadVersionPicker.svelte +++ b/src/lib/components/download/DownloadVersionPicker.svelte @@ -12,7 +12,12 @@ videoType: string | null; filesize: string | null; storagePool: string; - profile?: { name?: string; quality?: string | null; audioOnly?: boolean; audioFormat?: string | null } | null; + profile?: { + name?: string; + quality?: string | null; + audioOnly?: boolean; + audioFormat?: string | null; + } | null; }; let { @@ -113,7 +118,13 @@ $effect(() => { if (!open) return; function down(e: MouseEvent) { - if (menuEl && !menuEl.contains(e.target as Node) && btnEl && !btnEl.contains(e.target as Node)) open = false; + if ( + menuEl && + !menuEl.contains(e.target as Node) && + btnEl && + !btnEl.contains(e.target as Node) + ) + open = false; } function key(e: KeyboardEvent) { if (e.key === 'Escape') open = false; @@ -128,13 +139,27 @@
- {#if open && versions.length > 1} -