From 737bf19fd16c2b1d76e9ab58870ad1f00513182c Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 14 Jul 2026 01:35:52 +0400 Subject: [PATCH] initial state: muzick music player + recommendation engine --- .claude/settings.local.json | 10 + .env.example | 12 + .github/workflows/typecheck.yml | 23 + .gitignore | 29 + 03-music-core-backend.md | 163 + 04-music-recommendation-backend.md | 129 + 05-music-frontend.md | 80 + AGENTS.md | 28 + README.md | 44 + SESSION-07-07-2026.md | 96 + backend/.dockerignore | 4 + backend/Dockerfile | 8 + backend/package-lock.json | 3206 ++++++++++++++++ backend/package.json | 29 + backend/scripts/seed.ts | 52 + backend/scripts/setup-db.sh | 6 + backend/src/app.ts | 185 + backend/src/db/schema.sql | 490 +++ backend/src/index.ts | 1 + backend/src/routes/admin.routes.ts | 194 + backend/src/routes/discovery.routes.ts | 94 + backend/src/routes/graph.routes.ts | 152 + backend/src/routes/history.routes.ts | 52 + backend/src/routes/images.routes.ts | 49 + backend/src/routes/library.routes.ts | 185 + backend/src/routes/quarantine.routes.ts | 34 + backend/src/routes/search.routes.ts | 21 + backend/src/routes/settings.routes.ts | 53 + backend/src/routes/stream.routes.ts | 140 + backend/src/routes/v2.routes.ts | 143 + backend/src/routes/vibe.routes.ts | 76 + backend/src/server.ts | 21 + backend/src/services/db.service.test.ts | 182 + backend/src/services/db.service.ts | 2284 ++++++++++++ backend/src/services/discovery.service.ts | 297 ++ backend/src/services/generators.service.ts | 500 +++ backend/src/services/generators.test.ts | 179 + .../src/services/image-enrichment.service.ts | 105 + backend/src/services/job.service.ts | 133 + backend/src/services/search.service.ts | 94 + .../src/services/session-director.service.ts | 928 +++++ backend/src/services/session-director.test.ts | 117 + backend/src/types/job.types.ts | 27 + backend/tsconfig.json | 21 + backend/vitest.config.ts | 9 + docker-compose.yml | 68 + docs/architecture/01-system-overview.md | 52 + docs/architecture/02-invariants-and-risks.md | 37 + docs/architecture/03-backend-spec.md | 53 + docs/architecture/04-frontend-spec.md | 45 + docs/architecture/05-recommendation-spec.md | 60 + docs/architecture/06-lifecycle-spec.md | 53 + docs/architecture/07-worker-spec.md | 57 + docs/architecture/08-data-model.md | 63 + .../09-recommendation-and-identity-v2.md | 1182 ++++++ docs/architecture/v2-fix-plan.md | 797 ++++ docs/plans/2026-06-08-ui-overhaul.md | 1706 +++++++++ docs/ui-rework.md | 139 + frontend/.dockerignore | 4 + frontend/Dockerfile | 12 + frontend/index.html | 12 + frontend/nginx.conf | 18 + frontend/package-lock.json | 3208 +++++++++++++++++ frontend/package.json | 33 + frontend/postcss.config.js | 6 + frontend/public/fonts/Geist-Bold.woff2 | Bin 0 -> 46496 bytes frontend/public/fonts/Geist-Light.woff2 | Bin 0 -> 45532 bytes frontend/public/fonts/Geist-Medium.woff2 | Bin 0 -> 46372 bytes frontend/public/fonts/Geist-Regular.woff2 | Bin 0 -> 45244 bytes frontend/public/fonts/Geist-SemiBold.woff2 | Bin 0 -> 46596 bytes frontend/public/fonts/GeistMono-Medium.woff2 | Bin 0 -> 51500 bytes frontend/public/fonts/GeistMono-Regular.woff2 | Bin 0 -> 50356 bytes frontend/src/components/AppShell.tsx | 74 + frontend/src/components/ArtistLinks.tsx | 67 + frontend/src/components/Artwork.tsx | 66 + frontend/src/components/AudioEngine.tsx | 291 ++ frontend/src/components/BackLink.tsx | 42 + frontend/src/components/CommandPalette.tsx | 222 ++ frontend/src/components/EmptyState.tsx | 5 + frontend/src/components/Inspector.tsx | 169 + frontend/src/components/LoadingState.tsx | 29 + frontend/src/components/LyricsOverlay.tsx | 72 + frontend/src/components/MediaCard.tsx | 32 + frontend/src/components/NavRail.tsx | 105 + frontend/src/components/NowPlayingPanel.tsx | 114 + frontend/src/components/PageContainer.tsx | 31 + frontend/src/components/PageHeader.tsx | 35 + frontend/src/components/Pagination.tsx | 42 + frontend/src/components/PanelHeader.tsx | 36 + frontend/src/components/PlaybackBar.tsx | 143 + frontend/src/components/ShelfRow.tsx | 26 + frontend/src/components/SyncedLyrics.tsx | 72 + frontend/src/components/Toaster.tsx | 75 + frontend/src/components/TopBar.tsx | 194 + frontend/src/components/TrackRow.tsx | 125 + frontend/src/components/VibeTimeline.tsx | 53 + frontend/src/components/ethos/Badge.tsx | 47 + frontend/src/components/ethos/Button.tsx | 60 + frontend/src/components/ethos/EmptyState.tsx | 60 + frontend/src/components/ethos/Skeleton.tsx | 42 + frontend/src/hooks/useDislikeTrack.ts | 48 + frontend/src/hooks/useKeyboard.ts | 125 + frontend/src/index.css | 283 ++ frontend/src/lib/color.ts | 30 + frontend/src/lib/lyrics.ts | 67 + frontend/src/lib/theme.ts | 49 + frontend/src/main.tsx | 29 + frontend/src/pages/AlbumDetail.tsx | 81 + frontend/src/pages/Albums.tsx | 61 + frontend/src/pages/ArtistDetail.tsx | 65 + frontend/src/pages/Artists.tsx | 56 + frontend/src/pages/Discover.tsx | 120 + frontend/src/pages/Genres.tsx | 129 + frontend/src/pages/Home.tsx | 128 + frontend/src/pages/Jobs.tsx | 478 +++ frontend/src/pages/Quarantine.tsx | 112 + frontend/src/pages/Search.tsx | 62 + frontend/src/pages/Settings.tsx | 318 ++ frontend/src/pages/Tracks.tsx | 43 + frontend/src/pages/Vibe.tsx | 274 ++ frontend/src/router.tsx | 132 + frontend/src/services/albumService.ts | 39 + frontend/src/services/api.ts | 7 + frontend/src/services/artistService.ts | 39 + frontend/src/services/favoritesService.ts | 28 + frontend/src/services/genreService.ts | 31 + frontend/src/services/healthService.ts | 10 + frontend/src/services/historyService.ts | 36 + frontend/src/services/jobsService.ts | 140 + frontend/src/services/libraryService.ts | 8 + frontend/src/services/quarantineService.ts | 17 + frontend/src/services/searchService.ts | 10 + frontend/src/services/settingsService.ts | 48 + frontend/src/services/trackService.ts | 54 + frontend/src/services/vibeService.ts | 73 + frontend/src/store/usePlaybackStore.ts | 116 + frontend/src/store/useToastStore.ts | 45 + frontend/src/store/useVibeStore.ts | 53 + frontend/src/types.ts | 123 + frontend/tailwind.config.js | 84 + frontend/tsconfig.json | 23 + frontend/vite.config.js | 15 + muzick.service | 15 + opencode.json | 71 + package-lock.json | 128 + package.json | 5 + photo_2026-06-05_17-23-48.jpg | Bin 0 -> 117804 bytes progress.md | 36 + ui/REVIEW_PROMPT.md | 38 + ui/REVIEW_RESULTS.md | 289 ++ ui/photo_10_2026-06-29_16-17-26.jpg | Bin 0 -> 80605 bytes ui/photo_11_2026-06-29_16-17-26.jpg | Bin 0 -> 53206 bytes ui/photo_1_2026-06-29_16-17-26.jpg | Bin 0 -> 148505 bytes ui/photo_2_2026-06-29_16-17-26.jpg | Bin 0 -> 66406 bytes ui/photo_3_2026-06-29_16-17-26.jpg | Bin 0 -> 58537 bytes ui/photo_4_2026-06-29_16-17-26.jpg | Bin 0 -> 105767 bytes ui/photo_5_2026-06-29_16-17-26.jpg | Bin 0 -> 86021 bytes ui/photo_6_2026-06-29_16-17-26.jpg | Bin 0 -> 40179 bytes ui/photo_7_2026-06-29_16-17-26.jpg | Bin 0 -> 31010 bytes ui/photo_8_2026-06-29_16-17-26.jpg | Bin 0 -> 54499 bytes ui/photo_9_2026-06-29_16-17-26.jpg | Bin 0 -> 35064 bytes workers/.dockerignore | 4 + workers/.env.example | 16 + workers/Dockerfile | 9 + workers/package-lock.json | 1832 ++++++++++ workers/package.json | 29 + workers/src/audio-features.service.ts | 233 ++ workers/src/cleanup.service.ts | 98 + workers/src/enrichment.service.ts | 1311 +++++++ workers/src/essentia.d.ts | 13 + workers/src/index.ts | 478 +++ workers/src/integrations/config.ts | 112 + workers/src/integrations/coverart.client.ts | 102 + workers/src/integrations/deezer.client.ts | 84 + workers/src/integrations/discogs.client.ts | 130 + workers/src/integrations/fanart.client.ts | 69 + workers/src/integrations/http.ts | 270 ++ workers/src/integrations/index.ts | 46 + workers/src/integrations/itunes.client.ts | 98 + workers/src/integrations/lastfm.client.ts | 189 + workers/src/integrations/lrclib.client.ts | 76 + .../src/integrations/musicbrainz.client.ts | 519 +++ workers/src/integrations/theaudiodb.client.ts | 94 + workers/src/integrations/wikidata.client.ts | 140 + workers/src/integrations/wikimedia.client.ts | 102 + workers/src/integrity.service.ts | 211 ++ workers/src/mb-spine-writer.ts | 231 ++ workers/src/queue.ts | 11 + workers/src/scanner.service.ts | 293 ++ workers/src/scripts/dedup-artists-albums.ts | 170 + .../src/scripts/repair-corrupted-metadata.ts | 45 + workers/src/scripts/split-collab-artists.ts | 204 ++ workers/src/types.ts | 60 + workers/src/utils/artist-names.ts | 82 + workers/src/utils/fuzzy-match.ts | 167 + workers/tsconfig.json | 13 + 196 files changed, 32431 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 .github/workflows/typecheck.yml create mode 100644 .gitignore create mode 100644 03-music-core-backend.md create mode 100644 04-music-recommendation-backend.md create mode 100644 05-music-frontend.md create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 SESSION-07-07-2026.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/scripts/seed.ts create mode 100755 backend/scripts/setup-db.sh create mode 100644 backend/src/app.ts create mode 100644 backend/src/db/schema.sql create mode 100644 backend/src/index.ts create mode 100644 backend/src/routes/admin.routes.ts create mode 100644 backend/src/routes/discovery.routes.ts create mode 100644 backend/src/routes/graph.routes.ts create mode 100644 backend/src/routes/history.routes.ts create mode 100644 backend/src/routes/images.routes.ts create mode 100644 backend/src/routes/library.routes.ts create mode 100644 backend/src/routes/quarantine.routes.ts create mode 100644 backend/src/routes/search.routes.ts create mode 100644 backend/src/routes/settings.routes.ts create mode 100644 backend/src/routes/stream.routes.ts create mode 100644 backend/src/routes/v2.routes.ts create mode 100644 backend/src/routes/vibe.routes.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/services/db.service.test.ts create mode 100644 backend/src/services/db.service.ts create mode 100644 backend/src/services/discovery.service.ts create mode 100644 backend/src/services/generators.service.ts create mode 100644 backend/src/services/generators.test.ts create mode 100644 backend/src/services/image-enrichment.service.ts create mode 100644 backend/src/services/job.service.ts create mode 100644 backend/src/services/search.service.ts create mode 100644 backend/src/services/session-director.service.ts create mode 100644 backend/src/services/session-director.test.ts create mode 100644 backend/src/types/job.types.ts create mode 100644 backend/tsconfig.json create mode 100644 backend/vitest.config.ts create mode 100644 docker-compose.yml create mode 100644 docs/architecture/01-system-overview.md create mode 100644 docs/architecture/02-invariants-and-risks.md create mode 100644 docs/architecture/03-backend-spec.md create mode 100644 docs/architecture/04-frontend-spec.md create mode 100644 docs/architecture/05-recommendation-spec.md create mode 100644 docs/architecture/06-lifecycle-spec.md create mode 100644 docs/architecture/07-worker-spec.md create mode 100644 docs/architecture/08-data-model.md create mode 100644 docs/architecture/09-recommendation-and-identity-v2.md create mode 100644 docs/architecture/v2-fix-plan.md create mode 100644 docs/plans/2026-06-08-ui-overhaul.md create mode 100644 docs/ui-rework.md create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/fonts/Geist-Bold.woff2 create mode 100644 frontend/public/fonts/Geist-Light.woff2 create mode 100644 frontend/public/fonts/Geist-Medium.woff2 create mode 100644 frontend/public/fonts/Geist-Regular.woff2 create mode 100644 frontend/public/fonts/Geist-SemiBold.woff2 create mode 100644 frontend/public/fonts/GeistMono-Medium.woff2 create mode 100644 frontend/public/fonts/GeistMono-Regular.woff2 create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/components/ArtistLinks.tsx create mode 100644 frontend/src/components/Artwork.tsx create mode 100644 frontend/src/components/AudioEngine.tsx create mode 100644 frontend/src/components/BackLink.tsx create mode 100644 frontend/src/components/CommandPalette.tsx create mode 100644 frontend/src/components/EmptyState.tsx create mode 100644 frontend/src/components/Inspector.tsx create mode 100644 frontend/src/components/LoadingState.tsx create mode 100644 frontend/src/components/LyricsOverlay.tsx create mode 100644 frontend/src/components/MediaCard.tsx create mode 100644 frontend/src/components/NavRail.tsx create mode 100644 frontend/src/components/NowPlayingPanel.tsx create mode 100644 frontend/src/components/PageContainer.tsx create mode 100644 frontend/src/components/PageHeader.tsx create mode 100644 frontend/src/components/Pagination.tsx create mode 100644 frontend/src/components/PanelHeader.tsx create mode 100644 frontend/src/components/PlaybackBar.tsx create mode 100644 frontend/src/components/ShelfRow.tsx create mode 100644 frontend/src/components/SyncedLyrics.tsx create mode 100644 frontend/src/components/Toaster.tsx create mode 100644 frontend/src/components/TopBar.tsx create mode 100644 frontend/src/components/TrackRow.tsx create mode 100644 frontend/src/components/VibeTimeline.tsx create mode 100644 frontend/src/components/ethos/Badge.tsx create mode 100644 frontend/src/components/ethos/Button.tsx create mode 100644 frontend/src/components/ethos/EmptyState.tsx create mode 100644 frontend/src/components/ethos/Skeleton.tsx create mode 100644 frontend/src/hooks/useDislikeTrack.ts create mode 100644 frontend/src/hooks/useKeyboard.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/color.ts create mode 100644 frontend/src/lib/lyrics.ts create mode 100644 frontend/src/lib/theme.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AlbumDetail.tsx create mode 100644 frontend/src/pages/Albums.tsx create mode 100644 frontend/src/pages/ArtistDetail.tsx create mode 100644 frontend/src/pages/Artists.tsx create mode 100644 frontend/src/pages/Discover.tsx create mode 100644 frontend/src/pages/Genres.tsx create mode 100644 frontend/src/pages/Home.tsx create mode 100644 frontend/src/pages/Jobs.tsx create mode 100644 frontend/src/pages/Quarantine.tsx create mode 100644 frontend/src/pages/Search.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/Tracks.tsx create mode 100644 frontend/src/pages/Vibe.tsx create mode 100644 frontend/src/router.tsx create mode 100644 frontend/src/services/albumService.ts create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/artistService.ts create mode 100644 frontend/src/services/favoritesService.ts create mode 100644 frontend/src/services/genreService.ts create mode 100644 frontend/src/services/healthService.ts create mode 100644 frontend/src/services/historyService.ts create mode 100644 frontend/src/services/jobsService.ts create mode 100644 frontend/src/services/libraryService.ts create mode 100644 frontend/src/services/quarantineService.ts create mode 100644 frontend/src/services/searchService.ts create mode 100644 frontend/src/services/settingsService.ts create mode 100644 frontend/src/services/trackService.ts create mode 100644 frontend/src/services/vibeService.ts create mode 100644 frontend/src/store/usePlaybackStore.ts create mode 100644 frontend/src/store/useToastStore.ts create mode 100644 frontend/src/store/useVibeStore.ts create mode 100644 frontend/src/types.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.js create mode 100644 muzick.service create mode 100644 opencode.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 photo_2026-06-05_17-23-48.jpg create mode 100644 progress.md create mode 100644 ui/REVIEW_PROMPT.md create mode 100644 ui/REVIEW_RESULTS.md create mode 100644 ui/photo_10_2026-06-29_16-17-26.jpg create mode 100644 ui/photo_11_2026-06-29_16-17-26.jpg create mode 100644 ui/photo_1_2026-06-29_16-17-26.jpg create mode 100644 ui/photo_2_2026-06-29_16-17-26.jpg create mode 100644 ui/photo_3_2026-06-29_16-17-26.jpg create mode 100644 ui/photo_4_2026-06-29_16-17-26.jpg create mode 100644 ui/photo_5_2026-06-29_16-17-26.jpg create mode 100644 ui/photo_6_2026-06-29_16-17-26.jpg create mode 100644 ui/photo_7_2026-06-29_16-17-26.jpg create mode 100644 ui/photo_8_2026-06-29_16-17-26.jpg create mode 100644 ui/photo_9_2026-06-29_16-17-26.jpg create mode 100644 workers/.dockerignore create mode 100644 workers/.env.example create mode 100644 workers/Dockerfile create mode 100644 workers/package-lock.json create mode 100644 workers/package.json create mode 100644 workers/src/audio-features.service.ts create mode 100644 workers/src/cleanup.service.ts create mode 100644 workers/src/enrichment.service.ts create mode 100644 workers/src/essentia.d.ts create mode 100644 workers/src/index.ts create mode 100644 workers/src/integrations/config.ts create mode 100644 workers/src/integrations/coverart.client.ts create mode 100644 workers/src/integrations/deezer.client.ts create mode 100644 workers/src/integrations/discogs.client.ts create mode 100644 workers/src/integrations/fanart.client.ts create mode 100644 workers/src/integrations/http.ts create mode 100644 workers/src/integrations/index.ts create mode 100644 workers/src/integrations/itunes.client.ts create mode 100644 workers/src/integrations/lastfm.client.ts create mode 100644 workers/src/integrations/lrclib.client.ts create mode 100644 workers/src/integrations/musicbrainz.client.ts create mode 100644 workers/src/integrations/theaudiodb.client.ts create mode 100644 workers/src/integrations/wikidata.client.ts create mode 100644 workers/src/integrations/wikimedia.client.ts create mode 100644 workers/src/integrity.service.ts create mode 100644 workers/src/mb-spine-writer.ts create mode 100644 workers/src/queue.ts create mode 100644 workers/src/scanner.service.ts create mode 100644 workers/src/scripts/dedup-artists-albums.ts create mode 100644 workers/src/scripts/repair-corrupted-metadata.ts create mode 100644 workers/src/scripts/split-collab-artists.ts create mode 100644 workers/src/types.ts create mode 100644 workers/src/utils/artist-names.ts create mode 100644 workers/src/utils/fuzzy-match.ts create mode 100644 workers/tsconfig.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..39d33b5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(rtk npm *)", + "Bash(npm --prefix /mnt/server/home/kami/apps/muzick/workers run typecheck)", + "Bash(echo \"EXIT=$?\")", + "Bash(rtk ls *)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84e6739 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Last.fm API credentials (required for artist images, tags) +LASTFM_API_KEY="your-lastfm-api-key" +LASTFM_SHARED_SECRET="your-lastfm-shared-secret" + +# MusicBrainz contact header +MUSICBRAINZ_CONTACT="your-app-name/1.0 (https://github.com/your/repo)" + +# Discogs token (optional, for release metadata) +DISCOGS_TOKEN="your-discogs-token" + +# SOCKS5 proxy URL (optional, for geo-bypass) +SOCKS_PROXY_URL=socks5://127.0.0.1:10808 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..f1de51e --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,23 @@ +name: Typecheck + +on: + push: + pull_request: + +jobs: + typecheck: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: [backend, workers] + defaults: + run: + working-directory: ${{ matrix.package }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm run typecheck diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53571ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Node.js +node_modules/ +npm-debug.log +yarn-error.log +.pnpm-debug.log + +# Docker +*.log +docker-compose.override.yml + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Build output +dist/ +build/ + +# Data persistence +data/postgres/ +data/redis/ +data/typesense/ + +# OS +.DS_Store +Thumbs.db diff --git a/03-music-core-backend.md b/03-music-core-backend.md new file mode 100644 index 0000000..d05ec2a --- /dev/null +++ b/03-music-core-backend.md @@ -0,0 +1,163 @@ +# music ("resonance") — core backend spec + +FastAPI + sqlite. The streaming/library core. Recommendation/vibe engine is a separate spec (04). Music lives at `/mnt/hdd1/media/Music`. Dark/blue frontend is spec 05. + +> Working name: **resonance**. Rename freely. + +## Principles +- **Tag-indexed once into sqlite**, queried from there. Tag parsing is heavier than `stat()`, so it only runs on indexing, not per request. +- Incremental re-index: skip files whose mtime is unchanged since last index. +- Metadata: prefer embedded tags (ID3 for mp3, Vorbis for flac/ogg, MP4 atoms for m4a). Fall back to folder/filename parsing when tags are missing/garbage. +- Cover art: embedded first; fall back to `cover.jpg|folder.jpg|front.jpg` in the track's directory. + +## Libraries +- **mutagen** — tag reading (mp3/flac/ogg/m4a/wav). Mature, pure-python. +- Optional: **Pillow** to normalize/resize embedded cover art into a cache. + +## sqlite schema (music.db) + +```sql +CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT UNIQUE NOT NULL, + title TEXT, + artist TEXT, + album_artist TEXT, + album TEXT, + track_no INTEGER, + disc_no INTEGER, + year INTEGER, + genre TEXT, + duration REAL, -- seconds + bitrate INTEGER, + sample_rate INTEGER, + channels INTEGER, + codec TEXT, -- mp3/flac/... + feats TEXT, -- parsed featured artists, JSON array + mtime REAL NOT NULL, -- file mtime at index time + size INTEGER, + has_embedded_cover INTEGER DEFAULT 0, + cover_path TEXT, -- resolved external cover, if any + mbid TEXT, -- musicbrainz recording id (spec 04) + indexed_at TEXT NOT NULL, + -- library state + probation INTEGER DEFAULT 0, -- 1 = recommended candidate not yet promoted (spec 04) + source TEXT DEFAULT 'library', -- 'library' | 'recommendation' + rec_source TEXT, -- 'lastfm' | 'musicbrainz' (spec 04) + added_at TEXT +); + +CREATE TABLE IF NOT EXISTS albums ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + album_artist TEXT, + year INTEGER, + cover_path TEXT, + mbid TEXT, + UNIQUE(name, album_artist) +); + +CREATE TABLE IF NOT EXISTS artists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + mbid TEXT, + image_path TEXT, + genres TEXT -- JSON array (spec 04 fills via lastfm/mb) +); + +CREATE TABLE IF NOT EXISTS favorites ( + track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER REFERENCES tracks(id) ON DELETE CASCADE, + played_at TEXT NOT NULL, + completed INTEGER DEFAULT 0 -- 1 if played to ~end (counts toward play_count) +); + +CREATE TABLE IF NOT EXISTS play_counts ( + track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + count INTEGER DEFAULT 0, + last_played TEXT +); + +CREATE TABLE IF NOT EXISTS prefs ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +``` + +Indexes: `tracks(artist)`, `tracks(album)`, `tracks(album_artist)`, `tracks(genre)`, `tracks(probation)`, `history(played_at)`. + +## Indexing +- `index_library()`: + - rglob music root for audio extensions. + - For each file: if `path` in tracks and `mtime` unchanged → skip. Else parse tags via mutagen, upsert track row; upsert album + artist rows. + - Cover resolution: if embedded art present, extract once to a cover cache dir on hdd2 (`/mnt/hdd2/resonance/covers/.jpg`), store `cover_path`. Else look for sidecar cover files in dir. + - After full pass: delete track rows whose `path` no longer exists (and weren't promoted-from-recommendation pending — they still get removed if file's gone). +- Trigger: on startup (background thread), and a manual `POST /api/library/reindex`. +- Feats parsing: from title/artist via patterns — `feat.`, `ft.`, `featuring`, `(with …)`, `, ` in artist field — store normalized JSON in `feats`. + +## Endpoints — library + +``` +GET /api/artists?sort=name|count -> artists w/ track + album counts +GET /api/artists/{id} -> artist + albums + tracks +GET /api/albums?sort=name|year|artist -> albums w/ track counts, cover +GET /api/albums/{id} -> album + ordered tracks +GET /api/tracks?sort=title|artist|added|plays&limit=&offset= -> paginated +GET /api/track/{id} -> full track meta +GET /api/search?q= -> fuzzy across tracks/artists/albums +GET /api/cover/{track_id|album_id} -> image (from cover cache); 404->placeholder +``` + +### Fuzzy search +- Normalize (lowercase, strip diacritics for matching but keep originals for display — important for Cyrillic/mixed library). +- Match across title, artist, album, album_artist. Rank: exact > prefix > substring > token subsequence. Cap results per category. SQLite + python ranking is fine at this library size; consider `fts5` virtual table if it grows. + +## Endpoints — playback / streaming + +``` +GET /api/stream/{track_id} -> audio stream, MUST support HTTP Range +GET /api/lyrics/{track_id} -> synced lyrics (spec 04 provider chain) +``` + +- `/api/stream` uses range requests so the player can seek. FileResponse handles this, but verify `Accept-Ranges` for the player's needs; if transcoding is added later, gate behind a `?transcode=` param. v1 = direct file passthrough (no transcode). + +### Playback config (prefs) +`prefs` holds playback mode and options: +- `playback_mode`: `basic` | `gapless` | `crossfade` | `interstitial` +- `crossfade_ms`: int (when crossfade) +- `interstitial_track_id`: track id to insert between songs (the "Thomas the Tank Engine between every song" mode). Cute, keep it. +Gapless/crossfade are primarily **client-side** (Web Audio) concerns; backend just streams. Backend stores the prefs and serves the interstitial track like any other. + +## Endpoints — favorites / history / counts + +``` +POST /api/favorite/{track_id} -> toggle favorite +GET /api/favorites -> favorited tracks +POST /api/history body {track_id, completed} -> log play; if completed, bump play_counts +GET /api/history?limit= -> recent plays +GET /api/stats/top?by=plays&limit= -> most played +GET /api/prefs / PUT /api/prefs -> player + app prefs (same pattern as kdrive) +``` + +### Play accounting +- Client logs a play to `/api/history` with `completed=true` when playback passes a threshold (e.g. ≥50% or last 10s reached). That increments `play_counts`. Scrubbed-away early plays log with `completed=false` (history but no count). + +## Global shuffle +``` +GET /api/shuffle/all?limit=500&exclude_probation=false +``` +Returns a shuffled list of track ids spanning the whole library (optionally include probation candidates mixed in — see spec 04). Frontend loads this as the queue. The "one button, everything shuffled" requirement. + +## Deletion (ties into spec 04 dislike flow) +``` +DELETE /api/track/{track_id} -> remove file from fs + all db rows (favorites/history/counts cascade) +``` +Used by the dislike lifecycle's final step. Hard delete, irreversible. Path-safety: only delete within the music root. + +## Threading +Indexing + cover extraction run in a background thread on startup and on `reindex`. SQLite connections per-thread. diff --git a/04-music-recommendation-backend.md b/04-music-recommendation-backend.md new file mode 100644 index 0000000..3b6db6e --- /dev/null +++ b/04-music-recommendation-backend.md @@ -0,0 +1,129 @@ +# music — recommendation / vibe engine spec + +Layers on top of core (spec 03). Handles enrichment (MusicBrainz + Last.fm), the vibe-endless-queue, candidate lifecycle, and the dislike → delayed-delete flow. Acquisition is a pluggable hook — the engine never fetches copyrighted audio itself. + +## External providers +- **MusicBrainz** (no key, rate-limited 1 req/s, set a proper User-Agent): canonical recording/artist/release MBIDs, genres/tags. Used during enrichment to stamp `mbid` and genre data. +- **Last.fm** (free API key required): `track.getSimilar`, `artist.getSimilar`, `tag.getTopTracks`. Drives recommendations + vibe-queue. +- Store the **recommendation source** per candidate (`rec_source` on tracks: `lastfm` | `musicbrainz`) so feedback can be attributed. +- Respect rate limits: queue external calls, cache responses in sqlite. + +```sql +CREATE TABLE IF NOT EXISTS mb_cache ( + key TEXT PRIMARY KEY, -- e.g. "recording::" + payload TEXT NOT NULL, -- JSON + fetched_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS lastfm_cache ( + key TEXT PRIMARY KEY, -- e.g. "similar:<mbid|artist-title>" + payload TEXT NOT NULL, + fetched_at TEXT NOT NULL +); + +-- candidate dislike lifecycle +CREATE TABLE IF NOT EXISTS dislikes ( + track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + disliked_at TEXT NOT NULL, + warn_after TEXT NOT NULL, -- disliked_at + grace period ("a couple days") + warned_at TEXT, -- set when reminder fired + delete_after TEXT, -- warned_at + 24h + state TEXT NOT NULL -- 'hidden' | 'warned' | 'deleted' +); + +CREATE TABLE IF NOT EXISTS feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER, + rec_source TEXT, -- which provider suggested it + action TEXT NOT NULL, -- 'promoted' | 'disliked' | 'skipped' + at TEXT NOT NULL +); +``` + +## Enrichment +- After core indexing, a background enrichment pass fills missing `mbid`, `genre`, artist `genres`, artist `image_path`: + - MusicBrainz lookup by artist+title → recording MBID + tags. + - Last.fm `artist.getInfo` / `track.getInfo` for tags + similar seeds; cache. +- Throttled, cached, resumable. Never blocks playback. + +## Recommendation / vibe queue +``` +GET /api/vibe?seed_track_id=&limit= -> ordered vibe queue (track ids) +GET /api/vibe/from-genre?genre=&limit= +``` +Algorithm: +1. Seed = current track / chosen track / genre. +2. Pull similar from Last.fm (`track.getSimilar`, `artist.getSimilar`, `tag.getTopTracks`), cached. +3. Score each candidate against the **owned library**: + - +score if artist/album already owned, genre overlap, similar to favorites, low recent-play (freshness), not disliked. +4. **Mix**: vibe queue interleaves owned tracks with promoted-from-recommendation tracks — recommendations are NOT front-loaded; they're shuffled into the stream so it feels organic. +5. Candidates **not yet owned** → enter the candidate pipeline (below) rather than playing immediately. + +## Candidate lifecycle (acquisition) +- Hard cap: **max 5 candidates in probation at once** (`tracks.probation=1`). Engine won't request new acquisitions beyond the cap. +- When the engine wants to surface an unowned suggestion, it calls the acquisition hook: + +```kotlin +// or python equivalent — defined seam, implemented by the operator +interface AcquisitionProvider { + /** Fetch audio for a candidate. Return the local file path on success, null to skip. + * The engine does NOT care where the bytes come from. */ + suspend fun acquire(candidate: TrackCandidate): AcquiredFile? +} + +data class TrackCandidate( + val title: String, + val artist: String, + val album: String?, + val mbid: String?, + val recSource: String, // lastfm | musicbrainz +) +data class AcquiredFile(val path: String) +``` + - Default shipped impl: `NoopAcquisitionProvider` (returns null → candidate stays metadata-only, never plays). Operator wires a real one (bandcamp purchase dl, FMA/Jamendo CC, internet archive, personal rips, etc.). + - On successful acquire: file lands in music root, indexed as a track with `probation=1`, `source='recommendation'`, `rec_source` set, hidden from normal library lists (filtered by `probation=0`) but eligible to appear mixed into the **vibe queue**. + +### Promotion +- A probation track that gets played and **not disliked** (passes the play-completion threshold) → `probation=0`, `source` stays `recommendation` for analytics but it's now full library, eligible as a future recommendation seed. Log `feedback(action='promoted')`. + +## Dislike → delayed delete flow +Exact lifecycle: +1. **User dislikes a track** → insert/[update] `dislikes` row: `state='hidden'`, `disliked_at=now`, `warn_after=now + GRACE` (GRACE = "a couple days", e.g. 48h). Track is **hidden** from library + queues immediately. **File stays on disk.** Also `feedback(action='disliked')`. +2. **Reminder**: a periodic sweep finds `state='hidden'` rows past `warn_after` → fire notification (ntfy) / surface a toast in UI: *"You disliked '<track>'. It will be deleted in 24h — are you sure?"* Set `warned_at=now`, `delete_after=now + 24h`, `state='warned'`. +3. **Final**: sweep finds `state='warned'` rows past `delete_after` where the dislike still stands → **delete file from fs + all db rows** (calls core `DELETE /api/track`). Set `state='deleted'` (or just remove the dislike row since the track row is gone). +4. **Un-dislike** at any point before final deletion → remove `dislikes` row, un-hide the track. It's spared. + +``` +POST /api/dislike/{track_id} -> start lifecycle (hide + schedule) +DELETE /api/dislike/{track_id} -> un-dislike (spare it), un-hide +GET /api/dislikes -> pending dislikes + their states/timers +POST /api/dislikes/sweep -> manual trigger of the sweep (also runs on a timer) +``` + +### Sweep scheduler +- A background timer (e.g. hourly) runs the sweep: hidden→warned (fires ntfy), warned→deleted. Hourly granularity is fine for day-scale timers. +- ntfy integration: POST to the ntfy topic (reuse the server's ntfy from the parked list) for the reminder. + +## Feedback loop (future-facing, stub now) +- `feedback` table records promoted/disliked/skipped with `rec_source`. +- Later: weight providers/genres by acceptance rate (promoted vs disliked) to bias future recommendations. v1 just records; scoring tweak is a later iteration. + +## Endpoints summary (this layer) +``` +GET /api/vibe +GET /api/vibe/from-genre +POST /api/dislike/{id} +DELETE /api/dislike/{id} +GET /api/dislikes +POST /api/dislikes/sweep +POST /api/enrich -> manual enrichment pass +GET /api/recommendations -> current probation candidates + why (rec_source, seed) +``` + +## Config / prefs +- `lastfm_api_key` (prefs or env) +- `mb_user_agent` (required by MusicBrainz) +- `dislike_grace_hours` (default 48) +- `dislike_final_hours` (default 24) +- `max_candidates` (default 5) +- `ntfy_topic_url` for reminders diff --git a/05-music-frontend.md b/05-music-frontend.md new file mode 100644 index 0000000..a3cfb99 --- /dev/null +++ b/05-music-frontend.md @@ -0,0 +1,80 @@ +# music — frontend spec + +React (vite). Dark theme by default, blue accent. Web UI is v1; backend is API-first so a mobile app can reuse the same endpoints later. + +## Theme +- **Dark by default.** Blue accent (`--accent: #2D7FF9` or similar; pick one and define tints like kdrive's accent system). +- Reuse the design-token approach from kdrive (CSS vars, Geist font). This is a distinct app with its own palette but shared visual language. +- Animations: tasteful — now-playing bar slide-up, queue/lyrics panel slide-in, album art crossfade on track change, subtle hover/press states, progress bar smoothing, shuffle/loop button state transitions. + +## Layout +- **Left sidebar**: nav (Artists / Albums / Songs / Favorites / Recently played / Vibe), profile/avatar (opens settings modal). +- **Main**: list/grid views per section. +- **Bottom now-playing bar** (persistent): cover thumb, title, artist + feats, prev / play-pause / next, volume slider, shuffle toggle, loop toggle (off → all → one), progress/seek bar, lyrics button, queue button. + +## Views +### Artists +- List/grid of artists (image if enriched, else monogram). Click → artist page: header (image, name, genres), albums, all tracks. + +### Albums +- Grid of album covers (sort: name / year / artist). Click → album page: cover, title, artist, year, ordered tracklist, play / shuffle album. + +### Songs +- Virtualized list (library can be large): title, artist, album, duration, play count, favorite toggle. Sort by title/artist/added/plays. Fuzzy search box. + +### Favorites / Recently played +- Favorites: from `/api/favorites`. Recently played: from `/api/history`. + +### Vibe +- "Start a vibe" from current track / an artist / a genre. Calls `/api/vibe`. Shows the mixed queue; recommendation candidates are visually tagged subtly (small dot/"suggested") but interleaved, not grouped. + +## Player controls (now-playing bar) +- **Global shuffle button** (prominent, maybe in topbar or sidebar too): one tap → `GET /api/shuffle/all` → loads entire library shuffled as queue, starts playing. The headline feature. +- **Prev / Play-Pause / Next**. +- **Loop toggle**: cycles off → loop-all (current queue/album) → loop-one (single track). Distinct icons per state. +- **Volume slider**. +- **Seek bar**: draggable, shows elapsed/total. +- **Cover + title + artist + feats**: feats rendered subtly after artist (e.g. "Artist · feat. X, Y"). +- **Lyrics button**: opens lyrics panel. +- **Queue button**: opens queue panel. + +## Playback engine (client) +- Web Audio / `<audio>` with `/api/stream/{id}` (range-enabled). +- Playback modes from prefs: + - **basic**: sequential. + - **gapless**: preload next track, start without silence (dual audio elements or Web Audio buffering). + - **crossfade**: fade out current / fade in next over `crossfade_ms`. + - **interstitial**: play the configured interstitial track between every song (the gag mode). +- Log plays to `POST /api/history` with `completed` once threshold reached (≥50% or final 10s). + +## Lyrics panel +- Slide-in panel. Calls `/api/lyrics/{track_id}`. +- **Synced** (LRC): auto-scroll, highlight active line, tap a line to seek. +- Falls back to plain text or "no lyrics found". +- Provider chain handled server-side (Musixmatch → LRCLIB), frontend just renders. + +## Queue panel +- Slide-in. Shows **prev tracks** (history within session) and **next tracks** (upcoming). +- Reorder (drag), remove, jump-to. +- Queue resets on session end (no persistence — per the decision). + +## Favorites / dislike +- Heart toggle on tracks/now-playing → `POST /api/favorite/{id}`. +- **Dislike** control (e.g. thumbs-down in now-playing context menu) → `POST /api/dislike/{id}`. Track hides immediately. A toast confirms. +- When a dislike reminder fires (server sweep), the app surfaces a toast: *"You disliked '<track>' — deleting in 24h. Undo?"* with an Undo action calling `DELETE /api/dislike/{id}`. (Also delivered via ntfy out-of-app.) + +## Settings modal (avatar click) +- **Appearance**: theme (dark default, allow light), blue accent + maybe a couple alt accents. +- **Playback**: mode (basic/gapless/crossfade/interstitial), crossfade ms (when crossfade), interstitial track picker (when interstitial). +- **Recommendations**: enable/disable vibe acquisition, max candidates (read-only display of the cap), Last.fm key field, MusicBrainz UA. +- **Library**: reindex button (`POST /api/library/reindex`), enrich button (`POST /api/enrich`). +- Persist via `/api/prefs`. + +## Search +- Global fuzzy search (topbar) → `/api/search`, grouped results: Artists / Albums / Songs. Handles Cyrillic/mixed scripts (display original, match normalized). + +## API-first note +- All state lives behind the documented endpoints so a future mobile client reuses them. No frontend-only business logic that the API can't reproduce. Keep auth simple for v1 (WG-gated, like the rest); leave room for token auth later for mobile over the tunnel. + +## Deploy +- Same pattern as kdrive: vite build → served by the FastAPI app (or its own static mount). nginx vhost via the panel: `music.kvmx.ru` (replaces the swingmusic/navidrome entry) → `http://localhost:<port>`. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..023a1e2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Muzick — Agent Context + +music player + recommendation engine. `muzick.kvmx.ru:5174`. + +## ports +- `5174:80` — frontend (docker) +- `3000:3000` — backend api (docker, internal) + +## docker-compose services +- `db` — postgres 16, schema at `backend/src/db/schema.sql` +- `redis` — redis 7, job queue +- `search` — typesense 0.25.1 (pinned), full-text search +- `backend` — fastify/typescript, music dir mount at `/music` +- `frontend` — vite SPA, served by nginx inside container +- `worker` — metadata enrichment (lastfm, discogs, musicbrainz), `network_mode: host` + +## env (.env) +- `LASTFM_API_KEY`, `LASTFM_SHARED_SECRET` +- `MUSICBRAINZ_CONTACT` +- `DISCOGS_TOKEN` +- `SOCKS_PROXY_URL=socks5://192.168.1.104:10808` — for external api calls + +## gotchas +- worker uses `network_mode: host` + SOCKS5 proxy for metadata lookups. +- typesense is pinned to 0.25.1 — not latest. don't bump casually, the api changes between major versions. +- db schema is in `backend/src/db/schema.sql` — dropped in via docker-entrypoint-initdb.d. +- music dir is read-only bind from `/mnt/hdd1/media/Music`. +- there's also an older `muzick.service` systemd unit that runs the backend solo on port 5213 — that's the pre-docker version and may conflict. the docker compose is the active deployment. diff --git a/README.md b/README.md new file mode 100644 index 0000000..26afe18 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# muzick + +A high-performance, distributed music orchestration and recommendation platform. + +## Overview + +**muzick** is designed to manage a local music library while providing an "infinite vibe" listening experience. It bridges the gap between a local filesystem and advanced discovery engines through a tiered recommendation architecture. + +## Tech Stack + +### **Frontend** +- **Framework:** React +- **Routing:** TanStack Router +- **Data Fetching:** TanStack Query (with Look-ahead Buffering) +- **State Management:** Zustand (for Session/Vibe state) +- **Styling:** CSS Variables (Customizable Themes) + +### **Backend** +- **Runtime:** Node.js / TypeScript +- **Framework:** Fastify +- **Task Queue:** BullMQ (via Redis) +- **Search:** Typesense + +### **Infrastructure & Data** +- **Database:** PostgreSQL (Source of truth for metadata, relationships, and session state) +- **Cache/Queue:** Redis +- **Audio Analysis:** Essentia (via Worker processes) +- **External Metadata:** MusicBrainz, Discogs, LRCLib, Cover Art Archive + +## Core Concepts + +- **The Rolling Vibe:** A continuous, evolving stream of music that uses a "Rolling Window" of tracks. It interleaves owned library tracks with high-probability "probation" tracks (external discoveries). +- **The Dislike Lifecycle:** A multi-stage state machine that protects users from accidental deletions while ensuring the library stays clean. +- **Tiered Similarity:** Instant metadata-based matches, followed by deep audio-feature similarity. + +## Getting Started + +### Prerequisites +- Docker & Docker Compose + +### Running Locally +```bash +docker-compose up -d +``` diff --git a/SESSION-07-07-2026.md b/SESSION-07-07-2026.md new file mode 100644 index 0000000..5855eb6 --- /dev/null +++ b/SESSION-07-07-2026.md @@ -0,0 +1,96 @@ +# Session — 07 July 2026 + +## Scaffolded: v2 Recommendation Engine — code complete, not yet deployed (Systems A–E + Phase 4) + +All six axioms encoded as running code. Every claim is evidence, not fact. +MusicBrainz is the structural spine, not truth. Conflicts coexist in the graph. + +### Files created (8 new) + +| File | Lines | System | +|---|---|---| +| `backend/src/services/generators.service.ts` | 446 | **C** — 8 candidate generators | +| `backend/src/services/session-director.service.ts` | 761 | **D** — Session planner + fatigue + arcs | +| `backend/src/services/discovery.service.ts` | 297 | **E** — Graph walks + probation lifecycle | +| `backend/src/services/image-enrichment.service.ts` | 105 | **Phase 4** — Image candidate pipeline | +| `workers/src/mb-spine-writer.ts` | 136 | **A** — MB artist-credit → claims writer (wired into enrichment.service.ts) | +| `backend/src/routes/graph.routes.ts` | 152 | Graph API (claims, fusion, sources, evidence) | +| `backend/src/routes/v2.routes.ts` | 130 | v2 vibe endpoints (start, next, feedback, state) | +| `backend/src/routes/discovery.routes.ts` | 103 | Discovery + image API endpoints | + +### Files modified (3) + +| File | Changes | +|---|---| +| `backend/src/db/schema.sql` | +187 lines: 9 new tables + 2 ALTER TABLE + indexes | +| `backend/src/services/db.service.ts` | +700 lines: 6 migrations, 12 new methods, 6 interfaces, evidence wiring in recordPlay/recordSkip/recordFeedback/dislikeTrack, listener-behavior writer in recordPlay | +| `backend/src/app.ts` | +4 lines: imports + registrations for v2 + discovery routes | + +### System-by-system + +**System A — Knowledge Graph (probabilistic fusion)** +- `source_trust` table: configurable trust weights (mb=0.90, tag=0.30, listener_behavior=0.40) +- `claims` table: graph spine, unique on (subject, pred, object, source, user_id) +- `claim_fusion` view: weighted vote SUM(trust × confidence × recency) +- `track_artists_v2` / `album_artists_v2`: compatibility views over fusion +- `recording_mbid` on tracks: structural spine anchor +- Methods: upsertClaim, upsertClaims, getClaimsBySubject, getFusedValue, getFusedTrackArtists +- Backfill migration `20260707_backfill_claims`: existing track_artists → claims (tag), artist_similar → same_scene_as (lastfm) + +**System B — Listener Model** +- `evidence` table: append-only signal stream +- `listener_beliefs` table: per-profile beliefs with decay +- Every play/skip/feedback writes evidence rows automatically +- Methods: recordEvidence, recordEvidenceBatch, getListenerBeliefs, updateListenerBelief + +**System C — Candidate Generators (8 generators)** +- `comfortGenerator`: longterm affinity > 0.5 artists +- `adjacentGenerator`: 2-hop graph walks from seed artist +- `discoveryGenerator`: unfamiliar artists via same_scene_as from trusted artists, gated by novelty_tolerance +- `deepDiveGenerator`: obsession album deep cuts in album order +- `revivalGenerator`: stale high-affinity artists (>90d untouched) +- `experimentalGenerator`: random unfamiliar genres +- `contextualGenerator`: context-tagged preferences +- `noveltyGenerator`: recent releases (≤60d) via same_scene_as/same_label_as/produced edges from trusted artists +- All candidates carry non-empty `ClaimEdge[]` explanations (graph paths) + +**System D — Session Director (runs alongside v1; getNextVibeChunk not yet deleted)** +- `buildState`: energy from last 5 plays, novelty_hunger from discovery profile, session age +- `computeFatigue`: exponential decay per dimension (track/7d-30d, artist/24h-8h, genre/24h-8h, language/2h-1h) +- `getBudgets`: reads diversity_budgets, calculates spend from recent history +- `pickArc`/`getArcSlots`: energy+novelty-based arc templates (comfort/discovery/energetic/late-night) +- `rankCandidates`: multi-objective weighted sum (enjoyment, fatigue, diversity, entropy, repetition) +- `detectAntiLoop`: Herfindahl-Hirschman Index + fatigue threshold +- `buildPlan`/`replan`: full orchestration loop with slot filling +- 27KB of planner logic + +**System E — Acquisition Pipeline** +- `walkGraphForDiscovery`: walks same_scene_as/featured_on edges to artists not in library +- `evalCandidates`: fused relevance + novelty tolerance + diversity check → acquire/retire +- `evalProbation`/`sweepProbation`: evidence-based retain/retire lifecycle +- `runMetaLearning`: discovery source retention analysis +- `discovery_candidates` + `probation_status` columns + +**Phase 4 — Image Candidates** +- `fetchImagesForArtist`/`fetchImagesForAlbum`: write candidate rows per source +- `selectBestImage`: source-priority-tiered selection, updates image_path/artwork_id +- `image_candidates` table with source/verified tracking + +### Evidence wiring (every interaction) +- recordPlay → playback_completed (longterm +0.10) + replay_within_24h if applicable + alias_of/same_scene_as behavior claims +- recordSkip → skip_quick (negative -0.20) +- recordFeedback(promoted) → add_to_favorites (longterm +0.60) +- recordFeedback(disliked) → hidden (negative -0.60) +- dislikeTrack → hidden (negative -0.60) + +### Verification +- `npx tsc --noEmit` — 0 errors +- `npx vitest run` — 30/30 pass (mocked shape checks, not DB-state) +- No git repo — changes uncommitted +- NOT deployed: live backend container is pre-v2; `/api/v2/*` and `/api/graph/*` return 404; DB has zero v2 tables. See `docs/architecture/v2-fix-plan.md` for the fix + deploy plan. + +### Next +- Execute `docs/architecture/v2-fix-plan.md` (MV refresh, decay job, bug fixes, deploy) +- After deploy + verify: wire the v2 endpoint into the frontend Vibe page (replace v1 vibeService calls) +- Build yt-dlp worker for System E acquisition (download candidates) +- After v2 is verified in production: delete v1 CTE (`getNextVibeChunk`), `vibe.routes.ts`, `feedback` table, `artist_similar` table per the doc's "Retiring v1" list diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..3f29ae6 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.git +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..208d423 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-slim +WORKDIR /app +COPY package*.json ./ +RUN npm install --legacy-peer-deps +COPY . . +RUN npm run build +EXPOSE 3000 +CMD ["npm", "run", "start"] diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..816a975 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,3206 @@ +{ + "name": "muzick-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "muzick-backend", + "version": "0.1.0", + "dependencies": { + "bullmq": "^5.1.0", + "fastify": "^4.24.3", + "pg": "^8.11.3", + "redis": "^5.0.0", + "typesense": "^3.0.6" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/pg": "^8.20.0", + "tsx": "^4.6.2", + "typescript": "^5.3.3", + "vitest": "^4.1.10" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^5.7.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", + "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.3.0", + "fastq": "^1.17.1" + } + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/bullmq": { + "version": "5.78.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.78.0.tgz", + "integrity": "sha512-tT9jJmbobk9ueEfFc22egLmgwCcMGgOjZ5Y1cvgczBPv1JUmC7iHQVbQtqku2YBE5dE9uzdVpxIrBvL/YAjGwA==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.2", + "node-abort-controller": "3.1.1", + "semver": "7.8.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.1.0", + "ajv": "^8.10.0", + "ajv-formats": "^3.0.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^2.1.0", + "json-schema-ref-resolver": "^1.0.1", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", + "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", + "license": "MIT" + }, + "node_modules/fastify": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", + "abstract-logging": "^2.0.1", + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.2.tgz", + "integrity": "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pino/node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/safe-regex2": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "license": "MIT", + "dependencies": { + "ret": "~0.4.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typesense": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/typesense/-/typesense-3.0.6.tgz", + "integrity": "sha512-d3LL1qOLS8FCRxgAOqH+uDuK+VVA+/HYI1frU9fjYVwOg68mg/dX6XTtZ4yKKAkDCasB/se5uh/ZpMdOz/uJNg==", + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.15.0", + "loglevel": "^1.9.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..706f10c --- /dev/null +++ b/backend/package.json @@ -0,0 +1,29 @@ +{ + "name": "muzick-backend", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "node dist/server.js", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "prebuild": "tsc --noEmit", + "build": "tsc", + "setup-db": "./scripts/setup-db.sh" + }, + "dependencies": { + "bullmq": "^5.1.0", + "fastify": "^4.24.3", + "pg": "^8.11.3", + "redis": "^5.0.0", + "typesense": "^3.0.6" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/pg": "^8.20.0", + "tsx": "^4.6.2", + "typescript": "^5.3.3", + "vitest": "^4.1.10" + } +} diff --git a/backend/scripts/seed.ts b/backend/scripts/seed.ts new file mode 100644 index 0000000..855e256 --- /dev/null +++ b/backend/scripts/seed.ts @@ -0,0 +1,52 @@ +import { Client as PgClient } from 'pg'; + +async function seed() { + const pgClient = new PgClient({ + connectionString: process.env.DATABASE_URL, + }); + + await pgClient.connect(); + + console.log('Seeding database...'); + + try { + // Clear existing data + await pgClient.query('TRUNCATE artists, albums, tracks, genre, track_genre, dislikes, recommendation_batch, recommendation_batch_track, track_audio_features, track_lyrics CASCADE'); + + // Insert an artist + const artistRes = await pgClient.query( + 'INSERT INTO artists (name, mbid) VALUES ($1, $2) RETURNING id', + ['Daft Punk', '5742e173-e031-4848-90a4-977799791608'] + ); + const artistId = artistRes.rows[0].id; + + // Insert an album + const albumRes = await pgClient.query( + 'INSERT INTO albums (artist_id, title, year) VALUES ($1, $2, $3) RETURNING id', + [artistId, 'Discovery', 2001] + ); + const albumId = albumRes.rows[0].id; + + // Insert tracks + await pgClient.query( + `INSERT INTO tracks (path, hash, title, artist, album_id, duration, state, source_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + ['/music/daft_punk/discovery/one_more_time.mp3', 'hash1', 'One More Time', 'Daft Punk', albumId, 320, 'LIBRARY', 'MANUAL'] + ); + + await pgClient.query( + `INSERT INTO tracks (path, hash, title, artist, album_id, duration, state, source_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + ['/music/daft_punk/discovery/harder_better_faster_stronger.mp3', 'hash2', 'Harder, Better, Faster, Stronger', 'Daft Punk', albumId, 224, 'LIBRARY', 'MANUAL'] + ); + + console.log('Seeding successful!'); + } catch (err) { + console.error('Seeding failed:', err); + process.exit(1); + } finally { + await pgClient.end(); + } +} + +seed(); diff --git a/backend/scripts/setup-db.sh b/backend/scripts/setup-db.sh new file mode 100755 index 0000000..e660568 --- /dev/null +++ b/backend/scripts/setup-db.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Initialize the database schema +psql "$DATABASE_URL" -f src/db/schema.sql + + +echo "Database initialized successfully." diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..0f23a88 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,185 @@ +import Fastify from 'fastify'; +import { Client as PgClient } from 'pg'; +import { createClient as createRedisClient } from 'redis'; +import { DbService } from './services/db.service.js'; +import { JobService } from './services/job.service.js'; +import { SearchService } from './services/search.service.js'; +import libraryRoutes from './routes/library.routes.js'; +import searchRoutes from './routes/search.routes.js'; +import adminRoutes from './routes/admin.routes.js'; +import vibeRoutes from './routes/vibe.routes.js'; +import historyRoutes from './routes/history.routes.js'; +import streamRoutes from './routes/stream.routes.js'; +import quarantineRoutes from './routes/quarantine.routes.js'; +import settingsRoutes from './routes/settings.routes.js'; +import graphRoutes from './routes/graph.routes.js'; +import { SessionDirector } from './services/session-director.service.js'; +import v2Routes from './routes/v2.routes.js'; +import discoveryRoutes from './routes/discovery.routes.js'; +import imagesRoutes from './routes/images.routes.js'; + +export interface AppConfig { + port: number; + searchHost: string; + searchPort: number; + searchApiKey: string; +} + +export async function buildApp(config: AppConfig) { + const fastify = Fastify({ logger: true }); + + const pgClient = new PgClient({ + connectionString: process.env.DATABASE_URL, + }); + await pgClient.connect(); + + const redisClient = createRedisClient({ + url: process.env.REDIS_URL, + }); + await redisClient.connect(); + + const jobService = new JobService({ redisUrl: process.env.REDIS_URL! }); + const searchService = new SearchService({ + host: config.searchHost, + port: config.searchPort, + protocol: 'http', + apiKey: config.searchApiKey, + }); + const dbService = new DbService(pgClient, searchService); + + // Apply the idempotent schema on boot so tables added after the initial DB + // volume was created (e.g. play_history, feedback) exist. The init-time + // docker-entrypoint mount only runs on first init, so older volumes miss them. + await dbService.ensureSchema(); + await dbService.runMigrations(); + + // Keep the claim_fusion materialised view fresh. The trigger on + // `claims` fires NOTIFY on every change; rather than maintain a + // LISTEN consumer (separate long-lived connection), we refresh on a + // short interval. 10s staleness is well below any user-facing + // latency for a homelab music player. + const FUSION_REFRESH_MS = 10_000; + const fusionTimer = setInterval(() => { + dbService.refreshClaimFusion().catch(() => {}); + }, FUSION_REFRESH_MS); + + // Daily belief decay (spec §B.4). Runs hourly; the SQL only touches + // beliefs whose last_decayed_at is >1h old, so frequent runs are safe. + const DECAY_INTERVAL_MS = 60 * 60 * 1000; + const decayTimer = setInterval(() => { + dbService.decayBeliefs().catch((e) => console.error('[DB] belief decay failed:', e)); + }, DECAY_INTERVAL_MS); + + // Nightly 'forgotten' profile derivation (spec §B.2). + const FORGOTTEN_INTERVAL_MS = 24 * 60 * 60 * 1000; + const forgottenTimer = setInterval(() => { + dbService.deriveForgottenProfile().catch((e) => + console.error('[DB] forgotten derivation failed:', e) + ); + }, FORGOTTEN_INTERVAL_MS); + + // Run both once at boot so the first session benefits. + dbService.decayBeliefs().catch(() => {}); + dbService.deriveForgottenProfile().catch(() => {}); + + // Ensure the Typesense 'tracks' collection schema exists on boot so that + // the first search request doesn't hit a 404. + await searchService.ensureCollection(); + + // Health check route + + fastify.get('/api/health', async (request, reply) => { + const status = { + postgres: 'unknown', + redis: 'unknown', + }; + + try { + await pgClient.query('SELECT 1'); + status.postgres = 'ok'; + } catch (err) { + status.postgres = 'error'; + fastify.log.error(err); + } + + try { + const redisRes = await redisClient.ping(); + if (redisRes === 'PONG') { + status.redis = 'ok'; + } + } catch (err) { + status.redis = 'error'; + fastify.log.error(err); + } + + const isHealthy = status.postgres === 'ok' && status.redis === 'ok'; + + if (isHealthy) { + return reply.code(200).send(status); + } else { + return reply.code(503).send(status); + } + }); + + fastify.register(imagesRoutes, { prefix: '/api' }); + fastify.register(libraryRoutes, { prefix: '/api', dbService }); + fastify.register(searchRoutes, { prefix: '/api', dbService }); + fastify.register(adminRoutes, { prefix: '/api/admin', jobService, dbService }); + fastify.register(vibeRoutes, { prefix: '/api/vibe', dbService }); + fastify.register(historyRoutes, { prefix: '/api', dbService }); + fastify.register(streamRoutes, { prefix: '/api', dbService }); + fastify.register(quarantineRoutes, { prefix: '/api', dbService }); + fastify.register(settingsRoutes, { prefix: '/api', dbService }); + fastify.register(graphRoutes, { prefix: '/api', dbService }); + + const sessionDirector = new SessionDirector(dbService); + + fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector }); + fastify.register(discoveryRoutes, { prefix: '/api', dbService }); + fastify.post('/api/test/enqueue-job', async (request, reply) => { + const { jobType, trackId, payload } = request.body as any; + try { + if (jobType === 'metadataRefresh') { + await jobService.enqueueMetadataRefresh(trackId, payload.type); + } else if (jobType === 'audioAnalysis') { + await jobService.enqueueAudioAnalysis(trackId, payload.features); + } else if (jobType === 'cleanup') { + await jobService.enqueueCleanup(payload.reason, payload.targetFiles); + } else { + return reply.code(400).send({ error: 'Unknown job type' }); + } + await reply.send({ message: 'Job enqueued' }); + } catch (error) { + request.log.error(error); + await reply.status(500).send({ error: 'Internal server error' }); + } + }); + + // Register hooks to close connections on shutdown + fastify.addHook('onClose', async () => { + try { + clearInterval(fusionTimer); + clearInterval(decayTimer); + clearInterval(forgottenTimer); + } catch (err) { + fastify.log.error(err); + } + try { + await pgClient.end(); + } catch (err) { + fastify.log.error(err); + } + try { + await redisClient.quit(); + } catch (err) { + fastify.log.error(err); + } + try { + await jobService.close(); + } catch (err) { + fastify.log.error(err); + } + }); + + return { fastify, pgClient, redisClient }; +} diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql new file mode 100644 index 0000000..dd49a26 --- /dev/null +++ b/backend/src/db/schema.sql @@ -0,0 +1,490 @@ +-- Enums +-- Guarded so this file is idempotent and can be re-applied on every backend boot +-- (CREATE TYPE has no IF NOT EXISTS; swallow the duplicate_object error instead). +DO $$ BEGIN + CREATE TYPE track_state AS ENUM ('LIBRARY', 'RECOMMENDED', 'HIDDEN', 'MISSING', 'DELETED'); +EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN + CREATE TYPE track_source_type AS ENUM ('MANUAL', 'RECOMMENDATION'); +EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN + CREATE TYPE recommendation_status AS ENUM ('ACTIVE', 'RESOLVED', 'FAILED'); +EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN + CREATE TYPE dislike_state AS ENUM ('HIDDEN', 'WARNED', 'DELETED'); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Reduce an artist string to its PRIMARY (first-billed) artist, so that every +-- form of a collaboration maps to the same canonical identity: +-- "Artist feat. Guest", "Artist ft. Guest", "Artist x Guest", +-- "Artist & Guest", "Artist; Guest", "Artist, Guest", "Artist / Guest" +-- all map to "Artist". This is the identity used by the normalized_name / +-- normalized_artist generated columns, dedup, and the Vibe engine. The full +-- list of co-billed artists is preserved separately in the track_artists table +-- (populated by the scanner and the split-collab-artists migration); this +-- function intentionally only yields the main artist. +-- Also handles parenthesized feature forms like "(feat. X)". +CREATE OR REPLACE FUNCTION normalize_artist(artist TEXT) RETURNS TEXT AS $$ +DECLARE + result TEXT; +BEGIN + -- 1. Strip feat/ft/vs/x feature suffixes (+ everything after them), incl. + -- parenthesized forms like "(feat. X)" / "(Feat. X)". + result := REGEXP_REPLACE( + artist, + '\s*\(?\s*([fF]eat(uring)?\.?|[fF]t\.?|[vV]s\.?|[xX])\s+.*$', + '' + ); + -- 2. Cut at the first collaboration separator ( ; & / , ) and keep the part + -- before it: "$bunny, Metox" -> "$bunny", "Booker & ЗАМАЙ" -> "Booker". + result := REGEXP_REPLACE(result, '\s*[;&/,].*$', ''); + result := BTRIM(result); + RETURN result; +END; +$$ LANGUAGE plpgsql IMMUTABLE STRICT; + +-- Tables +CREATE TABLE IF NOT EXISTS artists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Canonical display name (e.g., "P!nk" not "Pink") + canonical_name TEXT NOT NULL, + -- Sort name for alphabetical ordering (e.g., "Pink, P!" or "Beatles, The") + sort_name TEXT, + -- MusicBrainz ID: the canonical identity. NULL for artists not in MB. + -- Unique when present so we never create duplicate MB artists. + mbid UUID UNIQUE, + -- Fallback: legacy name used before MBID resolution. + -- Not unique; multiple rows can have same name before dedup. + name TEXT NOT NULL, + normalized_name TEXT + GENERATED ALWAYS AS (normalize_artist(name)) STORED, + discogs_id TEXT, + image_path TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name); + +-- Artist aliases: alternative names for the same artist. +-- Enables matching "Pink", "P!nk", "PINK" to the same artist_id. +CREATE TABLE IF NOT EXISTS artist_aliases ( + artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + alias TEXT NOT NULL, + alias_normalized TEXT GENERATED ALWAYS AS (normalize_artist(alias)) STORED, + PRIMARY KEY (artist_id, alias) +); + +CREATE INDEX IF NOT EXISTS idx_artist_aliases_normalized ON artist_aliases(alias_normalized); + +-- Artist lookup cache: avoids repeated MusicBrainz queries. +-- Keyed by normalized artist name. +CREATE TABLE IF NOT EXISTS artist_lookup_cache ( + normalized_name TEXT PRIMARY KEY, + mbid UUID, + canonical_name TEXT, + sort_name TEXT, + fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- Track if we looked up and found nothing (negative cache) + not_found BOOLEAN DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS albums ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + title TEXT NOT NULL, + year INTEGER, + -- Full release date from MusicBrainz first-release-date (YYYY-MM-DD). + -- More precise than `year` (which can also come from Discogs). Used as a + -- deterministic tiebreaker in album dedup (earlier release = keeper). + release_date DATE, + artwork_id TEXT, + mbid UUID UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(artist_id, title) +); + +CREATE INDEX IF NOT EXISTS idx_albums_mbid ON albums(mbid) WHERE mbid IS NOT NULL; + +CREATE TABLE IF NOT EXISTS tracks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + path TEXT UNIQUE NOT NULL, + hash TEXT NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + album_id UUID REFERENCES albums(id) ON DELETE CASCADE, + duration REAL NOT NULL, + state track_state DEFAULT 'LIBRARY', + play_count INTEGER DEFAULT 0, + skip_count INTEGER DEFAULT 0, + dislike_count INTEGER DEFAULT 0, + last_played_at TIMESTAMP, + mtime REAL, + source_type track_source_type DEFAULT 'MANUAL', + quarantined_at TIMESTAMP, + deleted_at TIMESTAMP, + release_date DATE +); + +-- Add normalized columns as generated columns for existing databases where the +-- CREATE TABLE IF NOT EXISTS above was a no-op (column didn't exist before). + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tracks' AND column_name = 'release_date' + ) THEN + ALTER TABLE tracks ADD COLUMN release_date DATE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_tracks_release_date ON tracks (release_date) WHERE release_date IS NOT NULL; +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tracks' AND column_name = 'normalized_artist' + ) THEN + ALTER TABLE tracks ADD COLUMN normalized_artist TEXT + GENERATED ALWAYS AS (normalize_artist(artist)) STORED; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'artists' AND column_name = 'normalized_name' + ) THEN + ALTER TABLE artists ADD COLUMN normalized_name TEXT + GENERATED ALWAYS AS (normalize_artist(name)) STORED; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_tracks_hash ON tracks(hash); +CREATE INDEX IF NOT EXISTS idx_tracks_normalized_artist ON tracks(normalized_artist); +CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name); + +-- Tracks integrity issues found by the periodic integrity-sweep worker. +-- Created with IF NOT EXISTS so the worker can self-provision this table at +-- runtime on databases that predate this schema change (see IntegrityService.ensureSchema). +CREATE TABLE IF NOT EXISTS track_integrity_issues ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + issue_type TEXT NOT NULL, -- 'CORRUPT_METADATA' | 'MISSING_FILE' + status TEXT NOT NULL DEFAULT 'OPEN', -- 'OPEN' | 'FIXED' | 'NEEDS_REVIEW' + details TEXT, -- human-readable: e.g. the corrupted value + detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + resolved_at TIMESTAMP, + UNIQUE(track_id, issue_type) +); + +-- Last.fm artist similarity, feeds Vibe discovery. Populated by the worker's +-- `artist_similarity` job (see EnrichmentService.refreshArtistSimilarity). +-- Created with IF NOT EXISTS so the worker can self-provision this table at +-- runtime on databases that predate this schema change. +-- +-- mbid storage decision: artists.mbid is typed UUID and MusicBrainz MBIDs are +-- themselves UUID-format strings, so the worker stores the artist MBID directly +-- in the existing artists.mbid column (guarded with a UUID-shape check before +-- the write). No mbid_text column was needed. +CREATE TABLE IF NOT EXISTS artist_similar ( + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + similar_name TEXT NOT NULL, + match REAL NOT NULL DEFAULT 0, + fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (artist_id, similar_name) +); + +CREATE TABLE IF NOT EXISTS genre ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + parent_id UUID REFERENCES genre(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS track_genre ( + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + genre_id UUID REFERENCES genre(id) ON DELETE CASCADE, + weight DECIMAL NOT NULL DEFAULT 1.0, + PRIMARY KEY (track_id, genre_id) +); + +CREATE TABLE IF NOT EXISTS dislikes ( + track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + disliked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + warned_at TIMESTAMP, + deleted_at TIMESTAMP, + grace_hours INTEGER DEFAULT 48, + state dislike_state DEFAULT 'HIDDEN' +); + +CREATE TABLE IF NOT EXISTS favorites ( + user_id UUID NOT NULL, + track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS recommendation_batch ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + status recommendation_status DEFAULT 'ACTIVE', + last_interaction_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + seed_track_id UUID REFERENCES tracks(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS recommendation_batch_track ( + batch_id UUID REFERENCES recommendation_batch(id) ON DELETE CASCADE, + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + PRIMARY KEY (batch_id, track_id) +); + +-- Play history: one row per playback event. Feeds the Vibe engine's +-- "Success-Driven Center" rule (a completed play moves the active batch's center) +-- and the feedback learning loop. Created with IF NOT EXISTS so it can be +-- self-provisioned on databases that predate this schema change. +CREATE TABLE IF NOT EXISTS play_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + batch_id UUID REFERENCES recommendation_batch(id) ON DELETE SET NULL, + completed BOOLEAN NOT NULL DEFAULT false, + played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_play_history_user_played_at ON play_history(user_id, played_at DESC); + +-- Feedback: explicit user signals consumed by the Vibe scorer's feedback +-- learning loop. action is one of 'promoted' | 'disliked' | 'skipped' | 'deleted_permanent'. +CREATE TABLE IF NOT EXISTS feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + action TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_feedback_user_action ON feedback(user_id, action); + +CREATE TABLE IF NOT EXISTS track_audio_features ( + track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + bpm REAL, + key TEXT, + energy REAL, + danceability REAL, + valence REAL, + acousticness REAL, + instrumentalness REAL, + liveness REAL, + valence_score REAL, + tempo REAL +); + +CREATE TABLE IF NOT EXISTS track_lyrics ( + track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + lyrics_text TEXT, + provider TEXT, + language VARCHAR(10), + synced_lyrics JSONB +); + +-- Enrichment settings: toggles that control which external-enrichment steps the +-- worker runs. Default all to true (best-effort, credentials-permitting). +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO settings (key, value) VALUES ('enrich_metadata', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('enrich_cover_art', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('enrich_genres', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('enrich_lyrics', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('enrich_artist_similarity', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('enrich_audio_analysis', 'false') ON CONFLICT (key) DO NOTHING; + + +-- ========================================================================== +-- v2 Recommendation Engine — System A: Knowledge Graph (probabilistic fusion) +-- ========================================================================== + +-- Source trust weights. One row per source of claims. Tunable. +CREATE TABLE IF NOT EXISTS source_trust ( + key TEXT PRIMARY KEY, + trust REAL NOT NULL CHECK (trust >= 0 AND trust <= 1.0), + description TEXT NOT NULL +); + +INSERT INTO source_trust (key, trust, description) VALUES + ('curated', 1.00, 'Manual / human-curated claim. Never decayed.'), + ('mb', 0.90, 'MusicBrainz structural spine. High-trust seed; not infallible.'), + ('cover_art_archive', 0.85, 'Cover Art Archive, MB-backed.'), + ('discogs', 0.75, 'Discogs release/artist credits.'), + ('lastfm', 0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'), + ('listener_behavior', 0.40, 'Derived from observed play patterns. User-keyed.'), + ('tag', 0.30, 'File-tag-derived via scanner heuristic. Lowest trust.') +ON CONFLICT (key) DO NOTHING; + +-- Claims: the spine of the graph. One row per (subject, predicate, object, source). +-- user_id is NULL for objective claims (MB, Discogs, tags), non-NULL for +-- listener-behavior-derived claims. +CREATE TABLE IF NOT EXISTS claims ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID, + subject_type TEXT NOT NULL, + subject_id UUID NOT NULL, + predicate TEXT NOT NULL, + object_type TEXT NOT NULL, + object_id UUID NOT NULL, + source TEXT NOT NULL REFERENCES source_trust(key), + confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1.0), + evidence_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + raw JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (subject_type, subject_id, predicate, object_type, object_id, source, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims (subject_type, subject_id, predicate); +CREATE INDEX IF NOT EXISTS idx_claims_object ON claims (object_type, object_id, predicate); +CREATE INDEX IF NOT EXISTS idx_claims_user ON claims (user_id) WHERE user_id IS NOT NULL; + +-- recording_mbid on tracks — the structural spine anchor for the graph +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tracks' AND column_name = 'recording_mbid' + ) THEN + ALTER TABLE tracks ADD COLUMN recording_mbid UUID; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_tracks_recording_mbid ON tracks (recording_mbid) WHERE recording_mbid IS NOT NULL; + +-- ========================================================================== +-- System B: Listener Model +-- ========================================================================== + +-- Evidence: every observed interaction that should influence a belief. +-- Append-only. Never edited or deleted (purge policy separate). +CREATE TABLE IF NOT EXISTS evidence ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + signal TEXT NOT NULL, + profile TEXT NOT NULL, + weight REAL NOT NULL, + context JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_evidence_user_entity ON evidence (user_id, entity_type, entity_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_evidence_user_profile ON evidence (user_id, profile, created_at DESC); + +-- Listener beliefs: the derived state. Continuously decayed; reinforced by evidence. +CREATE TABLE IF NOT EXISTS listener_beliefs ( + user_id UUID NOT NULL, + profile TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + dimension TEXT NOT NULL, + value REAL NOT NULL CHECK (value >= -1.0 AND value <= 1.0), + confidence REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1.0), + evidence_count INTEGER NOT NULL DEFAULT 0, + last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_decayed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, profile, entity_type, entity_id, dimension) +); + +CREATE INDEX IF NOT EXISTS idx_listener_beliefs_user_profile ON listener_beliefs (user_id, profile, entity_type, entity_id); + +-- ========================================================================== +-- System D: Session Director +-- ========================================================================== + +-- Per-session state; persisted across heartbeats so resumes stay coherent. +CREATE TABLE IF NOT EXISTS session_state ( + session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_interaction TIMESTAMPTZ NOT NULL DEFAULT NOW(), + context TEXT, + state_vector JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_session_state_user ON session_state (user_id, last_interaction DESC); + +-- Diversity budgets for the session director's planner. +CREATE TABLE IF NOT EXISTS diversity_budgets ( + user_id UUID NOT NULL, + dimension TEXT NOT NULL, + budget_share REAL NOT NULL, + horizon_min INTEGER NOT NULL, + PRIMARY KEY (user_id, dimension, horizon_min) +); + +-- Adaptive minimum-distance repetition rules. +CREATE TABLE IF NOT EXISTS repetition_rules ( + user_id UUID NOT NULL, + dimension TEXT NOT NULL, + min_distance INTEGER NOT NULL, + PRIMARY KEY (user_id, dimension) +); + +-- ========================================================================== +-- System E: Acquisition Pipeline +-- ========================================================================== + +-- Discovery candidates: tracks not yet in the library, identified by E. +CREATE TABLE IF NOT EXISTS discovery_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source TEXT NOT NULL, + external_id TEXT NOT NULL, + title TEXT, + artist_credit JSONB, + notes JSONB, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_eval_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'candidate', + UNIQUE (source, external_id) +); + +-- Probation status for acquired tracks. +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tracks' AND column_name = 'probation_status' + ) THEN + ALTER TABLE tracks ADD COLUMN probation_status TEXT + DEFAULT 'retained' + CHECK (probation_status IN ('probation', 'retained', 'retired')); + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tracks' AND column_name = 'probation_entered_at' + ) THEN + ALTER TABLE tracks ADD COLUMN probation_entered_at TIMESTAMPTZ; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_tracks_probation ON tracks (probation_status) WHERE probation_status = 'probation'; + +-- ========================================================================== +-- Phase 4 (preserved): Image candidates +-- ========================================================================== + +CREATE TABLE IF NOT EXISTS image_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type TEXT NOT NULL CHECK (entity_type IN ('artist', 'album')), + entity_id UUID NOT NULL, + source TEXT NOT NULL, + url TEXT, + width INTEGER, + verified BOOLEAN DEFAULT FALSE, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (entity_type, entity_id, source) +); + +CREATE INDEX IF NOT EXISTS idx_image_candidates_entity ON image_candidates (entity_type, entity_id); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..02480c0 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1 @@ +console.log('Backend starting...'); diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts new file mode 100644 index 0000000..a3d7ab1 --- /dev/null +++ b/backend/src/routes/admin.routes.ts @@ -0,0 +1,194 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { JobService } from '../services/job.service.js'; +import { DbService } from '../services/db.service.js'; + +export default async function adminRoutes(fastify: FastifyInstance, options: { jobService: JobService; dbService: DbService }) { + const { jobService, dbService } = options; + + fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => { + const { directory } = request.body as { directory: string }; + if (!directory) { + return reply.code(400).send({ error: 'Directory is required' }); + } + await jobService.enqueueLibraryScan(directory); + return { status: 'Scan job enqueued', directory }; + }); + + fastify.post('/reindex-tracks', async (_request: FastifyRequest, reply: FastifyReply) => { + await jobService.enqueueReindexTracks(); + return { status: 'Reindex job enqueued' }; + }); + + fastify.post('/reprocess-artists', async (_request: FastifyRequest, reply: FastifyReply) => { + await jobService.enqueueReprocessArtists(); + return { status: 'Artist reprocessing job enqueued' }; + }); + + fastify.post('/dedup-albums', async (_request: FastifyRequest, reply: FastifyReply) => { + // Merge duplicate album rows directly (synchronous — it's just SQL, no + // external API calls). Returns the number of albums merged away. + // Tiebreaker for keeper selection: MBID > artwork > earliest release_date + // > most tracks > oldest created_at. + // + // Note: the two duplicate-detection passes (by title and by MBID) may find + // overlapping pairs; the UNION ALL in `pairs` can produce duplicates, but + // the DELETE at the end is idempotent (a loser deleted in one pair won't + // exist for the next). The folded/moved CTEs also tolerate this because + // COALESCE is idempotent and the loser row simply won't be found again. + const res = await dbService.pgClient.query<{ count: number }>(` + WITH duplicates AS ( + SELECT lower(title) AS lt, array_agg(id ORDER BY + CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END, + CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END, + release_date NULLS LAST, + (SELECT COUNT(*) FROM tracks t WHERE t.album_id = albums.id) DESC, + created_at + ) AS ids + FROM albums GROUP BY lower(title) HAVING COUNT(*) > 1 + ), + mbid_dupes AS ( + SELECT mbid, array_agg(id ORDER BY + CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END, + release_date NULLS LAST, + created_at + ) AS ids + FROM albums WHERE mbid IS NOT NULL + GROUP BY mbid HAVING COUNT(*) > 1 + ), + pairs AS ( + SELECT ids[1] AS keep_id, unnest(ids[2:]) AS loser_id FROM duplicates + UNION + SELECT ids[1] AS keep_id, unnest(ids[2:]) AS loser_id FROM mbid_dupes + ), + -- Fold metadata from losers onto keepers (idempotent via COALESCE). + folded AS ( + UPDATE albums a SET + artwork_id = COALESCE(a.artwork_id, src.artwork_id), + year = COALESCE(a.year, src.year), + mbid = COALESCE(a.mbid, src.mbid), + release_date = COALESCE(a.release_date, src.release_date) + FROM ( + SELECT DISTINCT ON (p.loser_id) p.keep_id, lo.artwork_id, lo.year, lo.mbid, lo.release_date, p.loser_id + FROM pairs p + JOIN albums lo ON lo.id = p.loser_id + ORDER BY p.loser_id + ) AS src + WHERE a.id = src.keep_id + ), + -- Move tracks from losers to keepers. + moved AS ( + UPDATE tracks SET album_id = src.keep_id + FROM (SELECT DISTINCT keep_id, loser_id FROM pairs) AS src + WHERE tracks.album_id = src.loser_id + ), + -- Delete losers. + deleted AS ( + DELETE FROM albums + WHERE id IN (SELECT DISTINCT loser_id FROM pairs) + RETURNING 1 + ) + SELECT COUNT(*)::int AS count FROM deleted + `); + return { status: 'Albums deduplicated', merged: res.rows[0]?.count ?? 0 }; + }); + + fastify.post('/reenrich-tracks', async (_request: FastifyRequest, reply: FastifyReply) => { + // Re-enqueue metadata_refresh for every LIBRARY track without re-reading + // files from disk. This re-runs the MusicBrainz canonicalisation (artist + // names, album titles, MBIDs) and re-triggers album_cover jobs — much + // faster than a full scan when only metadata needs refreshing. + const res = await dbService.pgClient.query<{ id: string }>( + `SELECT id FROM tracks WHERE state = 'LIBRARY' ORDER BY id` + ); + const trackIds = res.rows.map((r) => r.id); + const enqueued = await jobService.enqueueMetadataRefreshBatch(trackIds); + return { status: 'Re-enrich enqueued', trackCount: enqueued }; + }); + + fastify.get('/queue-stats', async () => { + return await jobService.getQueueStats(); + }); + + fastify.get('/job-history', async (request: FastifyRequest) => { + const { limit } = request.query as { limit?: string }; + return await jobService.getJobHistory(parseInt(limit || '100', 10)); + }); + + fastify.get('/duplicates', async (request) => { + const { mode } = request.query as { mode?: string }; + return await dbService.getDuplicateGroups(mode === 'title-artist' ? 'title-artist' : 'hash'); + }); + + fastify.post('/duplicates/merge', async (request: FastifyRequest, reply: FastifyReply) => { + const { keepId, deleteIds } = request.body as { keepId: string; deleteIds: string[] }; + if (!keepId || !Array.isArray(deleteIds) || deleteIds.length === 0) { + return reply.code(400).send({ error: 'keepId and deleteIds[] are required' }); + } + await dbService.mergeDuplicates(keepId, deleteIds); + return { status: 'merged', kept: keepId, deleted: deleteIds.length }; + }); + + fastify.get('/artist-stats', async (request: FastifyRequest, reply: FastifyReply) => { + const db = dbService.pgClient; + + const total = await db.query('SELECT COUNT(*)::int AS n FROM artists'); + const withMbid = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE mbid IS NOT NULL'); + const withCanonical = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE canonical_name IS NOT NULL'); + const withSort = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE sort_name IS NOT NULL'); + const withImage = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE image_path IS NOT NULL AND image_path != \'\''); + const aliases = await db.query('SELECT COUNT(*)::int AS n FROM artist_aliases'); + const cache = await db.query('SELECT COUNT(*)::int AS n FROM artist_lookup_cache'); + + const noImage = await db.query(` + SELECT name, canonical_name, mbid, sort_name + FROM artists + WHERE image_path IS NULL OR image_path = '' + ORDER BY name + LIMIT 50 + `); + + return { + total: total.rows[0].n, + withMbid: withMbid.rows[0].n, + withCanonicalName: withCanonical.rows[0].n, + withSortName: withSort.rows[0].n, + withImage: withImage.rows[0].n, + withoutImage: total.rows[0].n - withImage.rows[0].n, + aliases: aliases.rows[0].n, + cacheSize: cache.rows[0].n, + imageCoverage: `${((withImage.rows[0].n / total.rows[0].n) * 100).toFixed(1)}%`, + artistsWithoutImage: noImage.rows, + }; + }); + + fastify.get('/artist-verify/:name', async (request: FastifyRequest, reply: FastifyReply) => { + const { name } = request.params as { name: string }; + const db = dbService.pgClient; + + const exact = await db.query( + `SELECT id, name, canonical_name, sort_name, mbid, image_path + FROM artists WHERE name = $1`, + [name] + ); + + const normalized = await db.query( + `SELECT id, name, canonical_name, sort_name, mbid, image_path + FROM artists WHERE normalize_artist(name) = normalize_artist($1)`, + [name] + ); + + const aliases = await db.query( + `SELECT a.*, ar.canonical_name as artist_canonical, ar.mbid as artist_mbid + FROM artist_aliases a + JOIN artists ar ON ar.id = a.artist_id + WHERE a.alias_normalized = normalize_artist($1)`, + [name] + ); + + return { + exactMatch: exact.rows, + normalizedMatches: normalized.rows, + aliases: aliases.rows, + }; + }); +} diff --git a/backend/src/routes/discovery.routes.ts b/backend/src/routes/discovery.routes.ts new file mode 100644 index 0000000..2b82f19 --- /dev/null +++ b/backend/src/routes/discovery.routes.ts @@ -0,0 +1,94 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; +import { DiscoveryService } from '../services/discovery.service.js'; +import { ImageEnrichmentService } from '../services/image-enrichment.service.js'; + +export default async function discoveryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + const discovery = new DiscoveryService(dbService); + const images = new ImageEnrichmentService(dbService); + + /** + * POST /api/discovery/walk — trigger graph walk for discovery candidates + */ + fastify.post('/discovery/walk', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const count = await discovery.walkGraphForDiscovery(userId); + return reply.send({ newCandidates: count }); + }); + + /** + * GET /api/discovery/candidates — list discovery candidates + * Query: ?status=candidate&limit=50 + */ + fastify.get('/discovery/candidates', async (request, reply) => { + const query = request.query as { status?: string; limit?: string }; + const status = query.status || 'candidate'; + const limit = parseInt(query.limit || '50', 10); + + const res = await dbService.pgClient.query( + `SELECT * FROM discovery_candidates WHERE status = $1 ORDER BY first_seen_at DESC LIMIT $2`, + [status, limit] + ); + return reply.send({ candidates: res.rows }); + }); + + /** + * POST /api/discovery/eval — evaluate pending candidates for acquisition + */ + fastify.post('/discovery/eval', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const results = await discovery.evalCandidates(userId); + return reply.send({ evaluated: results.length, results }); + }); + + /** + * POST /api/discovery/sweep-probation — evaluate probation tracks + */ + fastify.post('/discovery/sweep-probation', async (_request, reply) => { + const result = await discovery.sweepProbation(); + return reply.send(result); + }); + + /** + * POST /api/discovery/meta-learn — run meta-learning + */ + fastify.post('/discovery/meta-learn', async (_request, reply) => { + await discovery.runMetaLearning(); + return reply.send({ status: 'ok' }); + }); + + /** + * POST /api/images/fetch — mark image candidates for an entity + * Body: { entity_type, entity_id } + */ + fastify.post('/images/fetch', async (request, reply) => { + const body = request.body as { entity_type: string; entity_id: string }; + if (!body.entity_type || !body.entity_id) { + return reply.code(400).send({ error: 'entity_type and entity_id required' }); + } + + let count = 0; + if (body.entity_type === 'artist') { + count = await images.fetchImagesForArtist(body.entity_id); + } else if (body.entity_type === 'album') { + count = await images.fetchImagesForAlbum(body.entity_id); + } else { + return reply.code(400).send({ error: 'entity_type must be "artist" or "album"' }); + } + return reply.send({ candidateRows: count }); + }); + + /** + * POST /api/images/select — select best image for an entity + * Body: { entity_type, entity_id } + */ + fastify.post('/images/select', async (request, reply) => { + const body = request.body as { entity_type: string; entity_id: string }; + if (!body.entity_type || !body.entity_id) { + return reply.code(400).send({ error: 'entity_type and entity_id required' }); + } + const url = await images.selectBestImage(body.entity_type, body.entity_id); + return reply.send({ url }); + }); +} diff --git a/backend/src/routes/graph.routes.ts b/backend/src/routes/graph.routes.ts new file mode 100644 index 0000000..f185dbc --- /dev/null +++ b/backend/src/routes/graph.routes.ts @@ -0,0 +1,152 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +export default async function graphRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + /** + * GET /api/graph/artists/:id/fusion — fused artist credits for a track or album + * Query: ?entity_type=track&entity_id=<uuid> + * Returns the fused view of who is credited as main/featured on this entity. + */ + fastify.get('/graph/artists/:id/fusion', async (request, reply) => { + const { id } = request.params as { id: string }; + const query = request.query as { entity_type?: string; entity_id?: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + + if (query.entity_type === 'track' && query.entity_id) { + const artists = await dbService.getFusedTrackArtists(query.entity_id, userId); + return reply.send({ entity_type: 'track', entity_id: query.entity_id, artists }); + } + + return reply.code(400).send({ error: 'Provide ?entity_type=track&entity_id=<uuid>' }); + }); + + /** + * GET /api/graph/tracks/:id/claims — all claims for a track + */ + fastify.get('/graph/tracks/:id/claims', async (request, reply) => { + const { id } = request.params as { id: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + + const claims = await dbService.getClaimsBySubject('track', id, undefined, userId); + return reply.send({ track_id: id, claims }); + }); + + /** + * GET /api/graph/artists/:id/claims — all claims for an artist + */ + fastify.get('/graph/artists/:id/claims', async (request, reply) => { + const { id } = request.params as { id: string }; + const predicate = (request.query as { predicate?: string }).predicate; + + const claims = await dbService.getClaimsBySubject('artist', id, predicate); + return reply.send({ artist_id: id, claims }); + }); + + /** + * GET /api/graph/artists/:id/beliefs — listener beliefs for an artist + */ + fastify.get('/graph/artists/:id/beliefs', async (request, reply) => { + const { id } = request.params as { id: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + + const beliefs = await dbService.getListenerBeliefs({ + userId, + entityType: 'artist', + entityId: id, + }); + return reply.send({ artist_id: id, beliefs }); + }); + + /** + * POST /api/graph/claim — upsert a claim into the graph + * Body: { subject_type, subject_id, predicate, object_type, object_id, source, confidence?, raw? } + */ + fastify.post('/graph/claim', async (request, reply) => { + const body = request.body as { + subject_type: string; + subject_id: string; + predicate: string; + object_type: string; + object_id: string; + source: string; + confidence?: number; + raw?: unknown; + }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + + if (!body.subject_type || !body.subject_id || !body.predicate || !body.object_type || !body.object_id || !body.source) { + return reply.code(400).send({ error: 'Missing required fields: subject_type, subject_id, predicate, object_type, object_id, source' }); + } + + const id = await dbService.upsertClaim({ + user_id: userId === '00000000-0000-0000-0000-000000000000' ? null : userId, + subject_type: body.subject_type, + subject_id: body.subject_id, + predicate: body.predicate, + object_type: body.object_type, + object_id: body.object_id, + source: body.source, + confidence: body.confidence, + raw: body.raw, + }); + return reply.code(201).send({ id }); + }); + + /** + * POST /api/graph/evidence — record an evidence signal + * Body: { entity_type, entity_id, signal, profile, weight, context? } + */ + fastify.post('/graph/evidence', async (request, reply) => { + const body = request.body as { + entity_type: string; + entity_id: string; + signal: string; + profile: string; + weight: number; + context?: unknown; + }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + + if (!body.entity_type || !body.entity_id || !body.signal || body.weight === undefined) { + return reply.code(400).send({ error: 'Missing required fields: entity_type, entity_id, signal, weight' }); + } + + const id = await dbService.recordEvidence({ + user_id: userId, + entity_type: body.entity_type, + entity_id: body.entity_id, + signal: body.signal, + profile: body.profile || 'longterm', + weight: body.weight, + context: body.context, + }); + return reply.code(201).send({ id }); + }); + + /** + * GET /api/graph/sources — list all claim sources and their trust weights + */ + fastify.get('/graph/sources', async (_request, reply) => { + const res = await (dbService as any).pgClient.query( + 'SELECT * FROM source_trust ORDER BY trust DESC' + ); + return reply.send({ sources: res.rows }); + }); + + /** + * GET /api/graph/summary — aggregate graph stats (claim counts per source) + */ + fastify.get('/graph/summary', async (_request, reply) => { + const counts = await (dbService as any).pgClient.query( + `SELECT c.source, st.trust, COUNT(*)::int AS claim_count + FROM claims c + JOIN source_trust st ON st.key = c.source + GROUP BY c.source, st.trust + ORDER BY claim_count DESC` + ); + const total = counts.rows.reduce((sum: number, r: any) => sum + r.claim_count, 0); + return reply.send({ total_claims: total, by_source: counts.rows }); + }); +} diff --git a/backend/src/routes/history.routes.ts b/backend/src/routes/history.routes.ts new file mode 100644 index 0000000..4aabb98 --- /dev/null +++ b/backend/src/routes/history.routes.ts @@ -0,0 +1,52 @@ +import { FastifyInstance } from 'fastify'; +import { DbService, FEEDBACK_ACTIONS, FeedbackAction } from '../services/db.service.js'; + +export default async function historyRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + // Record a playback event. completed defaults to false. + fastify.post('/history', async (request, reply) => { + const { trackId, completed, batchId } = request.body as { + trackId: string; + completed?: boolean; + batchId?: string; + }; + if (!trackId) { + return reply.code(400).send({ error: 'trackId is required' }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const historyId = await dbService.recordPlay(userId, trackId, completed === true, batchId); + return reply.send({ historyId }); + }); + + // Record a skip (transient negative signal). + fastify.post('/history/skip', async (request, reply) => { + const { trackId } = request.body as { trackId: string }; + if (!trackId) { + return reply.code(400).send({ error: 'trackId is required' }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.recordSkip(userId, trackId); + return reply.send({ status: 'ok' }); + }); + + // Recent play history for the user. + fastify.get('/history', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + return await dbService.getHistory(userId); + }); + + // Explicit feedback. + fastify.post('/feedback', async (request, reply) => { + const { trackId, action } = request.body as { trackId: string; action: string }; + if (!trackId) { + return reply.code(400).send({ error: 'trackId is required' }); + } + if (!FEEDBACK_ACTIONS.includes(action as FeedbackAction)) { + return reply.code(400).send({ error: `Invalid action. Allowed: ${FEEDBACK_ACTIONS.join(', ')}` }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.recordFeedback(userId, trackId, action as FeedbackAction); + return reply.send({ status: 'ok' }); + }); +} diff --git a/backend/src/routes/images.routes.ts b/backend/src/routes/images.routes.ts new file mode 100644 index 0000000..1773de3 --- /dev/null +++ b/backend/src/routes/images.routes.ts @@ -0,0 +1,49 @@ +import { FastifyInstance } from 'fastify'; + +/** + * Image proxy — fetches external artwork URLs server-side and returns them + * with aggressive caching headers so the browser never re-fetches from + * Discogs / Cover Art Archive on repeat page loads. + */ +export default async function imagesRoutes(fastify: FastifyInstance) { + fastify.get('/images/proxy', async (request, reply) => { + const { url } = request.query as { url?: string }; + if (!url) { + return reply.code(400).send({ error: 'url query parameter is required' }); + } + + // Only proxy http(s) URLs — don't be an open proxy for file:// etc. + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return reply.code(400).send({ error: 'Only http/https URLs are supported' }); + } + + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + return reply.code(response.status).send({ error: `Upstream returned ${response.status}` }); + } + + const buffer = await response.arrayBuffer(); + const contentType = response.headers.get('content-type') || 'image/jpeg'; + + // Cache aggressively — artwork URLs are immutable (Discogs, Cover Art + // Archive etc. use content-addressed paths). 1 year. + return reply + .headers({ + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Length': buffer.byteLength, + }) + .send(Buffer.from(buffer)); + } catch (err: any) { + if (err?.name === 'TimeoutError' || err?.code === 'UND_ERR_CONNECT_TIMEOUT') { + return reply.code(504).send({ error: 'Upstream timed out' }); + } + request.log.error({ err, url }, 'Image proxy failed'); + return reply.code(502).send({ error: 'Failed to fetch image' }); + } + }); +} diff --git a/backend/src/routes/library.routes.ts b/backend/src/routes/library.routes.ts new file mode 100644 index 0000000..8d018a7 --- /dev/null +++ b/backend/src/routes/library.routes.ts @@ -0,0 +1,185 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +export default async function libraryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + fastify.get('/tracks', async (request, reply) => { + const query = request.query as any; + const tracks = await dbService.getTracks({ + limit: query.limit ? parseInt(query.limit) : undefined, + offset: query.offset ? parseInt(query.offset) : undefined, + sort_by: query.sort_by, + order: query.order, + search: query.search, + }); + return tracks; + }); + + fastify.get('/artists', async (request) => { + const query = request.query as any; + return await dbService.getArtists({ + limit: query.limit ? parseInt(query.limit) : undefined, + offset: query.offset ? parseInt(query.offset) : undefined, + }); + }); + + fastify.get('/artists/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const artist = await dbService.getArtistsById(id); + if (!artist) { + return reply.code(404).send({ error: 'Artist not found' }); + } + return artist; + }); + + fastify.get('/artists/:id/similar', async (request, reply) => { + const { id } = request.params as { id: string }; + const artist = await dbService.getArtistsById(id); + if (!artist) { + return reply.code(404).send({ error: 'Artist not found' }); + } + return await dbService.getSimilarArtists(id); + }); + + fastify.get('/albums', async (request) => { + const query = request.query as any; + return await dbService.getAlbums({ + limit: query.limit ? parseInt(query.limit) : undefined, + offset: query.offset ? parseInt(query.offset) : undefined, + }); + }); + + fastify.get('/albums/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const album = await dbService.getAlbumById(id); + if (!album) { + return reply.code(404).send({ error: 'Album not found' }); + } + return album; + }); + + // Genres + fastify.get('/genres', async () => { + return await dbService.getGenres(); + }); + + fastify.get('/genres/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const genre = await dbService.getGenreById(id); + if (!genre) { + return reply.code(404).send({ error: 'Genre not found' }); + } + return genre; + }); + + fastify.get('/genres/:id/tracks', async (request, reply) => { + const { id } = request.params as { id: string }; + const query = request.query as any; + return await dbService.getTracksByGenre( + id, + query.limit ? parseInt(query.limit) : undefined, + query.offset ? parseInt(query.offset) : undefined + ); + }); + + // Favorites + fastify.get('/favorites', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + return await dbService.getFavorites(userId); + }); + + fastify.post('/favorites/:trackId', async (request, reply) => { + const { trackId } = request.params as { trackId: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.addFavorite(userId, trackId); + return reply.send({ status: 'added' }); + }); + + fastify.delete('/favorites/:trackId', async (request, reply) => { + const { trackId } = request.params as { trackId: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.removeFavorite(userId, trackId); + return reply.send({ status: 'removed' }); + }); + + // Dislikes + fastify.post('/tracks/:trackId/dislike', async (request, reply) => { + const { trackId } = request.params as { trackId: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.dislikeTrack(userId, trackId); + return reply.send({ status: 'disliked' }); + }); + + // Artists CRUD + fastify.post('/artists', async (request, reply) => { + const artist = await dbService.createArtist(request.body as any); + return reply.code(201).send(artist); + }); + + fastify.put('/artists/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const artist = await dbService.updateArtist(id, request.body as any); + return artist; + }); + + fastify.delete('/artists/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + await dbService.deleteArtist(id); + return reply.send({ status: 'deleted' }); + }); + + // Albums CRUD + fastify.post('/albums', async (request, reply) => { + const album = await dbService.createAlbum(request.body as any); + return reply.code(201).send(album); + }); + + fastify.put('/albums/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const album = await dbService.updateAlbum(id, request.body as any); + return album; + }); + + fastify.delete('/albums/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + await dbService.deleteAlbum(id); + return reply.send({ status: 'deleted' }); + }); + + // Tracks CRUD + fastify.get('/tracks/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const track = await dbService.getTrackById(id); + if (!track) { + return reply.code(404).send({ error: 'Track not found' }); + } + return track; + }); + + fastify.get('/tracks/:id/lyrics', async (request, reply) => { + const { id } = request.params as { id: string }; + const lyrics = await dbService.getTrackLyrics(id); + if (!lyrics) { + return reply.code(404).send({ error: 'No lyrics found' }); + } + return lyrics; + }); + + fastify.post('/tracks', async (request, reply) => { + const track = await dbService.createTrack(request.body as any); + return reply.code(201).send(track); + }); + + fastify.put('/tracks/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const track = await dbService.updateTrack(id, request.body as any); + return track; + }); + + fastify.delete('/tracks/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + await dbService.deleteTrack(id); + return reply.send({ status: 'deleted' }); + }); +} diff --git a/backend/src/routes/quarantine.routes.ts b/backend/src/routes/quarantine.routes.ts new file mode 100644 index 0000000..200f812 --- /dev/null +++ b/backend/src/routes/quarantine.routes.ts @@ -0,0 +1,34 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +export default async function quarantineRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + // List all disliked tracks (HIDDEN + WARNED states) + fastify.get('/dislikes', async () => { + return await dbService.getDislikedTracks(); + }); + + // Restore a disliked track back to LIBRARY + fastify.post('/dislikes/:trackId/restore', async (request, reply) => { + const { trackId } = request.params as { trackId: string }; + const entry = await dbService.getDislikeByTrackId(trackId); + if (!entry) { + return reply.code(404).send({ error: 'Dislike record not found' }); + } + await dbService.restoreDislike(trackId); + return reply.send({ status: 'restored' }); + }); + + // Hard-delete a disliked track immediately (skips grace period) + fastify.delete('/dislikes/:trackId', async (request, reply) => { + const { trackId } = request.params as { trackId: string }; + const entry = await dbService.getDislikeByTrackId(trackId); + if (!entry) { + return reply.code(404).send({ error: 'Dislike record not found' }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.permanentlyDeleteTrack(userId, trackId, entry.track_path); + return reply.send({ status: 'deleted' }); + }); +} diff --git a/backend/src/routes/search.routes.ts b/backend/src/routes/search.routes.ts new file mode 100644 index 0000000..068f6de --- /dev/null +++ b/backend/src/routes/search.routes.ts @@ -0,0 +1,21 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +export default async function searchRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + fastify.get('/search', async (request, reply) => { + const query = (request.query as any).q; + if (!query) { + return reply.code(400).send({ error: 'Query parameter "q" is required' }); + } + + try { + // Typesense-first with a Postgres ILIKE fallback (Typesense isn't indexed yet). + return await dbService.searchTracks(String(query)); + } catch (error) { + request.log.error(error); + return reply.code(500).send({ error: 'Search failed' }); + } + }); +} diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts new file mode 100644 index 0000000..f62ce7b --- /dev/null +++ b/backend/src/routes/settings.routes.ts @@ -0,0 +1,53 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +const SETTING_KEYS = [ + 'enrich_metadata', + 'enrich_cover_art', + 'enrich_genres', + 'enrich_lyrics', + 'enrich_artist_similarity', + 'enrich_audio_analysis', +] as const; + +type SettingKey = typeof SETTING_KEYS[number]; + +export default async function settingsRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + // GET /api/settings — return all settings as { key: value } map. + fastify.get('/settings', async () => { + const rows = await dbService.pgClient.query('SELECT key, value FROM settings'); + const map: Record<string, string> = {}; + for (const row of rows.rows) { + map[row.key] = row.value; + } + return map; + }); + + // PUT /api/settings/:key — update one setting. + // Validates the key against known keys and the value as 'true'/'false'. + fastify.put<{ Params: { key: string }; Body: { value: string } }>( + '/settings/:key', + async (request, reply) => { + const { key } = request.params; + const { value } = request.body; + + if (!SETTING_KEYS.includes(key as SettingKey)) { + return reply.code(400).send({ error: `Unknown setting: ${key}` }); + } + if (value !== 'true' && value !== 'false') { + return reply.code(400).send({ error: 'Value must be "true" or "false"' }); + } + + await dbService.pgClient.query( + `INSERT INTO settings (key, value, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`, + [key, value] + ); + + return { status: 'ok', key, value }; + } + ); +} diff --git a/backend/src/routes/stream.routes.ts b/backend/src/routes/stream.routes.ts new file mode 100644 index 0000000..90d8893 --- /dev/null +++ b/backend/src/routes/stream.routes.ts @@ -0,0 +1,140 @@ +import { FastifyInstance } from 'fastify'; +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; +import path from 'path'; +import { DbService } from '../services/db.service.js'; + +// Music library root on disk. The worker scanner writes absolute file paths into +// tracks.path rooted here; the backend container must mount the same path so they +// resolve. Path-traversal guard below verifies the resolved file stays inside. +const MUSIC_DIR = path.resolve(process.env.MUSIC_DIR || '/mnt/hdd1/media/Music'); + +const CONTENT_TYPES: Record<string, string> = { + '.mp3': 'audio/mpeg', + '.flac': 'audio/flac', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', +}; + +function contentTypeFor(filePath: string): string { + return CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; +} + +// True when resolvedPath is the music root itself or a descendant of it. +function isInsideRoot(resolvedPath: string, root: string): boolean { + return resolvedPath === root || resolvedPath.startsWith(root + path.sep); +} + +export default async function streamRoutes( + fastify: FastifyInstance, + options: { dbService: DbService } +) { + const { dbService } = options; + + fastify.get('/tracks/:id/stream', async (request, reply) => { + const { id } = request.params as { id: string }; + + const track = await dbService.getTrackById(id); + if (!track) { + return reply.code(404).send({ error: 'Track not found' }); + } + + // SECURITY: resolve the path and confirm it stays within MUSIC_DIR. This + // rejects relative paths, symlink-style escapes and any path outside root. + const resolvedPath = path.resolve(track.path); + if (!isInsideRoot(resolvedPath, MUSIC_DIR)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + let fileSize: number; + try { + const stats = await stat(resolvedPath); + if (!stats.isFile()) { + return reply.code(404).send({ error: 'File not found' }); + } + fileSize = stats.size; + } catch (err: any) { + if (err && err.code === 'ENOENT') { + // File missing on disk; the integrity worker would flag this track MISSING. + return reply.code(404).send({ error: 'File not found on disk' }); + } + throw err; + } + + const contentType = contentTypeFor(resolvedPath); + const rangeHeader = request.headers.range; + + // No Range header: stream the whole file with a 200. + if (!rangeHeader) { + reply + .code(200) + .header('Content-Type', contentType) + .header('Content-Length', fileSize) + .header('Accept-Ranges', 'bytes'); + const stream = createReadStream(resolvedPath); + stream.on('error', (err) => { + request.log.error(err); + reply.raw.destroy(err); + }); + return reply.send(stream); + } + + // Parse "bytes=start-end". Either bound may be omitted. + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!match || (match[1] === '' && match[2] === '')) { + return reply + .code(416) + .header('Content-Range', `bytes */${fileSize}`) + .send({ error: 'Invalid range' }); + } + + let start: number; + let end: number; + if (match[1] === '') { + // suffix range: last N bytes + const suffixLength = parseInt(match[2], 10); + if (suffixLength <= 0) { + return reply + .code(416) + .header('Content-Range', `bytes */${fileSize}`) + .send({ error: 'Unsatisfiable range' }); + } + start = Math.max(fileSize - suffixLength, 0); + end = fileSize - 1; + } else { + start = parseInt(match[1], 10); + end = match[2] === '' ? fileSize - 1 : parseInt(match[2], 10); + } + + if (end > fileSize - 1) end = fileSize - 1; + + if ( + Number.isNaN(start) || + Number.isNaN(end) || + start > end || + start < 0 || + start >= fileSize + ) { + return reply + .code(416) + .header('Content-Range', `bytes */${fileSize}`) + .send({ error: 'Unsatisfiable range' }); + } + + const chunkSize = end - start + 1; + reply + .code(206) + .header('Content-Type', contentType) + .header('Content-Range', `bytes ${start}-${end}/${fileSize}`) + .header('Accept-Ranges', 'bytes') + .header('Content-Length', chunkSize); + + const stream = createReadStream(resolvedPath, { start, end }); + stream.on('error', (err) => { + request.log.error(err); + reply.raw.destroy(err); + }); + return reply.send(stream); + }); +} diff --git a/backend/src/routes/v2.routes.ts b/backend/src/routes/v2.routes.ts new file mode 100644 index 0000000..78b5f9f --- /dev/null +++ b/backend/src/routes/v2.routes.ts @@ -0,0 +1,143 @@ +import { FastifyInstance } from 'fastify'; +import { createClient, RedisClientType } from 'redis'; +import { DbService } from '../services/db.service.js'; +import { SessionDirector } from '../services/session-director.service.js'; +import { Candidate } from '../services/generators.service.js'; + +interface ActivePlan { + sessionId: string; + plan: Candidate[]; + seedTrackId: string | null; +} + +const PLAN_TTL_SEC = 2 * 3600; + +function planKey(userId: string): string { + return `v2:plan:${userId}`; +} + +export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) { + const { dbService, sessionDirector: director } = options; + + const redisClient: RedisClientType = createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379', + }); + await redisClient.connect(); + fastify.addHook('onClose', async () => { await redisClient.quit(); }); + + async function getActivePlan(userId: string): Promise<ActivePlan | null> { + const raw = await redisClient.get(planKey(userId)); + if (!raw) return null; + return JSON.parse(raw) as ActivePlan; + } + + async function setActivePlan(userId: string, plan: ActivePlan): Promise<void> { + await redisClient.setEx(planKey(userId), PLAN_TTL_SEC, JSON.stringify(plan)); + } + + async function delActivePlan(userId: string): Promise<void> { + await redisClient.del(planKey(userId)); + } + + /** + * POST /api/v2/vibe/start — start a v2 session + * Body: { seedTrackId? } + * Returns: { sessionId, plan: Candidate[] } + */ + fastify.post('/v2/vibe/start', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const { seedTrackId } = request.body as { seedTrackId?: string }; + + const sessionId = await dbService.createSessionState(userId, undefined, { energy: 0.5, novelty_hunger: 0.3 }); + const plan = await director.buildPlan(userId, sessionId, seedTrackId); + + await setActivePlan(userId, { sessionId, plan, seedTrackId: seedTrackId ?? null }); + return reply.send({ sessionId, plan: plan.slice(0, 10) }); + }); + + /** + * GET /api/v2/vibe/next — get next track from the plan + * Returns: { track, planRemaining } + */ + fastify.get('/v2/vibe/next', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const active = await getActivePlan(userId); + + if (!active || active.plan.length === 0) { + return reply.code(404).send({ error: 'No active plan. POST /api/v2/vibe/start first.' }); + } + + const next = active.plan.shift()!; + // Enrich with track details + const track = await dbService.getTrackById(next.trackId); + + // Replan if running low + if (active.plan.length < 5) { + const refill = await director.replan(userId, active.sessionId, active.plan, [next.trackId], active.seedTrackId ?? undefined); + active.plan.push(...refill); + } + + await setActivePlan(userId, active); + + return reply.send({ track, explanation: next.explanation, planRemaining: active.plan.length }); + }); + + /** + * POST /api/v2/vibe/feedback — feedback that triggers replan + * Body: { trackId, action: 'completed' | 'skipped' | 'promoted' | 'disliked' } + */ + fastify.post('/v2/vibe/feedback', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const { trackId, action } = request.body as { trackId: string; action: string }; + + if (!trackId || !action) { + return reply.code(400).send({ error: 'trackId and action are required' }); + } + + // Route to existing handlers for evidence wiring + if (action === 'completed') { + await dbService.recordPlay(userId, trackId, true); + } else if (action === 'skipped') { + await dbService.recordSkip(userId, trackId); + } else if (action === 'promoted') { + await dbService.addFavorite(userId, trackId); + await dbService.recordFeedback(userId, trackId, 'promoted'); + } else if (action === 'disliked') { + await dbService.dislikeTrack(userId, trackId); + } + + // Replan the session + const active = await getActivePlan(userId); + if (active) { + const playedTrackIds = [trackId]; + const refill = await director.replan(userId, active.sessionId, active.plan, playedTrackIds, active.seedTrackId ?? undefined); + active.plan.push(...refill); + await setActivePlan(userId, active); + } + + return reply.send({ status: 'ok', planRemaining: active?.plan.length ?? 0 }); + }); + + /** + * GET /api/v2/vibe/plan — current plan for debugging + */ + fastify.get('/v2/vibe/plan', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const active = await getActivePlan(userId); + if (!active) return reply.send({ plan: [] }); + return reply.send({ sessionId: active.sessionId, planRemaining: active.plan.length, plan: active.plan }); + }); + + /** + * GET /api/v2/state — current listener state (debugging) + */ + fastify.get('/v2/state', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const state = await director.buildState(userId); + const fatigue = await director.computeFatigue(userId); + const budgets = await director.getBudgets(userId); + return reply.send({ state, fatigue: Object.fromEntries( + Object.entries(fatigue).map(([k, v]) => [k, v instanceof Map ? Object.fromEntries(v) : v]) + ), budgets }); + }); +} diff --git a/backend/src/routes/vibe.routes.ts b/backend/src/routes/vibe.routes.ts new file mode 100644 index 0000000..50632fd --- /dev/null +++ b/backend/src/routes/vibe.routes.ts @@ -0,0 +1,76 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +export default async function vibeRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + // Start a new vibe session + fastify.post('/start', async (request, reply) => { + const { seedTrackId } = request.body as { seedTrackId: string }; + if (!seedTrackId) { + return reply.code(400).send({ error: 'seedTrackId is required' }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const batchId = await dbService.createVibeSession(userId, seedTrackId); + return reply.send({ batchId }); + }); + + // Get the next chunk of tracks for the active session + fastify.get('/next', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const activeSession = await dbService.getActiveVibeSession(userId); + + if (!activeSession) { + return reply.code(404).send({ error: 'No active vibe session found' }); + } + + const tracks = await dbService.getNextVibeChunk(activeSession.batchId); + + // Update the session timestamp to keep it alive + await dbService.updateVibeSession(activeSession.batchId); + + return tracks; + }); + + // Start/return a chunk seeded by a genre (id or name) — no active session required. + fastify.get('/from-genre', async (request, reply) => { + const { genre } = request.query as { genre?: string }; + if (!genre) { + return reply.code(400).send({ error: 'genre query param is required' }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const tracks = await dbService.getVibeChunkFromGenre(genre, userId); + return tracks; + }); + + // Current ACTIVE batch metadata for the user, or 404. + fastify.get('/current', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const session = await dbService.getCurrentVibeSession(userId); + if (!session) { + return reply.code(404).send({ error: 'No active vibe session found' }); + } + return reply.send(session); + }); + + // Heartbeat: keep the active batch alive by bumping last_interaction_at. + fastify.post('/heartbeat', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const updated = await dbService.heartbeatVibeSession(userId); + if (!updated) { + return reply.code(404).send({ error: 'No active vibe session found' }); + } + return reply.send({ status: 'ok' }); + }); + + // End the vibe session + fastify.post('/end', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const activeSession = await dbService.getActiveVibeSession(userId); + + if (activeSession) { + await dbService.endVibeSession(activeSession.batchId); + } + return reply.send({ status: 'session_ended' }); + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..3055f41 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,21 @@ +import { buildApp } from './app.js'; + +const port = parseInt(process.env.PORT || '3000', 10); + +async function start() { + try { + const { fastify } = await buildApp({ + port, + searchHost: process.env.TYPESENSE_HOST || 'search', + searchPort: parseInt(process.env.TYPESENSE_PORT || '8108', 10), + searchApiKey: process.env.TYPESENSE_API_KEY || 'muzick-key' + }); + await fastify.listen({ port, host: '0.0.0.0' }); + console.log(`Server listening at http://localhost:${port}`); + } catch (err) { + console.error(err); + process.exit(1); + } +} + +start(); diff --git a/backend/src/services/db.service.test.ts b/backend/src/services/db.service.test.ts new file mode 100644 index 0000000..5ce99ad --- /dev/null +++ b/backend/src/services/db.service.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi } from 'vitest'; +import { DbService } from './db.service.js'; + +function makeService(): { service: DbService; mockQuery: ReturnType<typeof vi.fn> } { + const mockQuery = vi.fn(); + const service = new DbService({ query: mockQuery } as any); + return { service, mockQuery }; +} + +describe('DbService v2 methods', () => { + describe('upsertClaim', () => { + it('calls INSERT ... ON CONFLICT with correct parameters', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [{ id: 'claim-1' }] }); + + const id = await service.upsertClaim({ + subject_type: 'track', + subject_id: 'track-1', + predicate: 'credited_main_on', + object_type: 'artist', + object_id: 'artist-1', + source: 'mb', + confidence: 1.0, + }); + + expect(id).toBe('claim-1'); + expect(mockQuery).toHaveBeenCalledTimes(1); + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain('INSERT INTO claims'); + expect(sql).toContain('ON CONFLICT'); + expect(params).toContain('track'); + expect(params).toContain('track-1'); + expect(params).toContain('credited_main_on'); + }); + + it('handles user_id null for objective claims', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [{ id: 'c1' }] }); + await service.upsertClaim({ + subject_type: 'artist', subject_id: 'a1', predicate: 'alias_of', + object_type: 'artist', object_id: 'a2', source: 'listener_behavior', + user_id: 'user-1', + }); + const params = mockQuery.mock.calls[0][1]; + expect(params[0]).toBe('user-1'); + }); + }); + + describe('getClaimsBySubject', () => { + it('filters by subject type and id', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [] }); + await service.getClaimsBySubject('track', 'track-1'); + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain('subject_type = $1'); + expect(sql).toContain('subject_id = $2'); + expect(params).toEqual(['track', 'track-1']); + }); + + it('optionally filters by predicate and user_id', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [] }); + await service.getClaimsBySubject('artist', 'a1', 'alias_of', 'user-1'); + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('predicate'); + expect(sql).toContain('user_id IS NULL'); + }); + }); + + describe('recordEvidence', () => { + it('appends evidence row', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [{ id: 'ev-1' }] }); + const id = await service.recordEvidence({ + user_id: 'user-1', entity_type: 'track', entity_id: 'track-1', + signal: 'playback_completed', profile: 'longterm', weight: 0.10, + }); + expect(id).toBe('ev-1'); + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('INSERT INTO evidence'); + }); + }); + + describe('updateListenerBelief', () => { + it('UPSERTs with delta formula', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rowCount: 1 }); + await service.updateListenerBelief({ + user_id: 'user-1', profile: 'longterm', + entity_type: 'track', entity_id: 'track-1', + dimension: 'affinity', value_delta: 0.10, + }); + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('INSERT INTO listener_beliefs'); + expect(sql).toContain('ON CONFLICT'); + expect(sql).toContain('GREATEST(-1.0, LEAST(1.0'); + }); + }); + + describe('recordEvidence → belief derivation wiring', () => { + it('derives a fresh longterm affinity belief after playback_completed', async () => { + const { service, mockQuery } = makeService(); + // first call: INSERT evidence → returns id; second call: UPSERT belief → rowCount 1 + mockQuery + .mockResolvedValueOnce({ rows: [{ id: 'ev-1' }] }) + .mockResolvedValueOnce({ rowCount: 1 }); + + const id = await service.recordEvidence({ + user_id: 'user-1', entity_type: 'track', entity_id: 'track-1', + signal: 'playback_completed', profile: 'longterm', weight: 0.10, + }); + + expect(id).toBe('ev-1'); + expect(mockQuery).toHaveBeenCalledTimes(2); + + // First call INSERTs the evidence row. + const [evidSql, evidParams] = mockQuery.mock.calls[0]; + expect(evidSql).toContain('INSERT INTO evidence'); + expect(evidParams[4]).toBe('longterm'); // profile + expect(evidParams[5]).toBe(0.10); // weight + + // Second call UPSERTs the matching listener_belief. For a fresh + // belief the INSERT path sets value = weight directly (per spec §B.4 + // INSERT branch), so the resulting row has value=0.10, confidence=0.05. + const [beliefSql, beliefParams] = mockQuery.mock.calls[1]; + expect(beliefSql).toContain('INSERT INTO listener_beliefs'); + expect(beliefSql).toContain('ON CONFLICT'); + // [user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta] + expect(beliefParams[0]).toBe('user-1'); + expect(beliefParams[1]).toBe('longterm'); + expect(beliefParams[2]).toBe('track'); + expect(beliefParams[3]).toBe('track-1'); + expect(beliefParams[4]).toBe('affinity'); + expect(beliefParams[5]).toBe(0.10); // value_delta = weight + expect(beliefParams[6]).toBe(0.05); // confidence_delta default + }); + + it('maps play_of_never_seen to the novelty_tolerance dimension', async () => { + const { service, mockQuery } = makeService(); + mockQuery + .mockResolvedValueOnce({ rows: [{ id: 'ev-2' }] }) + .mockResolvedValueOnce({ rowCount: 1 }); + + await service.recordEvidence({ + user_id: 'user-1', entity_type: 'track', entity_id: 'track-2', + signal: 'play_of_never_seen', profile: 'discovery', weight: 0.05, + }); + + const beliefParams = mockQuery.mock.calls[1][1] as unknown[]; + expect(beliefParams[4]).toBe('novelty_tolerance'); + expect(beliefParams[1]).toBe('discovery'); + expect(beliefParams[5]).toBe(0.05); + }); + + it('still appends an evidence row before deriving the belief', async () => { + const { service, mockQuery } = makeService(); + mockQuery + .mockResolvedValueOnce({ rows: [{ id: 'ev-3' }] }) + .mockResolvedValueOnce({ rowCount: 1 }); + const id = await service.recordEvidence({ + user_id: 'user-1', entity_type: 'track', entity_id: 'track-3', + signal: 'skip_quick', profile: 'negative', weight: -0.20, + }); + expect(id).toBe('ev-3'); + expect(mockQuery.mock.calls[0][0]).toContain('INSERT INTO evidence'); + expect(mockQuery.mock.calls[1][0]).toContain('listener_beliefs'); + expect(mockQuery.mock.calls[1][1][4]).toBe('affinity'); + }); + }); + + describe('getFusedTrackArtists', () => { + it('reads from claim_fusion view', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [{ id: 'a1', name: 'Artist 1', role: 'main', confidence: 0.9 }] }); + const result = await service.getFusedTrackArtists('track-1'); + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('claim_fusion'); + expect(result).toHaveLength(1); + expect(result[0].role).toBe('main'); + }); + }); +}); diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts new file mode 100644 index 0000000..98cc8e0 --- /dev/null +++ b/backend/src/services/db.service.ts @@ -0,0 +1,2284 @@ +import { readFile, unlink } from 'fs/promises'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { Client as PgClient } from 'pg'; +import { SearchService } from './search.service.js'; + +export interface Artist { + id: string; + name: string; + mbid?: string | null; + discogs_id?: string | null; + image_path?: string | null; +} + +export interface Album { + id: string; + artist_id: string; + title: string; + year?: number | null; + artwork_id?: string | null; +} + +export interface TrackArtist { + id: string; + name: string; + role: 'main' | 'featured'; +} + +export interface Track { + id: string; + path: string; + hash: string; + title: string; + artist: string; + album_id: string; + duration: number; + state: string; + play_count: number; + skip_count: number; + dislike_count: number; + last_played_at?: Date | null; + mtime?: number | null; + source_type: string; + artists?: TrackArtist[]; +} + +export const FEEDBACK_ACTIONS = ['promoted', 'disliked', 'skipped', 'deleted_permanent'] as const; +export type FeedbackAction = (typeof FEEDBACK_ACTIONS)[number]; + +export interface HistoryEntry extends Track { + history_id: string; + batch_id: string | null; + played_at: Date; + completed: boolean; +} + +export interface Genre { + id: string; + name: string; + parent_id?: string | null; + track_count?: number; +} + +export interface DislikeEntry { + track_id: string; + disliked_at: Date; + warned_at: Date | null; + deleted_at: Date | null; + grace_hours: number; + state: string; // 'HIDDEN' | 'WARNED' | 'DELETED' + track_title: string; + track_artist: string; + track_path: string; +} + +export interface ArtistWithAlbums { + id: string; + name: string; + mbid?: string | null; + discogs_id?: string | null; + image_path?: string | null; + albums: Album[]; +} + +export interface AlbumWithTracks { + id: string; + artist_id: string; + title: string; + year?: number | null; + artwork_id?: string | null; + tracks: Track[]; +} + + +// --------------------------------------------------------------------------- +// v2 Recommendation Engine types +// --------------------------------------------------------------------------- + +export interface Claim { + id: string; + user_id: string | null; + subject_type: string; + subject_id: string; + predicate: string; + object_type: string; + object_id: string; + source: string; + confidence: number; + evidence_at: Date; + last_reinforced_at: Date; + raw: unknown | null; + created_at: Date; +} + +export interface Evidence { + id: string; + user_id: string; + entity_type: string; + entity_id: string; + signal: string; + profile: string; + weight: number; + context: unknown | null; + created_at: Date; +} + +export interface ListenerBelief { + user_id: string; + profile: string; + entity_type: string; + entity_id: string; + dimension: string; + value: number; + confidence: number; + evidence_count: number; + last_reinforced_at: Date; + last_decayed_at: Date; +} + +export interface ClaimEdge { + subjectType: string; + subjectId: string; + predicate: string; + objectType: string; + objectId: string; + fusedValue: number; +} + +export interface SessionState { + session_id: string; + user_id: string; + started_at: Date; + last_interaction: Date; + context: string | null; + state_vector: Record<string, unknown>; +} + +export interface DiversityBudget { + user_id: string; + dimension: string; + budget_share: number; + horizon_min: number; +} + +export interface RepetitionRule { + user_id: string; + dimension: string; + min_distance: number; +} + +// --------------------------------------------------------------------------- +// Migrations registry +// Add new entries at the END. Never edit or remove existing entries. +// Convention for id: "YYYYMMDD_short_description" +// --------------------------------------------------------------------------- +const MIGRATIONS: { id: string; sql: string }[] = [ + { + id: '20260608_track_artists', + sql: ` + CREATE TABLE IF NOT EXISTS track_artists ( + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'main', + PRIMARY KEY (track_id, artist_id, role) + ); + CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id); + + -- Backfill main artist from albums for tracks not yet in track_artists + INSERT INTO track_artists (track_id, artist_id, role) + SELECT t.id, al.artist_id, 'main' + FROM tracks t + JOIN albums al ON al.id = t.album_id + WHERE NOT EXISTS ( + SELECT 1 FROM track_artists ta WHERE ta.track_id = t.id + ); + `, + }, + { + id: '20260608_clear_lastfm_placeholder_images', + sql: ` + UPDATE artists SET image_path = NULL + WHERE image_path LIKE '%2a96cbd8b46e442fc41c2b86b821562f%'; + `, + }, + { + // normalize_artist() was extended (schema.sql) to also split collaboration + // separators ( ; & / ) — not just commas/feat. STORED generated columns are + // NOT recomputed when the function definition changes, so force a recompute + // by touching the base column of every dependent row. ensureSchema() (which + // installs the new function) runs before migrations, so the new definition + // is already active here. + id: '20260612_recompute_normalized_artist', + sql: ` + UPDATE artists SET name = name; + UPDATE tracks SET artist = artist; + `, + }, + { + // The name-based Wikimedia Commons image fallback (now removed from the + // enrichment chain) frequently attached the wrong photo. Clear those rows so + // they fall back to the placeholder / a better source. Verified Wikidata + // images (fetched via MBID, step 3) also live on wikimedia.org but only on + // artists that HAVE an mbid, so restricting to mbid IS NULL spares them. + id: '20260612_clear_namebased_wikimedia_images', + sql: ` + UPDATE artists SET image_path = NULL + WHERE mbid IS NULL AND image_path LIKE '%wikimedia.org%'; + `, + }, + { + // claim_fusion materialised view + compatibility views for v2 graph. + // Depends on claims, source_trust tables which are created by schema.sql + // (run before migrations). The MV resolves truth at read time as a weighted + // vote across claims per the fusion formula in spec §A.4. + id: '20260707_claim_fusion', + sql: ` + CREATE OR REPLACE VIEW claim_fusion AS + SELECT + c.subject_type, + c.subject_id, + c.predicate, + c.object_type, + c.object_id, + COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id, + SUM( + st.trust * c.confidence * + GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0) + ) AS fused_value, + COUNT(*) AS claim_count, + MAX(c.last_reinforced_at) AS last_reinforced_at + FROM claims c + JOIN source_trust st ON st.key = c.source + GROUP BY c.subject_type, c.subject_id, c.predicate, c.object_type, c.object_id, c.user_id; + + -- Compatibility view: track → artist credits via fusion + CREATE OR REPLACE VIEW track_artists_v2 AS + SELECT t.id AS track_id, + a.id AS artist_id, + a.name AS artist_name, + CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM tracks t + JOIN claim_fusion cf + ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + JOIN artists a ON a.id = cf.object_id; + + -- Compatibility view: album → artist credits via fusion + CREATE OR REPLACE VIEW album_artists_v2 AS + SELECT al.id AS album_id, + a.id AS artist_id, + a.name AS artist_name, + CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM albums al + JOIN claim_fusion cf + ON cf.subject_type = 'album' AND cf.subject_id = al.id + AND cf.predicate IN ('credited_main_on_album', 'featured_on_album') + AND cf.object_type = 'artist' + JOIN artists a ON a.id = cf.object_id; + + `, + }, + { + // Backfill existing data into the claims graph: + // 1. track_artists → credited_main_on / featured_on claims (source=tag) + // 2. artist_similar → same_scene_as claims (source=lastfm, confidence=match) + // This makes the graph immediately usable without waiting for re-enrichment. + id: '20260707_backfill_claims', + sql: ` + -- 1. Populate claims from track_artists (tag-derived) + INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at) + SELECT + 'track' AS subject_type, + ta.track_id AS subject_id, + CASE WHEN ta.role = 'main' THEN 'credited_main_on' ELSE 'featured_on' END AS predicate, + 'artist' AS object_type, + ta.artist_id AS object_id, + 'tag' AS source, + 1.0 AS confidence, + NOW() AS evidence_at + FROM track_artists ta + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; + + -- 2. Populate claims from artist_similar (Last.fm-derived) + INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at) + SELECT + 'artist' AS subject_type, + ar.id AS subject_id, + 'same_scene_as' AS predicate, + 'artist' AS object_type, + similar_ar.id AS object_id, + 'lastfm' AS source, + LEAST(asim.match, 1.0) AS confidence, + COALESCE(asim.fetched_at, NOW()) AS evidence_at + FROM artist_similar asim + JOIN artists ar ON ar.id = asim.artist_id + -- Resolve similar_name to an artist row so object_id is a real entity + JOIN artists similar_ar ON similar_ar.normalized_name = normalize_artist(asim.similar_name) + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; + + -- 3. Also write claims with source='lastfm' for similar_name that didn't + -- resolve to an artist row (store as 'artist' object_type with name in raw) + INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at, raw) + SELECT + 'artist' AS subject_type, + ar.id AS subject_id, + 'same_scene_as' AS predicate, + 'artist_name' AS object_type, + gen_random_uuid() AS object_id, + 'lastfm' AS source, + LEAST(asim.match, 1.0) AS confidence, + COALESCE(asim.fetched_at, NOW()) AS evidence_at, + jsonb_build_object('similar_name', asim.similar_name) + FROM artist_similar asim + JOIN artists ar ON ar.id = asim.artist_id + WHERE NOT EXISTS ( + SELECT 1 FROM artists a WHERE a.normalized_name = normalize_artist(asim.similar_name) + ) + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; + `, + }, + { + id: '20260708_materialize_claim_fusion', + sql: ` + -- Drop old view + dependent views + DROP VIEW IF EXISTS claim_fusion CASCADE; + DROP VIEW IF EXISTS track_artists_v2 CASCADE; + DROP VIEW IF EXISTS album_artists_v2 CASCADE; + + -- Create materialized view (same query as old view) + CREATE MATERIALIZED VIEW IF NOT EXISTS claim_fusion AS + SELECT + c.subject_type, + c.subject_id, + c.predicate, + c.object_type, + c.object_id, + COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id, + SUM( + st.trust * c.confidence * + GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0) + ) AS fused_value, + COUNT(*) AS claim_count, + MAX(c.last_reinforced_at) AS last_reinforced_at + FROM claims c + JOIN source_trust st ON st.key = c.source + GROUP BY c.subject_type, c.subject_id, c.predicate, c.object_type, c.object_id, c.user_id; + + -- Unique index on the MV + CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_fusion_pk ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000')); + + -- Recreate compatibility views (now reading from MV) + CREATE OR REPLACE VIEW track_artists_v2 AS + SELECT t.id AS track_id, + a.id AS artist_id, + a.name AS artist_name, + CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM tracks t + JOIN claim_fusion cf + ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + JOIN artists a ON a.id = cf.object_id; + + CREATE OR REPLACE VIEW album_artists_v2 AS + SELECT al.id AS album_id, + a.id AS artist_id, + a.name AS artist_name, + CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM albums al + JOIN claim_fusion cf + ON cf.subject_type = 'album' AND cf.subject_id = al.id + AND cf.predicate IN ('credited_main_on_album', 'featured_on_album') + AND cf.object_type = 'artist' + JOIN artists a ON a.id = cf.object_id; + + -- Refresh function for the MV + CREATE OR REPLACE FUNCTION refresh_claim_fusion() RETURNS void AS $$ + BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY claim_fusion; + END; + $$ LANGUAGE plpgsql; + + -- Trigger function that notifies on claims changes + CREATE OR REPLACE FUNCTION notify_claim_fusion_change() RETURNS trigger AS $$ + BEGIN + NOTIFY claim_fusion_changed; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + + -- Trigger on claims table + DROP TRIGGER IF EXISTS trg_claim_fusion_refresh ON claims; + CREATE TRIGGER trg_claim_fusion_refresh AFTER INSERT OR UPDATE OR DELETE ON claims FOR EACH STATEMENT EXECUTE FUNCTION notify_claim_fusion_change(); + `, + }, + { + id: '20260708_fix_claim_fusion_index', + sql: ` + -- Drop the expression-based unique index that blocks CONCURRENTLY refresh. + -- The MV's user_id column is already COALESCE'd (non-null) from the SELECT, + -- so we can use the plain column name instead. + DROP INDEX IF EXISTS idx_claim_fusion_pk; + CREATE UNIQUE INDEX idx_claim_fusion_pk + ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, user_id); + `, + }, + { + id: '20260709_artists_mbid_unique', + sql: ` + -- Replace the non-unique partial index on artists.mbid with a unique one, + -- so ON CONFLICT (mbid) works in MbSpineWriter.resolveArtist(). The partial + -- predicate (WHERE mbid IS NOT NULL) allows multiple artists with no MBID. + DROP INDEX IF EXISTS idx_artists_mbid; + CREATE UNIQUE INDEX IF NOT EXISTS artists_mbid_unique + ON artists (mbid) WHERE mbid IS NOT NULL; + `, + }, +]; + +export class DbService { + /** Exposed so route handlers (e.g. settings) can query the database directly. */ + readonly pgClient: PgClient; + + constructor( + pgClient: PgClient, + private searchService?: SearchService + ) { + this.pgClient = pgClient; + } + + /** + * Apply the canonical schema (backend/src/db/schema.sql) on boot. The file is + * fully idempotent — enums are guarded with DO/EXCEPTION blocks and every + * table/index uses IF NOT EXISTS — so running it on every startup is safe and + * self-provisions tables on databases whose volume predates a schema change + * (the docker-entrypoint-initdb.d mount only runs on FIRST init). This is what + * keeps "relation \"play_history\"/\"feedback\" does not exist" from recurring. + */ + async ensureSchema(): Promise<void> { + // Resolve relative to this module. At runtime this is dist/services/, and + // the SQL ships unbuilt at src/db/schema.sql (Dockerfile `COPY . .`), so go + // up two levels from dist/services -> app root, then into src/db. + const here = dirname(fileURLToPath(import.meta.url)); + const schemaPath = join(here, '..', '..', 'src', 'db', 'schema.sql'); + const sql = await readFile(schemaPath, 'utf8'); + await this.pgClient.query(sql); + } + + /** + * Run pending schema migrations on boot. + * + * Each migration is a { id, sql } object. `id` must be a stable, unique string + * (convention: "YYYYMMDD_short_description"). Once applied, the id is recorded + * in `schema_migrations` and never re-run — even if the SQL changes. + * + * To add a new migration: append to the MIGRATIONS array below. Never edit or + * remove an existing entry — that would leave the migration "applied" in the DB + * but with different SQL in code, which is a lie. Instead, add a new migration. + */ + async runMigrations(): Promise<void> { + await this.pgClient.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + const applied = await this.pgClient.query('SELECT id FROM schema_migrations'); + const appliedIds = new Set(applied.rows.map((r: any) => r.id as string)); + + for (const migration of MIGRATIONS) { + if (appliedIds.has(migration.id)) continue; + console.log(`[DB] Running migration: ${migration.id}`); + await this.pgClient.query('BEGIN'); + try { + await this.pgClient.query(migration.sql); + await this.pgClient.query( + 'INSERT INTO schema_migrations (id) VALUES ($1)', + [migration.id] + ); + await this.pgClient.query('COMMIT'); + console.log(`[DB] Migration applied: ${migration.id}`); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + console.error(`[DB] Migration failed: ${migration.id}`, err); + throw err; + } + } + } + + async getTracks(params: { limit?: number; offset?: number; sort_by?: string; order?: 'ASC' | 'DESC'; search?: string } = {}) { + const { limit = 100, offset = 0, sort_by = 'title', order = 'ASC', search } = params; + const validSortBy = ['title', 'artist', 'album_id', 'duration', 'play_count']; + const sortColumn = validSortBy.includes(sort_by) ? `"${sort_by}"` : '"title"'; + const sortOrder = order === 'DESC' ? 'DESC' : 'ASC'; + + if (search && this.searchService) { + const searchResults = await this.searchService.search('tracks', search, { + query_by: 'title,artist' + }); + return (searchResults?.hits || []).map((h: any) => h.document) as Track[]; + } + + // Exclude HIDDEN (disliked) and DELETED tracks from library views. + // Join albums to get artwork_id for track cover display. + let query = ` + SELECT t.*, al.artwork_id + FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.state NOT IN ('HIDDEN', 'DELETED') + `; + const queryParams: any[] = [limit, offset]; + let paramIndex = 3; + + if (search) { + query += ` AND (t.title ILIKE $${paramIndex} OR t.artist ILIKE $${paramIndex})`; + queryParams.push(`%${search}%`); + } + + query += ` ORDER BY ${sortColumn} ${sortOrder} LIMIT $1 OFFSET $2`; + + const res = await this.pgClient.query(query, queryParams); + return this.attachArtists(res.rows as Track[]); + } + + /** Attach artists array (from track_artists join) to a list of tracks. */ + private async attachArtists(tracks: Track[]): Promise<Track[]> { + if (tracks.length === 0) return tracks; + const ids = tracks.map((t) => t.id); + const artistsRes = await this.pgClient.query( + `SELECT ta.track_id, ta.role, a.id, a.name + FROM track_artists ta + JOIN artists a ON a.id = ta.artist_id + WHERE ta.track_id = ANY($1) + ORDER BY ta.role DESC`, + [ids] + ); + const byTrack = new Map<string, TrackArtist[]>(); + for (const row of artistsRes.rows) { + if (!byTrack.has(row.track_id)) byTrack.set(row.track_id, []); + byTrack.get(row.track_id)!.push({ id: row.id, name: row.name, role: row.role }); + } + return tracks.map((t) => ({ ...t, artists: byTrack.get(t.id) ?? [] })); + } + + // Search returning a Typesense-compatible shape ({ found, hits:[{document}] }). + // Prefers Typesense when its 'tracks' collection is populated; falls back to a + // Postgres ILIKE scan when Typesense isn't indexed/reachable yet (no indexing + // pipeline exists today — see TODO: build a tracks reindex into Typesense). + async searchTracks(q: string, limit = 50): Promise<{ found: number; hits: { document: Track }[] }> { + if (this.searchService) { + try { + const res: any = await this.searchService.search('tracks', q, { + query_by: 'title,artist', + per_page: limit, + }); + // Only trust Typesense when it actually returns hits. An empty result is + // ambiguous: it usually means the 'tracks' collection is unindexed (no + // indexing pipeline runs on scan), not that there are genuinely no + // matches — so fall through to the Postgres scan instead of returning []. + if (res && Array.isArray(res.hits) && res.hits.length > 0) { + return { + found: res.found ?? res.hits.length, + hits: res.hits.map((h: any) => ({ document: h.document as Track })), + }; + } + } catch { + // Typesense collection missing/unreachable — fall through to Postgres. + } + } + + const like = `%${q}%`; + const res = await this.pgClient.query( + `SELECT t.*, al.artwork_id FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.state = 'LIBRARY' AND (t.title ILIKE $1 OR t.artist ILIKE $1) + ORDER BY t.play_count DESC, t.title ASC + LIMIT $2`, + [like, limit] + ); + const rows = res.rows as Track[]; + return { found: rows.length, hits: rows.map((t) => ({ document: t })) }; + } + + async getArtists(params: { limit?: number; offset?: number } = {}): Promise<Artist[]> { + const { limit = 50, offset = 0 } = params; + const res = await this.pgClient.query( + 'SELECT * FROM artists ORDER BY name ASC LIMIT $1 OFFSET $2', + [limit, offset] + ); + return res.rows as Artist[]; + } + + async getArtistsById(id: string): Promise<ArtistWithAlbums | null> { + const artistRes = await this.pgClient.query('SELECT * FROM artists WHERE id = $1', [id]); + const artist = artistRes.rows[0] as Artist; + if (!artist) return null; + + const albumsRes = await this.pgClient.query('SELECT * FROM albums WHERE artist_id = $1', [id]); + const albums = albumsRes.rows as Album[]; + + return { + ...artist, + albums, + }; + } + + async getSimilarArtists(artistId: string): Promise<{ similar_name: string; match: number }[]> { + const res = await this.pgClient.query( + `SELECT similar_name, match + FROM artist_similar + WHERE artist_id = $1 + ORDER BY match DESC`, + [artistId] + ); + return res.rows; + } + + async getAlbums(params: { limit?: number; offset?: number } = {}): Promise<Album[]> { + const { limit = 50, offset = 0 } = params; + const res = await this.pgClient.query( + `SELECT al.*, ar.name AS artist_name + FROM albums al + LEFT JOIN artists ar ON ar.id = al.artist_id + ORDER BY al.title ASC + LIMIT $1 OFFSET $2`, + [limit, offset] + ); + return res.rows as Album[]; + } + + async getAlbumById(id: string): Promise<AlbumWithTracks | null> { + const albumRes = await this.pgClient.query('SELECT * FROM albums WHERE id = $1', [id]); + const album = albumRes.rows[0] as Album; + if (!album) return null; + + const tracksRes = await this.pgClient.query( + `SELECT t.*, al.artwork_id FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.album_id = $1 ORDER BY t.title ASC`, [id]); + const tracks = await this.attachArtists(tracksRes.rows as Track[]); + + return { + ...album, + tracks, + }; + } + + async getGenres(): Promise<Genre[]> { + const res = await this.pgClient.query( + `SELECT g.id, g.name, g.parent_id, COUNT(tg.track_id)::int AS track_count + FROM genre g + LEFT JOIN track_genre tg ON tg.genre_id = g.id + GROUP BY g.id + ORDER BY track_count DESC, g.name` + ); + return res.rows as Genre[]; + } + + async getGenreById(id: string): Promise<Genre | null> { + const res = await this.pgClient.query( + `SELECT g.id, g.name, g.parent_id, COUNT(tg.track_id)::int AS track_count + FROM genre g + LEFT JOIN track_genre tg ON tg.genre_id = g.id + WHERE g.id = $1 + GROUP BY g.id`, + [id] + ); + return (res.rows[0] as Genre) || null; + } + + async getTracksByGenre(genreId: string, limit = 100, offset = 0): Promise<Track[]> { + const res = await this.pgClient.query( + `SELECT t.id, t.path, t.hash, t.title, t.artist, t.album_id, t.duration, + t.state, t.play_count, t.skip_count, t.dislike_count, + t.last_played_at, t.mtime, t.source_type, al.artwork_id + FROM tracks t + JOIN track_genre tg ON tg.track_id = t.id + LEFT JOIN albums al ON al.id = t.album_id + WHERE tg.genre_id = $1 AND t.state = 'LIBRARY' + ORDER BY tg.weight DESC + LIMIT $2 OFFSET $3`, + [genreId, limit, offset] + ); + return res.rows as Track[]; + } + + async getFavorites(userId: string): Promise<Track[]> { + // Favorites and disliked tracks are mutually exclusive — a disliked track + // has state='HIDDEN' so it won't appear here. Defensive filter anyway. + const query = ` + SELECT t.*, al.artwork_id FROM tracks t + JOIN favorites f ON t.id = f.track_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE f.user_id = $1 AND t.state = 'LIBRARY' + `; + const res = await this.pgClient.query(query, [userId]); + return res.rows as Track[]; + } + + async addFavorite(userId: string, trackId: string): Promise<void> { + await this.pgClient.query('INSERT INTO favorites (user_id, track_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [userId, trackId]); + } + + async removeFavorite(userId: string, trackId: string): Promise<void> { + await this.pgClient.query('DELETE FROM favorites WHERE user_id = $1 AND track_id = $2', [userId, trackId]); + } + + /** + * Dislike a track: atomically hides the track, inserts a dislike row, and + * logs a feedback event. Per the lifecycle spec, the track transitions from + * LIBRARY -> HIDDEN and is removed from all active views immediately. + */ + async dislikeTrack(userId: string, trackId: string): Promise<void> { + try { + await this.pgClient.query('BEGIN'); + + // Phase 1: hide the track in all active views + await this.pgClient.query( + "UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'", + [trackId] + ); + + // Phase 1: insert dislike row (idempotent — won't create duplicate) + await this.pgClient.query( + 'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING', + [trackId] + ); + + // Phase 1: log feedback signal for the Vibe learning loop + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')", + [userId, trackId] + ); + + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + + // Write evidence: hidden → negative profile (only on success) + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'hidden', + profile: 'negative', + weight: -0.60, + }); + } + + /** + * Restore a disliked track: atomically removes the dislike row and sets the + * track back to LIBRARY. Per the lifecycle spec, this is the "User Recovery" + * reversal of the dislike action. + */ + async restoreDislike(trackId: string): Promise<void> { + try { + await this.pgClient.query('BEGIN'); + + await this.pgClient.query('DELETE FROM dislikes WHERE track_id = $1', [trackId]); + await this.pgClient.query( + "UPDATE tracks SET state = 'LIBRARY' WHERE id = $1", + [trackId] + ); + + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + /** + * Fetch all currently disliked tracks (in HIDDEN state via dislike rows). + * Returns DislikeEntry rows joined with track metadata. Used by the + * Quarantine/dislikes list endpoint. + */ + async getDislikedTracks(): Promise<DislikeEntry[]> { + const res = await this.pgClient.query( + `SELECT d.track_id, d.disliked_at, d.warned_at, d.deleted_at, + d.grace_hours, d.state, + t.title AS track_title, t.artist AS track_artist, t.path AS track_path + FROM dislikes d + JOIN tracks t ON t.id = d.track_id + ORDER BY d.disliked_at DESC` + ); + return res.rows as DislikeEntry[]; + } + + /** + * Fetch a single dislike row by track_id, or null if not disliked. + */ + async getDislikeByTrackId(trackId: string): Promise<DislikeEntry | null> { + const res = await this.pgClient.query( + `SELECT d.track_id, d.disliked_at, d.warned_at, d.deleted_at, + d.grace_hours, d.state, + t.title AS track_title, t.artist AS track_artist, t.path AS track_path + FROM dislikes d + JOIN tracks t ON t.id = d.track_id + WHERE d.track_id = $1`, + [trackId] + ); + return (res.rows[0] as DislikeEntry) || null; + } + + /** + * Permanently delete a track: remove from DB (cascades to related tables) + * and delete the physical file. Used by the cleanup_sweep worker after the + * full lifecycle (grace period + 24h warning) has elapsed. + */ + async hardDeleteTrack(userId: string, trackId: string, filePath: string): Promise<void> { + const { unlink } = await import('fs/promises'); + + // Log the permanent deletion feedback event first (before the track is gone) + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')", + [userId, trackId] + ); + + // Write evidence: manual_deleted → negative profile (strongest negative + // signal, spec §B.3). entity_id has no FK to tracks, so the row survives. + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'manual_deleted', + profile: 'negative', + weight: -0.90, + }); + + // Delete DB record (ON DELETE CASCADE handles track_genre, play_history, + // feedback, track_audio_features, track_lyrics, recommendation_batch_track) + await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]); + + // Delete physical file from disk + try { + await unlink(filePath); + } catch { + // File may already be gone (e.g. missing). Log but don't abort. + } + } + + async recordPlay(userId: string, trackId: string, completed: boolean, batchId?: string): Promise<string> { + if (!completed) { + const res = await this.pgClient.query( + 'INSERT INTO play_history (user_id, track_id, batch_id, completed) VALUES ($1, $2, $3, $4) RETURNING id', + [userId, trackId, batchId ?? null, false] + ); + return res.rows[0].id as string; + } + + // Completed play: history insert + play_count bump + Success-Driven Center + // + evidence writing + listener-behavior claims. All atomic. + try { + await this.pgClient.query('BEGIN'); + + // 1. Record play history + const insertRes = await this.pgClient.query( + 'INSERT INTO play_history (user_id, track_id, batch_id, completed) VALUES ($1, $2, $3, $4) RETURNING id', + [userId, trackId, batchId ?? null, true] + ); + const historyId = insertRes.rows[0].id as string; + + // 2. Bump play count + last_played_at + await this.pgClient.query( + 'UPDATE tracks SET play_count = play_count + 1, last_played_at = NOW() WHERE id = $1', + [trackId] + ); + + // 3. Success-Driven Center: move the user's most-recent ACTIVE batch seed + await this.pgClient.query( + `UPDATE recommendation_batch + SET seed_track_id = $2, last_interaction_at = NOW() + WHERE id = ( + SELECT id FROM recommendation_batch + WHERE user_id = $1 AND status = 'ACTIVE' + ORDER BY last_interaction_at DESC + LIMIT 1 + )`, + [userId, trackId] + ); + + // 4. Write evidence: playback_completed → longterm affinity + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'playback_completed', + profile: 'longterm', + weight: 0.10, + context: batchId ? { batch_id: batchId } : undefined, + }); + + // 5. Check for replay within 24h → strengthens longterm + obsession + const recentPlays = await this.pgClient.query( + `SELECT COUNT(*)::int AS cnt FROM play_history + WHERE user_id = $1 AND track_id = $2 AND completed = true + AND played_at > NOW() - INTERVAL '24 hours'`, + [userId, trackId] + ); + if ((recentPlays.rows[0]?.cnt as number) > 1) { + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'replay_within_24h', + profile: 'longterm', + weight: 0.25, + }); + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'replay_within_24h', + profile: 'obsession', + weight: 0.40, + }); + } + + // 6. Listener-behavior writer: back-to-back play within 30 min → weak edges + // Resolve artist IDs for current track and previous track, then write + // alias_of (same artist, different name) or same_scene_as (different artists). + const currentArtist = await this.pgClient.query( + `SELECT a.id AS artist_id, a.normalized_name + FROM track_artists ta + JOIN artists a ON a.id = ta.artist_id + WHERE ta.track_id = $1 AND ta.role = 'main' + LIMIT 1`, + [trackId] + ); + const currentArtistRow = currentArtist.rows[0] as { artist_id: string; normalized_name: string } | undefined; + + if (currentArtistRow) { + // Get the previous completed play's track + artist + const prevPlay = await this.pgClient.query( + `SELECT ph_prev.track_id AS prev_track_id + FROM play_history ph_this + JOIN play_history ph_prev ON ph_prev.user_id = ph_this.user_id AND ph_prev.completed = true + WHERE ph_this.track_id = $1 AND ph_this.user_id = $2 AND ph_this.completed = true + AND ph_prev.played_at < ph_this.played_at + ORDER BY ph_prev.played_at DESC + LIMIT 1`, + [trackId, userId] + ); + const prevTrackId = prevPlay.rows[0]?.prev_track_id as string | undefined; + + if (prevTrackId) { + // Check time gap + const times = await this.pgClient.query( + `SELECT EXTRACT(EPOCH FROM (ph_this.played_at - ph_prev.played_at)) / 60 AS min_gap + FROM play_history ph_this + JOIN play_history ph_prev ON ph_prev.id = ( + SELECT id FROM play_history + WHERE user_id = $1 AND track_id = $2 AND completed = true + ORDER BY played_at DESC LIMIT 1 + ) + WHERE ph_this.id = ( + SELECT id FROM play_history + WHERE user_id = $1 AND track_id = $3 AND completed = true + ORDER BY played_at DESC LIMIT 1 + )`, + [userId, prevTrackId, trackId] + ); + const gapMinutes = times.rows[0]?.min_gap as number | undefined; + + if (gapMinutes !== undefined && gapMinutes <= 30) { + const prevArtist = await this.pgClient.query( + `SELECT a.id AS artist_id, a.normalized_name + FROM track_artists ta + JOIN artists a ON a.id = ta.artist_id + WHERE ta.track_id = $1 AND ta.role = 'main' + LIMIT 1`, + [prevTrackId] + ); + const prevArtistRow = prevArtist.rows[0] as { artist_id: string; normalized_name: string } | undefined; + + if (prevArtistRow) { + if (prevArtistRow.normalized_name === currentArtistRow.normalized_name) { + // Same normalized artist name → weak alias_of + await this.upsertClaim({ + user_id: userId, + subject_type: 'artist', + subject_id: prevArtistRow.artist_id, + predicate: 'alias_of', + object_type: 'artist', + object_id: currentArtistRow.artist_id, + source: 'listener_behavior', + confidence: 0.2, + }); + } else { + // Different artists played back-to-back → weak same_scene_as + await this.upsertClaim({ + user_id: userId, + subject_type: 'artist', + subject_id: prevArtistRow.artist_id, + predicate: 'same_scene_as', + object_type: 'artist', + object_id: currentArtistRow.artist_id, + source: 'listener_behavior', + confidence: 0.3, + }); + } + } + } + } + } + + await this.pgClient.query('COMMIT'); + return historyId; + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + async recordSkip(userId: string, trackId: string): Promise<void> { + // Skips do NOT move the center (transient per spec). Also writes negative evidence. + try { + await this.pgClient.query('BEGIN'); + await this.pgClient.query('UPDATE tracks SET skip_count = skip_count + 1 WHERE id = $1', [trackId]); + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'skipped')", + [userId, trackId] + ); + // Write evidence: skip_quick → negative profile + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'skip_quick', + profile: 'negative', + weight: -0.20, + }); + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + async recordFeedback(userId: string, trackId: string, action: FeedbackAction): Promise<void> { + if (!FEEDBACK_ACTIONS.includes(action)) { + throw new Error(`Invalid feedback action: ${action}`); + } + await this.pgClient.query( + 'INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, $3)', + [userId, trackId, action] + ); + + // Also write evidence for promoted/disliked signals + if (action === 'promoted') { + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'add_to_favorites', + profile: 'longterm', + weight: 0.60, + }); + } else if (action === 'disliked') { + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'hidden', + profile: 'negative', + weight: -0.60, + }); + } + } + + async getHistory(userId: string, limit = 50): Promise<HistoryEntry[]> { + const res = await this.pgClient.query( + `SELECT t.*, al.artwork_id, ph.played_at AS played_at, ph.completed AS completed, ph.id AS history_id, ph.batch_id AS batch_id + FROM play_history ph + JOIN tracks t ON t.id = ph.track_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE ph.user_id = $1 + ORDER BY ph.played_at DESC + LIMIT $2`, + [userId, limit] + ); + return res.rows as HistoryEntry[]; + } + + async createVibeSession(userId: string, seedTrackId: string): Promise<string> { + const res = await this.pgClient.query( + 'INSERT INTO recommendation_batch (user_id, seed_track_id, status) VALUES ($1, $2, \'ACTIVE\') RETURNING id', + [userId, seedTrackId] + ); + return res.rows[0].id; + } + + async getActiveVibeSession(userId: string): Promise<{ batchId: string, seedTrackId: string } | null> { + const res = await this.pgClient.query( + `SELECT b.id as "batchId", b.seed_track_id as "seedTrackId" + FROM recommendation_batch b + WHERE b.user_id = $1 AND b.status = 'ACTIVE' + ORDER BY b.last_interaction_at DESC LIMIT 1`, + [userId] + ); + return res.rows[0] || null; + } + + /** + * "Rolling Vibe" recommendation engine (full spec implementation). + * + * This is a DB-only, single-round-trip scorer: NO external API calls happen on + * the request path. All enrichment (artist_similar, track_genre, audio + * features) is populated out-of-band by the workers; here we only read it. + * + * ============================ SCORING MODEL ============================ + * Every LIBRARY candidate gets a weighted score: + * + * score = W_GENRE * genre_overlap (dominant signal) + * + W_ARTSIM * artist_similarity (Last.fm similar-artist match) + * + W_SAMEART * same_artist (mild "more of this artist") + * + W_FEEDBCK * feedback_affinity (per-user promoted/disliked genres) + * + W_AUDIO * audio_closeness (NULL-safe, 0 when data missing) + * + W_RANDOM * jitter (tie-break / exploration) + * + * - genre_overlap: SUM(track_genre.weight) over genres shared with the seed. + * Dominant because genre is the most reliable similarity signal we have. + * - artist_similarity: if the candidate's artist name is listed in + * artist_similar for the SEED's artist, add the stored Last.fm `match` + * (0..1). This is the "discovery within library" nudge. + * - same_artist: small flat bonus when candidate shares the seed's artist. + * - feedback_affinity: bounded per-user term. Genres the user has 'promoted' + * push a candidate up; genres they've 'disliked' push it down. Clamped to + * [-1, 1] so a noisy feedback history can never dominate genre matching. + * - audio_closeness: see audioClosenessSQL() — NULL-safe, contributes 0 when + * either side lacks features. Low weight until real Essentia data lands. + * - jitter: RANDOM() in [0,1), scaled small, purely for variety / tie-breaks. + * + * ===================== 80/20 LOCAL vs PROBATION ======================== + * The 20-track chunk is split into two pools UNIONed with a `source` tag: + * - LOCAL (~16): the full scoring model above. "More of what you know." + * - PROBATION (~4): discovery-leaning. Scores LIBRARY tracks by + * artist_similar.match + least-recently-played recency, with genre + * overlap down-weighted, so it feels exploratory. If the probation pool + * is empty (no similarity data yet) the local pool simply fills the full + * 20 (see the genre-cap fill step), so the chunk is never short. + * + * ===================== DIVERSITY CONSTRAINTS ========================== + * Two caps are enforced AFTER scoring, over a generously-sized candidate set: + * 1. Max 1 track per ARTIST: ROW_NUMBER() OVER (PARTITION BY artist ...) = 1. + * 2. Max 2 tracks per GENRE: we attribute each track a single "primary genre" + * (highest-weight track_genre row) and apply a running + * COUNT() <= 2 window over that primary genre, ordered by the merged + * pool priority. Tracks with no genre are never capped. We over-fetch + * (LIMIT 60) before the caps so the caps don't starve the final 20 when + * more diverse tracks are actually available. + * + * ============================ FALLBACK =============================== + * If the seed has no genres AND no artist-similar data, every structured term + * is 0 and ordering collapses to (same_artist + feedback + jitter) — i.e. a + * graceful, mostly-random ordering over LIBRARY tracks rather than empties. + * + * After selection we record the chunk into recommendation_batch_track + * (ON CONFLICT DO NOTHING) so subsequent chunks for this batch exclude them. + */ + async getNextVibeChunk(batchId: string): Promise<Track[]> { + // --- Named scoring weights (see scoring-model comment block above) --- + const W_GENRE = 1.0; // dominant: shared-genre weight sum + const W_ARTSIM = 0.8; // Last.fm similar-artist match (0..1) + const W_SAMEART = 0.4; // flat bonus for same artist as the seed + const W_FEEDBCK = 0.6; // per-user promoted/disliked genre affinity, clamped + const W_AUDIO = 0.4; // NULL-safe audio closeness (energy + bpm + danceability) + const W_RANDOM = 0.3; // jitter for tie-breaking / exploration + + // Probation (discovery) pool weights: lean on artist similarity + recency, + // de-emphasise direct genre overlap so it feels like exploration. + const P_ARTSIM = 1.0; // similar-artist match is the primary discovery signal + const P_GENRE = 0.25; // genre overlap matters less in discovery + const P_RECENCY = 0.5; // least-recently-played gets surfaced + const P_RANDOM = 0.4; + + const LOCAL_TARGET = 16; // ~80% of the 20-track chunk + const PROBATION_TARGET = 4; // ~20% of the 20-track chunk + const CHUNK_SIZE = 20; + const OVERFETCH = 60; // fetch extra so diversity caps don't starve the 20 + const GENRE_CAP = 2; // max tracks per primary genre per chunk + + const trackCols = ` + id, path, hash, title, artist, album_id, duration, state, + play_count, skip_count, dislike_count, last_played_at, mtime, source_type`; + + const query = ` + WITH seed AS ( + SELECT t.id, t.artist, t.normalized_artist + FROM recommendation_batch rb + JOIN tracks t ON t.id = rb.seed_track_id + WHERE rb.id = $1 + ), + seed_user AS ( + SELECT user_id FROM recommendation_batch WHERE id = $1 + ), + -- seed artist UUID (tracks.artist is a name; artist_similar keys on artists.id) + -- Join on normalized identity, NOT raw name, so "The Beatles" (track) still + -- matches "the beatles" / "Beatles" (artist row). A raw-name join silently + -- fails on any case/feature difference and zeroes the artist_sim signal. + seed_artist AS ( + SELECT a.id AS artist_id + FROM seed s + JOIN artists a ON a.normalized_name = s.normalized_artist + ), + seed_genres AS ( + SELECT tg.genre_id, tg.weight + FROM seed s + JOIN track_genre tg ON tg.track_id = s.id + ), + -- artists Last.fm-similar to the seed's artist (by name, for candidate join) + -- normalize_artist() on similar_name so the join to tracks.normalized_artist + -- matches case- and feature-insensitively. Handles both old rows (raw + -- Last.fm names) and new rows (already normalized by the worker). + similar_artists AS ( + SELECT normalize_artist(asim.similar_name) AS similar_name, asim.match + FROM seed_artist sa + JOIN artist_similar asim ON asim.artist_id = sa.artist_id + ), + -- per-user genre affinity from feedback: +promoted, -disliked, clamped [-1,1] + feedback_genre AS ( + SELECT tg.genre_id, + GREATEST(-1.0, LEAST(1.0, + SUM(CASE f.action + WHEN 'promoted' THEN 0.5 + WHEN 'disliked' THEN -0.5 + ELSE 0 END) + )) AS affinity + FROM feedback f + JOIN seed_user su ON su.user_id = f.user_id + JOIN track_genre tg ON tg.track_id = f.track_id + WHERE f.action IN ('promoted', 'disliked') + GROUP BY tg.genre_id + ), + -- primary genre per track = its highest-weight track_genre row (for genre cap) + primary_genre AS ( + SELECT track_id, genre_id FROM ( + SELECT tg.track_id, tg.genre_id, + ROW_NUMBER() OVER (PARTITION BY tg.track_id ORDER BY tg.weight DESC) AS rn + FROM track_genre tg + ) pg WHERE rn = 1 + ), + base AS ( + SELECT + t.*, + pg.genre_id AS primary_genre_id, + -- genre overlap: sum of shared-genre weights with the seed + COALESCE(( + SELECT SUM(tg.weight) + FROM track_genre tg + JOIN seed_genres sg ON sg.genre_id = tg.genre_id + WHERE tg.track_id = t.id + ), 0) AS genre_overlap, + -- best similar-artist match for this candidate's normalized artist (0 if none) + COALESCE(( + SELECT MAX(sa.match) FROM similar_artists sa + WHERE sa.similar_name = t.normalized_artist + ), 0) AS artist_sim, + (CASE WHEN t.normalized_artist = s.normalized_artist THEN 1 ELSE 0 END) AS same_artist, + -- bounded feedback affinity: sum candidate's genre affinities, clamp + GREATEST(-1.0, LEAST(1.0, COALESCE(( + SELECT SUM(fg.affinity) + FROM track_genre tg + JOIN feedback_genre fg ON fg.genre_id = tg.genre_id + WHERE tg.track_id = t.id + ), 0))) AS feedback_affinity, + -- NULL-safe audio closeness vs seed (0 when either side has no features) + ${this.audioClosenessSQL('t.id')} AS audio_closeness, + -- recency: oldest last_played_at scores highest (NULLs = never played = max) + COALESCE(EXTRACT(EPOCH FROM (NOW() - t.last_played_at)) / 2592000.0, 1.0) + AS recency, + -- session-level artist play count: how many times this normalized artist + -- has already been recommended in the current batch (for decay scoring). + -- Using normalized_artist so "Artist feat. Guest" and "Artist" share a count. + (SELECT COUNT(*) FROM recommendation_batch_track rbt + JOIN tracks tr ON tr.id = rbt.track_id + WHERE rbt.batch_id = $1 AND tr.normalized_artist = t.normalized_artist) AS artist_play_count + FROM tracks t + CROSS JOIN seed s + LEFT JOIN primary_genre pg ON pg.track_id = t.id + WHERE t.state = 'LIBRARY' + AND t.id != s.id + AND NOT EXISTS ( + SELECT 1 FROM recommendation_batch_track rbt + WHERE rbt.batch_id = $1 AND rbt.track_id = t.id + ) + ), + -- LOCAL pool: full scoring model + local_pool AS ( + SELECT b.*, + 'local'::text AS source, + ( ( ${W_GENRE} * b.genre_overlap + + ${W_ARTSIM} * b.artist_sim + + ${W_SAMEART} * b.same_artist + + ${W_FEEDBCK} * b.feedback_affinity + + ${W_AUDIO} * b.audio_closeness + + ${W_RANDOM} * RANDOM() ) + * GREATEST(0.2, 1.0 - (b.artist_play_count - 1) * 0.20) ) AS score + FROM base b + ), + -- PROBATION pool: discovery-leaning, only candidates with a similarity signal + probation_pool AS ( + SELECT b.*, + 'probation'::text AS source, + ( ( ${P_ARTSIM} * b.artist_sim + + ${P_GENRE} * b.genre_overlap + + ${P_RECENCY} * LEAST(b.recency, 2.0) + + ${P_RANDOM} * RANDOM() ) + * GREATEST(0.2, 1.0 - (b.artist_play_count - 1) * 0.20) ) AS score + FROM base b + WHERE b.artist_sim > 0 + ), + local_ranked AS ( + SELECT lp.*, ROW_NUMBER() OVER (ORDER BY lp.score DESC) AS rn + FROM local_pool lp + ), + probation_ranked AS ( + SELECT pp.*, ROW_NUMBER() OVER (ORDER BY pp.score DESC) AS rn + FROM probation_pool pp + ), + -- merge: take top probation candidates first, then top local, dedup by id + merged AS ( + SELECT * FROM ( + SELECT pr.*, 0 AS pool_order FROM probation_ranked pr WHERE pr.rn <= ${PROBATION_TARGET} + UNION ALL + SELECT lr.*, 1 AS pool_order FROM local_ranked lr WHERE lr.rn <= ${OVERFETCH} + ) u + ), + -- dedup (a track can appear in both pools): keep its best (probation-first) row + deduped AS ( + SELECT m.* FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY id ORDER BY pool_order ASC, score DESC + ) AS dedup_rn + FROM merged + ) m WHERE m.dedup_rn = 1 + ), + -- DIVERSITY CAP 1: max 1 per normalized artist (keep best-scoring row per + -- normalized artist). Uses normalized_artist so "Artist feat. Guest" and + -- "Artist" are treated as the same artist for dedup. + artist_capped AS ( + SELECT d.* FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY normalized_artist ORDER BY pool_order ASC, score DESC + ) AS artist_rn + FROM deduped + ) d WHERE d.artist_rn = 1 + ), + -- DIVERSITY CAP 2: max ${GENRE_CAP} per primary genre. NULL-genre tracks + -- are never capped (assigned rank 1). Order by pool/score so the best + -- representatives of each genre survive. + genre_capped AS ( + SELECT g.* FROM ( + SELECT *, + CASE WHEN primary_genre_id IS NULL THEN 1 + ELSE ROW_NUMBER() OVER ( + PARTITION BY primary_genre_id ORDER BY pool_order ASC, score DESC + ) END AS genre_rn + FROM artist_capped + ) g WHERE g.genre_rn <= ${GENRE_CAP} + ), + chosen AS ( + SELECT * FROM genre_capped + ORDER BY pool_order ASC, score DESC + LIMIT ${CHUNK_SIZE} + ), + recorded AS ( + INSERT INTO recommendation_batch_track (batch_id, track_id) + SELECT $1, id FROM chosen + ON CONFLICT DO NOTHING + ) + SELECT ${trackCols}, al.artwork_id + FROM chosen + LEFT JOIN albums al ON al.id = chosen.album_id + ORDER BY score DESC; + `; + + void LOCAL_TARGET; // documented split target; LOCAL fills remainder via OVERFETCH + const res = await this.pgClient.query(query, [batchId]); + return res.rows as Track[]; + } + + /** + * NULL-safe audio-feature closeness term. + * + * Returns a SQL scalar expression (0..1, higher = more similar) comparing the + * candidate track (`candidateIdExpr`) against the seed's audio features via a + * LEFT JOIN-style correlated lookup. It is COALESCE/NULL-safe: if EITHER the + * candidate OR the seed lacks a track_audio_features row (or the compared + * columns are NULL), the term evaluates to 0 so missing audio data never zeroes + * a track out of the running — it simply doesn't contribute. + * + * Closeness = average normalized closeness across energy, bpm and danceability. + * energy/danceability are 0..1; bpm is normalised over a 200 BPM span. + * Each dimension is optional: only dimensions where BOTH seed and candidate + * have a non-NULL value contribute, and the divisor shrinks accordingly so a + * track missing one feature isn't unfairly penalised. + */ + private audioClosenessSQL(candidateIdExpr: string): string { + return ` + COALESCE(( + SELECT + ( CASE WHEN sf.energy IS NOT NULL AND cf.energy IS NOT NULL + THEN (1 - LEAST(ABS(cf.energy - sf.energy), 1)) ELSE NULL END + + CASE WHEN sf.bpm IS NOT NULL AND cf.bpm IS NOT NULL + THEN (1 - LEAST(ABS(cf.bpm - sf.bpm) / 200.0, 1)) ELSE NULL END + + CASE WHEN sf.danceability IS NOT NULL AND cf.danceability IS NOT NULL + THEN (1 - LEAST(ABS(cf.danceability - sf.danceability), 1)) ELSE NULL END + ) / NULLIF( + (CASE WHEN sf.energy IS NOT NULL AND cf.energy IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN sf.bpm IS NOT NULL AND cf.bpm IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN sf.danceability IS NOT NULL AND cf.danceability IS NOT NULL THEN 1 ELSE 0 END + ), 0) + FROM track_audio_features cf + JOIN track_audio_features sf ON sf.track_id = (SELECT id FROM seed) + WHERE cf.track_id = ${candidateIdExpr} + ), 0)`; + } + + /** + * GET /api/vibe/from-genre — start a chunk seeded by a genre rather than a track. + * + * Accepts a genre id (UUID) or a genre name. Scores LIBRARY tracks by their + * membership weight in that genre (+ jitter), applies the same diversity caps + * (max 1 per artist, max 2 per primary genre) and returns up to 20 Track[]. + * + * Design decision: this does NOT create a recommendation_batch and does NOT + * require an active session. It is a lightweight, stateless "play this genre" + * entry point; the caller can subsequently POST /start to roll a real session. + * Because there's no batch, returned tracks are not recorded anywhere. + */ + async getVibeChunkFromGenre(genreIdOrName: string, _userId: string): Promise<Track[]> { + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + genreIdOrName + ); + const GENRE_CAP = 2; + // Note: no table prefix — these cols flow through CTEs (genre_capped) + // where the `t.` / `al.` aliases no longer apply. + const trackCols = ` + id, path, hash, title, artist, album_id, duration, state, + play_count, skip_count, dislike_count, last_played_at, mtime, source_type, artwork_id`; + + const query = ` + WITH target_genre AS ( + SELECT id FROM genre WHERE ${isUuid ? 'id = $1::uuid' : 'name = $1'} + ), + base AS ( + SELECT t.*, al.artwork_id, + tg.weight AS genre_weight, + pg.genre_id AS primary_genre_id, + (tg.weight * 1.0 + RANDOM() * 0.3) AS score + FROM tracks t + JOIN track_genre tg ON tg.track_id = t.id + JOIN target_genre g ON g.id = tg.genre_id + LEFT JOIN albums al ON al.id = t.album_id + LEFT JOIN ( + SELECT track_id, genre_id FROM ( + SELECT tg2.track_id, tg2.genre_id, + ROW_NUMBER() OVER (PARTITION BY tg2.track_id ORDER BY tg2.weight DESC) AS rn + FROM track_genre tg2 + ) p WHERE rn = 1 + ) pg ON pg.track_id = t.id + WHERE t.state = 'LIBRARY' + ), + artist_capped AS ( + SELECT b.* FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY normalize_artist(artist) ORDER BY score DESC) AS artist_rn + FROM base + ) b WHERE b.artist_rn = 1 + ), + genre_capped AS ( + SELECT g.* FROM ( + SELECT *, + CASE WHEN primary_genre_id IS NULL THEN 1 + ELSE ROW_NUMBER() OVER (PARTITION BY primary_genre_id ORDER BY score DESC) END + AS genre_rn + FROM artist_capped + ) g WHERE g.genre_rn <= ${GENRE_CAP} + ) + SELECT ${trackCols} FROM genre_capped + ORDER BY score DESC + LIMIT 20; + `; + const res = await this.pgClient.query(query, [genreIdOrName]); + return res.rows as Track[]; + } + + /** + * GET /api/vibe/current — active batch metadata for a user, or null. + */ + async getCurrentVibeSession(userId: string): Promise< + { batchId: string; seedTrackId: string | null; lastInteractionAt: Date } | null + > { + const res = await this.pgClient.query( + `SELECT id AS "batchId", seed_track_id AS "seedTrackId", + last_interaction_at AS "lastInteractionAt" + FROM recommendation_batch + WHERE user_id = $1 AND status = 'ACTIVE' + ORDER BY last_interaction_at DESC + LIMIT 1`, + [userId] + ); + return res.rows[0] || null; + } + + /** + * POST /api/vibe/heartbeat — bump last_interaction_at on the user's ACTIVE batch. + * Returns true if a session was found and updated. + */ + async heartbeatVibeSession(userId: string): Promise<boolean> { + const res = await this.pgClient.query( + `UPDATE recommendation_batch + SET last_interaction_at = CURRENT_TIMESTAMP + WHERE id = ( + SELECT id FROM recommendation_batch + WHERE user_id = $1 AND status = 'ACTIVE' + ORDER BY last_interaction_at DESC + LIMIT 1 + )`, + [userId] + ); + return (res.rowCount ?? 0) > 0; + } + + async updateVibeSession(batchId: string): Promise<void> { + await this.pgClient.query( + 'UPDATE recommendation_batch SET last_interaction_at = CURRENT_TIMESTAMP WHERE id = $1', + [batchId] + ); + } + + async endVibeSession(batchId: string): Promise<void> { + await this.pgClient.query( + "UPDATE recommendation_batch SET status = 'RESOLVED' WHERE id = $1", + [batchId] + ); + } + + /** + * Reap stale ACTIVE vibe sessions — Invariant B ("No Deadlocks"): every + * ACTIVE batch must eventually reach a terminal state. Sessions with no + * interaction for `staleHours` (default 24, per spec §4) are transitioned to + * RESOLVED so a returning user starts a fresh session instead of resuming a + * frozen one. Returns the number of sessions reaped. + */ + async reapStaleVibeSessions(staleHours = 24): Promise<number> { + const res = await this.pgClient.query( + `UPDATE recommendation_batch + SET status = 'RESOLVED' + WHERE status = 'ACTIVE' + AND last_interaction_at < NOW() - ($1 || ' hours')::INTERVAL`, + [String(staleHours)] + ); + return res.rowCount ?? 0; + } + + async createArtist(data: Artist): Promise<Artist> { + const res = await this.pgClient.query( + `INSERT INTO artists (name, mbid, discogs_id, image_path) + VALUES (normalize_artist($1), $2, $3, $4) RETURNING *`, + [data.name, data.mbid, data.discogs_id, data.image_path] + ); + return res.rows[0]; + } + + async updateArtist(id: string, data: Partial<Artist>): Promise<Artist> { + const fields = Object.keys(data).filter(k => data[k as keyof Artist] !== undefined); + if (fields.length === 0) throw new Error('No fields to update'); + + // Normalize name if it's being updated (the normalized_name generated column + // handles it automatically, but we want the stored name itself to match). + if (data.name !== undefined) { + const norm = await this.pgClient.query('SELECT normalize_artist($1) AS n', [data.name]); + data.name = norm.rows[0].n; + } + + const setClause = fields.map((f, i) => `"${f}" = $${i + 2}`).join(', '); + const values = fields.map(f => data[f as keyof Artist]); + + const res = await this.pgClient.query( + `UPDATE artists SET ${setClause} WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0]; + } + + async deleteArtist(id: string): Promise<void> { + await this.pgClient.query('DELETE FROM artists WHERE id = $1', [id]); + } + + async createAlbum(data: Album): Promise<Album> { + const res = await this.pgClient.query( + 'INSERT INTO albums (artist_id, title, year, artwork_id) VALUES ($1, $2, $3, $4) RETURNING *', + [data.artist_id, data.title, data.year, data.artwork_id] + ); + return res.rows[0]; + } + + async updateAlbum(id: string, data: Partial<Album>): Promise<Album> { + const fields = Object.keys(data).filter(k => data[k as keyof Album] !== undefined); + if (fields.length === 0) throw new Error('No fields to update'); + + const setClause = fields.map((f, i) => `"${f}" = $${i + 2}`).join(', '); + const values = fields.map(f => (data as any)[f]); + + const res = await this.pgClient.query( + `UPDATE albums SET ${setClause} WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0]; + } + + async deleteAlbum(id: string): Promise<void> { + await this.pgClient.query('DELETE FROM albums WHERE id = $1', [id]); + } + + async getTrackById(id: string): Promise<Track | null> { + const res = await this.pgClient.query( + `SELECT t.*, al.artwork_id FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.id = $1`, [id]); + return (res.rows[0] as Track) || null; + } + + async getTrackLyrics(trackId: string): Promise<{ lyrics_text: string | null; synced_lyrics: unknown | null; provider: string | null } | null> { + const res = await this.pgClient.query( + 'SELECT lyrics_text, synced_lyrics, provider FROM track_lyrics WHERE track_id = $1', + [trackId] + ); + return res.rows[0] ?? null; + } + + async createTrack(data: Track): Promise<Track> { + const res = await this.pgClient.query( + `INSERT INTO tracks (path, hash, title, artist, album_id, duration, state, source_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [data.path, data.hash, data.title, data.artist, data.album_id, data.duration, data.state, data.source_type] + ); + return res.rows[0]; + } + + async updateTrack(id: string, data: Partial<Track>): Promise<Track> { + const fields = Object.keys(data).filter(k => data[k as keyof Track] !== undefined); + if (fields.length === 0) throw new Error('No fields to update'); + + const setClause = fields.map((f, i) => `"${f}" = $${i + 2}`).join(', '); + const values = fields.map(f => (data as any)[f]); + + const res = await this.pgClient.query( + `UPDATE tracks SET ${setClause} WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0]; + } + + async deleteTrack(id: string): Promise<void> { + await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [id]); + } + + /** + * Permanently delete a disliked track: remove the file from disk, then cascade- + * delete the DB record (which also removes the dislikes row via ON DELETE CASCADE). + * Logs a 'deleted_permanent' feedback event. Atomic: DB deletion only happens after + * the file is gone (or the file was already missing). + */ + async permanentlyDeleteTrack(userId: string, trackId: string, filePath: string): Promise<void> { + try { + await unlink(filePath); + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + } + + try { + await this.pgClient.query('BEGIN'); + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')", + [userId, trackId] + ); + // CASCADE deletes dislikes, play_history, feedback, etc. + await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]); + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + /** + * Return duplicate track groups, keyed by the given mode. + * + * `hash` (default) — groups of tracks with the same content hash (byte- + * identical duplicates). Excludes placeholder-hash rows that were written + * before proper hashing existed. + * + * `title-artist` — groups of tracks with the same normalised title AND + * normalised artist across different albums (catches the same song appearing + * in a compilation or reissue). Excludes same-hash groups since those are + * already caught by the hash mode. + * + * Each group returned as `{ key, tracks }` where `key` is the hash (mode + * `hash`) or `"title // artist"` (mode `title-artist`). + */ + async getDuplicateGroups( + mode: 'hash' | 'title-artist' = 'hash' + ): Promise<{ key: string; tracks: Track[] }[]> { + if (mode === 'title-artist') { + const res = await this.pgClient.query<Track & { normalized_title: string; normalized_artist: string }>( + `SELECT t.*, LOWER(TRIM(t.title)) AS normalized_title + FROM tracks t + JOIN ( + SELECT LOWER(TRIM(title)) AS nt, normalized_artist AS na + FROM tracks + WHERE state = 'LIBRARY' + AND hash IS NOT NULL AND hash <> '' AND hash <> 'placeholder-hash' + AND normalized_artist IS NOT NULL AND normalized_artist <> '' + GROUP BY nt, na + HAVING COUNT(*) > 1 + ) dup ON LOWER(TRIM(t.title)) = dup.nt AND t.normalized_artist = dup.na + WHERE t.state = 'LIBRARY' + AND t.hash IS NOT NULL AND t.hash <> '' AND t.hash <> 'placeholder-hash' + AND t.normalized_artist IS NOT NULL AND t.normalized_artist <> '' + ORDER BY dup.nt, dup.na, t.play_count DESC, t.last_played_at DESC NULLS LAST` + ); + + const groups = new Map<string, Track[]>(); + for (const row of res.rows) { + const key = `${row.normalized_title} // ${row.normalized_artist}`; + const list = groups.get(key) ?? []; + list.push(row); + groups.set(key, list); + } + + // Exclude groups where all tracks have the same hash (hash dedup already + // covers those). + const result: { key: string; tracks: Track[] }[] = []; + for (const [key, tracks] of groups) { + const uniqueHashes = new Set(tracks.map((t) => t.hash)); + if (uniqueHashes.size <= 1) continue; + result.push({ key, tracks }); + } + return result; + } + + // Default: hash-based dedup. + const res = await this.pgClient.query<Track & { hash: string }>( + `SELECT t.* + FROM tracks t + JOIN ( + SELECT hash FROM tracks + WHERE hash IS NOT NULL AND hash <> '' AND hash <> 'placeholder-hash' + GROUP BY hash HAVING COUNT(*) > 1 + ) dup ON dup.hash = t.hash + ORDER BY t.hash, t.play_count DESC, t.last_played_at DESC NULLS LAST` + ); + const groups = new Map<string, Track[]>(); + for (const row of res.rows) { + const list = groups.get(row.hash) ?? []; + list.push(row); + groups.set(row.hash, list); + } + return [...groups.entries()].map(([key, tracks]) => ({ key, tracks })); + } + + /** + * Keep `keepId`, re-parent its play history / feedback onto it, delete the + * remaining files from disk, then hard-delete the rest. All inside a + * transaction so a partial failure leaves no orphans. + * + * File deletion happens BEFORE the transaction (best-effort: we delete the + * file, then commit the DB changes; if the DB commit fails the file is + * already gone but the track stays orphaned in the DB — a subsequent + * integrity sweep will catch it and mark it MISSING). + */ + async mergeDuplicates(keepId: string, deleteIds: string[]): Promise<void> { + // 1. Fetch paths for the tracks being deleted (before they're gone). + const { rows: losers } = await this.pgClient.query<{ id: string; path: string }>( + `SELECT id, path FROM tracks WHERE id = ANY($1::uuid[])`, + [deleteIds] + ); + + // 2. Delete files from disk (best-effort — tolerate missing files). + for (const row of losers) { + try { + await unlink(row.path); + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + } + } + + // 3. DB transaction: re-parent history + delete rows. + await this.pgClient.query('BEGIN'); + try { + for (const id of deleteIds) { + await this.pgClient.query( + `UPDATE play_history SET track_id = $1 WHERE track_id = $2`, + [keepId, id] + ); + await this.pgClient.query( + `UPDATE feedback SET track_id = $1 WHERE track_id = $2`, + [keepId, id] + ); + await this.pgClient.query(`DELETE FROM tracks WHERE id = $1`, [id]); + } + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + /** + * Advance a dislike to WARNED state: set warned_at and update state. + * Called by the cleanup sweep worker after the grace period expires. + */ + async markDislikeWarned(trackId: string): Promise<void> { + await this.pgClient.query( + `UPDATE dislikes SET warned_at = NOW(), state = 'WARNED' WHERE track_id = $1`, + [trackId] + ); + } + + // ========================================================================= + // v2 — System A: Knowledge Graph (probabilistic fusion) + // ========================================================================= + + /** + * UPSERT a claim into the graph. Idempotent: same (subject, predicate, object, + * source, user_id) refreshes last_reinforced_at without duplicating. + */ + async upsertClaim(claim: { + user_id?: string | null; + subject_type: string; + subject_id: string; + predicate: string; + object_type: string; + object_id: string; + source: string; + confidence?: number; + raw?: unknown; + }): Promise<string> { + const res = await this.pgClient.query( + `INSERT INTO claims (user_id, subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) + DO UPDATE SET last_reinforced_at = NOW(), evidence_at = NOW(), confidence = $8, raw = COALESCE($9, claims.raw) + RETURNING id`, + [ + claim.user_id ?? null, + claim.subject_type, + claim.subject_id, + claim.predicate, + claim.object_type, + claim.object_id, + claim.source, + claim.confidence ?? 1.0, + claim.raw ? JSON.stringify(claim.raw) : null, + ] + ); + return res.rows[0].id as string; + } + + /** + * Batch UPSERT claims. Wraps multiple upsertClaim calls in a transaction. + */ + async upsertClaims(claims: Parameters<DbService['upsertClaim']>[0][]): Promise<string[]> { + const ids: string[] = []; + await this.pgClient.query('BEGIN'); + try { + for (const claim of claims) { + const id = await this.upsertClaim(claim); + ids.push(id); + } + await this.pgClient.query('COMMIT'); + return ids; + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + /** + * Get claims by subject (entity + predicate filter). + */ + async getClaimsBySubject( + subjectType: string, + subjectId: string, + predicate?: string, + userId?: string + ): Promise<Claim[]> { + let sql = `SELECT * FROM claims WHERE subject_type = $1 AND subject_id = $2`; + const params: unknown[] = [subjectType, subjectId]; + let idx = 3; + + if (predicate) { + sql += ` AND predicate = $${idx}`; + params.push(predicate); + idx++; + } + if (userId) { + sql += ` AND (user_id IS NULL OR user_id = $${idx})`; + params.push(userId); + } + + sql += ` ORDER BY last_reinforced_at DESC`; + const res = await this.pgClient.query(sql, params); + return res.rows as Claim[]; + } + + /** + * Get fused value for a (subject, predicate, object) triple, optionally + * scoped to a user (includes user-keyed claims). + */ + async getFusedValue( + subjectType: string, + subjectId: string, + predicate: string, + objectType: string, + objectId: string, + userId?: string + ): Promise<number> { + let sql = ` + SELECT COALESCE(SUM(st.trust * c.confidence * + GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0)), 0) AS fused + FROM claims c + JOIN source_trust st ON st.key = c.source + WHERE c.subject_type = $1 AND c.subject_id = $2 + AND c.predicate = $3 + AND c.object_type = $4 AND c.object_id = $5 + `; + const params: unknown[] = [subjectType, subjectId, predicate, objectType, objectId]; + + if (userId) { + sql += ` AND (c.user_id IS NULL OR c.user_id = $6)`; + params.push(userId); + } else { + sql += ` AND c.user_id IS NULL`; + } + + const res = await this.pgClient.query(sql, params); + return (res.rows[0]?.fused as number) ?? 0; + } + + /** + * Get fused artist credits for a track, returning the same shape as the old + * attachArtists() for backward compatibility, but sourced from claim_fusion. + */ + async getFusedTrackArtists(trackId: string, userId?: string): Promise<TrackArtist[]> { + let sql = ` + SELECT a.id, a.name, + CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM claim_fusion cf + JOIN artists a ON a.id = cf.object_id + WHERE cf.subject_type = 'track' AND cf.subject_id = $1 + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + `; + const params: unknown[] = [trackId]; + + if (userId) { + sql += ` AND cf.user_id IN ('00000000-0000-0000-0000-000000000000', $2) ORDER BY cf.fused_value DESC, cf.predicate`; + params.push(userId); + } else { + sql += ` AND cf.user_id = '00000000-0000-0000-0000-000000000000' ORDER BY cf.fused_value DESC, cf.predicate`; + } + + const res = await this.pgClient.query(sql, params); + return res.rows as TrackArtist[]; + } + + // ========================================================================= + // v2 — System B: Listener Model + // ========================================================================= + + /** + * Record evidence (append-only). Writes a signal into the evidence stream. + */ + async recordEvidence(evidence: { + user_id: string; + entity_type: string; + entity_id: string; + signal: string; + profile: string; + weight: number; + context?: unknown; + }): Promise<string> { + const res = await this.pgClient.query( + `INSERT INTO evidence (user_id, entity_type, entity_id, signal, profile, weight, context) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, + [ + evidence.user_id, + evidence.entity_type, + evidence.entity_id, + evidence.signal, + evidence.profile, + evidence.weight, + evidence.context ? JSON.stringify(evidence.context) : null, + ] + ); + const id = res.rows[0].id as string; + + // Derive the belief dimension from the signal. Per spec §B.3, every + // signal feeds the 'affinity' dimension EXCEPT 'play_of_never_seen', + // which feeds 'novelty_tolerance'. Each new evidence row must also + // upsert the matching listener_belief (spec §B.4) — otherwise evidence + // accumulates but beliefs never materialise. + const dimension = evidence.signal === 'play_of_never_seen' ? 'novelty_tolerance' : 'affinity'; + await this.updateListenerBelief({ + user_id: evidence.user_id, + profile: evidence.profile, + entity_type: evidence.entity_type, + entity_id: evidence.entity_id, + dimension, + value_delta: evidence.weight, + confidence_delta: 0.05, + }); + + return id; + } + + /** + * Batch record evidence. All or nothing. + */ + async recordEvidenceBatch( + evidenceList: Parameters<DbService['recordEvidence']>[0][] + ): Promise<string[]> { + const ids: string[] = []; + await this.pgClient.query('BEGIN'); + try { + for (const ev of evidenceList) { + const id = await this.recordEvidence(ev); + ids.push(id); + } + await this.pgClient.query('COMMIT'); + return ids; + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + /** + * Get listener beliefs for a user, optionally filtered by profile/entity. + */ + async getListenerBeliefs(params: { + userId: string; + profile?: string; + entityType?: string; + entityId?: string; + dimension?: string; + limit?: number; + orderBy?: 'value' | 'last_reinforced_at'; + order?: 'ASC' | 'DESC'; + }): Promise<ListenerBelief[]> { + const { + userId, profile, entityType, entityId, dimension, + limit = 50, orderBy = 'value', order = 'DESC', + } = params; + + let sql = `SELECT * FROM listener_beliefs WHERE user_id = $1`; + const sqlParams: unknown[] = [userId]; + let idx = 2; + + if (profile) { sql += ` AND profile = $${idx}`; sqlParams.push(profile); idx++; } + if (entityType) { sql += ` AND entity_type = $${idx}`; sqlParams.push(entityType); idx++; } + if (entityId) { sql += ` AND entity_id = $${idx}`; sqlParams.push(entityId); idx++; } + if (dimension) { sql += ` AND dimension = $${idx}`; sqlParams.push(dimension); idx++; } + + const validOrderBy = ['value', 'last_reinforced_at', 'confidence', 'evidence_count']; + const sortCol = validOrderBy.includes(orderBy) ? orderBy : 'value'; + const sortOrder = order === 'ASC' ? 'ASC' : 'DESC'; + sql += ` ORDER BY ${sortCol} ${sortOrder} LIMIT $${idx}`; + sqlParams.push(limit); + + const res = await this.pgClient.query(sql, sqlParams); + return res.rows as ListenerBelief[]; + } + + /** + * UPSERT a listener belief. Updates value, confidence, and evidence_count. + * Implements the belief update formula from spec §B.4. + */ + async updateListenerBelief(params: { + user_id: string; + profile: string; + entity_type: string; + entity_id: string; + dimension: string; + value_delta: number; + confidence_delta?: number; + }): Promise<void> { + const { user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta = 0.05 } = params; + + await this.pgClient.query( + `INSERT INTO listener_beliefs (user_id, profile, entity_type, entity_id, dimension, value, confidence, evidence_count, last_reinforced_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW()) + ON CONFLICT (user_id, profile, entity_type, entity_id, dimension) + DO UPDATE SET + value = GREATEST(-1.0, LEAST(1.0, listener_beliefs.value + $6 * (1.0 - listener_beliefs.confidence))), + confidence = GREATEST(0, LEAST(1.0, listener_beliefs.confidence + $7)), + evidence_count = listener_beliefs.evidence_count + 1, + last_reinforced_at = NOW()`, + [user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta] + ); + } + + // ========================================================================= + // v2 — System D: Session support + // ========================================================================= + + /** + * Create a new session state row. + */ + async createSessionState(userId: string, context?: string, stateVector?: Record<string, unknown>): Promise<string> { + const res = await this.pgClient.query( + `INSERT INTO session_state (user_id, context, state_vector) VALUES ($1, $2, $3) RETURNING session_id`, + [userId, context ?? null, stateVector ? JSON.stringify(stateVector) : '{}'] + ); + return res.rows[0].session_id as string; + } + + /** + * Get the most recent session state for a user. + */ + async getLatestSessionState(userId: string): Promise<SessionState | null> { + const res = await this.pgClient.query( + `SELECT * FROM session_state WHERE user_id = $1 ORDER BY last_interaction DESC LIMIT 1`, + [userId] + ); + return (res.rows[0] as SessionState) || null; + } + + /** + * Upsert a diversity budget for a user. + */ + async upsertDiversityBudget(budget: { + user_id: string; + dimension: string; + budget_share: number; + horizon_min: number; + }): Promise<void> { + await this.pgClient.query( + `INSERT INTO diversity_budgets (user_id, dimension, budget_share, horizon_min) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, dimension, horizon_min) + DO UPDATE SET budget_share = $3`, + [budget.user_id, budget.dimension, budget.budget_share, budget.horizon_min] + ); + } + + /** + * Seed default diversity budgets for a new user. + */ + async seedDefaultDiversityBudgets(userId: string): Promise<void> { + const defaults: { dimension: string; share: number; horizon: number }[] = [ + { dimension: 'artist', share: 0.20, horizon: 30 }, + { dimension: 'genre', share: 0.40, horizon: 30 }, + { dimension: 'language', share: 0.60, horizon: 30 }, + { dimension: 'instrumental', share: 0.10, horizon: 30 }, + { dimension: 'new_artist', share: 0.15, horizon: 60 }, + { dimension: 'favorite', share: 0.25, horizon: 60 }, + ]; + + for (const d of defaults) { + await this.upsertDiversityBudget({ + user_id: userId, + dimension: d.dimension, + budget_share: d.share, + horizon_min: d.horizon, + }); + } + } + + /** + * Refresh the claim_fusion materialised view. Called on a periodic + * timer so the graph's read path stays current with new claims. + * CONCURRENTLY requires the unique index (idx_claim_fusion_pk), + * which the 20260708_materialize_claim_fusion migration creates. + */ + async refreshClaimFusion(): Promise<void> { + try { + await this.pgClient.query('SELECT refresh_claim_fusion()'); + } catch (err) { + // Non-fatal: the MV may not exist yet on first boot before + // migrations run. Log and move on; the next tick will retry. + console.error('[DB] refresh_claim_fusion failed:', err); + } + } + + /** + * Decay all listener beliefs whose last_decayed_at is older than 1 + * hour. Implements the decay formula from spec §B.4: + * value *= 0.5 ^ (elapsed / halflife) + * confidence *= 0.5 ^ (elapsed / halflife) + * Halflife is per-profile (longterm=365d, obsession=14d, discovery=30d, + * negative=180d, contextual=7d). The 'forgotten' profile is excluded + * — it is fully derived nightly by deriveForgottenProfile(), not + * decayed. + */ + async decayBeliefs(): Promise<number> { + const res = await this.pgClient.query(` + WITH halflives AS ( + SELECT profile, + CASE profile + WHEN 'longterm' THEN 365 * 86400 + WHEN 'obsession' THEN 14 * 86400 + WHEN 'discovery' THEN 30 * 86400 + WHEN 'negative' THEN 180 * 86400 + WHEN 'contextual' THEN 7 * 86400 + ELSE 30 * 86400 + END AS halflife_sec + ) + UPDATE listener_beliefs lb + SET value = GREATEST(-1.0, LEAST(1.0, lb.value * POWER(0.5, + EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))), + confidence = GREATEST(0, LEAST(1.0, lb.confidence * POWER(0.5, + EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))), + last_decayed_at = NOW() + FROM halflives h + WHERE lb.profile = h.profile + AND lb.profile <> 'forgotten' + AND lb.last_decayed_at < NOW() - INTERVAL '1 hour' + `); + return res.rowCount ?? 0; + } + + /** + * Derive the 'forgotten' profile nightly (spec §B.2): + * longterm affinity > 0.3 AND not reinforced in 90+ days. + * Wipes and repopulates — 'forgotten' is fully derived, not evidence-fed. + */ + async deriveForgottenProfile(): Promise<number> { + await this.pgClient.query( + `DELETE FROM listener_beliefs WHERE profile = 'forgotten'` + ); + const res = await this.pgClient.query(` + INSERT INTO listener_beliefs + (user_id, profile, entity_type, entity_id, dimension, value, + confidence, evidence_count, last_reinforced_at, last_decayed_at) + SELECT user_id, 'forgotten', entity_type, entity_id, dimension, + value, confidence, evidence_count, last_reinforced_at, NOW() + FROM listener_beliefs + WHERE profile = 'longterm' + AND dimension = 'affinity' + AND value > 0.3 + AND last_reinforced_at < NOW() - INTERVAL '90 days' + ON CONFLICT (user_id, profile, entity_type, entity_id, dimension) + DO UPDATE SET + value = EXCLUDED.value, + confidence = EXCLUDED.confidence, + evidence_count = EXCLUDED.evidence_count, + last_reinforced_at = EXCLUDED.last_reinforced_at + `); + return res.rowCount ?? 0; + } +} diff --git a/backend/src/services/discovery.service.ts b/backend/src/services/discovery.service.ts new file mode 100644 index 0000000..2e87009 --- /dev/null +++ b/backend/src/services/discovery.service.ts @@ -0,0 +1,297 @@ +import { DbService } from './db.service.js'; + +export interface DiscoveryCandidate { + id: string; + source: string; + externalId: string; + title: string | null; + artistCredit: unknown; + notes: unknown; + status: string; + relevance: number; + explanation: string; +} + +export class DiscoveryService { + constructor(private db: DbService) {} + + // --------------------------------------------------------------- + // E.1 — Graph exploration: walk the graph beyond the library + // --------------------------------------------------------------- + async walkGraphForDiscovery(userId: string): Promise<number> { + const beliefs = await this.db.getListenerBeliefs({ + userId, + profile: 'longterm', + entityType: 'artist', + dimension: 'affinity', + limit: 100, + orderBy: 'value', + order: 'DESC', + }); + + const highAffinity = beliefs.filter((b) => b.value > 0.3); + let newCount = 0; + + for (const belief of highAffinity) { + const candidates = await this.db.pgClient.query<{ candidate_artist_id: string }>( + `SELECT cf.object_id AS candidate_artist_id + FROM claim_fusion cf + WHERE cf.subject_id = $1::uuid + AND cf.predicate IN ('same_scene_as', 'featured_on') + AND cf.object_type = 'artist' + AND NOT EXISTS ( + SELECT 1 FROM tracks t + JOIN claim_fusion cf2 ON cf2.subject_id = t.id + WHERE cf2.object_id = cf.object_id + AND cf2.predicate = 'credited_main_on' + ) + LIMIT 20`, + [belief.entity_id] + ); + + for (const row of candidates.rows) { + const dcRes = await this.db.pgClient.query<{ id: string }>( + `INSERT INTO discovery_candidates (source, external_id, artist_credit, notes) + VALUES ($1, $2, $3, $4) + ON CONFLICT (source, external_id) DO NOTHING + RETURNING id`, + [ + 'graph_exploration', + row.candidate_artist_id, + JSON.stringify([{ artist_id: row.candidate_artist_id }]), + JSON.stringify({ + discovery_source: 'graph_exploration', + path: [ + { + entity_id: belief.entity_id, + predicate: 'affinity_source', + profile: 'longterm', + affinity: belief.value, + }, + { + entity_id: row.candidate_artist_id, + predicate: 'same_scene_as', + }, + ], + source_artist_belief_id: belief.entity_id, + }), + ] + ); + + if (dcRes.rows.length === 0) continue; + + const dcId = dcRes.rows[0].id; + const relevance = Math.min(belief.value, 0.8); + + await this.db.upsertClaim({ + subject_type: 'track', + subject_id: dcId, + predicate: 'discovery_candidate', + object_type: 'artist', + object_id: row.candidate_artist_id, + source: 'graph_exploration', + confidence: relevance, + raw: { + discovery_source: 'graph_exploration', + path: [ + { entity_id: belief.entity_id, relationship: 'affinity_source', belief_value: belief.value }, + { entity_id: row.candidate_artist_id, relationship: 'same_scene_as' }, + ], + }, + }); + + newCount++; + } + } + + return newCount; + } + + // --------------------------------------------------------------- + // E.3 — Evaluate discovery candidates for acquisition + // --------------------------------------------------------------- + async evalCandidates( + userId: string, + limit?: number + ): Promise<{ candidateId: string; shouldAcquire: boolean; reason: string }[]> { + const cap = limit ?? 20; + const results: { candidateId: string; shouldAcquire: boolean; reason: string }[] = []; + + const candidates = await this.db.pgClient.query( + `SELECT * FROM discovery_candidates + WHERE status = 'candidate' + ORDER BY first_seen_at ASC + LIMIT $1`, + [cap] + ); + + const backlogRes = await this.db.pgClient.query( + `SELECT COUNT(*)::int AS cnt FROM discovery_candidates WHERE status = 'acquiring'` + ); + let backlog = backlogRes.rows[0]?.cnt as number ?? 0; + + for (const row of candidates.rows) { + const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>( + `SELECT object_id, fused_value + FROM claim_fusion + WHERE subject_type = 'track' AND subject_id = $1::uuid + AND predicate = 'discovery_candidate' + LIMIT 1`, + [row.id] + ); + + const relevance = claimRes.rows[0]?.fused_value ?? 0; + const candidateArtistId = claimRes.rows[0]?.object_id; + + const noveltyBeliefs = await this.db.getListenerBeliefs({ + userId, + profile: 'discovery', + entityType: 'artist', + entityId: candidateArtistId, + dimension: 'tolerance', + limit: 1, + }); + const tolerance = noveltyBeliefs.length > 0 ? noveltyBeliefs[0].value : 0.5; + + let artistCount = 0; + if (candidateArtistId) { + const acRes = await this.db.pgClient.query( + `SELECT COUNT(*)::int AS cnt + FROM discovery_candidates dc + JOIN claims c ON c.subject_id = dc.id + WHERE dc.status = 'acquiring' + AND c.predicate = 'discovery_candidate' + AND c.object_id = $1::uuid`, + [candidateArtistId] + ); + artistCount = acRes.rows[0]?.cnt as number ?? 0; + } + + const shouldAcquire = relevance > 0.3 && tolerance > 0.2 && backlog < 20 && artistCount < 3; + let reason: string; + + if (shouldAcquire) { + await this.db.pgClient.query( + `UPDATE discovery_candidates SET status = 'acquiring', last_eval_at = NOW() WHERE id = $1`, + [row.id] + ); + backlog++; + reason = 'meets criteria'; + } else { + if (relevance <= 0.3) reason = 'relevance too low'; + else if (tolerance <= 0.2) reason = 'novelty tolerance exceeded'; + else if (backlog >= 20) reason = 'backlog full'; + else if (artistCount >= 3) reason = 'artist diversity limit'; + else reason = 'unknown'; + + await this.db.pgClient.query( + `UPDATE discovery_candidates SET status = 'retired', last_eval_at = NOW() WHERE id = $1`, + [row.id] + ); + } + + results.push({ + candidateId: row.id, + shouldAcquire, + reason, + }); + } + + return results; + } + + // --------------------------------------------------------------- + // E.4 — Probation lifecycle + // --------------------------------------------------------------- + async evalProbation(trackId: string): Promise<'retained' | 'retired' | 'probation'> { + const completedRes = await this.db.pgClient.query( + `SELECT COUNT(*)::int AS cnt FROM evidence + WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'playback_completed'`, + [trackId] + ); + const completedPlays = completedRes.rows[0]?.cnt as number ?? 0; + + const skipRes = await this.db.pgClient.query( + `SELECT COUNT(*)::int AS cnt FROM evidence + WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'skip_quick'`, + [trackId] + ); + const skips = skipRes.rows[0]?.cnt as number ?? 0; + + const trackRes = await this.db.pgClient.query<{ probation_entered_at: Date | null }>( + `SELECT probation_entered_at FROM tracks WHERE id = $1`, + [trackId] + ); + const probTrack = trackRes.rows[0]; + + if (completedPlays >= 3) { + await this.db.pgClient.query( + `UPDATE tracks SET probation_status = 'retained' WHERE id = $1`, + [trackId] + ); + + const claimRes = await this.db.pgClient.query<{ source: string }>( + `SELECT source FROM claims + WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'discovery_candidate' + LIMIT 1`, + [trackId] + ); + if (claimRes.rows[0]) { + await this.db.pgClient.query( + `UPDATE source_trust SET trust = LEAST(1.0, trust + 0.05) WHERE key = $1`, + [claimRes.rows[0].source] + ); + } + + return 'retained'; + } + + const daysSinceProbation = probTrack?.probation_entered_at + ? (Date.now() - new Date(probTrack.probation_entered_at).getTime()) / (1000 * 86400) + : 0; + + if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) { + await this.db.pgClient.query( + `UPDATE tracks SET probation_status = 'retired' WHERE id = $1`, + [trackId] + ); + return 'retired'; + } + + return 'probation'; + } + + async sweepProbation(): Promise<{ retained: number; retired: number }> { + const res = await this.db.pgClient.query( + `SELECT id FROM tracks WHERE probation_status = 'probation'` + ); + + let retained = 0; + let retired = 0; + + for (const row of res.rows) { + const result = await this.evalProbation(row.id as string); + if (result === 'retained') retained++; + else if (result === 'retired') retired++; + } + + return { retained, retired }; + } + + // --------------------------------------------------------------- + // E.5 — Meta-learning stub + // --------------------------------------------------------------- + async runMetaLearning(): Promise<void> { + const res = await this.db.pgClient.query( + `SELECT c.source, COUNT(*)::int AS cnt + FROM claims c + JOIN tracks t ON t.id = c.subject_id + WHERE c.predicate = 'discovery_candidate' + AND t.probation_status = 'retained' + GROUP BY c.source + ORDER BY cnt DESC` + ); + + console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows)); + } +} diff --git a/backend/src/services/generators.service.ts b/backend/src/services/generators.service.ts new file mode 100644 index 0000000..4f6f295 --- /dev/null +++ b/backend/src/services/generators.service.ts @@ -0,0 +1,500 @@ +import { DbService, ListenerBelief } from './db.service.js'; + +// --------------------------------------------------------------------------- +// System C — Candidate Generators +// Each generator returns candidates with graph-path explanations. +// No scoring — the session director handles ranking. +// --------------------------------------------------------------------------- + +export interface ClaimEdge { + subjectType: string; + subjectId: string; + predicate: string; + objectType: string; + objectId: string; + fusedValue: number; +} + +export interface Candidate { + trackId: string; + generatorId: string; + explanation: ClaimEdge[]; + relevance: number; +} + +export interface GeneratorContext { + userId: string; + seedTrackId: string | null; + seedArtistId: string | null; + beliefs: ListenerBelief[]; + recentExclusions: string[]; + toleranceMap: Record<string, number>; + state: { + energy: number; + lastArtistIds: string[]; + lastGenreIds: string[]; + context: string | null; + noveltyHunger: number; + sessionAgeMin: number; + }; +} + +export type Generator = (db: DbService, ctx: GeneratorContext) => Promise<Candidate[]>; + +const OBJECTIVE_USER = '00000000-0000-0000-0000-000000000000'; + +// --------------------------------------------------------------------------- +// 1. COMFORT — Top artists by longterm affinity > 0.5 +// --------------------------------------------------------------------------- +async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const topArtists = ctx.beliefs + .filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.5) + .sort((a, b) => b.value - a.value) + .slice(0, 20); + + const candidates: Candidate[] = []; + + for (const belief of topArtists) { + const res = await db.pgClient.query( + `SELECT t.id + FROM tracks t + JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' AND cf.object_id = $1 + AND (cf.user_id = $2 OR cf.user_id = $3) + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($4::uuid[])) + ORDER BY cf.fused_value DESC + LIMIT 2`, + [belief.entity_id, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] + ); + + for (const row of res.rows as { id: string }[]) { + candidates.push({ + trackId: row.id, + generatorId: 'comfort', + explanation: [{ + subjectType: 'artist', + subjectId: belief.entity_id, + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: belief.value, + }], + relevance: belief.value, + }); + } + } + + return candidates; +} + +// --------------------------------------------------------------------------- +// 2. ADJACENT — Walk graph from seed artist, exclude comfort pool +// --------------------------------------------------------------------------- +async function adjacentGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + if (!ctx.seedArtistId) return []; + + const comfortArtistIds = new Set( + ctx.beliefs + .filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.5) + .map(b => b.entity_id) + ); + + // Walk: seedArtist -> (credited_main_on|featured_on) -> track -> (credited_main_on|featured_on) -> reachedArtist + // cf1 finds tracks where seed artist appears; cf2 finds OTHER artists on those same tracks + const reachedRes = await db.pgClient.query( + `SELECT DISTINCT cf2.object_id AS artist_id + FROM claim_fusion cf1 + JOIN claim_fusion cf2 ON cf2.subject_type = 'track' + AND cf2.subject_id = cf1.subject_id + AND cf2.predicate IN ('credited_main_on', 'featured_on') + AND cf2.object_type = 'artist' + AND cf2.object_id != $1 + AND (cf2.user_id = $2 OR cf2.user_id = $3) + WHERE cf1.subject_type = 'track' + AND cf1.predicate IN ('credited_main_on', 'featured_on') + AND cf1.object_type = 'artist' + AND cf1.object_id = $1 + AND (cf1.user_id = $2 OR cf1.user_id = $3) + LIMIT 30`, + [ctx.seedArtistId, OBJECTIVE_USER, ctx.userId] + ); + + const reachedArtistIds = (reachedRes.rows as { artist_id: string }[]) + .map(r => r.artist_id) + .filter(id => !comfortArtistIds.has(id)); + + if (reachedArtistIds.length === 0) return []; + + const trackRes = await db.pgClient.query( + `SELECT id FROM ( + SELECT DISTINCT t.id + FROM tracks t + JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + AND cf.object_id = ANY($1::uuid[]) + AND (cf.user_id = $2 OR cf.user_id = $3) + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($4::uuid[])) + ) sub + ORDER BY RANDOM() + LIMIT 20`, + [reachedArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] + ); + + return (trackRes.rows as { id: string }[]).map(row => ({ + trackId: row.id, + generatorId: 'adjacent', + explanation: [{ + subjectType: 'artist', + subjectId: ctx.seedArtistId!, + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.6, + }], + relevance: 0.6, + })); +} + +// --------------------------------------------------------------------------- +// 3. DISCOVERY — Unfamiliar artists via graph edges from trusted artists +// --------------------------------------------------------------------------- +async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const trustedIds = ctx.beliefs + .filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3) + .map(b => b.entity_id); + + if (trustedIds.length === 0) return []; + + const noveltyTolerance = ctx.toleranceMap.novelty_tolerance ?? 0.3; + const maxCandidates = Math.max(1, Math.floor(10 * noveltyTolerance)); + + const unfamiliarRes = await db.pgClient.query( + `SELECT DISTINCT cf.object_id AS artist_id + FROM claim_fusion cf + WHERE cf.subject_type = 'artist' + AND cf.subject_id = ANY($1::uuid[]) + AND cf.predicate IN ('same_scene_as', 'same_label_as', 'produced') + AND cf.object_type = 'artist' + AND NOT EXISTS ( + SELECT 1 FROM listener_beliefs lb + WHERE lb.user_id = $2 + AND lb.entity_type = 'artist' + AND lb.entity_id = cf.object_id + AND lb.profile IN ('longterm', 'obsession') + ) + LIMIT 30`, + [trustedIds, ctx.userId] + ); + + const unfamiliarArtistIds = (unfamiliarRes.rows as { artist_id: string }[]).map(r => r.artist_id); + if (unfamiliarArtistIds.length === 0) return []; + + const trackRes = await db.pgClient.query( + `SELECT id FROM ( + SELECT DISTINCT t.id + FROM tracks t + JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + AND cf.object_id = ANY($1::uuid[]) + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($2::uuid[])) + ) sub + ORDER BY RANDOM() + LIMIT $3`, + [unfamiliarArtistIds, ctx.recentExclusions, maxCandidates] + ); + + return (trackRes.rows as { id: string }[]).map(row => ({ + trackId: row.id, + generatorId: 'discovery', + explanation: [{ + subjectType: 'artist', + subjectId: unfamiliarArtistIds[0], + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.4, + }], + relevance: 0.4, + })); +} + +// --------------------------------------------------------------------------- +// 4. DEEP-DIVE — Albums from obsession artists, unplayed tracks first +// --------------------------------------------------------------------------- +async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const obsessedIds = ctx.beliefs + .filter(b => b.profile === 'obsession' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3) + .map(b => b.entity_id); + + if (obsessedIds.length === 0) return []; + + const albumRes = await db.pgClient.query( + `SELECT al.id AS album_id, al.artist_id + FROM albums al + WHERE al.artist_id = ANY($1::uuid[]) + ORDER BY al.year ASC NULLS LAST, al.title ASC + LIMIT 20`, + [obsessedIds] + ); + + const candidates: Candidate[] = []; + + for (const album of albumRes.rows as { album_id: string; artist_id: string }[]) { + const trackRes = await db.pgClient.query( + `SELECT t.id + FROM tracks t + WHERE t.album_id = $1 AND t.state = 'LIBRARY' + AND NOT (t.id = ANY($2::uuid[])) + ORDER BY t.title ASC + LIMIT 5`, + [album.album_id, ctx.recentExclusions] + ); + + for (const row of trackRes.rows as { id: string }[]) { + candidates.push({ + trackId: row.id, + generatorId: 'deep-dive', + explanation: [{ + subjectType: 'artist', + subjectId: album.artist_id, + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.7, + }], + relevance: 0.7, + }); + } + } + + return candidates; +} + +// --------------------------------------------------------------------------- +// 5. REVIVAL — Stale longterm affinity (last_reinforced > 90 days ago) +// --------------------------------------------------------------------------- +async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const staleRes = await db.pgClient.query( + `SELECT lb.entity_id AS artist_id, lb.value AS affinity + FROM listener_beliefs lb + WHERE lb.user_id = $1 + AND lb.profile = 'longterm' + AND lb.entity_type = 'artist' + AND lb.dimension = 'affinity' + AND lb.value > 0.3 + AND lb.last_reinforced_at < NOW() - INTERVAL '90 days' + ORDER BY lb.value DESC + LIMIT 20`, + [ctx.userId] + ); + + const staleArtists = staleRes.rows as { artist_id: string; affinity: number }[]; + if (staleArtists.length === 0) return []; + + const staleArtistIds = staleArtists.map(a => a.artist_id); + + const trackRes = await db.pgClient.query( + `SELECT id FROM ( + SELECT DISTINCT t.id + FROM tracks t + JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + AND cf.object_id = ANY($1::uuid[]) + AND (cf.user_id = $2 OR cf.user_id = $3) + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($4::uuid[])) + ) sub + ORDER BY RANDOM() + LIMIT 20`, + [staleArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] + ); + + return (trackRes.rows as { id: string }[]).map(row => ({ + trackId: row.id, + generatorId: 'revival', + explanation: [{ + subjectType: 'artist', + subjectId: staleArtistIds[0], + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.6, + }], + relevance: 0.6, + })); +} + +// --------------------------------------------------------------------------- +// 6. NOVELTY — Recently released tracks by graph-adjacent artists +// --------------------------------------------------------------------------- +async function noveltyGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const trustedIds = ctx.beliefs + .filter(b => b.entity_type === 'artist' && b.value > 0.3) + .map(b => b.entity_id); + + if (trustedIds.length === 0) return []; + + const res = await db.pgClient.query( + `SELECT id FROM ( + SELECT DISTINCT t.id, t.release_date + FROM tracks t + JOIN claim_fusion cf_edge ON cf_edge.subject_type = 'artist' + AND cf_edge.subject_id = ANY($2::uuid[]) + AND cf_edge.predicate IN ('same_scene_as', 'same_label_as', 'produced') + AND cf_edge.object_type = 'artist' + JOIN claim_fusion cf_track ON cf_track.subject_type = 'track' + AND cf_track.subject_id = t.id + AND cf_track.predicate IN ('credited_main_on', 'featured_on') + AND cf_track.object_type = 'artist' + AND cf_track.object_id = cf_edge.object_id + WHERE t.release_date IS NOT NULL + AND t.release_date >= NOW() - INTERVAL '60 days' + AND t.state = 'LIBRARY' + AND NOT (t.id = ANY($1::uuid[])) + ) sub + ORDER BY release_date DESC + LIMIT 20`, + [ctx.recentExclusions.length > 0 ? ctx.recentExclusions : ['00000000-0000-0000-0000-000000000000'], trustedIds] + ); + + return res.rows.map((row: { id: string }) => ({ + trackId: row.id, + generatorId: 'novelty', + relevance: 0.5, + explanation: [{ + subjectType: 'track', subjectId: row.id, + predicate: 'release_date', + objectType: 'date', objectId: 'recent', + fusedValue: 0.5, + }], + })); +} + +// --------------------------------------------------------------------------- +// 7. EXPERIMENTAL — Genres with high network distance from favourites +// --------------------------------------------------------------------------- +async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const favArtistIds = ctx.beliefs + .filter(b => b.entity_type === 'artist' && b.value > 0.4) + .map(b => b.entity_id); + + if (favArtistIds.length === 0) return []; + + const favGenreIds = ctx.beliefs + .filter(b => b.entity_type === 'genre' && b.value > 0.2) + .map(b => b.entity_id); + + const result = await db.pgClient.query( + `WITH unfamiliar_genres AS ( + SELECT g.id, g.name, + (SELECT COUNT(*) FROM track_genre tg2 WHERE tg2.genre_id = g.id) AS track_count + FROM genre g + WHERE NOT (g.id = ANY($1::uuid[])) + AND EXISTS (SELECT 1 FROM track_genre tg WHERE tg.genre_id = g.id) + ORDER BY RANDOM() + LIMIT 3 + ), + candidate_tracks AS ( + SELECT DISTINCT t.id, tg.genre_id + FROM tracks t + JOIN track_genre tg ON tg.track_id = t.id + JOIN unfamiliar_genres ug ON ug.id = tg.genre_id + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($2::uuid[])) + LIMIT 30 + ) + SELECT ct.id, ct.genre_id + FROM candidate_tracks ct + ORDER BY RANDOM() + LIMIT 6`, + [ + favGenreIds.length > 0 ? favGenreIds : ['00000000-0000-0000-0000-000000000000'], + ctx.recentExclusions.length > 0 ? ctx.recentExclusions : ['00000000-0000-0000-0000-000000000000'], + ] + ); + + return result.rows.map((row: { id: string; genre_id: string }) => ({ + trackId: row.id, + generatorId: 'experimental', + relevance: 0.2, + explanation: [{ + subjectType: 'genre', subjectId: row.genre_id, + predicate: 'belongs_to_genre', + objectType: 'track', objectId: row.id, + fusedValue: 0.2, + }], + })); +} + +// --------------------------------------------------------------------------- +// 8. CONTEXTUAL — Contextual profile beliefs +// --------------------------------------------------------------------------- +async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + if (!ctx.state.context) return []; + + const contextualBeliefs = await db.getListenerBeliefs({ + userId: ctx.userId, + profile: 'contextual', + limit: 30, + orderBy: 'value', + order: 'DESC', + }); + + const targetArtistIds = contextualBeliefs + .filter(b => b.entity_type === 'artist' && b.value > 0.2) + .map(b => b.entity_id); + + if (targetArtistIds.length === 0) return []; + + const trackRes = await db.pgClient.query( + `SELECT id FROM ( + SELECT DISTINCT t.id + FROM tracks t + JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + AND cf.object_id = ANY($1::uuid[]) + AND (cf.user_id = $2 OR cf.user_id = $3) + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($4::uuid[])) + ) sub + ORDER BY RANDOM() + LIMIT 15`, + [targetArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] + ); + + return (trackRes.rows as { id: string }[]).map(row => ({ + trackId: row.id, + generatorId: 'contextual', + explanation: [{ + subjectType: 'artist', + subjectId: targetArtistIds[0], + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.5, + }], + relevance: 0.5, + })); +} + +// --------------------------------------------------------------------------- +// All generators, ordered by priority (comfort first, experimental last) +// --------------------------------------------------------------------------- +export const ALL_GENERATORS: Generator[] = [ + comfortGenerator, + adjacentGenerator, + deepDiveGenerator, + revivalGenerator, + discoveryGenerator, + noveltyGenerator, + contextualGenerator, + experimentalGenerator, +]; diff --git a/backend/src/services/generators.test.ts b/backend/src/services/generators.test.ts new file mode 100644 index 0000000..14d6250 --- /dev/null +++ b/backend/src/services/generators.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ALL_GENERATORS, type GeneratorContext, type Candidate } from './generators.service.js'; +import { DbService } from './db.service.js'; + +function makeMockDb(overrides: Record<string, any> = {}): DbService { + const mockQuery = vi.fn(); + return { + pgClient: { query: mockQuery }, + getListenerBeliefs: vi.fn().mockResolvedValue([]), + ...overrides, + } as unknown as DbService; +} + +function makeCtx(overrides: Partial<GeneratorContext> = {}): GeneratorContext { + return { + userId: '00000000-0000-0000-0000-000000000000', + seedTrackId: null, + seedArtistId: null, + beliefs: [], + recentExclusions: [], + toleranceMap: {}, + state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: null, noveltyHunger: 0.3, sessionAgeMin: 10 }, + ...overrides, + }; +} + +// Index generators by name for easy test access +const generatorByName: Record<string, (typeof ALL_GENERATORS)[0]> = { + comfort: ALL_GENERATORS[0], + adjacent: ALL_GENERATORS[1], + deepDive: ALL_GENERATORS[2], + revival: ALL_GENERATORS[3], + discovery: ALL_GENERATORS[4], + novelty: ALL_GENERATORS[5], + contextual: ALL_GENERATORS[6], + experimental: ALL_GENERATORS[7], +}; + +describe('generators', () => { + describe('comfort', () => { + it('returns tracks for artists with affinity > 0.5', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'track-1' }, { id: 'track-2' }] }); + const ctx = makeCtx({ + beliefs: [ + { entity_type: 'artist', entity_id: 'artist-1', value: 0.8, confidence: 0.9, profile: 'longterm', dimension: 'affinity' } as any, + ], + }); + const results = await generatorByName.comfort(db, ctx); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0]).toHaveProperty('trackId'); + expect(results[0]).toHaveProperty('generatorId', 'comfort'); + expect(results[0].explanation.length).toBeGreaterThanOrEqual(1); + }); + + it('returns empty when no high-affinity artists', async () => { + const db = makeMockDb(); + const ctx = makeCtx({ beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.3, profile: 'longterm', dimension: 'affinity' } as any ] }); + const results = await generatorByName.comfort(db, ctx); + expect(results).toHaveLength(0); + }); + }); + + describe('adjacent', () => { + it('returns tracks from graph walks', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ reached_artist_id: 'artist-2' }] }); + (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 'track-3' }] }); + const ctx = makeCtx({ seedTrackId: 'track-1', seedArtistId: 'artist-1' }); + const results = await generatorByName.adjacent(db, ctx); + if (results.length > 0) { + expect(results[0].explanation.length).toBeGreaterThanOrEqual(1); + } + }); + + it('returns empty when no seed artist', async () => { + const results = await generatorByName.adjacent(makeMockDb(), makeCtx()); + expect(results).toHaveLength(0); + }); + }); + + describe('discovery', () => { + it('respects novelty_tolerance cap', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ artist_id: 'a1' }, { artist_id: 'a2' }] }); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, confidence: 0.8, profile: 'longterm', dimension: 'affinity' } as any ], + toleranceMap: { novelty_tolerance: 0.1 }, + }); + const results = await generatorByName.discovery(db, ctx); + const maxCandidates = Math.max(2, Math.floor(20 * 0.1)); + expect(results.length).toBeLessThanOrEqual(maxCandidates); + }); + }); + + describe('deepDive', () => { + it('returns album-ordered tracks for obsession artists', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ album_id: 'alb-1', artist_id: 'a1', title: 'Album 1' }] }); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }, { id: 't2' }] }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'a1', dimension: 'affinity', value: 0.6, profile: 'obsession' } as any ], + }); + const results = await generatorByName.deepDive(db, ctx); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].generatorId).toBe('deep-dive'); + }); + }); + + describe('revival', () => { + it('returns tracks for stale high-affinity artists', async () => { + const db = makeMockDb(); + // First query: stale listener_beliefs + (db.pgClient.query as any).mockResolvedValueOnce({ + rows: [{ artist_id: 'a1', affinity: 0.7 }] + }); + // Second query: tracks by those artists + (db.pgClient.query as any).mockResolvedValue({ + rows: [{ id: 't1' }] + }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'a1', dimension: 'affinity', value: 0.5, profile: 'forgotten' } as any ], + }); + const results = await generatorByName.revival(db, ctx); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].generatorId).toBe('revival'); + }); + }); + describe('novelty', () => { + it('returns recent tracks', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ], + }); + const results = await generatorByName.novelty(db, ctx); + if (results.length > 0) { + expect(results[0].generatorId).toBe('novelty'); + } + }); + }); + + describe('experimental', () => { + it('returns tracks from unfamiliar genres', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1', genre_id: 'g1' }] }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.6, profile: 'longterm', dimension: 'affinity' } as any ], + }); + const results = await generatorByName.experimental(db, ctx); + expect(results).toBeDefined(); + }); + }); + + describe('contextual', () => { + it('returns tracks matching context when set', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 't1' }] }); + const ctx = makeCtx({ + state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: 'coding', noveltyHunger: 0.3, sessionAgeMin: 10 }, + }); + const results = await generatorByName.contextual(db, ctx); + expect(results).toBeDefined(); + }); + + it('returns empty when no context set', async () => { + const results = await generatorByName.contextual(makeMockDb(), makeCtx()); + expect(results).toHaveLength(0); + }); + }); +}); + +describe('ALL_GENERATORS', () => { + it('contains 8 generators', () => { + expect(ALL_GENERATORS).toHaveLength(8); + ALL_GENERATORS.forEach(g => expect(typeof g).toBe('function')); + }); +}); diff --git a/backend/src/services/image-enrichment.service.ts b/backend/src/services/image-enrichment.service.ts new file mode 100644 index 0000000..bad4cbb --- /dev/null +++ b/backend/src/services/image-enrichment.service.ts @@ -0,0 +1,105 @@ +import { DbService } from './db.service.js'; + +export class ImageEnrichmentService { + private sourcePriority: Record<string, number> = { + cover_art_archive: 1, + theaudiodb: 2, + fanart: 3, + deezer: 4, + discogs: 5, + lastfm: 6, + }; + + constructor(private db: DbService) {} + + // --------------------------------------------------------------- + // Phase 4 — Fetch image candidates from all sources + // --------------------------------------------------------------- + async fetchImagesForArtist(artistId: string): Promise<number> { + const artistRes = await this.db.pgClient.query<{ id: string; name: string; mbid: string | null; discogs_id: string | null }>( + `SELECT id, name, mbid, discogs_id FROM artists WHERE id = $1`, + [artistId] + ); + const artist = artistRes.rows[0]; + if (!artist) return 0; + + const sources: { key: string; condition: string }[] = [ + { key: 'cover_art_archive', condition: artist.mbid ? 'mbid present' : 'no mbid' }, + { key: 'deezer', condition: 'always' }, + { key: 'discogs', condition: artist.discogs_id ? 'discogs_id present' : 'no discogs_id' }, + { key: 'lastfm', condition: 'always' }, + ]; + + let count = 0; + + for (const src of sources) { + const res = await this.db.pgClient.query( + `INSERT INTO image_candidates (entity_type, entity_id, source, url, width) + VALUES ('artist', $1, $2, NULL, NULL) + ON CONFLICT (entity_type, entity_id, source) DO NOTHING + RETURNING 1 AS ins`, + [artistId, src.key] + ); + if (res.rows.length > 0) count++; + } + + return count; + } + + async fetchImagesForAlbum(albumId: string): Promise<number> { + const sources = ['cover_art_archive', 'itunes', 'deezer', 'discogs']; + let count = 0; + + for (const source of sources) { + const res = await this.db.pgClient.query( + `INSERT INTO image_candidates (entity_type, entity_id, source, url, width) + VALUES ('album', $1, $2, NULL, NULL) + ON CONFLICT (entity_type, entity_id, source) DO NOTHING + RETURNING 1 AS ins`, + [albumId, source] + ); + if (res.rows.length > 0) count++; + } + + return count; + } + + async selectBestImage(entityType: string, entityId: string): Promise<string | null> { + const candidates = await this.db.pgClient.query<{ url: string; source: string; verified: boolean }>( + `SELECT url, source, verified + FROM image_candidates + WHERE entity_type = $1 AND entity_id = $2 AND url IS NOT NULL + ORDER BY + CASE source + WHEN 'cover_art_archive' THEN 1 + WHEN 'theaudiodb' THEN 2 + WHEN 'fanart' THEN 3 + WHEN 'deezer' THEN 4 + WHEN 'discogs' THEN 5 + WHEN 'lastfm' THEN 6 + ELSE 99 + END, + verified DESC, + width DESC NULLS LAST + LIMIT 1`, + [entityType, entityId] + ); + + const best = candidates.rows[0]; + if (!best) return null; + + if (entityType === 'artist') { + await this.db.pgClient.query( + `UPDATE artists SET image_path = $1 WHERE id = $2`, + [best.url, entityId] + ); + } else if (entityType === 'album') { + await this.db.pgClient.query( + `UPDATE albums SET artwork_id = $1 WHERE id = $2`, + [best.url, entityId] + ); + } + + return best.url; + } +} diff --git a/backend/src/services/job.service.ts b/backend/src/services/job.service.ts new file mode 100644 index 0000000..7e6da7b --- /dev/null +++ b/backend/src/services/job.service.ts @@ -0,0 +1,133 @@ +import { Queue, Job } from 'bullmq'; +import { MetadataRefreshJob, AudioAnalysisJob, CleanupJob, LibraryScanJob, ReindexTracksJob, ReprocessArtistsJob } from '../types/job.types.js'; + +export interface JobServiceConfig { + redisUrl: string; +} + +export const QUEUE_NAME = 'muzick-queue'; + +export interface QueueStats { + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + paused: number; +} + +export interface JobHistoryEntry { + id: string; + name: string; + data: Record<string, unknown>; + timestamp: number; + finishedOn?: number; + failedReason?: string; + returnvalue?: unknown; +} + +export class JobService { + private queue: Queue; + + constructor(config: JobServiceConfig) { + this.queue = new Queue(QUEUE_NAME, { + connection: { + url: config.redisUrl, + }, + }); + } + + async enqueueMetadataRefresh(trackId: string, type: 'full' | 'partial') { + const payload: MetadataRefreshJob = { trackId, refreshType: type }; + await this.queue.add('metadata_refresh', payload); + } + + async enqueueAudioAnalysis(trackId: string, features: string[]) { + const payload: AudioAnalysisJob = { trackId, features }; + await this.queue.add('audio_analysis', payload); + } + + async enqueueCleanup(reason: 'expired' | 'manual', targetFiles: string[]) { + const payload: CleanupJob = { reason, targetFiles }; + await this.queue.add('cleanup', payload); + } + + async enqueueLibraryScan(directory: string) { + const payload: LibraryScanJob = { directory }; + await this.queue.add('scan_library', payload); + } + + async enqueueReindexTracks() { + const payload: ReindexTracksJob = {}; + await this.queue.add('reindex_tracks', payload); + } + + async enqueueReprocessArtists() { + const payload: ReprocessArtistsJob = { batchSize: 100, offset: 0 }; + await this.queue.add('reprocess_artists', payload); + } + + /** + * Enqueue metadata_refresh jobs for a batch of track IDs. Used by the + * /admin/reenrich-tracks endpoint to re-canonicalize metadata (artist names, + * album titles, MBIDs, cover art) without re-scanning files from disk. + * + * Each job is deduped by `jobId: meta-<trackId>` so re-running the endpoint + * doesn't stack duplicate jobs. Old completed/failed jobs with the same ID + * are removed first so re-enrichment actually works (BullMQ otherwise treats + * existing jobIds as duplicates and silently skips them). + */ + async enqueueMetadataRefreshBatch(trackIds: string[]): Promise<number> { + let enqueued = 0; + for (const trackId of trackIds) { + const jobId = `meta-${trackId}`; + await this.queue.remove(jobId).catch(() => {}); + const payload: MetadataRefreshJob = { trackId, refreshType: 'full' }; + await this.queue.add('metadata_refresh', payload, { + jobId, + removeOnComplete: { age: 86400, count: 10000 }, + removeOnFail: { age: 86400, count: 10000 }, + }); + enqueued++; + } + return enqueued; + } + + async getQueueStats(): Promise<QueueStats> { + const [waiting, active, completed, failed, delayed] = await Promise.all([ + this.queue.getWaitingCount(), + this.queue.getActiveCount(), + this.queue.getCompletedCount(), + this.queue.getFailedCount(), + this.queue.getDelayedCount(), + ]); + return { waiting, active, completed, failed, delayed, paused: 0 }; + } + + async getJobHistory(limit = 100): Promise<JobHistoryEntry[]> { + // Get jobs from completed and failed queues (most recent first) + const [completedJobs, failedJobs] = await Promise.all([ + this.queue.getJobs(['completed'], 0, limit), + this.queue.getJobs(['failed'], 0, limit), + ]); + + const allJobs = [...completedJobs, ...failedJobs].map(job => ({ + id: job.id as string, + name: job.name, + data: job.data as Record<string, unknown>, + timestamp: job.timestamp, + finishedOn: job.finishedOn, + failedReason: job.failedReason, + returnvalue: job.returnvalue, + })); + + // Sort by timestamp descending (most recent first) + allJobs.sort((a, b) => b.timestamp - a.timestamp); + + return allJobs.slice(0, limit); + } + + async close() { + await this.queue.close(); + } +} diff --git a/backend/src/services/search.service.ts b/backend/src/services/search.service.ts new file mode 100644 index 0000000..10d10b5 --- /dev/null +++ b/backend/src/services/search.service.ts @@ -0,0 +1,94 @@ +import { Client } from 'typesense'; + +export interface SearchServiceConfig { + host: string; + port: number; + protocol: 'http' | 'https'; + apiKey: string; +} + +export class SearchService { + private client: Client; + private ready = false; + + constructor(config: SearchServiceConfig) { + this.client = new Client({ + nodes: [{ + host: config.host, + port: config.port, + protocol: config.protocol, + }], + apiKey: config.apiKey, + }); + } + + get isReady(): boolean { + return this.ready; + } + + async search(collection: string, query: string, options: any = {}) { + const searchParameters = { + 'q': query, + 'query_by': options.query_by || 'title,artist', + ...options, + }; + + return await this.client.collections(collection).documents().search(searchParameters); + } + + /** + * Create the 'tracks' collection schema if it does not already exist. + * Gracefully handles Typesense not being ready yet (503) — the collection + * can be created later by calling ensureCollection again or via the admin + * reindex endpoint. Search falls back to Postgres ILIKE when Typesense is + * unavailable, so a missing collection is never a hard failure. + */ + async ensureCollection(): Promise<void> { + const collectionSchema = { + name: 'tracks', + fields: [ + { name: 'id', type: 'string' as const }, + { name: 'title', type: 'string' as const }, + { name: 'artist', type: 'string' as const }, + { name: 'album', type: 'string' as const }, + { name: 'duration', type: 'int32' as const }, + { name: 'play_count', type: 'int32' as const }, + { name: 'genre', type: 'string[]' as const, facet: true }, + { name: 'source_type', type: 'string' as const }, + ], + }; + + // First check if the collection already exists (retrieve succeeds). + try { + await this.client.collections(collectionSchema.name).retrieve(); + this.ready = true; + return; + } catch (checkErr: any) { + // 404 means collection doesn't exist — proceed to create. + // 503 means Typesense isn't ready yet — skip creation, search falls back. + if (checkErr?.httpStatus === 503) { + console.warn('[SearchService] Typesense not ready yet (503). Search will use Postgres ILIKE fallback.'); + return; + } + } + + // Collection doesn't exist — create it. + try { + await this.client.collections().create(collectionSchema); + this.ready = true; + } catch (createErr: any) { + // 409 / "already exists" — another process created it between our check and create. + if (createErr?.message?.includes('already exists')) { + this.ready = true; + return; + } + // 503 — Typesense not ready yet, not a hard failure. + if (createErr?.httpStatus === 503) { + console.warn('[SearchService] Typesense not ready yet (503). Search will use Postgres ILIKE fallback.'); + return; + } + // Unexpected error — log and continue; search falls back to Postgres. + console.warn('[SearchService] Failed to create tracks collection:', createErr?.message ?? createErr); + } + } +} diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts new file mode 100644 index 0000000..722954e --- /dev/null +++ b/backend/src/services/session-director.service.ts @@ -0,0 +1,928 @@ +import { DbService, ListenerBelief } from './db.service.js'; +import { Candidate, GeneratorContext, Generator, ALL_GENERATORS } from './generators.service.js'; + +export interface FatigueState { + artist: Map<string, number>; + genre: Map<string, number>; + language: Map<string, number>; + track: Map<string, number>; + vocal: number; +} + +export interface RecentPlay { + trackId: string; + artistId: string | null; + genreId: string | null; + bpm: number | null; + energy: number | null; + language: string | null; + vocal: boolean; + decade: number | null; + valence: number | null; +} + +export interface DiversityBudget { + dimension: string; + budgetShare: number; + horizonMin: number; + spent: number; +} + +const W_ENJOY = 1.0; +const W_FATIGUE = 0.4; +const W_DIVERSITY = 0.3; +const W_ENTROPY = 0.2; +const W_REPETITION = 0.5; + +export class SessionDirector { + constructor(private db: DbService) {} + + // --------------------------------------------------------------- + // D.1 — Build listener state vector + // --------------------------------------------------------------- + async buildState(userId: string, sessionId?: string): Promise<GeneratorContext['state']> { + let savedState: GeneratorContext['state'] | null = null; + + if (sessionId) { + const res = await this.db.pgClient.query( + 'SELECT * FROM session_state WHERE session_id = $1 AND user_id = $2', + [sessionId, userId] + ); + if (res.rows[0]) { + const row = res.rows[0] as { state_vector: Record<string, unknown>; context: string | null; started_at: Date }; + savedState = { + energy: (row.state_vector?.energy as number) ?? 0.5, + lastArtistIds: (row.state_vector?.lastArtistIds as string[]) ?? [], + lastGenreIds: (row.state_vector?.lastGenreIds as string[]) ?? [], + context: row.context, + noveltyHunger: (row.state_vector?.noveltyHunger as number) ?? 0.3, + sessionAgeMin: row.started_at + ? (Date.now() - new Date(row.started_at).getTime()) / 60000 + : 0, + }; + } + } + + if (!savedState) { + const latest = await this.db.getLatestSessionState(userId); + if (latest) { + savedState = { + energy: (latest.state_vector?.energy as number) ?? 0.5, + lastArtistIds: (latest.state_vector?.lastArtistIds as string[]) ?? [], + lastGenreIds: (latest.state_vector?.lastGenreIds as string[]) ?? [], + context: latest.context, + noveltyHunger: (latest.state_vector?.noveltyHunger as number) ?? 0.3, + sessionAgeMin: latest.started_at + ? (Date.now() - new Date(latest.started_at).getTime()) / 60000 + : 0, + }; + } + } + + // Compute fresh energy from last 5 completed plays + const energyRes = await this.db.pgClient.query( + `SELECT COALESCE(AVG(taf.energy), 0.5) AS energy + FROM ( + SELECT ph.track_id + FROM play_history ph + WHERE ph.user_id = $1 AND ph.completed = true + ORDER BY ph.played_at DESC + LIMIT 5 + ) recent + JOIN track_audio_features taf ON taf.track_id = recent.track_id + WHERE taf.energy IS NOT NULL`, + [userId] + ); + const energy = (energyRes.rows[0]?.energy as number) ?? 0.5; + + // Read novelty hunger from discovery profile + const noveltyRes = await this.db.pgClient.query( + `SELECT value FROM listener_beliefs + WHERE user_id = $1 AND profile = 'discovery' AND dimension = 'novelty_tolerance' + LIMIT 1`, + [userId] + ); + const noveltyHunger = (noveltyRes.rows[0]?.value as number) ?? 0.3; + + // Last distinct artist IDs from recent completed plays. + // Use a subquery to order first, then DISTINCT — avoids PG's rule that + // DISTINCT + ORDER BY expressions must appear in the select list. + const lastArtistsRes = await this.db.pgClient.query( + `SELECT DISTINCT artist_id FROM ( + SELECT ta.artist_id + FROM play_history ph + JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' + WHERE ph.user_id = $1 AND ph.completed = true + ORDER BY ph.played_at DESC + LIMIT 40 + ) recent + LIMIT 10`, + [userId] + ); + const lastArtistIds = lastArtistsRes.rows.map((r: { artist_id: string }) => r.artist_id); + + // Last distinct genre IDs + const lastGenresRes = await this.db.pgClient.query( + `SELECT DISTINCT genre_id FROM ( + SELECT tg.genre_id + FROM play_history ph + JOIN track_genre tg ON tg.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.completed = true + ORDER BY ph.played_at DESC + LIMIT 40 + ) recent + LIMIT 10`, + [userId] + ); + const lastGenreIds = lastGenresRes.rows.map((r: { genre_id: string }) => r.genre_id); + + const age = savedState?.sessionAgeMin ?? 0; + + return { + energy, + lastArtistIds, + lastGenreIds, + context: savedState?.context ?? null, + noveltyHunger, + sessionAgeMin: age, + }; + } + + // --------------------------------------------------------------- + // D.2 — Fatigue model + // --------------------------------------------------------------- + async computeFatigue(userId: string): Promise<FatigueState> { + // Track fatigue: last 7 days, decay half-life 30d (2592000 seconds) + const TRACK_DECAY_SEC = 30 * 24 * 3600; + const trackRes = await this.db.pgClient.query( + `SELECT ph.track_id, + LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue + FROM play_history ph + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '7 days' AND ph.completed = true + GROUP BY ph.track_id`, + [userId, TRACK_DECAY_SEC] + ); + const track = new Map<string, number>(); + for (const row of trackRes.rows as { track_id: string; fatigue: number }[]) { + track.set(row.track_id, row.fatigue); + } + + // Artist fatigue: last 24h, decay half-life 8h (28800 seconds) + const ARTIST_DECAY_SEC = 8 * 3600; + const artistRes = await this.db.pgClient.query( + `SELECT ta.artist_id, + LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue + FROM play_history ph + JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours' AND ph.completed = true + GROUP BY ta.artist_id`, + [userId, ARTIST_DECAY_SEC] + ); + const artist = new Map<string, number>(); + for (const row of artistRes.rows as { artist_id: string; fatigue: number }[]) { + artist.set(row.artist_id, row.fatigue); + } + + // Genre fatigue: last 24h, decay half-life 8h + const genreRes = await this.db.pgClient.query( + `SELECT tg.genre_id, + LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue + FROM play_history ph + JOIN track_genre tg ON tg.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours' AND ph.completed = true + GROUP BY tg.genre_id`, + [userId, ARTIST_DECAY_SEC] + ); + const genre = new Map<string, number>(); + for (const row of genreRes.rows as { genre_id: string; fatigue: number }[]) { + genre.set(row.genre_id, row.fatigue); + } + + // Language fatigue: last 2h, decay half-life 1h (3600 seconds) + const LANG_DECAY_SEC = 3600; + const langRes = await this.db.pgClient.query( + `SELECT tl.language, + LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue + FROM play_history ph + JOIN track_lyrics tl ON tl.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '2 hours' AND ph.completed = true + AND tl.language IS NOT NULL + GROUP BY tl.language`, + [userId, LANG_DECAY_SEC] + ); + const language = new Map<string, number>(); + for (const row of langRes.rows as { language: string; fatigue: number }[]) { + language.set(row.language, row.fatigue); + } + + // Vocal fatigue: fraction of last 2h plays that are vocal (instrumentalness < 0.5) + const vocalRes = await this.db.pgClient.query( + `SELECT CASE WHEN COUNT(*) = 0 THEN 0.5 + ELSE COUNT(*) FILTER (WHERE COALESCE(taf.instrumentalness, 0) < 0.5)::float8 / COUNT(*)::float8 + END AS vocal_fatigue + FROM play_history ph + LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '2 hours' AND ph.completed = true`, + [userId] + ); + const vocal = (vocalRes.rows[0]?.vocal_fatigue as number) ?? 0.5; + + return { artist, genre, language, track, vocal }; + } + + // --------------------------------------------------------------- + // D.3 — Diversity budgets + // --------------------------------------------------------------- + async getBudgets(userId: string): Promise<DiversityBudget[]> { + const res = await this.db.pgClient.query( + 'SELECT * FROM diversity_budgets WHERE user_id = $1 ORDER BY dimension', + [userId] + ); + + let rows: { dimension: string; budget_share: number; horizon_min: number }[]; + if (res.rows.length === 0) { + await this.db.seedDefaultDiversityBudgets(userId); + const res2 = await this.db.pgClient.query( + 'SELECT * FROM diversity_budgets WHERE user_id = $1 ORDER BY dimension', + [userId] + ); + rows = res2.rows; + } else { + rows = res.rows; + } + + const budgets: DiversityBudget[] = []; + for (const row of rows) { + const spent = await this.calcBudgetSpent(userId, row.dimension, row.horizon_min); + budgets.push({ + dimension: row.dimension, + budgetShare: row.budget_share, + horizonMin: row.horizon_min, + spent, + }); + } + return budgets; + } + + private async calcBudgetSpent(userId: string, dimension: string, horizonMin: number): Promise<number> { + const interval = `${horizonMin} minutes`; + + switch (dimension) { + case 'artist': { + const res = await this.db.pgClient.query( + `WITH sub AS ( + SELECT COUNT(*) AS cnt + FROM play_history ph + JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true + GROUP BY ta.artist_id + ) + SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent + FROM sub`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + case 'genre': { + const res = await this.db.pgClient.query( + `WITH sub AS ( + SELECT COUNT(*) AS cnt + FROM play_history ph + JOIN track_genre tg ON tg.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true + GROUP BY tg.genre_id + ) + SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent + FROM sub`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + case 'language': { + const res = await this.db.pgClient.query( + `WITH sub AS ( + SELECT tl.language, COUNT(*) AS cnt + FROM play_history ph + JOIN track_lyrics tl ON tl.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true + AND tl.language IS NOT NULL + GROUP BY tl.language + ) + SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent + FROM sub`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + case 'instrumental': { + const res = await this.db.pgClient.query( + `SELECT COALESCE( + COUNT(*) FILTER (WHERE COALESCE(taf.instrumentalness, 0) > 0.5)::float8 / NULLIF(COUNT(*), 0), + 0) AS spent + FROM play_history ph + LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + case 'new_artist': { + const res = await this.db.pgClient.query( + `WITH recent_artists AS ( + SELECT DISTINCT ta.artist_id + FROM play_history ph + JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true + ) + SELECT COALESCE( + SUM(CASE WHEN NOT EXISTS ( + SELECT 1 FROM play_history ph3 + JOIN track_artists_v2 ta3 ON ta3.track_id = ph3.track_id AND ta3.role = 'main' + WHERE ph3.user_id = $1 AND ph3.played_at <= NOW() - $2::interval + AND ta3.artist_id = ra.artist_id + ) THEN 1 ELSE 0 END)::float8 / NULLIF(COUNT(*), 0), + 0) AS spent + FROM recent_artists ra`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + case 'favorite': { + const res = await this.db.pgClient.query( + `SELECT COALESCE( + COUNT(*) FILTER (WHERE f.track_id IS NOT NULL)::float8 / NULLIF(COUNT(*), 0), + 0) AS spent + FROM play_history ph + LEFT JOIN favorites f ON f.track_id = ph.track_id AND f.user_id = $1 + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + default: + return 0; + } + } + + // --------------------------------------------------------------- + // D.4 — Arc selection + // --------------------------------------------------------------- + pickArc(state: GeneratorContext['state']): string { + if (state.energy < 0.3) return 'late-night'; + if (state.energy > 0.6 && state.noveltyHunger > 0.5) return 'discovery'; + if (state.energy > 0.6) return 'energetic'; + return 'comfort'; + } + + getArcSlots(arcType: string, count: number): { position: number; role: string }[] { + const pattern = this.getArcPattern(arcType); + const slots: { position: number; role: string }[] = []; + for (let i = 0; i < count; i++) { + slots.push({ position: i, role: pattern[i % pattern.length] }); + } + return slots; + } + + private getArcPattern(arcType: string): string[] { + switch (arcType) { + case 'comfort': + return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite']; + case 'discovery': + return ['favorite', 'similar', 'new', 'favorite']; + case 'energetic': + return ['medium', 'medium', 'high', 'high', 'high', 'peak', 'cooldown', 'cooldown']; + case 'late-night': + return ['soft', 'soft', 'ambient', 'ambient', 'acoustic', 'slow']; + default: + return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite']; + } + } + + private roleToGeneratorIds(role: string): string[] { + switch (role) { + case 'known': + case 'medium': + case 'soft': + case 'acoustic': + case 'slow': + case 'cooldown': + return ['comfort']; + case 'adjacent': + case 'similar': + return ['adjacent']; + case 'favorite': + return ['deep-dive', 'comfort']; + case 'new': + case 'high': + return ['discovery']; + case 'peak': + return ['deep-dive', 'contextual']; + case 'ambient': + return ['contextual', 'comfort']; + default: + return ['comfort']; + } + } + + // --------------------------------------------------------------- + // D.5 — Entropy, anti-loop + // --------------------------------------------------------------- + computeEntropy(candidates: Candidate[]): number { + if (candidates.length === 0) return 0; + const artistCounts = new Map<string, number>(); + for (const c of candidates) { + const mainEdge = c.explanation.find( + e => e.subjectType === 'artist' || e.objectType === 'artist' + ); + const key = mainEdge?.subjectId ?? mainEdge?.objectId ?? 'unknown'; + artistCounts.set(key, (artistCounts.get(key) ?? 0) + 1); + } + const n = candidates.length; + let hhi = 0; + for (const count of artistCounts.values()) { + const share = count / n; + hhi += share * share; + } + return hhi; + } + + async detectAntiLoop( + state: GeneratorContext['state'], + fatigue: FatigueState, + budgets: DiversityBudget[], + recentPlays: RecentPlay[] + ): Promise<string | null> { + const n = recentPlays.length; + if (n < 3) return null; + + // 1. ARTIST: single artist > 30% of recent plays + const artistCounts = new Map<string, number>(); + for (const p of recentPlays) { + if (p.artistId) artistCounts.set(p.artistId, (artistCounts.get(p.artistId) ?? 0) + 1); + } + for (const count of artistCounts.values()) { + if (count / n > 0.3) return 'artist'; + } + + // 2. GENRE: single genre > 40% of recent plays + const genreCounts = new Map<string, number>(); + for (const p of recentPlays) { + if (p.genreId) genreCounts.set(p.genreId, (genreCounts.get(p.genreId) ?? 0) + 1); + } + for (const count of genreCounts.values()) { + if (count / n > 0.4) return 'genre'; + } + + // 3. LANGUAGE: single language > 50% of recent plays + const langCounts = new Map<string, number>(); + for (const p of recentPlays) { + if (p.language) langCounts.set(p.language, (langCounts.get(p.language) ?? 0) + 1); + } + for (const count of langCounts.values()) { + if (count / n > 0.5) return 'language'; + } + + // 4. ENERGY: >60% of plays in same energy quartile + const energies = recentPlays.filter(p => p.energy != null).map(p => p.energy!); + if (energies.length >= 3) { + const quartileCounts = [0, 0, 0, 0]; + for (const e of energies) { + const q = Math.min(Math.floor(e / 0.25), 3); + quartileCounts[q]++; + } + if (Math.max(...quartileCounts) / energies.length > 0.6) return 'energy'; + } + + // 5. BPM: all plays within 20 BPM of each other + const bpms = recentPlays.filter(p => p.bpm != null).map(p => p.bpm!); + if (bpms.length >= 3) { + const bpmMin = Math.min(...bpms); + const bpmMax = Math.max(...bpms); + if (bpmMax - bpmMin <= 20) return 'bpm'; + } + + // 6. VOCAL: >80% all-vocal or all-instrumental + if (n >= 3) { + const vocalCount = recentPlays.filter(p => p.vocal).length; + const vocalRatio = vocalCount / n; + if (vocalRatio > 0.8 || vocalRatio < 0.2) return 'vocal'; + } + + // 7. DECADE: >50% from same decade + const decadeCounts = new Map<number, number>(); + for (const p of recentPlays) { + if (p.decade != null) decadeCounts.set(p.decade, (decadeCounts.get(p.decade) ?? 0) + 1); + } + for (const count of decadeCounts.values()) { + if (count / n > 0.5) return 'decade'; + } + + // 8. PRODUCER: single producer > 3 tracks + const trackIds = recentPlays.map(p => p.trackId).filter(Boolean); + if (trackIds.length > 0) { + const prodRes = await this.db.pgClient.query( + `SELECT c.object_id + FROM claims c + WHERE c.predicate = 'produced' + AND c.subject_id = ANY($1::uuid[]) + GROUP BY c.object_id + HAVING COUNT(DISTINCT c.subject_id) > 3`, + [trackIds] + ); + if (prodRes.rows.length > 0) return 'producer'; + } + + // 9. LABEL: single label > 3 tracks + if (trackIds.length > 0) { + const labelRes = await this.db.pgClient.query( + `SELECT c.object_id + FROM claims c + WHERE c.predicate = 'same_label_as' + AND c.subject_id = ANY($1::uuid[]) + GROUP BY c.object_id + HAVING COUNT(DISTINCT c.subject_id) > 3`, + [trackIds] + ); + if (labelRes.rows.length > 0) return 'label'; + } + + // 10. MOOD: all plays same mood (valence > 0.5 = positive, <= 0.5 = negative) + const valences = recentPlays.filter(p => p.valence != null).map(p => p.valence!); + if (valences.length >= 3) { + const positiveCount = valences.filter(v => v > 0.5).length; + if (positiveCount === valences.length || positiveCount === 0) return 'mood'; + } + + return null; + } + + // --------------------------------------------------------------- + // D.6 — Repetition rules + // --------------------------------------------------------------- + async checkRepetition(trackId: string, artistId: string, userId: string): Promise<boolean> { + const rulesRes = await this.db.pgClient.query( + 'SELECT dimension, min_distance FROM repetition_rules WHERE user_id = $1', + [userId] + ); + + const ruleMap = new Map<string, number>(); + for (const row of rulesRes.rows as { dimension: string; min_distance: number }[]) { + ruleMap.set(row.dimension, row.min_distance); + } + + const trackMin = ruleMap.get('track') ?? 120; + + if (trackMin > 0) { + const res = await this.db.pgClient.query( + `SELECT 1 FROM play_history + WHERE user_id = $1 AND track_id = $2 AND completed = true + AND played_at > NOW() - ($3 || ' minutes')::interval + LIMIT 1`, + [userId, trackId, String(trackMin)] + ); + if (res.rows.length > 0) return true; + } + + const artistMin = ruleMap.get('artist') ?? 20; + if (artistId && artistMin > 0) { + const res = await this.db.pgClient.query( + `SELECT 1 FROM play_history ph + JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.artist_id = $2 AND ta.role = 'main' + WHERE ph.user_id = $1 AND ph.completed = true + AND ph.played_at > NOW() - ($3 || ' minutes')::interval + LIMIT 1`, + [userId, artistId, String(artistMin)] + ); + if (res.rows.length > 0) return true; + } + + return false; + } + + // --------------------------------------------------------------- + // D.8 — Multi-objective ranking + // --------------------------------------------------------------- + async rankCandidates( + candidates: Candidate[], + fatigue: FatigueState, + budgets: DiversityBudget[], + state: GeneratorContext['state'], + repetitionCheck: (trackId: string, artistId: string) => Promise<boolean> + ): Promise<Candidate[]> { + if (candidates.length === 0) return []; + + const trackIds = [...new Set(candidates.map(c => c.trackId))]; + const artistMap = new Map<string, string>(); + if (trackIds.length > 0) { + const artRes = await this.db.pgClient.query( + `SELECT DISTINCT ON (ta.track_id) ta.track_id, ta.artist_id + FROM track_artists_v2 ta + WHERE ta.track_id = ANY($1::uuid[]) AND ta.role = 'main'`, + [trackIds] + ); + for (const row of artRes.rows as { track_id: string; artist_id: string }[]) { + artistMap.set(row.track_id, row.artist_id); + } + } + + const genreMap = new Map<string, string>(); + if (trackIds.length > 0) { + const genreRes = await this.db.pgClient.query( + `SELECT DISTINCT ON (tg.track_id) tg.track_id, tg.genre_id + FROM track_genre tg + WHERE tg.track_id = ANY($1::uuid[]) + ORDER BY tg.track_id, tg.weight DESC`, + [trackIds] + ); + for (const row of genreRes.rows as { track_id: string; genre_id: string }[]) { + genreMap.set(row.track_id, row.genre_id); + } + } + + const artistBudget = budgets.find(b => b.dimension === 'artist'); + const currentEntropy = this.computeEntropy(candidates); + const targetEntropy = 0.55; + + const scored: { candidate: Candidate; score: number }[] = []; + for (const c of candidates) { + const artistId = artistMap.get(c.trackId) ?? ''; + const genreId = genreMap.get(c.trackId) ?? ''; + + const trackFatigue = fatigue.track.get(c.trackId) ?? 0; + const artistFatigue = fatigue.artist.get(artistId) ?? 0; + const genreFatigue = fatigue.genre.get(genreId) ?? 0; + const avgFatigue = (trackFatigue + artistFatigue + genreFatigue) / 3; + + const artistSpendRatio = artistBudget ? artistBudget.spent : 0; + const diversityBonus = 1 - artistSpendRatio; + const entropyBonus = 1 - Math.abs(currentEntropy - targetEntropy); + const wouldRepeat = await repetitionCheck(c.trackId, artistId); + + let score = W_ENJOY * c.relevance + - W_FATIGUE * avgFatigue + + W_DIVERSITY * diversityBonus + + W_ENTROPY * entropyBonus; + + if (wouldRepeat) { + score *= 0.1; + } + + scored.push({ candidate: c, score }); + } + + const entropyDrift = Math.abs(currentEntropy - targetEntropy); + if (entropyDrift > 0.2) { + const genCounts = new Map<string, number>(); + for (const s of scored) { + genCounts.set(s.candidate.generatorId, (genCounts.get(s.candidate.generatorId) ?? 0) + 1); + } + const maxCount = Math.max(...genCounts.values(), 1); + for (const s of scored) { + const genCount = genCounts.get(s.candidate.generatorId) ?? 0; + s.score += (1 - genCount / maxCount) * 0.15; + } + } + + scored.sort((a, b) => b.score - a.score); + return scored.map(s => s.candidate); + } + + // --------------------------------------------------------------- + // D.9 — Plan + replan loop + // --------------------------------------------------------------- + async buildPlan(userId: string, sessionId: string, seedTrackId?: string): Promise<Candidate[]> { + const allBeliefs = await this.db.getListenerBeliefs({ userId, limit: 200 }); + + // Fetch recent completed plays for anti-loop detection + const recentPlaysRes = await this.db.pgClient.query( + `SELECT t.id AS track_id, ta.artist_id, tg.genre_id, + af.bpm, af.energy, af.valence, af.instrumentalness, + tl.language, + t.release_date + FROM play_history ph + JOIN tracks t ON t.id = ph.track_id + LEFT JOIN track_audio_features af ON af.track_id = t.id + LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main' + LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = ( + SELECT MAX(weight) FROM track_genre WHERE track_id = t.id + ) + LEFT JOIN track_lyrics tl ON tl.track_id = t.id + WHERE ph.user_id = $1 AND ph.completed = true + ORDER BY ph.played_at DESC + LIMIT 20`, + [userId] + ); + const recentPlays: RecentPlay[] = recentPlaysRes.rows.map((r: any) => ({ + trackId: r.track_id, + artistId: r.artist_id ?? null, + genreId: r.genre_id ?? null, + bpm: r.bpm ?? null, + energy: r.energy ?? null, + language: r.language ?? null, + vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5, + decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null, + valence: r.valence ?? null, + })); + + const state = await this.buildState(userId, sessionId); + const fatigue = await this.computeFatigue(userId); + const budgets = await this.getBudgets(userId); + + const arcType = this.pickArc(state); + const planSize = 20; + const slots = this.getArcSlots(arcType, planSize); + + let seedArtistId: string | null = null; + if (seedTrackId) { + seedArtistId = await this.resolveSeedArtistId(seedTrackId) ?? null; + } + + const recentExclusions: string[] = []; + const toleranceMap: Record<string, number> = {}; + const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery'); + for (const b of discoveryBeliefs) { + if (b.dimension) toleranceMap[b.dimension] = b.value; + } + + const ctx: GeneratorContext = { + userId, + seedTrackId: seedTrackId ?? null, + seedArtistId, + beliefs: allBeliefs, + recentExclusions, + toleranceMap, + state, + }; + + const allCandidates: Candidate[] = []; + for (const gen of ALL_GENERATORS) { + const result = await gen(this.db, ctx); + allCandidates.push(...result); + } + + if (allCandidates.length === 0) { + return []; + } + + const repetitionCheckFn = (tid: string, aid: string) => + this.checkRepetition(tid, aid, userId); + const ranked = await this.rankCandidates( + allCandidates, fatigue, budgets, state, repetitionCheckFn + ); + + const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); + let forcedExperimental = false; + if (loopDim && ranked.length > 0) { + const expCtx: GeneratorContext = { + ...ctx, + recentExclusions: ctx.recentExclusions.slice(0, Math.min(ctx.recentExclusions.length, 50)), + }; + const extraCandidates: Candidate[] = []; + for (const gen of ALL_GENERATORS) { + const result = await gen(this.db, expCtx); + extraCandidates.push(...result); + } + const expRanked = await this.rankCandidates( + extraCandidates, fatigue, budgets, state, repetitionCheckFn + ); + const injected = expRanked.filter( + c => c.generatorId === 'experimental' || c.generatorId === 'discovery' + ); + ranked.unshift(...injected); + forcedExperimental = true; + } + + const seen = new Set<string>(); + const deduped: Candidate[] = []; + for (const c of ranked) { + if (!seen.has(c.trackId)) { + seen.add(c.trackId); + deduped.push(c); + } + } + + const plan: Candidate[] = []; + const usedTrackIds = new Set<string>(); + + if (!forcedExperimental) { + const unused = [...deduped]; + for (const slot of slots) { + const prefGenIds = this.roleToGeneratorIds(slot.role); + let idx = unused.findIndex( + c => prefGenIds.includes(c.generatorId) && !usedTrackIds.has(c.trackId) + ); + if (idx === -1) { + idx = unused.findIndex(c => !usedTrackIds.has(c.trackId)); + } + if (idx === -1) break; + const chosen = unused[idx]; + usedTrackIds.add(chosen.trackId); + plan.push(chosen); + unused.splice(idx, 1); + } + + if (plan.length < planSize) { + for (const c of deduped) { + if (plan.length >= planSize) break; + if (!usedTrackIds.has(c.trackId)) { + usedTrackIds.add(c.trackId); + plan.push(c); + } + } + } + } else { + for (const c of deduped) { + if (plan.length >= planSize) break; + plan.push(c); + } + } + + return plan.slice(0, planSize); + } + + async replan( + userId: string, + sessionId: string, + currentPlan: Candidate[], + playedTrackIds: string[], + seedTrackId?: string + ): Promise<Candidate[]> { + const remainingSlots = currentPlan.filter( + c => !playedTrackIds.includes(c.trackId) + ); + + if (remainingSlots.length >= 10 && currentPlan.length > 0) { + const fatigue = await this.computeFatigue(userId); + const budgets = await this.getBudgets(userId); + const state = await this.buildState(userId, sessionId); + + // Fetch recent plays for anti-loop + const recentPlaysRes = await this.db.pgClient.query( + `SELECT t.id AS track_id, ta.artist_id, tg.genre_id, + af.bpm, af.energy, af.valence, af.instrumentalness, + tl.language, + t.release_date + FROM play_history ph + JOIN tracks t ON t.id = ph.track_id + LEFT JOIN track_audio_features af ON af.track_id = t.id + LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main' + LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = ( + SELECT MAX(weight) FROM track_genre WHERE track_id = t.id + ) + LEFT JOIN track_lyrics tl ON tl.track_id = t.id + WHERE ph.user_id = $1 AND ph.completed = true + ORDER BY ph.played_at DESC + LIMIT 20`, + [userId] + ); + const recentPlays: RecentPlay[] = recentPlaysRes.rows.map((r: any) => ({ + trackId: r.track_id, + artistId: r.artist_id ?? null, + genreId: r.genre_id ?? null, + bpm: r.bpm ?? null, + energy: r.energy ?? null, + language: r.language ?? null, + vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5, + decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null, + valence: r.valence ?? null, + })); + + const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); + if (loopDim) { + return this.buildPlan(userId, sessionId, seedTrackId); + } + + const entropy = this.computeEntropy(currentPlan); + if (Math.abs(entropy - 0.55) > 0.2) { + return this.buildPlan(userId, sessionId, seedTrackId); + } + + return remainingSlots; + } + + return this.buildPlan(userId, sessionId, seedTrackId); + } + + // --------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------- + private async resolveSeedArtistId(seedTrackId: string): Promise<string | undefined> { + const res = await this.db.pgClient.query( + `SELECT ta.artist_id + FROM track_artists_v2 ta + WHERE ta.track_id = $1 AND ta.role = 'main' + LIMIT 1`, + [seedTrackId] + ); + if (res.rows[0]?.artist_id) return res.rows[0].artist_id as string; + + const fallback = await this.db.pgClient.query( + `SELECT al.artist_id + FROM tracks t + JOIN albums al ON al.id = t.album_id + WHERE t.id = $1`, + [seedTrackId] + ); + return fallback.rows[0]?.artist_id as string | undefined; + } +} diff --git a/backend/src/services/session-director.test.ts b/backend/src/services/session-director.test.ts new file mode 100644 index 0000000..d2c8897 --- /dev/null +++ b/backend/src/services/session-director.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SessionDirector } from './session-director.service.js'; +import { DbService } from './db.service.js'; + +function makeMockDb(overrides: Record<string, any> = {}): DbService { + const mockQuery = vi.fn(); + return { + pgClient: { query: mockQuery }, + getListenerBeliefs: vi.fn().mockResolvedValue([]), + getLatestSessionState: vi.fn().mockResolvedValue(null), + seedDefaultDiversityBudgets: vi.fn().mockResolvedValue(undefined), + upsertDiversityBudget: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as DbService; +} + +describe('SessionDirector', () => { + describe('pickArc', () => { + const director = new SessionDirector(makeMockDb()); + + it('returns late-night for low energy', () => { + const arc = director.pickArc({ energy: 0.2, noveltyHunger: 0.3, sessionAgeMin: 10 } as any); + expect(arc).toBe('late-night'); + }); + + it('returns discovery for high energy + high novelty', () => { + const arc = director.pickArc({ energy: 0.7, noveltyHunger: 0.6, sessionAgeMin: 5 } as any); + expect(arc).toBe('discovery'); + }); + + it('returns energetic for high energy + low novelty', () => { + const arc = director.pickArc({ energy: 0.7, noveltyHunger: 0.3, sessionAgeMin: 5 } as any); + expect(arc).toBe('energetic'); + }); + + it('returns comfort for medium energy', () => { + const arc = director.pickArc({ energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10 } as any); + expect(arc).toBe('comfort'); + }); + }); + + describe('getArcSlots', () => { + const director = new SessionDirector(makeMockDb()); + + it('returns correct slot count', () => { + expect(director.getArcSlots('comfort', 20).length).toBe(20); + expect(director.getArcSlots('discovery', 20).length).toBe(20); + expect(director.getArcSlots('energetic', 10).length).toBe(10); + expect(director.getArcSlots('late-night', 8).length).toBe(8); + }); + + it('has valid role names', () => { + const slots = director.getArcSlots('comfort', 20); + const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow']; + slots.forEach(s => expect(validRoles).toContain(s.role)); + }); + }); + + describe('computeEntropy', () => { + const director = new SessionDirector(makeMockDb()); + + it('returns 0 for empty set', () => { + expect(director.computeEntropy([])).toBe(0); + }); + + it('returns 1 for all-same-artist', () => { + const candidates = [ + { trackId: 't1', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, + { trackId: 't2', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, + ] as any; + expect(director.computeEntropy(candidates)).toBe(1); + }); + + it('returns ~0.5 for two-artist split', () => { + const candidates = [ + { trackId: 't1', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, + { trackId: 't2', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, + ] as any; + const hhi = director.computeEntropy(candidates); + expect(hhi).toBeCloseTo(0.5); + }); + }); + + describe('rankCandidates', () => { + it('sorts candidates by score descending', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [] }); + const director = new SessionDirector(db); + + const candidates = [ + { trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, + { trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, + ]; + const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 }; + const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }]; + const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null }; + + const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, async () => false); + expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance); + }); + }); + + describe('buildState', () => { + it('returns state with default values when no prior session', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [] }); + (db.getListenerBeliefs as any).mockResolvedValue([]); + (db.getLatestSessionState as any).mockResolvedValue(null); + const director = new SessionDirector(db); + const state = await director.buildState('user-1'); + expect(state).toHaveProperty('energy'); + expect(state).toHaveProperty('noveltyHunger'); + expect(state).toHaveProperty('sessionAgeMin'); + expect(typeof state.energy).toBe('number'); + }); + }); +}); diff --git a/backend/src/types/job.types.ts b/backend/src/types/job.types.ts new file mode 100644 index 0000000..3b1d2f9 --- /dev/null +++ b/backend/src/types/job.types.ts @@ -0,0 +1,27 @@ +export interface MetadataRefreshJob { + trackId: string; + refreshType: 'full' | 'partial'; +} + +export interface AudioAnalysisJob { + trackId: string; + features: string[]; +} + +export interface CleanupJob { + reason: 'expired' | 'manual'; + targetFiles: string[]; +} + +export interface LibraryScanJob { + directory: string; +} + +export interface ReindexTracksJob {} + +export interface ReprocessArtistsJob { + batchSize?: number; + offset?: number; +} + +export type JobPayload = MetadataRefreshJob | AudioAnalysisJob | CleanupJob | LibraryScanJob | ReindexTracksJob | ReprocessArtistsJob; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..225d137 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "*": ["node_modules/*"] + }, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..7dd1325 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b933b4c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +services: + db: + image: postgres:16 + restart: always + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: muzick + ports: + - "5432:5432" + volumes: + - ./data/postgres:/var/lib/postgresql/data + - ./backend/src/db/schema.sql:/docker-entrypoint-initdb.d/schema.sql + + redis: + image: redis:7 + restart: always + ports: + - "6379:6379" + volumes: + - ./data/redis:/data + + search: + image: typesense/typesense:0.25.1 + restart: always + ports: + - "8108:8108" + volumes: + - ./data/typesense:/data + command: --data-dir /data --api-key=muzick-key + + backend: + build: ./backend + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://user:password@db:5432/muzick + REDIS_URL: redis://redis:6379 + TYPESENSE_API_KEY: muzick-key + MUSIC_DIR: /music + volumes: + - /mnt/hdd1/media/Music:/music:ro + depends_on: + - db + - redis + - search + + frontend: + build: ./frontend + ports: + - "5174:80" + depends_on: + - backend + + worker: + build: ./workers + network_mode: host + environment: + DATABASE_URL: postgresql://user:password@127.0.0.1:5432/muzick + REDIS_URL: redis://127.0.0.1:6379 + MUSICBRAINZ_CONTACT: ${MUSICBRAINZ_CONTACT} + LASTFM_API_KEY: ${LASTFM_API_KEY} + LASTFM_SHARED_SECRET: ${LASTFM_SHARED_SECRET} + DISCOGS_TOKEN: ${DISCOGS_TOKEN} + SOCKS_PROXY_URL: ${SOCKS_PROXY_URL} + MUSIC_DIR: /music + volumes: + - /mnt/hdd1/media/Music:/music:ro diff --git a/docs/architecture/01-system-overview.md b/docs/architecture/01-system-overview.md new file mode 100644 index 0000000..fd1f819 --- /dev/null +++ b/docs/architecture/01-system-overview.md @@ -0,0 +1,52 @@ +# System Overview + +## High-Level Architecture + +`muzick` follows a distributed architecture centered around a shared PostgreSQL database and a distributed task queue (BullMQ). + +```mermaid +graph TD + User((User)) --> Web[Frontend - React/Vite] + Web --> API[Backend - Fastify] + API --> DB[(PostgreSQL)] + API --> Search[Typesense] + API --> Cache[(Redis)] + API --> Queue[BullMQ] + + subgraph Workers + W1[Metadata Worker] + W2[Essentia Audio Worker] + W3[Cleanup/Sweep Worker] + end + + Queue --> W1 + Queue --> W2 + Queue --> W3 + + W1 --> DB + W2 --> DB + W3 --> DB + W1 --> External[External APIs: MusicBrainz/Discogs] + W2 --> Audio[/Filesystem/Music] +``` + +## Component Roles + +### **1. Frontend (The Interface)** +- **React/Vite:** High-performance UI. +- **TanStack Router/Query:** Handles complex navigation and provides the **Look-ahead Buffer** for the continuous playback stream. +- **Zustand:** Manages the active "Vibe" session state and local playback queue. + +### **2. Backend (The Brain)** +- **Fastify:** High-throughput API server. +- **Business Logic:** Manages the **Rolling Window** recommendation algorithm, the **Dislike State Machine**, and the **Session Management**. +- **Typesense:** Provides ultra-fast fuzzy search across the entire library. + +### **3. Workers (The Muscle)** +- **Metadata Worker:** Orchestrates enrichment via MusicBrainz, Discogs, and LRCLib. +- **Essentia Worker:** Performs heavy CPU-bound audio feature extraction (BPM, Key, etc.). +- **Sweep Worker:** Handles periodic cleanup (Dislike $\rightarrow$ Deletion) and filesystem-to-DB reconciliation. + +### **4. Data Layer** +- **PostgreSQL:** The ultimate source of truth for metadata, user preferences, and session history. +- **Redis:** Powers the task queue (BullMQ) and provides ephemeral session data. diff --git a/docs/architecture/02-invariants-and-risks.md b/docs/architecture/02-invariants-and-risks.md new file mode 100644 index 0000000..6add01d --- /dev/null +++ b/docs/architecture/02-invariants-and-risks.md @@ -0,0 +1,37 @@ +# Invariants and Risks + +This document outlines the critical rules that MUST be respected to maintain system integrity and the identified technical risks. + +## 1. System Invariants (The "Never Break" Rules) + +### **A. Data Consistency (The "No Ghost Tracks" Rule)** +* **Invariant:** Every `track` record in the database must correspond to a physical file on the disk. +* **Mechanism:** The **Consistency Worker** must run periodically to reconcile the database with the `/mnt/hdd1/media/Music` directory. Any discrepancy must result in the track being marked as `MISSING` in the DB, rather than deleted immediately. + +### **B. Session Integrity (The "No Deadlocks" Rule)** +* **Invariant:** An `ACTIVE` recommendation batch must eventually reach a terminal state (`RESOLVED` or `FAILED`). +* **Mechanism:** Every batch must have a `last_interaction_at` timestamp. A background sweep must transition stale `ACTIVE` sessions to `RESOLVED` to allow new sessions to start. + +### **C. Filesystem Safety (The "Irreversible Action" Rule)** +* **Invariant:** Hard deletion of a file from the filesystem is the final, irreversible step in the `PENDING_REMOVAL` lifecycle. +* **Mechanism:** A track only enters the `DELETE_FILE` state after it has been `warned` for at least 24 hours and the user has not explicitly triggered a `RESTORE`. + +## 2. Known Technical Risks + +### **A. The "Similarity Explosion" (Scalability)** +* **Risk:** Precomputing a $O(n^2)$ similarity matrix for large libraries will exhaust database resources. +* **Mitigation:** + * Use **Tiered Similarity**: Metadata-based matches (Instant) $\rightarrow$ Audio-feature matches (Asynchronous/On-demand). + * Limit similarity computation to tracks within the same genre or recent listening window. + +### **B. Computational Exhaustion (Resource Management)** +* **Risk:** Heavy audio analysis (Essentia) can starve the API of CPU/RAM. +* **Mitigation:** Audio analysis and metadata enrichment must run in dedicated worker processes (containers) with strict resource limits (cgroups/Docker). + +### **C. Metadata Drift** +* **Risk:** External providers (MusicBrainz/Discogs) may provide conflicting or low-quality data. +* **Mitigation:** Implement a priority-based enrichment pipeline and allow manual user overrides via the UI. + +### **D. Race Conditions (The "Cleanup Race")** +* **Risk:** A user interacts with a track at the exact moment the Sweep Worker attempts to delete the file. +* **Mitigation:** Use transactional state transitions (e.g., `UPDATE tracks SET state = 'HIDDEN' WHERE id = X AND state = 'PENDING_REMOVAL'`) to ensure an action only happens if the state hasn't changed. diff --git a/docs/architecture/03-backend-spec.md b/docs/architecture/03-backend-spec.md new file mode 100644 index 0000000..dd77566 --- /dev/null +++ b/docs/architecture/03-backend-spec.md @@ -0,0 +1,53 @@ +# Backend Specification + +## Core Technology Stack +- **Runtime:** Node.js (TypeScript) +- **Framework:** Fastify +- **Communication:** REST API (primary) +- **Queueing:** BullMQ with Redis + +## 1. API Architecture + +### **Library Management** +- `GET /api/tracks`: Paginated list of tracks (supports sort/filter). +- `GET /api/tracks/{id}`: Full track metadata. +- `GET /api/search?q={query}`: Fuzzy search via Typesense. +- `POST /api/library/reindex`: Manual trigger for the indexing worker. + +### **The Vibe Engine (Recommendation)** +- `GET /api/vibe`: Returns a **Sequence Chunk** of track IDs. + - *Client Implementation:* Uses TanStack Query to implement a "Look-ahead Buffer." +- `GET /api/vibe/from-genre?genre={id}`: Generates a session based on a specific genre. + +### **The Lifecycle (Dislike/Removal)** +- `POST /api/dislike/{track_id}`: Moves track to `PENDING_REMOVAL` (State: `HIDDEN`). +- `DELETE /api/dislike/{id}`: Restores track to `LIBRARY`. +- `GET /api/dislikes`: Lists all tracks in the quarantine. +- `POST /api/dislikes/sweep`: Manual trigger for the background cleanup worker. + +### **Session Management** +- `GET /api/sessions/current`: Returns the metadata for the current `ACTIVE` recommendation batch. +- `POST /api/sessions/heartbeat`: Updates `last_interaction_at` for the active batch. + +## 2. The "Rolling Window" Algorithm + +To provide an infinite, evolving stream, the backend implements a **Stateful Sequence Generator**. + +### **Algorithm Steps:** +1. **Seed Selection:** Identify the `center_track_id` (the last successfully played track). +2. **Candidate Generation:** + - **Primary (80%):** Fetch tracks similar to the `center_track_id` (Metadata + Audio Feature match). + - **Discovery (20%):** Fetch tracks from the **Probation Pool** (newly acquired recommendations). +3. **Sequence Construction:** + - Group results into a "Chunk" (e.g., 20 tracks). + - Apply **Batch Rules**: + - Max 1 song per artist in a single chunk. + - Max 2 songs per genre in a single chunk. + - Mix in "probation" tracks at a controlled rate. +4. **Response:** Return a JSON array of track objects with pre-calculated sequence order. + +## 3. Business Logic Invariants + +- **The "No Ghost" Rule:** The backend MUST verify file existence before returning a track in a `Vibe` sequence. +- **The "Success-Driven Center" Rule:** The `center_track_id` is updated ONLY when a track's `completed` flag is set to `true` via `/api/history`. +- **The "Atomicity" Rule:** All state transitions (e.g., `PENDING_REMOVAL` $\rightarrow$ `DELETED`) must be handled within a database transaction. diff --git a/docs/architecture/04-frontend-spec.md b/docs/architecture/04-frontend-spec.md new file mode 100644 index 0000000..46c6139 --- /dev/null +++ b/docs/architecture/04-frontend-spec.md @@ -0,0 +1,45 @@ +# Frontend Specification + +## Core Technology Stack +- **Framework:** React (Vite-based) +- **Routing:** TanStack Router (Type-safe routing) +- **Data Fetching:** TanStack Query (Managing server state & caching) +- **State Management:** Zustand (Managing local client-side playback and vibe state) +- **Styling:** CSS Variables (Enabling easy theme switching) + +## 1. Key UI Patterns + +### **The "Look-ahead Buffer" (Seamless Playback)** +To prevent playback gaps, the frontend implements a **Prefetching Queue**. +- **Mechanism:** The client maintains a `playback_buffer` in Zustand containing the next 20 tracks. +- **Implementation:** When the user reaches track $N$, TanStack Query triggers a fetch for the next chunk ($N+20$) in the background. +- **User Experience:** Tapping "next" is near-instantaneous as the track is already in memory. + +### **The "Vibe" Interface** +The core feature is the **Active Vibe Page**. +- **Visuals:** Shows the current "Rolling Window" as a progress bar/timeline. +- **Real-time Updates:** Displays "Incoming Recommendations" (Probation tracks) as they are discovered. +- **Controls:** Quick-access "Keep" / "Dislike" buttons that trigger the lifecycle state machine. + +### **The "Quarantine" Page** +A management view for the `PENDING_REMOVAL` state. +- **Features:** List of hidden tracks, "time remaining" timers, and "Restore" / "Delete" actions. + +## 2. State Management Strategy + +### **Zustand Stores** +- **`usePlaybackStore`:** Tracks `current_track`, `playback_position`, `is_playing`, and the `local_queue`. +- **`useVibeStore`:** Manages the `active_session_id` and the current "Center Track" context. + +### **TanStack Query** +- Used for all standard CRUD operations (Library, Artists, Albums). +- Configured with aggressive `staleTime` for static data (Artists/Albums) and low `staleTime` for dynamic data (History/Stats). + +## 3. Navigation Structure + +- **Home:** Continue Listening, Recently Added, Recently Played. +- **Library:** Artists, Albums, Tracks, Genres (hierarchical). +- **Vibe:** The infinite player/discovery interface. +- **Discover:** Manual exploration of similar artists/tracks. +- **Search:** Global fuzzy search. +- **Settings:** Theme, playback modes, and automation rules. diff --git a/docs/architecture/05-recommendation-spec.md b/docs/architecture/05-recommendation-spec.md new file mode 100644 index 0000000..eb6cd0c --- /dev/null +++ b/docs/architecture/05-recommendation-spec.md @@ -0,0 +1,60 @@ +# Recommendation & Vibe Specification + +This document defines the logic for the "Vibe" engine, moving from simple similarity to a continuous, evolving listening experience. + +## 1. The "Vibe" Concept +The "Vibe" is an infinite, stateful listening session. Unlike a static playlist, it is a **Rolling Window** that evolves based on user interaction. + +## 2. Scoring Logic + +When generating a sequence, every candidate track is assigned a score. + +$$Score = (W_{genre} \cdot S_{genre}) + (W_{artist} \cdot S_{artist}) + (W_{random} \cdot R)$$ + +### **Scoring Components** +* **$S_{genre}$ (Genre Match):** + * Uses hierarchical weights (e.g., `Deep House` (1.0) $\rightarrow$ `House` (0.8) $\rightarrow$ `Electronic` (0.5)). + * Calculated as the highest weight match between candidate and "Center Track." +* **$S_{artist}$ (Artist Similarity):** + * A binary or weighted score based on whether the artist is in the user's "Liked" list or frequent listening history. +* **$R$ (Randomness/Exploration):** + * A jitter factor to ensure the queue doesn't feel repetitive. + +## 3. The Rolling Window Algorithm + +The backend does not return a fixed list. It returns a **Sequence Chunk**. + +### **Step-by-Step Generation** +1. **Identify the Center:** Find the `last_successfully_played_track_id`. +2. **Candidate Selection:** + * **Local Pool (80%):** Tracks from the user's library similar to the center track. + * **Probation Pool (20%):** Tracks from the `recommendation_batch` that have `probation=1`. +3. **Constraint Application (Batch Rules):** + * **Diversity Check:** No more than 1 track from the same artist per chunk. + * **Genre Cap:** No more than 2 tracks from the same genre per chunk. +4. **Chunking:** Return a sequence of $N$ tracks (e.g., 20). + +## 4. The "Vibe" Session Lifecycle + +A "Vibe" is represented by a `recommendation_batch` record. + +| State | Description | +| :--- | :--- | +| **ACTIVE** | The user is currently listening. The stream is being generated. | +| **RESOLVED** | The session has ended naturally or timed out. | +| **FAILED** | The session was interrupted by a critical error or manual reset. | + +### **Transition Logic** +* **Start:** User clicks "Start Vibe" $\rightarrow$ Create `ACTIVE` batch. +* **Progress:** User listens $\rightarrow$ Update `last_interaction_at` in the batch. +* **Termination:** + * **Natural:** User exits the app $\rightarrow$ Session marked `RESOLVED` after 24h. + * **Manual:** User ends session $\rightarrow$ Mark `RESOLVED` immediately. + * **Timeout:** No interaction for 24h $\rightarrow$ Mark `RESOLVED`. + +## 5. Success/Failure Feedback Loop + +The engine learns from the `feedback` table: +* **`action = 'promoted'`:** (High Score) Increase weight of this genre/artist in future seeds. +* **`action = 'disliked'`:** (Negative Signal) Decrease weight of this genre/artist. +* **`action = 'skipped'`:** (Transient Negative) Do not adjust long-term weights, but avoid this specific track in the current session window. diff --git a/docs/architecture/06-lifecycle-spec.md b/docs/architecture/06-lifecycle-spec.md new file mode 100644 index 0000000..ece80c2 --- /dev/null +++ b/docs/architecture/06-lifecycle-spec.md @@ -0,0 +1,53 @@ +# Lifecycle & Removal Specification + +This document defines the "Dislike $\rightarrow$ Delayed Deletion" lifecycle. This state machine ensures user intent is respected while preventing accidental permanent loss of music. + +## 1. The Dislike State Machine + +A track follows this state transition to ensure a "Grace Period" before physical deletion. + +| Current State | Action | Next State | Side Effects | +| :--- | :--- | :--- | :--- | +| **LIBRARY** | User Dislikes | **PENDING_REMOVAL** | `dislikes` row created; Track becomes `HIDDEN`. | +| **PENDING_REMOVAL** | User Restores | **LIBRARY** | `dislikes` row deleted; Track becomes `VISIBLE`. | +| **PENDING_REMOVAL** | Grace Period Ends | **WARNING_SENT** | `ntfy` notification sent; `warned_at` timestamp set. | +| **WARNING_SENT** | 24h Passes | **DELETED** | File deleted from FS; Track record removed from DB. | + +## 2. Detailed Transitions + +### **Phase 1: The Dislike (Immediate)** +When a user triggers a dislike: +1. **DB Transaction:** + * Update `tracks.state = 'HIDDEN'`. + * Insert into `dislikes` table `{track_id, disliked_at: now}`. + * Log `feedback(action='disliked')`. +2. **UI Update:** The track immediately disappears from all "active" views (Library, Playlists, Vibe Queue, Search). + +### **Phase 2: The Grace Period (The "Safety Net")** +* **Duration ($X$):** Configurable (default: 48 hours). +* **Behavior:** The track remains on disk and in the database, but is filtered out of all user-facing discovery and playback. + +### **Phase 3: The Warning (The "Nudge")** +When `now > disliked_at + X`: +1. **Worker Action:** The `Cleanup/Sweep Worker` identifies the track. +2. **Notification:** Sends a message via `ntfy` (e.g., *"Are you sure? '<Track Name>' is scheduled for deletion in 24h"*). +3. **DB Update:** Set `dislikes.warned_at = now`. + +### **Phase 4: The Finality (The "Cleanup")** +When `now > warned_at + 24h`: +1. **FS Action:** Delete the physical file at `tracks.path`. +2. **DB Action:** + * Cascade delete all related records (history, play_counts, etc.). + * Remove the `tracks` record. +3. **Logging:** Log `feedback(action='deleted_permanent')`. + +## 2. Safety Invariants + +- **No Immediate Deletion:** No user action (other than a "Hard Delete" admin command) can trigger immediate file deletion. +- **State Consistency:** A track cannot be in `PENDING_REMOVAL` and `LIBRARY` simultaneously. +- **Atomic Deletion:** The file deletion and the database removal must be treated as a single logical unit of work to prevent "Orphaned Files" (files on disk with no DB record) or "Ghost Records" (DB records with no file). + +## 3. User Recovery +The "Restore" action is a simple reversal: +- `DELETE FROM dislikes WHERE track_id = X;` +- `UPDATE tracks SET state = 'LIBRARY' WHERE id = X;` diff --git a/docs/architecture/07-worker-spec.md b/docs/architecture/07-worker-spec.md new file mode 100644 index 0000000..d3a86aa --- /dev/null +++ b/docs/architecture/07-worker-spec.md @@ -0,0 +1,57 @@ +# Worker & Job Specification + +This document defines the background processing architecture using **BullMQ**. Workers are responsible for CPU-intensive tasks and time-sensitive cleanup. + +## 1. Job Architecture + +All jobs are dispatched via the Backend API and processed by specialized Worker containers. + +| Job Name | Priority | Responsibility | Trigger | +| :--- | :--- | :--- | :--- | +| `metadata_refresh` | Medium | Re-scanning files for tag/metadata changes. | Manual (`POST /api/library/reindex`) | +| `artwork_download` | Low | Fetching covers from Cover Art Archive/Discogs. | On metadata enrichment or new file. | +| `lyrics_download` | Low | Fetching synced lyrics (LRCLib). | On metadata enrichment. | +| `recommendation_gen` | Low | Calculating new "Vibe" seeds and batching. | On session start or after playback completion. | +| `audio_analysis` | High | Running `essentia` for BPM, Key, Energy. | New file/re-index. | +| `filesystem_rescan` | High | Reconciling DB with actual disk state. | Scheduled (Daily/Weekly). | +| `cleanup_sweep` | Medium | Managing the Dislike Lifecycle/Deletion. | Scheduled (Hourly). | + +## 2. Detailed Job Workflows + +### **A. Audio Analysis Pipeline (`audio_analysis`)** +This is the most resource-intensive job. +1. **Input:** `track_id`. +2. **Process:** + * Spin up `essentia` subprocess. + * Extract BPM, Key, Energy, and Melodic features. +3. **Output:** Update `track_audio_features` table and mark `audio_features_ready = true`. + +### **B. Metadata Enrichment Pipeline (`metadata_refresh` / `artwork_download`)** +Triggered when a new file is detected or a re-index occurs. +1. **Input:** `track_id`. +2. **Process:** + * Lookup `mbid` via MusicBrainz. + * Fetch lyrics via LRCLib. + * Fetch artwork via Cover Art Archive. +3. **Output:** Update `tracks`, `artists`, and `albums` tables. + +### **C. The "Consistency" Worker (`filesystem_rescan`)** +Ensures the database is an accurate reflection of the disk. +1. **Process:** + * Walk through `/mnt/hdd1/media/Music`. + * Compare `mtime` and `size` against DB. + * **Action:** If a file is missing, set `track.state = 'MISSING'`. If a new file is found, trigger `metadata_refresh`. + +### **D. The "Cleanup" Worker (`cleanup_sweep`)** +Handles the temporal logic of the dislike lifecycle. +1. **Process:** + * Check `dislikes` where `state = 'warned'` and `warned_at < now - 24h`. + * Trigger physical file deletion and DB row removal. + * Check `dislikes` where `state = 'hidden'` and `disliked_at < now - 48h`. + * Trigger `ntfy` notification. + +## 3. Error Handling & Retries + +- **Exponential Backoff:** All external API jobs (MusicBrainz, etc.) must use exponential backoff to respect rate limits. +- **Dead Letter Queue (DLQ):** Jobs that fail after 5 retries are moved to a DLQ for manual inspection via the Admin Dashboard. +- **Idempotency:** All jobs must be idempotent. Running `audio_analysis` twice on the same `track_id` must not create duplicate data or errors. diff --git a/docs/architecture/08-data-model.md b/docs/architecture/08-data-model.md new file mode 100644 index 0000000..d90700e --- /dev/null +++ b/docs/architecture/08-data-model.md @@ -0,0 +1,63 @@ +# Data Model Specification + +This document defines the data schema across PostgreSQL (Primary), Typesense (Search), and Redis (Caching/Queueing). + +## 1. Relational Schema (PostgreSQL) + +### **Core Tables** + +#### `tracks` +* `id` (UUID, PK) +* `path` (TEXT, UNIQUE) - Physical disk path. +* `hash` (TEXT, INDEX) - BLOB/MD5 hash for deduplication. +* `title`, `artist`, `album` (TEXT) +* `duration` (REAL) - In seconds. +* `state` (ENUM) - `[LIBRARY, RECOMMENDED, HIDDEN, MISSING, DELETED]` +* `play_count`, `skip_count`, `dislike_count` (INTEGER) +* `last_played_at` (TIMESTAMP) +* `mtime` (REAL) - File mtime at last index. +* `source_type` (ENUM) - `[MANUAL, RECOMMENDATION]` + +#### `artists` & `albums` +* `artists`: `id`, `name`, `mbid`, `discogs_id`, `image_path`. +* `albums`: `id`, `artist_id`, `title`, `year`, `artwork_id`. + +#### `genre` & `track_genre` +* `genre`: `id`, `name`, `parent_id` (Self-join for hierarchy). +* `track_genre`: `track_id`, `genre_id`, `weight` (Decimal). + +### **Recommendation & Lifecycle Tables** + +#### `recommendation_batch` +* `id` (UUID, PK) +* `user_id` (UUID) +* `status` (ENUM) - `[ACTIVE, RESOLVED, FAILED]` +* `last_interaction_at` (TIMESTAMP) +* `seed_track_id` (UUID, FK) + +#### `dislikes` +* `track_id` (UUID, FK) +* `disliked_at` (TIMESTAMP) +* `warned_at` (TIMESTAMP, NULLABLE) +* `state` (ENUM) - `[HIDDEN, WARNED, DELETED]` + +### **Metadata & Enrichment** +* `track_audio_features`: `track_id`, `bpm`, `key`, `energy`, `danceability`, etc. +* `track_lyrics`: `track_id`, `lyrics_text`, `provider`. +* `mb_cache` / `lastfm_cache`: Key-Value stores for external API responses. + +## 2. Search Schema (Typesense) + +Typesense is used for ultra-fast, fuzzy search. Indices are rebuilt from PostgreSQL. + +**Index: `tracks`** +* `title` (string, facet) +* `artist` (string, facet) +* `album` (string, facet) +* `genres` (string, facet) +* `state` (string, filterable) + +## 3. Cache & Queue (Redis) + +* **BullMQ:** Stores job payloads and processing states. +* **Session Cache:** Stores ephemeral playback metadata and current "rolling window" track IDs. diff --git a/docs/architecture/09-recommendation-and-identity-v2.md b/docs/architecture/09-recommendation-and-identity-v2.md new file mode 100644 index 0000000..f753619 --- /dev/null +++ b/docs/architecture/09-recommendation-and-identity-v2.md @@ -0,0 +1,1182 @@ +# Recommendation & Identity v2 + +This document retires the v1 recommendation engine; it does not tune it. +Where the old `05-recommendation-spec.md` shipped a single CTE inside +`db.service.ts:getNextVibeChunk` (line ~715) that simultaneously did +candidate generation, scoring, evaluation, exploration policy, and +session composition, v2 splits that loading into five cooperating +systems. The CTE stays untouched (and buggy) until System D lands, +then is deleted. + +The two philosophy docs — *Music Intelligence System* and *Discovery +Pipeline / Session Director* — describe the shape. This doc is the +engineering plan: schema, write paths, read paths, acceptance, and an +explicit *replaces* list per system. + +### What survives from the old v2 plan + +- **Phase 4 — Image candidates** (`image_candidates` table). Preserved + verbatim in §F below. Orthogonal to recommendation; the bad-image + problem is a provenance problem, unrelated to the engine. +- **§1.1's intent** (the engine must learn from plays, not from a Keep + button the user does not press) — re-implemented under System B as + evidence rows feeding per-profile beliefs, not as a + `feedback(action='promoted')` row. + +### What is retired by this plan + +The old v2 **Phases 1, 2, 3, 5** are subsumed and displaced: + +| Old phase | Retired by | Why | +|---|---|---| +| Phase 1 (engine tuning: recency, overplay, same-art, cap) | System D | All four are symptoms of doing the session director's job inside a scorer. In D they stop being tuning constants and become structural consequences of fatigue + budgets. | +| Phase 1 §1.1 (implicit promote) | System B | `feedback(action='promoted')` is the wrong shape; evidence rows under per-profile beliefs are the right shape. | +| Phase 2 (`album_artists` junction) | System A | A single probabilistic claims graph subsumes album ownership, MB credit, and identity groups as predicate triples. No separate `album_artists` table. | +| Phase 3 (MB authoritative credit, re-credit pass) | System A | MB is the structural *spine* (it seeds high-trust claims), not the *truth*. Re-credit pass becomes "fetch MB claims into the graph"; no destructive overwrite of `track_artists`. | +| Phase 5 (`artist_groups`) | System A | `alias_of` is a continuous belief (`P(DOOM ≡ Madvillain)`), not a curated flag table. | + +### Architectural principles + +1. **Music is a graph of entities + probabilistic claims**, not a + folder of files or a table of flat similarity rows. +2. **Truth is probabilistic fusion.** Every claim is evidence, not + fact. Conflicts coexist; resolution happens at read time, weighted by + source trust and recency. +3. **MusicBrainz is the structural spine** (MBIDs + credit bands as + high-trust claim seeds), never the truth by decree. When MB and tags + disagree, both claims live in the graph with different trust weights. +4. **Aliasing is continuous** and evolves with listening behavior. A + `alias_of` claim is a belief with a confidence value, reinforced + when the listener plays both aliases back-to-back in a session. +5. **Listener identity is multidimensional**, keyed on `user_id` from + the start (single user today, multi-user tomorrow — no retrofit). + Multiple profiles coexist: long-term, current obsession, discovery, + negative, forgotten, contextual. +6. **Sessions are directed, not scored.** The objective is the best + next *hour*, not the best next *track*. Fatigue, diversity budgets, + arcs, surprise, callbacks, and an entropy target all live in a + planner that re-plans continuously. +7. **Discovery is autonomous and separate from playback.** Acquisition + writes candidates into the graph; probation is a state on those + candidates; the session director consumes probation-tracked tracks + without knowing they are probation. +8. **Everything decays unless reinforced.** Beliefs, claims' confidence, + and fatigue all weaken over time. This prevents permanent historical + bias and keeps the system learning the *current* listener, not the + listener of two years ago. + +--- + +## The five systems + +``` + A Knowledge graph (probabilistic fusion) + │ + ┌────┴────┐ + B E + Listener Acquisition + model pipeline + │ + C Candidate generators + │ + D Session director +``` + +- **A** is the foundation; no other system can run without it. +- **B** and **E** depend only on A and may be built in parallel. +- **C** depends on A (graph traversal) and reads B (profiles inform + generator selection, e.g. revival generator reads the forgotten + profile, discovery generator reads the discovery profile). +- **D** depends on C (needs generators to populate the plan) and B + (needs listener state from beliefs); it is the final piece. +- **Phase 4 (image candidates)** ships any time, independent of A–E. + +Critical path: **A → {B, E} → C → D**. + +--- + +## System A — Knowledge graph (probabilistic fusion) + +Maintains the connected entity graph with probabilistic relationships. +Never contains user-specific knowledge (objective claims carry +`user_id = NULL`); listener-behavior-derived claims carry `user_id` +and fuse into the per-user graph view at read time. + +### A.1 Schema + +```sql +-- Source trust weights. One row per source of claims. Tunable. +CREATE TABLE source_trust ( + key TEXT PRIMARY KEY, -- 'mb' | 'discogs' | 'lastfm' | 'tag' | 'listener_behavior' | 'curated' + trust REAL NOT NULL CHECK (trust >= 0 AND trust <= 1.0), + description TEXT NOT NULL +); + +-- Claims: the spine of the graph. One row per (subject, predicate, object, source). +-- user_id is NULL for objective claims (MB, Discogs, tags), non-NULL for +-- listener-behavior-derived claims (e.g. weak alias_of from adjacent plays). +CREATE TABLE claims ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID, -- NULL = objective + subject_type TEXT NOT NULL, -- 'artist'|'album'|'track'|'label'|'genre'|'scene' + subject_id UUID NOT NULL, + predicate TEXT NOT NULL, -- see A.2 + object_type TEXT NOT NULL, + object_id UUID NOT NULL, + source TEXT NOT NULL REFERENCES source_trust(key), + confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1.0), + evidence_at TIMESTAMPTZ NOT NULL, -- when the source asserted this + last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + raw JSONB, -- original payload for audit + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (subject_type, subject_id, predicate, object_type, object_id, source, user_id) +); +CREATE INDEX claims_subject_idx ON claims (subject_type, subject_id, predicate); +CREATE INDEX claims_object_idx ON claims (object_type, object_id, predicate); +CREATE INDEX claims_user_idx ON claims (user_id) WHERE user_id IS NOT NULL; + +-- Every entity that participates in the graph carries an optional MBID as +-- the structural spine anchor. These columns already exist on artists/albums +-- today; we add to tracks. MBID presence raises the entity's identity +-- resolution priority (see A.5). +ALTER TABLE tracks ADD COLUMN IF NOT EXISTS recording_mbid UUID; +CREATE INDEX IF NOT EXISTS tracks_recording_mbid_idx ON tracks (recording_mbid) WHERE recording_mbid IS NOT NULL; +``` + +### A.2 Predicates + +The enumerated set. Adding a predicate is a code change (a generator +or fusion view that reads it), not a schema migration — predicates +live in the `claims.predicate` free-text column, validated in code. + +| Predicate | subject → object | Meaning | +|---|---|---| +| `credited_main_on` | artist → track | artist is the main credit on the recording | +| `featured_on` | artist → track | artist is a featured performer on the track | +| `credited_main_on_album` | artist → album | artist is the main credit on the album as a whole | +| `featured_on_album` | artist → album | artist is a co-owner / featured credit on the album | +| `alias_of` | artist → artist | subject is an alias of object (directional; confidence = belief) | +| `member_of` | artist → artist | subject is a member of the group object | +| `produced` | artist → track | subject produced the track | +| `composed` | artist → track | subject composed the track | +| `same_label_as` | artist → artist | both artists release on the same label | +| `same_scene_as` | artist → artist | both artists belong to the same scene | +| `influences` | artist → artist | subject influenced object | +| `remix_of` | track → track | subject is a remix of object | +| `cover_of` | track → track | subject is a cover of object | +| `soundtrack_contrib` | artist → franchise | subject contributed to a soundtrack (anime/game/film) | +| `belongs_to_genre` | track/artist → genre | subject belongs to genre (weighted; replaces exact-id match) | + +### A.3 Source trust seed + +```sql +INSERT INTO source_trust (key, trust, description) VALUES + ('curated', 1.00, 'Manual / human-curated claim. Never decayed.'), + ('mb', 0.90, 'MusicBrainz structural spine. High-trust seed; not infallible (re-credits disagree with tags).'), + ('cover_art_archive',0.85,'Cover Art Archive, MB-backed.'), + ('discogs', 0.75, 'Discogs release/artist credits. Strong for releases, weaker for person aliases.'), + ('lastfm', 0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'), + ('listener_behavior',0.40, 'Derived from observed play patterns (e.g. back-to-back play → weak alias_of). User-keyed.'), + ('tag', 0.30, 'File-tag-derived via scanner heuristic. Lowest trust; the &-split fallback.') +ON CONFLICT (key) DO NOTHING; +``` + +### A.4 Fusion read path + +Truth is resolved at read time as a weighted vote across all claims +for a given `(subject, predicate, object)`. The fusion formula: + +``` +fused(subject, pred, object, user_id) = + Σ over claims c with matching (subject, pred, object) + where c.user_id IS NULL OR c.user_id = $user_id + of source_trust(c.source) * c.confidence * recency(c) +``` + +where `recency(c) = clamp(0.1, 1.0, days_since(c.last_reinforced_at) / 180)`, +so a claim never reinforced for 180+ days contributes at 10% floor. + +A materialised view `claim_fusion` exposes the per-(subject, pred, +object, user) fused value. **View shims** over `claim_fusion` provide +the v1 shapes so existing reads survive the transition without rewrite: + +```sql +CREATE OR REPLACE VIEW track_artists_v2 AS +SELECT t.id AS track_id, + a.id AS artist_id, + CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence +FROM tracks t +JOIN claim_fusion cf + ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on','featured_on') + AND cf.object_type = 'artist' +JOIN artists a ON a.id = cf.object_id; + +-- Same shape for albums. Drops albums.artist_id reads over time. +CREATE OR REPLACE VIEW album_artists_v2 AS +SELECT al.id AS album_id, + a.id AS artist_id, + CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence +FROM albums al +JOIN claim_fusion cf + ON cf.subject_type = 'album' AND cf.subject_id = al.id + AND cf.predicate IN ('credited_main_on_album','featured_on_album') + AND cf.object_type = 'artist' +JOIN artists a ON a.id = cf.object_id; +``` + +`artists.artist_similar` is retired in favour of the graph itself; a +compatibility view maps `same_scene_as` + `alias_of` fused edges to +the old `(artist_id, similar_artist_id, match)` shape for the lifetime +of any read that still wants it. + +Genre hierarchy (`genre.parent_id`) is retired as a separate column. +`belongs_to_genre` claims carry a `confidence` weight; the hierarchy +becomes a `parent_of` claim series on genre entities, and the fusion +view's hierarchical rollup walks these claims instead of a column. + +### A.5 Write paths + +Five independent writers; all UPSERT into `claims`: + +1. **MB spine writer** (in the worker enrichment pipeline): when + `lookupRecording` resolves a recording MBID, fetch the full + `artist-credit` (not just the first entry — extend the MB client). + Write one `credited_main_on` claim (artist = first credit) and one + `featured_on` claim per additional credit, with `source='mb'`, + `confidence=1.0`, `evidence_at=NOW()`. For release-group MBIDs on + albums, write `credited_main_on_album` / `featured_on_album` + analogously. For artist-relation ARs (`member of`, ` collaborations`, + `vocal`/`instrument`), write `member_of` / `featured_on` claims. + MBIDs anchor identity: if the credited artist resolves to a known + MBID, claims attach to that artist entity; otherwise a new artist + is created with the MBID set. +2. **Discogs writer**: `discogs_id` already set on artists today; + extend to write `credited_main_on_album` / `same_label_as` claims + from the release's label and artist credits, `source='discogs'`, + `confidence=0.7`. +3. **Last.fm writer**: existing `artist_similar` fetcher writes + `same_scene_as` claims (`source='lastfm'`, `confidence=match/100`) + instead of the `artist_similar` table. Last.fm tags write + `belongs_to_genre` claims with `confidence=tag.count/100`. +4. **Tag-derived writer** (scanner fallback): when no MBID, the + existing `parseArtistsFromMetadata` heuristic writes + `credited_main_on` / `featured_on` claims with `source='tag'`, + `confidence=1.0` (the trust weight, low at 0.30, is what dims it). + `resolveOrCreateArtist` writes the entity row; the claim carries + the entity, not the name string. Re-keying when MB later resolves + is an UPSERT, not an overwrite. +5. **Listener-behavior writer** (writes into the *user-keyed* region + of `claims`): on play sessions, derive weak edges — adjacent plays + within 30 min of two artists write `same_scene_as` (confidence + 0.3); back-to-back play of two artists within a session writes + `alias_of` (confidence 0.2). These are *evidence*, not conclusions; + they fuse with the objective `alias_of` claim from MB (if any) at + read time. Behavioral claims decay by `last_reinforced_at`; the + belief-strengthening path in System B reinforces them on repeat. + +All writes UPSERT on `(subject, predicate, object, source, user_id)`: +re-fetching a source refreshes `last_reinforced_at` and `evidence_at` +without duplicating rows. + +### A.6 Replaces + +| Old surface | Status | +|---|---| +| `artist_similar` table | Retired; replaced by `same_scene_as` / `alias_of` claims. Compat view for the transition. | +| `track_artists` (as truth) | Retired as truth; survives as a *view* `track_artists_v2` over `claim_fusion`. No insert path; reads only. | +| `albums.artist_id` (single-FK ownership) | Survives as a denormalised pointer (written by a trigger off `claim_fusion`'s main credit) for back-compat. Truth lives in `album_artists_v2` view. | +| `genre.parent_id` (column) | Retired; replaced by `parent_of` claims on genre entities. | +| Scanner `resolveAlbumArtist` `parts[0]` behaviour | The heuristic now writes *claims*, not truth. `parts[1:]` become `featured_on_album` claims instead of being dropped. | +| MB client `best['artist-credit']?.[0]` first-credit-only read | Extended to read the full `artist-credit` array. | +| Old Phase 2 (`album_artists` junction table) | Not built — subsumed by A. | +| Old Phase 3 (re-credit pass over whole library) | Not built as a destructive overwrite. MB claims UPSERT into the graph; no `track_artists.source` column, no destructive re-credit. | +| Old Phase 5 (`artist_groups`, `artist_group_members`) | Not built — `alias_of` / `member_of` claims are the grouping. | + +### A.7 Acceptance + +- A track tagged `artist="MF DOOM & Madlib"` with a resolved recording + MBID shows a `credited_main_on` claim from MB for "Madvillain" (if MB + credits Madvillain) AND a `credited_main_on` claim from the tag for + "MF DOOM". Both coexist. The fusion view, weighted by trust (MB 0.90 + > tag 0.30), shows "Madvillain" as the higher-confidence main credit. +- An album tagged `albumartist="A & B"` has two `credited_main_on_album` + claims — A from tags, A and B from MB if MB credits both. The album + appears on both A's and B's artist pages via `album_artists_v2`. +- `SELECT * FROM claims WHERE subject_type='artist' AND subject_id=$doom + AND predicate='alias_of'` returns rows from MB (if it asserts an + alias) and from listener_behavior (if the user has played DOOM and + Madvillain back-to-back). Both decay; both reinforce. +- A never-played genre fished via the future `/vibe/from-genre` path + reaches tracks through the `belongs_to_genre` claims + hierarchical + rollup, not exact-id match. + +### A.8 Risks + +- **Fusion reasoning cost.** Every read now resolves a vote across + multiple claims. Mitigation: `claim_fusion` materialised view, + refreshed on `claims` insert/update; reads hit the view, not the raw + table. Estimate: a single `(subject, predicate, object)` fused value + is a point lookup on the materialised view. +- **MB rate limits during spine backfill.** Initial population walks the + whole library (~3.9k tracks) fetching full `artist-credit`. Expected + hours, not minutes, gated by MB's rate policy. +- **Display flips during transition.** When the fusion view's + high-confidence main credit is "Madvillain" but the file tag says + "MF DOOM & Madlib", the library view shows "Madvillain". This is + intended (MB is the structural spine) but may surprise the user at + first. Both claims remain auditable via `SELECT * FROM claims`. +- **Tag-only tracks** (no MBID) inherit the lowest-trust claims. This + is correct: the graph is honest about how much it knows. + +--- + +## System B — Listener model + +Maintains probabilistic beliefs about the listener. Keyed on +`user_id` from the start; behaviours and beliefs cannot be retrofitted +later without painful re-keying once belief data accumulates. + +### B.1 Schema + +```sql +-- Evidence: every observed interaction that should influence a belief. +-- Append-only. Never edited or deleted (purge policy separate). +CREATE TABLE evidence ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + entity_type TEXT NOT NULL, -- 'track'|'artist'|'genre'|'album' + entity_id UUID NOT NULL, + signal TEXT NOT NULL, -- see B.3 + profile TEXT NOT NULL, -- which profile this evidence feeds; see B.2 + weight REAL NOT NULL, -- signal strength, set by the signal rule + context JSONB, -- optional: {session_id, hour, weekday, activity, ...} + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX evidence_user_entity_idx ON evidence (user_id, entity_type, entity_id, created_at DESC); +CREATE INDEX evidence_user_profile_idx ON evidence (user_id, profile, created_at DESC); + +-- Listener beliefs: the derived state. Continuously decayed; reinforced by evidence. +CREATE TABLE listener_beliefs ( + user_id UUID NOT NULL, + profile TEXT NOT NULL, -- see B.2 + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + dimension TEXT NOT NULL, -- 'affinity'|'fatigue'|'familiarity'|'novelty_tolerance' + value REAL NOT NULL CHECK (value >= -1.0 AND value <= 1.0), + confidence REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1.0), + evidence_count INTEGER NOT NULL DEFAULT 0, + last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_decayed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, profile, entity_type, entity_id, dimension) +); +CREATE INDEX listener_beliefs_user_profile_idx ON listener_beliefs (user_id, profile, entity_type, entity_id); +``` + +### B.2 Profiles + +Enumerated. Adding a profile is a code change (an evaluator or +generator that reads it), not a schema migration. + +| Profile | Decay half-life | Fed by signals | Read by | +|---|---|---|---| +| `longterm` | 365 days (slow) | replays, manual search, add-to-favorites, multiple sessions | comfort, deep-dive generators | +| `obsession` | 14 days (fast) | disproportionate play concentration | adjacent, novelty generators (to bound) | +| `discovery` | 30 days | play-of-never-before-seen-entity, accept-after-skip | discovery, experimental generators | +| `negative` | 180 days | skips, queue-removal, hide, manual-delete | all generators (exclusion) | +| `forgotten` | n/a (derived) | `longterm` high-affinity + 90d no plays | revival generator | +| `contextual` | 7 days | context-tagged play sessions | contextual generator | + +`forgotten` is derived nightly from `longterm` beliefs that have not +been reinforced in 90 days; it is not written by signals directly. + +### B.3 Signal → weight rules + +A signal writes one `evidence` row with a `weight`. The weight feeds +into the belief update (B.4). Signal weights are constants, tunable: + +| Signal | Profile | Weight | Direction | +|---|---|---|---| +| `playback_completed` | longterm | +0.10 | affinity up | +| `replay_within_24h` | longterm | +0.25 | affinity up | +| `replay_within_24h` | obsession | +0.40 | affinity up | +| `manual_search` | longterm | +0.50 | affinity up | +| `add_to_favorites` | longterm | +0.60 | affinity up | +| `shared` | longterm | +0.70 | affinity up | +| `play_of_never_seen` | discovery | +0.05 | discovery tolerance up | +| `accept_after_probe` | discovery | +0.30 | affinity up (mild) | +| `skip_quick` (≤ 30s) | negative | -0.20 | affinity down | +| `skip_repeated` | negative | -0.40 | affinity down | +| `queue_removed` | negative | -0.30 | affinity down | +| `hidden` | negative | -0.60 | affinity down | +| `manual_deleted` | negative | -0.90 | affinity down (strong) | + +Neutral actions (seek, pause, volume) write no evidence. Lack of +interaction writes no evidence (a core principle: *absence of +interaction is not dislike*). + +`weight` is per-signal; an event may write multiple `evidence` rows +across multiple profiles (a `replay_within_24h` writes both a +`longterm` +0.25 row and an `obsession` +0.40 row). + +### B.4 Belief update + decay + +On each new evidence row matching `(user, profile, entity, dimension)`: + +``` +belief.value = clamp(-1, 1, belief.value + Σ new_evidence.weight * (1 - belief.confidence)) +belief.confidence = clamp(0, 1, belief.confidence + 0.05) +belief.evidence_count += count(new rows) +belief.last_reinforced_at = NOW() +``` + +Daily decay job (or on-read lazy decay): + +``` +h = (NOW() - belief.last_decayed_at) / profile.halflife_days +belief.value *= 0.5 ^ h +belief.confidence *= 0.5 ^ h -- confidence also decays +belief.last_decayed_at = NOW() +``` + +A belief not reinforced for 3 halflives approaches zero. Old evidence +becomes irrelevant; new evidence dominates. This prevents the v1 +failure mode where heavily-played artists keep winning forever. + +### B.5 Replaces + +| Old surface | Status | +|---|---| +| `feedback(action='promoted')` | Not written. The implicit-promote intent from old §1.1 lives as a `playback_completed` evidence row → longterm affinity up. | +| `feedback(action='disliked'|'skipped'|'deleted_permanent')` | Not written. Same signals now write `negative`-profile evidence rows. The `feedback` table is retired; existing data is backfilled into `evidence` once and then the table is dropped. | +| `favorites` table | Survives as a UI collection (the Keep button is a UI concept, separate from engine affinity). Old §1.1 explicitly kept this split; we keep it. Keep writes a `favorites` row AND a `add_to_favorites` evidence row. | +| The clamped genre-affinity term in `local_pool` | Retired. Affinity is now per-(user, profile, entity) in `listener_beliefs`; generators read beliefs directly. | +| `play_history` reads by nothing in v1 | `play_history` survives (it is the audit log of plays) but the scorer doesn't read it; the evidence writer does, once per play, converting a play row into evidence signals. | + +### B.6 Acceptance + +- After one completed play of a never-played track, a `longterm` + affinity belief for that track exists at `value=+0.05`, `confidence=0.05`. +- After 5 completed replays within a week, that track's `longterm` + affinity is above +0.30; the artist's affinity (rolled up from track + beliefs) is above +0.20. +- A track skipped 3× in 30 days has a `negative`-profile affinity below + -0.50. The session director's exclusion filter consults this. +- A track not played for 365 days has decayed its `longterm` affinity + to ~50% of peak; it appears in the `forgotten` derived profile. +- `SELECT value FROM listener_beliefs WHERE user_id=$1 AND profile='obsession' + AND entity_type='artist' ORDER BY value DESC LIMIT 5` returns the current + obsessions, which the session director uses to bound overplay. + +--- + +## System C — Candidate generators + +Recommendations originate from independent generators. Each proposes +candidates without knowledge of final ranking — the session director +(D) does the ranking, mixing, and session composition. Each generator +returns candidates with a **graph-path explanation** (the chain of +claims that led to this candidate), so every recommendation is +auditable. + +### C.1 Generator interface + +```ts +interface Generator { + id: string; // 'comfort' | 'adjacent' | 'discovery' | ... + run(ctx: GeneratorContext): Promise<Candidate[]>; +} + +interface GeneratorContext { + userId: string; + listenerState: ListenerState; // from D's state builder + beliefs: BeliefReader; // reads listener_beliefs + graph: GraphReader; // reads claim_fusion (A) + profile: ProfileName; // which profile this generator prefers + recentExclusions: Set<string>; // (entityType, entityId) already churned this session +} + +interface Candidate { + trackId: string; + generatorId: string; + explanation: ClaimEdge[]; // the graph path that produced this candidate + // No score. Generators don't rank; the director does. +} +interface ClaimEdge { + subjectType: string; subjectId: string; + predicate: string; + objectType: string; objectId: string; + fusedValue: number; +} +``` + +### C.2 The generators + +Each generator wraps a graph query. All read `claim_fusion` and +`listener_beliefs`; all return `Candidate[]` with explanations. + +1. **Comfort** — reads `longterm` affinity beliefs, picks tracks by + artist with affinity > +0.5, fuses with `credited_main_on` / + `featured_on` claims to find tracks by those artists. Goal: + maintain satisfaction. +2. **Adjacent** — for each seed artist in current session state, walks + 1–2 graph hops: `seed → credited_main_on → track → featured_on → + artist → member_of → group → member_of → artist`. Returns tracks + by reached artists, excluding those in the comfort pool. +3. **Discovery** — picks tracks whose artists have no `longterm` / + `obsession` belief (truly unfamiliar), filtered to those with at + least one graph edge to a trusted artist (`same_scene_as`, + `same_label_as`, `produced` by a producer who produced a favourite). + Reads the `discovery` profile's `novelty_tolerance` to set how many + to return. +4. **Deep-dive** — prioritises complete albums. Picks an album owned + (via `album_artists_v2`) by an artist with `obsession` affinity and + returns overlooked tracks (those with low `familiarity` belief) in + album order. Prefers tracks with no play history. +5. **Revival** — reads the `forgotten` derived profile, returns tracks + whose `longterm` affinity is high but `last_reinforced_at` is old + (> 90 days). Time window is adaptive: nostalgia horizon scales with + how established the longterm profile is. +6. **Novelty** — queries for tracks with `release_date` in the last 60 + days whose artists share a `same_label_as` / `same_scene_as` edge + with a favourite, OR a `produced` edge from a known producer. Most + recent first, gated by `discovery` profile tolerance. +7. **Experimental** — deliberately challenges current assumptions. + Finds genres with very few `longterm` beliefs of any sign (i.e. + the system is uncertain), picks tracks from those genres with the + highest network-distance from favourites. Goal: learning, not + satisfaction. Run rate is low (one track per N, configurable). +8. **Contextual** — reads the `contextual` profile. If the listener + state has a context tag (coding / driving / sleeping), returns + tracks whose `listener_beliefs` context entries match that + context. + +### C.3 Replaces + +| Old surface | Status | +|---|---| +| `local_pool` CTE | Retired. Comfort and adjacent generators together cover what local_pool tried to be (genre-overlap + artist_sim + same-artist + audio + jitter). | +| `probation_pool` CTE (gated on `artist_sim > 0`) | Retired. Discovery + deep-dive generators cover the intended-but-unshipped behavior. | +| `getVibeChunkFromGenre` (stateless genre seed) | Survives briefly as a thin wrapper over the discovery generator seeded with a genre; cleaned up when D lands. | + +### C.4 Acceptance + +- Each candidate returned by any generator carries a non-empty + `explanation` array (graph path). A recommendation with no graph + path is invalid; generators refuse to return it. +- Seeding a DOOM track: adjacent generator returns tracks by artists + reached via `featured_on` from DOOM tracks (i.e. Madlib's other + projects) and via `member_of` from DOOM (i.e. Madvillain tracks) — + *all as candidates*, with explanations; the session director decides + whether to use them given the fatigue model. +- A genre with no library coverage (seeded via `/vibe/from-genre`) + returns zero comfort candidates and a non-empty discovery candidate + list — surfacing fresh material, not the empty result of v1's + `artist_sim > 0` gate. + +--- + +## System D — Session director + +The planner. Replaces `getNextVibeChunk` entirely. Where the old CTE +selected the highest-scoring 20 tracks in one query, D maintains a +rolling 20–50-track plan that is rewritten on every feedback event, +pursuing invisible long-term goals (finish an album over days, +introduce an artist gradually, balance decades) while optimising +multiple objectives simultaneously. + +### D.1 Listener state + +Built at the start of each session and updated on each play/skip: + +```sql +-- Per-session state; persisted across heartbeats so resumes stay coherent. +CREATE TABLE session_state ( + session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_interaction TIMESTAMPTZ NOT NULL DEFAULT NOW(), + context TEXT, -- 'coding'|'driving'|'sleeping'|NULL (auto-detected or manual) + state_vector JSONB NOT NULL -- the computed state; see below +); +``` + +`state_vector` fields (computed on state build, refreshed on each event): + +```jsonc +{ + "energy": 0.62, // avg energy of last 5 plays + "focus": 0.40, // manual focus intent, 0..1 + "novelty_hunger": 0.30, // from discovery profile's novelty_tolerance + "artist_fatigue": { "<artistId>": 0.71, ... }, // see D.2 + "genre_fatigue": { "<genreId>": 0.55, ... }, + "language_fatigue": { "ja": 0.83, "en": 0.10 }, + "vocal_fatigue": 0.40, // 0 = vocals ok, 1 = want instrumental + "session_age_min": 73, + "current_mood": "energetic", + "target_entropy": 0.55 // see D.5 +} +``` + +### D.2 Fatigue model + +Everything gets fatigued. Everything recovers over time. Fatigues are +per-dimension cumulative decays over recent play history, NOT session +counters (v1's `artist_play_count` batch counter is retired). + +For each dimension X (track / artist / genre / language / vocalist): + +``` +fatigue_X(entity, t) = Σ over plays p of X in last T_window + of exp( -(t - p.played_at) / decay_X ) + +T_window = 24h (artist, genre) | 7d (track) | 2h (language, vocalist) +decay_X = 8h (artist, genre) | 30d (track) | 1h (language, vocalist) +``` + +- `track` fatigue: a track played in the last hour `exp(-(0)/30d)=1`; + played yesterday `exp(-(1d)/30d)≈0.97` — *strong* recent-play penalty + on tracks; this is the v1 missing cross-session overplay fix, made + structural. Played a month ago: `exp(-(30d)/30d)≈0.37`. +- `artist` fatigue: rolled up from track fatigue over the artist's + tracks; collapses MF DOOM / Madvillain / Viktor Vaughn **iff** their + `alias_of` claims fuse them at read time (which depends on whether + MB or listener-behavior has asserted the alias). This is the honest + v1 Phase 5 fix — aliasing is a graph belief, not a flag. +- `genre` / `language` / `vocalist` fatigue: same formula, same + recency-aware decay. + +A candidate's final rank incorporates `1 - fatigue_X(candidate, now)` +as multipliers per dimension; the v1 `GREATEST(0.15, ...)` floor +becomes a tunable per-dimension floor in `session_floor` config. + +### D.3 Diversity budgets + +Instead of hard caps ("max 1 per artist per chunk", "max 2 per genre"), +a budget the planner spends. Per-session, refreshed at session start: + +```sql +INSERT INTO source_trust VALUES ('budget_default', 0.0, 'non-graph config sentinel') ON CONFLICT DO NOTHING; + +CREATE TABLE diversity_budgets ( + user_id UUID NOT NULL, + dimension TEXT NOT NULL, -- 'artist'|'genre'|'language'|'instrumental'|'new_artist'|'favorite' + budget_share REAL NOT NULL, -- fraction of session, e.g. 0.20 + horizon_min INTEGER NOT NULL, -- budget window, e.g. 30 (min) + PRIMARY KEY (user_id, dimension, horizon_min) +); +``` + +Default budgets (seeded on first session per user): + +```jsonc +{ + "artist": { "share": 0.20, "horizon": 30 }, + "genre": { "share": 0.40, "horizon": 30 }, + "language": { "share": 0.60, "horizon": 30 }, + "instrumental": { "share": 0.10, "horizon": 30 }, + "new_artist": { "share": 0.15, "horizon": 60 }, + "favorite": { "share": 0.25, "horizon": 60 } +} +``` + +"Not more than 2 songs of the same artist in the last 30 min" becomes +a 20%-of-30min budget. The planner spends, replenishes at window edge. +If recent listening has blown a budget, the planner refuses further +spends in that dimension — the structural replacement for v1's +diversity cap. + +### D.4 Arcs + +The planner doesn't pick songs; it picks *arcs* and slots songs into +them. Templates: + +- **Comfort arc**: known → known → adjacent → favorite. +- **Discovery arc**: favorite → similar → new → favorite. +- **Energetic arc**: medium → high → peak → cooldown. +- **Late-night arc**: soft → ambient → acoustic → slow electronic. + +At session start, given the state vector and the long-term schedule +(D.7), the planner picks an arc template and fills it. The plan is a +list of "slots" (`{arc_position, role}`, e.g. `{1, "peak"}`); the +planner queries the matching generator for each slot. On replan, +remaining slots can shift arc. + +### D.5 Surprise, callbacks, entropy target, anti-loop + +- **Surprise budget**: ~1 per hour (configurable). Reserved slot in + the arc for a forgotten favorite, live version, cover, acoustic + version, producer side project, or old obsession. Surfaced via the + revival generator with a `surprise=true` flag. +- **Callbacks**: every N tracks the planner intentionally re-introduces + an artist / theme / energy level from earlier in the session (or + earlier session that day). Makes the session feel intentional. +- **Entropy target**: the state vector carries `target_entropy`. If + the autocorrelation of last-20 chosen-track features is too high + (predictable), entropy goes up (planner prefers experimental / + discovery candidates). If too low (chaotic), planner injects + comfort. Target is **controlled unpredictability**, not randomness. +- **Anti-loop detector**: continuously monitor the last-50 plan + choices for collapsing into a narrow graph region (same artist / + label / producer / genre / decade / BPM / mood / language). If + collapse detected, the planner forcibly expands: zero-out the + dominant dimension's budget for the next window and surge a + non-dominant generator. + +### D.6 Repetition rules + +Adaptive minimum-distance, not absolute "don't repeat": + +```sql +CREATE TABLE repetition_rules ( + user_id UUID NOT NULL, + dimension TEXT NOT NULL, -- 'track'|'artist'|'album'|'genre'|'energy' + min_distance INTEGER NOT NULL, -- adaptive; minutes + PRIMARY KEY (user_id, dimension) +); +``` + +Defaults: `track=2h, artist=20min, album=no immediate; spread across +hours, genre=don't-dominate, energy=smooth transitions`. All adapt: +if a listener shows high `focus` (deep work signal), distances relax +(loop tolerance up); if skipping-after-replay pattern appears, +distances tighten. + +### D.7 Long-term scheduling + invisible goals + +The planner also has week-scale objectives, tracked in +`session_state.state_vector.goals`: + +- Finish an album over several days (track which album is "in + progress"; the deep-dive generator keeps returning its overlooked + tracks until the album is fully played). +- Introduce a new artist gradually (e.g. one track per session for a + week, escalating if survival rate is high). +- Revisit old favorites monthly. +- Balance decades, languages, producers (the budgets cover most of + this; the planner periodically nudges an under-represented decade to + surge). +- Complete discovery probation (see System E). +- Guarantee at least one surprise per hour. + +The listener should never notice these goals directly. + +### D.8 Don't maximise enjoyment + +The planner optimises multiple objectives simultaneously: + +``` +maximise: + enjoyment (predicted from beliefs × relevance) + discovery (fraction of unfamiliar entities in the plan) + diversity (1 - Herfindahl index across artists in horizon) + learning (information gain on uncertain beliefs) + session coherence (arc-template adherence) + long-term freshness (entropy target met) + +minimise: + fatigue (cumulative per-dimension fatigue) + repetition (autocorrelation of recent choices) + predictability (1 - entropy) + wasted discoveries (candidates surfaced then immediately skipped) +``` + +This is why single-objective score maximisation (the v1 approach) +collapses to "ADO, Yoasobi, ADO, Zutomayo, ADO" — those are the +"optimal" tracks by predicted enjoyment alone. + +### D.9 Plan + replan loop + +``` +session start + ↓ +build state_vector (D.1) + ↓ +pick arc template (D.4) + target entropy (D.5) + ↓ +for each slot in arc: + query matching generator (C) → candidates + rank candidates across the D.8 objectives + pick winner, respecting budgets (D.3) + repetition rules (D.6) + ↓ +20–50 track plan + ↓ +playback + ↓ +on play / skip / manual action: + write evidence (B.3) + refresh state_vector fatigue (D.2) + if plan slot < 10 remaining OR anti-loop fires OR entropy drift > 0.2: + replan from current state + ↓ +loop +``` + +### D.10 Replaces + +| Old surface | Status | +|---|---| +| `getNextVibeChunk` CTE (~220 lines in `db.service.ts`) | Retired entirely on D ship. | +| `recommendation_batch_track` exclusion set | Survives as the recent-exclusions `Set` passed to generators; no longer the source of artist-play-count. | +| `recommendation_batch` row + `seed_track_id` center-walk in `recordPlay` | Retired. The center-walk was a hack for "engine can't escape the seed neighbourhood"; D's arc + fatigue together replace it. | +| Old §1.2 (recency term added to local_pool) | Not a scored term anymore; recency is a fatigue-dimension multiplier in D.2. | +| Old §1.3 (track-level overplay penalty) | D.2's `track` fatigue, structural. | +| Old §1.4 (artist-level overplay penalty, identity-best-effort) | D.2's `artist` fatigue rolled up via alias fusion in A. Identity collapse happens *iff the claims graph says so*, not as a separate code path. | +| Old §1.5 (lower W_SAMEART) | No `W_SAMEART` to tune; the comfort generator alone handles "more of this artist" and is naturally bounded by D.3's artist budget. | +| Old §1.7 (cap on `track_artists.artist_id` not name string) | Subsumed by D.3's budgets (artist dimension). | + +### D.11 Acceptance + +- After 6 hours of listening, the listener is still engaged, has + discovered at least one unfamiliar but tolerable track, has not + become fatigued by any single artist / genre / language, and the + next session would still feel fresh. +- Seeding a DOOM track does not collapse the next chunks into DOOM + pseudonyms even though alias fusion may treat them as one artist — + because D.2's artist fatigue rises fast in the session, D.3's + artist budget blocks further spends, and D.4's arc pulls toward + adjacent generators (Madlib's other projects reached via graph + hops, not DOOM). +- Within a session, tracks played in the last hour do not re-appear + (track fatigue multiplier ≈ 0 after recent plays). +- Across sessions, the same top-20 does not return: track fatigue + half-life of 30 days means yesterday's plays still dampen today's + rank. +- Forgotten favorites resurface naturally (revival generator + monthly + long-term goal). +- Anti-loop detector fires when the dominant dimension's share exceeds + budget × 1.5, forcibly diversifying the next window. + +--- + +## System E — Acquisition pipeline + +The library is not the universe. E continuously searches beyond the +current collection, identifies music worth evaluating, acquires it +(via the unbuilt yt-dlp worker, `progress.md:29`), validates it, and +either permanently integrates it into the graph (A) or discards it. +Discovery is independent of playback; it writes into A. + +### E.1 Discovery sources + +Six independent strategies run continuously as low-priority worker +jobs: + +1. **Graph exploration** — walk the graph beyond the library. For each + favourite artist (per `longterm` beliefs), follow `featured_on` / + `member_of` / `produced` / `same_label_as` / `same_scene_as` edges + to artists not in the library. Each traversal is a discovery path + candidate. +2. **Release monitoring** — monitor favourite artists, related artists + (graph adjacents), labels, and producers for new releases. New + releases become discovery candidates at high priority. +3. **Scene exploration** — discover music through communities rather + than artists: city scenes, internet communities, niche genres, + underground movements, independent labels. Avoids recommendation + loops around the same popular artists. +4. **Temporal exploration** — search different musical eras for + forgotten classics, overlooked releases, albums that became + influential years later. +5. **Relationship expansion** — instead of "people also listen to", + prefer structural relationships: same producer, same composer, + live band members, guest vocalists, touring partners, soundtrack + contributors. +6. **Curiosity exploration** — dedicated exploration budget for + unfamiliar genres, different languages, experimental music, + geographically distant scenes. Success is measured by learning, + not immediate satisfaction. + +### E.2 Candidate universe + +Before downloading, discoveries live as `claims` rows with +`subject_type='track'` and a special marker — they are *candidate* +tracks, not library tracks. The candidate carries the discovery +source, the relationship path that led to it, an estimated relevance, +and an explanation. + +This reuses `claims` rather than a dedicated table, with a dedicated +predicate: + +```sql +-- A discovery candidate is a claim: subject=track (candidate), predicate='discovery_candidate', object=source artist / scene / label. +-- The 'confidence' field is the estimated relevance; 'raw' holds the full path + explanation. +INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw) +VALUES ('track', $candidateId, 'discovery_candidate', 'artist', $relatedArtistId, 'graph_exploration', $relevance, $pathJson) +ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; +``` + +Candidate tracks themselves are stored as stub rows in a +`discovery_candidates` table — thin rows holding the external identity +only, no library path / audio / metadata yet: + +```sql +CREATE TABLE discovery_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source TEXT NOT NULL, -- 'mb'|'discogs'|'lastfm'|'spotify'|... + external_id TEXT NOT NULL, -- MBID / discogs_id / etc. + title TEXT, + artist_credit JSONB, -- the full artist-credit array from the source + notes JSONB, -- discovery path, source-only fields + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_eval_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'candidate', -- 'candidate'|'acquiring'|'probation'|'retained'|'retired' + UNIQUE (source, external_id) +); + +-- Keeping the graph reference: +-- discovery_candidates.id is referenced by claims rows with subject_type='track' (UUID aligns). +``` + +### E.3 Acquisition policy + queue + +Not every candidate downloads. Decision factors: + +- expected usefulness (relevance confidence from the discovery claim) +- novelty (does the listener's `discovery` profile tolerate this + territory?) +- storage budget +- artist diversity (don't acquire 10 tracks from one new artist in a + day) +- existing backlog depth +- current listener fatigue (don't acquire more of a fatigued artist) +- current exploration budget share + +The decision maximises **expected information gain**, not download +count. + +Priority queue: + +| Priority | Source | +|---|---| +| 1 (highest) | favourite artists' new releases | +| 1 | active obsession new releases | +| 2 | adjacent artists, collaborations, graph discoveries | +| 3 (lowest) | experimental discoveries, curiosity experiments | + +Downloads happen invisibly (yt-dlp worker, throttled, low bandwidth). +On successful download, the audio is scanned (existing scanner), which +writes `credited_main_on` / `featured_on` claims and a `tracks` row. +The discovery_candidate row transitions to `probation`. + +### E.4 Probation + +Downloaded music is never trusted immediately. Every acquisition +enters probation. During probation, the session director (D) +occasionally injects probation tracks into normal sessions — the +listener should not feel they are being tested. + +Probation is a `tracks.probation_status` column (new), one short +migration: + +```sql +ALTER TABLE tracks ADD COLUMN IF NOT EXISTS probation_status TEXT + DEFAULT 'retained' CHECK (probation_status IN ('probation','retained','retired')); +ALTER TABLE tracks ADD COLUMN IF NOT EXISTS probation_entered_at TIMESTAMPTZ; +CREATE INDEX tracks_probation_idx ON tracks (probation_status) WHERE probation_status = 'probation'; +``` + +Existing library tracks default to `retained`. Newly acquired tracks +are `probation` with `probation_entered_at = NOW()`. + +Each probation track accumulates evidence (B.3) over multiple +sessions — single interactions rarely provide enough. Possible +outcomes: + +- **retain** — survival threshold met; probation_status → `retained`. + Associated artist's `longterm` affinity gets a small bump, the + discovery path that produced this candidate gets reinforced (a meta + signal for E.5). +- **archive** — kept on disk but hidden from normal sessions; exempt + from the planner. +- **delete** — file removed, `tracks` row marked `retired`. Library + is not an ever-growing archive. +- **ignore temporarily** — back to candidate state for re-evaluation + later; rarer path. + +Probation duration adapts to confidence: a candidate discovered via a +trusted path (favourite producer's new signing) gets a longer +probation than a curiosity-experiment candidate. + +### E.5 Meta-learning + +The discovery system continuously evaluates itself. A periodic job +writes claims back into the graph about which *strategies* and *graph +paths* have produced long-term retainers, vs which consistently retire: +`source_trust` already tunes per-source; meta-learning additionally +tunes per-predicate-path: + +- which discovery sources (graph_exploration, release_monitoring, + scene_exploration, ...) produce long-term favourites? +- which graph paths consistently fail? (e.g. `same_label_as` may be a + weak edge; down-weight it.) +- which labels repeatedly introduce successful artists? +- which exploration depth performs best? +- which experiments produce the highest information gain? + +This meta-learning itself writes back as `source_trust` adjustments +and as tunable per-path-weight constants. Discovery learns how to +discover better, not just what to recommend. + +### E.6 Acceptance + +- A discovery candidate surfaced via "producer A produced favourite B + AND new artist C" is auditable: `SELECT raw FROM claims WHERE + subject_id=$candidateId AND predicate='discovery_candidate'` shows + the full path. +- A downloaded probation track on which the listener completed 3 plays + in its first 2 sessions transitions to `retained` automatically. +- A downloaded probation track skipped on every injection retires to + `retired` after its probation window; file is removed; library does + not accumulate indefinitely. +- The meta-learning job, run weekly, down-weights a discovery strategy + (e.g. `scene_exploration`) whose recent candidates have a <20% + retention rate, observable in `source_trust` deltas or per-path + weight constants. + +### E.7 Replaces + +Nothing — E is net new. It consumes A (graph) and writes back into A. +Built on top of the yet-unbuilt yt-dlp worker (`progress.md:29`), +independent of B/C/D. + +--- + +## §F — Phase 4: Image quality (preserved from old v2) + +**This section is preserved verbatim from the previous v2 doc.** It is +orthogonal to recommendation; the bad-image problem is a provenance +problem, unrelated to the engine. Ships any time, independent of A–E. + +### F.1 Schema + +```sql +CREATE TABLE image_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type TEXT NOT NULL CHECK (entity_type IN ('artist','album')), + entity_id UUID NOT NULL, + source TEXT NOT NULL, + url TEXT, + width INTEGER, + verified BOOLEAN DEFAULT FALSE, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (entity_type, entity_id, source) +); +CREATE INDEX image_candidates_entity_idx ON image_candidates (entity_type, entity_id); +``` + +- `artists.image_path` and `albums.artwork_id` remain as the + "currently preferred" denormalised pointer, written by the selection + step. Existing queries keep working. + +### F.2 Enrichment write + +Each image-fetch step (Wikidata, TheAudioDB, Fanart, iTunes, Deezer, +Discogs, Last.fm, Cover Art Archive) writes a `image_candidates` row +even on failure to find one — a "negative" row with `url=NULL` so we +don't re-fetch that source for that entity until the row is aged out. + +### F.3 Selection + +A selection step (worker job or enrichment sub-step) picks the +preferred URL by tier: + +1. Wikidata via MBID (verified, broad coverage) — highest tier. +2. TheAudioDB via MBID. +3. Fanart via MBID. +4. Cover Art Archive (albums) / Deezer (albums) — high-res. +5. iTunes upscaled to 600 — broad coverage fallback. +6. Last.fm — last resort. +7. Wikimedia via name match — excluded (historically wrong; migrations + cleared these twice). + +Tiers are a config table or constants, not magic strings in code. +Selection writes the winner into `artists.image_path` / +`albums.artwork_id`. + +### F.4 Re-evaluation + +- `image_candidates` rows older than `N` days (config, default 90) are + eligible for re-fetch. A periodic job re-runs enrichment for stale + candidates, replacing rows. +- The selector re-runs whenever candidates change. So if a low-tier + winner was selected and a higher-tier candidate lands later, the + preferred pointer is upgraded in place. + +### F.5 Acceptance + +- After re-enrichment, an artist that previously showed a 100×100 + Last.fm thumbnail shows the Wikidata/TheAudioDB image instead. +- Re-running enrichment does not re-fetch sources that already returned + (negative cache). +- Selection is auditable: `SELECT * FROM image_candidates WHERE + entity_id = X` shows every candidate considered. + +--- + +## Rollout + +Recommended order, each system independently shippable; the old v1 +CTE stays until D lands: + +1. **A** (knowledge graph + claim_fusion view). Existing queries move + to the compat views (`track_artists_v2`, `album_artists_v2`); v1 + engine keeps running against the views. MB spine backfill runs in + the background. +2. **B and E in parallel** (B listener model + evidence writer; E + acquisition pipeline + yt-dlp worker + probation). Both need only + A. The evidence writer starts converting play_history into + evidence; the existing CTE ignores evidence for now. +3. **C** (generators). Built and run in shadow mode alongside the v1 + CTE — both produce chunks; the UI shows v1 chunks, but C's outputs + are logged for comparison. Generators don't replace v1 reads until + D ships. +4. **D** (session director). D switches over and the v1 CTE is + deleted in the same release. Acceptance is the session-feel test + (D.11). +5. **Phase 4 (image candidates)** at any point. Independent. + +No phase is blocked except B/E on A, C on A+B, D on C+B. Phase 4 is +fully independent. + +## Retiring v1 — explicit deletion list + +On System D ship, this code goes: + +- `db.service.ts` `getNextVibeChunk` (~220 lines). +- `recordPlay`'s center-walk (`UPDATE recommendation_batch SET + seed_track_id = $2 ...`, line ~580–590). +- The `artist_play_count` decay term (line ~829). +- The `W_SAMEART`, `W_ARTSIM`, `W_FEEDBCK`, `W_AUDIO`, `W_RANDOM` + constants + `local_pool` / `probation_pool` CTEs (lines 717–866). +- The "max 1 per artist per chunk" cap, replaced by D.3's budgets. +- `getVibeChunkFromGenre` (it becomes a thin wrapper over the discovery + generator; then collapses into D's session-by-genre entry). +- The `feedback` table write paths (`recordSkip`, `recordFeedback`, + hardDelete insert). The `feedback` table itself is dropped after + backfill into `evidence`. +- `recommendation_batch_track` exclusion set (replaced by D's + recent-exclusions set, in-memory per session). +- `albums.artist_id` single-FK pointer (kept as a denormalised + trigger-maintained column off the fusion view; removed as a *read* + source). +- `artist_similar` table (compat view retained briefly, then dropped). +- `genre.parent_id` column (replaced by `parent_of` claims; column + dropped after a backfill claims-migration). + +## Out of scope + +- **Filesystem reorganisation.** Confirmed out of scope. The bind is + read-only; the DB is the index; reorganising the FS inverts the + dependency in the wrong direction. +- **Auth itself.** Schemas are `user_id`-keyed from the start so no + retrofit is needed later, but building auth is a separate project + (progress.md #30). +- **A user-facing override UI for MB credits.** Competing claims + coexist in the graph; resolution at read time is weighted. A future + UI can show "per MB" vs "per tag" and let the user assert a + `curated` claim (trust 1.0) that overrides. Out of scope here. +- **Manual artist-group / alias curation UI.** `alias_of` is a graph + belief; `curated` claims (trust 1.0) override the learned belief + when human assertion is needed — but the UI to do that is separate. \ No newline at end of file diff --git a/docs/architecture/v2-fix-plan.md b/docs/architecture/v2-fix-plan.md new file mode 100644 index 0000000..0186f3e --- /dev/null +++ b/docs/architecture/v2-fix-plan.md @@ -0,0 +1,797 @@ +# v2 Fix Plan — foolproof execution + +This plan fixes the gaps between the overnight v2 work and +`docs/architecture/09-recommendation-and-identity-v2.md`. Every step +has exact file paths, exact old/new code, and a verification command. +Execute steps 1–8 in order, then step 9 (build + deploy + verify). + +**Rules for the executing agent:** + +- Do NOT edit or remove existing entries in the `MIGRATIONS` array in + `db.service.ts`. The three v2 migrations (`20260707_claim_fusion`, + `20260707_backfill_claims`, `20260708_materialize_claim_fusion`) have + not applied to the live DB yet, but leave them as-is — they run + cleanly on first boot. +- Do NOT delete the v1 `getNextVibeChunk` CTE or `vibe.routes.ts` in + this plan. The doc says v1 is deleted *when D ships and is + verified*. That's a follow-up, not this plan. +- After all code edits (steps 1–7), run `npx tsc --noEmit` and + `npx vitest run` from `backend/` before deploying. +- All file paths are relative to `/home/kami/apps/muzick/`. + +--- + +## Step 1 — claim_fusion MV refresh consumer (blocker) + +**Problem:** `claim_fusion` is a MATERIALIZED VIEW. It is populated at +creation time (migration) and never refreshed again. A trigger fires +`NOTIFY claim_fusion_changed` on claims changes, but nothing LISTENs. +Every generator and compat view reads the frozen MV — new claims are +invisible. + +**Fix:** Add a `refreshClaimFusion()` method to `DbService` and a +background interval in `app.ts` that calls it every 10 seconds. +`CONCURRENTLY` won't block reads. + +### 1a. Add method to `backend/src/services/db.service.ts` + +Insert this method immediately before the closing `}` of the class +(after `seedDefaultDiversityBudgets`, which ends at line 2062): + +```ts + + /** + * Refresh the claim_fusion materialised view. Called on a periodic + * timer so the graph's read path stays current with new claims. + * CONCURRENTLY requires the unique index (idx_claim_fusion_pk), + * which the 20260708_materialize_claim_fusion migration creates. + */ + async refreshClaimFusion(): Promise<void> { + try { + await this.pgClient.query('SELECT refresh_claim_fusion()'); + } catch (err) { + // Non-fatal: the MV may not exist yet on first boot before + // migrations run. Log and move on; the next tick will retry. + console.error('[DB] refresh_claim_fusion failed:', err); + } + } +``` + +The `oldString` to match for the edit (the end of +`seedDefaultDiversityBudgets` + the class closing brace): + +``` + for (const d of defaults) { + await this.upsertDiversityBudget({ + user_id: userId, + dimension: d.dimension, + budget_share: d.share, + horizon_min: d.horizon, + }); + } + } +} +``` + +Replace with the same block + the new method inserted before the +final `}`. + +### 1b. Start the refresh interval in `backend/src/app.ts` + +After the line `await dbService.runMigrations();` (line 53), add: + +```ts + + // Keep the claim_fusion materialised view fresh. The trigger on + // `claims` fires NOTIFY on every change; rather than maintain a + // LISTEN consumer (separate long-lived connection), we refresh on a + // short interval. 10s staleness is well below any user-facing + // latency for a homelab music player. + const FUSION_REFRESH_MS = 10_000; + const fusionTimer = setInterval(() => { + dbService.refreshClaimFusion().catch(() => {}); + }, FUSION_REFRESH_MS); +``` + +Then in the `onClose` hook (around line 128), add `clearInterval` for +the new timer. Find: + +```ts + fastify.addHook('onClose', async () => { + try { + await pgClient.end(); +``` + +Insert before `await pgClient.end();`: + +```ts + clearInterval(fusionTimer); +``` + +**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors. + +--- + +## Step 2 — daily belief decay + nightly forgotten derivation (blocker) + +**Problem:** `listener_beliefs.last_decayed_at` is set at insert and +never advanced. No decay job exists. This violates the core axiom +"everything decays unless reinforced" and reproduces the v1 failure +mode (heavily-played artists win forever). Also, the `forgotten` +profile is "derived nightly" per the doc but nothing populates it, so +the revival generator always returns empty. + +**Fix:** Add `decayBeliefs()` and `deriveForgottenProfile()` methods +to `DbService` and periodic intervals in `app.ts`. + +### 2a. Add decay method to `backend/src/services/db.service.ts` + +Insert after `refreshClaimFusion()` (the method added in step 1a): + +```ts + + /** + * Decay all listener beliefs whose last_decayed_at is older than 1 + * hour. Implements the decay formula from spec §B.4: + * value *= 0.5 ^ (elapsed / halflife) + * confidence *= 0.5 ^ (elapsed / halflife) + * Halflife is per-profile (longterm=365d, obsession=14d, discovery=30d, + * negative=180d, contextual=7d). The 'forgotten' profile is excluded + * — it is fully derived nightly by deriveForgottenProfile(), not + * decayed. + */ + async decayBeliefs(): Promise<number> { + const res = await this.pgClient.query(` + WITH halflives AS ( + SELECT profile, + CASE profile + WHEN 'longterm' THEN 365 * 86400 + WHEN 'obsession' THEN 14 * 86400 + WHEN 'discovery' THEN 30 * 86400 + WHEN 'negative' THEN 180 * 86400 + WHEN 'contextual' THEN 7 * 86400 + ELSE 30 * 86400 + END AS halflife_sec + ) + UPDATE listener_beliefs lb + SET value = GREATEST(-1.0, LEAST(1.0, lb.value * POWER(0.5, + EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))), + confidence = GREATEST(0, LEAST(1.0, lb.confidence * POWER(0.5, + EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))), + last_decayed_at = NOW() + FROM halflives h + WHERE lb.profile = h.profile + AND lb.profile <> 'forgotten' + AND lb.last_decayed_at < NOW() - INTERVAL '1 hour' + `); + return res.rowCount ?? 0; + } + + /** + * Derive the 'forgotten' profile nightly (spec §B.2): + * longterm affinity > 0.3 AND not reinforced in 90+ days. + * Wipes and repopulates — 'forgotten' is fully derived, not evidence-fed. + */ + async deriveForgottenProfile(): Promise<number> { + await this.pgClient.query( + `DELETE FROM listener_beliefs WHERE profile = 'forgotten'` + ); + const res = await this.pgClient.query(` + INSERT INTO listener_beliefs + (user_id, profile, entity_type, entity_id, dimension, value, + confidence, evidence_count, last_reinforced_at, last_decayed_at) + SELECT user_id, 'forgotten', entity_type, entity_id, dimension, + value, confidence, evidence_count, last_reinforced_at, NOW() + FROM listener_beliefs + WHERE profile = 'longterm' + AND dimension = 'affinity' + AND value > 0.3 + AND last_reinforced_at < NOW() - INTERVAL '90 days' + ON CONFLICT (user_id, profile, entity_type, entity_id, dimension) + DO UPDATE SET + value = EXCLUDED.value, + confidence = EXCLUDED.confidence, + evidence_count = EXCLUDED.evidence_count, + last_reinforced_at = EXCLUDED.last_reinforced_at + `); + return res.rowCount ?? 0; + } +``` + +### 2b. Start decay + forgotten intervals in `backend/src/app.ts` + +After the `fusionTimer` block added in step 1b, add: + +```ts + + // Daily belief decay (spec §B.4). Runs hourly; the SQL only touches + // beliefs whose last_decayed_at is >1h old, so frequent runs are safe. + const DECAY_INTERVAL_MS = 60 * 60 * 1000; + const decayTimer = setInterval(() => { + dbService.decayBeliefs().catch((e) => console.error('[DB] belief decay failed:', e)); + }, DECAY_INTERVAL_MS); + + // Nightly 'forgotten' profile derivation (spec §B.2). + const FORGOTTEN_INTERVAL_MS = 24 * 60 * 60 * 1000; + const forgottenTimer = setInterval(() => { + dbService.deriveForgottenProfile().catch((e) => + console.error('[DB] forgotten derivation failed:', e) + ); + }, FORGOTTEN_INTERVAL_MS); + + // Run both once at boot so the first session benefits. + dbService.decayBeliefs().catch(() => {}); + dbService.deriveForgottenProfile().catch(() => {}); +``` + +In the `onClose` hook, add (after `clearInterval(fusionTimer);`): + +```ts + clearInterval(decayTimer); + clearInterval(forgottenTimer); +``` + +**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors. + +--- + +## Step 3 — fix MB spine writer generated-column bug + +**File:** `workers/src/mb-spine-writer.ts` + +**Problem:** `resolveArtist` (line 128) tries to INSERT into +`normalized_name`, which is `GENERATED ALWAYS AS normalize_artist(name) +STORED`. PostgreSQL rejects this: +`ERROR: cannot insert a non-DEFAULT value into column "normalized_name"`. +The stub-creation path is broken — the spine writer can only attach +claims to *existing* artists; any newly-credited artist is dropped. + +**Fix:** Remove `normalized_name` from the INSERT column list and the +`normalized` value from the params. The generated column auto-computes +from `name`. + +Find (lines 127–134): + +```ts + const result = await this.pgClient.query<{ id: string }>( + `INSERT INTO artists (name, canonical_name, sort_name, mbid, normalized_name) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (mbid) DO UPDATE SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP + RETURNING id`, + [creditName, artistName, sortName, mbid, normalized] + ); +``` + +Replace with: + +```ts + const result = await this.pgClient.query<{ id: string }>( + `INSERT INTO artists (name, canonical_name, sort_name, mbid) + VALUES ($1, $2, $3, $4) + ON CONFLICT (mbid) DO UPDATE SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP + RETURNING id`, + [creditName, artistName, sortName, mbid] + ); +``` + +**Verify:** `npx tsc --noEmit` in `workers/` — 0 errors. + +--- + +## Step 4 — fix dislikeTrack evidence-in-catch bug + +**File:** `backend/src/services/db.service.ts` + +**Problem:** `dislikeTrack` (line 712) writes the `hidden` evidence +row in the `catch` block — i.e. only when the transaction **fails**. +A successful dislike writes no negative evidence via this path. + +**Fix:** Move the evidence write out of the catch block to after the +try/catch, so it runs only on success. + +Find (lines 712–748): + +```ts + async dislikeTrack(userId: string, trackId: string): Promise<void> { + try { + await this.pgClient.query('BEGIN'); + + // Phase 1: hide the track in all active views + await this.pgClient.query( + "UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'", + [trackId] + ); + + // Phase 1: insert dislike row (idempotent — won't create duplicate) + await this.pgClient.query( + 'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING', + [trackId] + ); + + // Phase 1: log feedback signal for the Vibe learning loop + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')", + [userId, trackId] + ); + + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + // Write evidence: hidden → negative profile + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'hidden', + profile: 'negative', + weight: -0.60, + }); + throw err; + } + } +``` + +Replace with: + +```ts + async dislikeTrack(userId: string, trackId: string): Promise<void> { + try { + await this.pgClient.query('BEGIN'); + + // Phase 1: hide the track in all active views + await this.pgClient.query( + "UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'", + [trackId] + ); + + // Phase 1: insert dislike row (idempotent — won't create duplicate) + await this.pgClient.query( + 'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING', + [trackId] + ); + + // Phase 1: log feedback signal for the Vibe learning loop + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')", + [userId, trackId] + ); + + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + + // Write evidence: hidden → negative profile (only on success) + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'hidden', + profile: 'negative', + weight: -0.60, + }); + } +``` + +**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors. + +--- + +## Step 5 — fix hardDeleteTrack missing manual_deleted evidence + +**File:** `backend/src/services/db.service.ts` + +**Problem:** `hardDeleteTrack` (line 810) writes a legacy `feedback` +row but no evidence. The doc's strongest negative signal +(`manual_deleted → negative -0.90`) is missing. Permanent deletion has +no effect on listener beliefs. + +**Fix:** Add a `recordEvidence` call between the feedback insert and +the track DELETE. `evidence.entity_id` has no FK to `tracks`, so the +evidence row survives the deletion. + +Find (lines 813–821): + +```ts + // Log the permanent deletion feedback event first (before the track is gone) + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')", + [userId, trackId] + ); + + // Delete DB record (ON DELETE CASCADE handles track_genre, play_history, + // feedback, track_audio_features, track_lyrics, recommendation_batch_track) + await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]); +``` + +Replace with: + +```ts + // Log the permanent deletion feedback event first (before the track is gone) + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')", + [userId, trackId] + ); + + // Write evidence: manual_deleted → negative profile (strongest negative + // signal, spec §B.3). entity_id has no FK to tracks, so the row survives. + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'manual_deleted', + profile: 'negative', + weight: -0.90, + }); + + // Delete DB record (ON DELETE CASCADE handles track_genre, play_history, + // feedback, track_audio_features, track_lyrics, recommendation_batch_track) + await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]); +``` + +**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors. + +--- + +## Step 6 — switch session director artist reads to track_artists_v2 + +**File:** `backend/src/services/session-director.service.ts` + +**Problem:** The session director reads the legacy `track_artists` +table for artist fatigue, budget spend, repetition checks, seed +resolution, and recent-play artist lookup. Doc D.2 requires artist +fatigue to roll up via `alias_of` fusion (so DOOM / Madvillain / Viktor +Vaughn collapse into one artist). By bypassing `claim_fusion` / +`track_artists_v2`, the alias collapse cannot happen. + +**Fix:** Replace all `track_artists ta` → `track_artists_v2 ta` and +`track_artists ta3` → `track_artists_v2 ta3` in this file. The v2 view +has the same `track_id`, `artist_id`, `role` columns, so it's a +drop-in replacement. This depends on step 1 (MV refresh) being +deployed so the view has data. + +There are 11 occurrences across the file. Do two `replaceAll` edits: + +### 6a. Replace all `track_artists ta` with `track_artists_v2 ta` + +Use `replaceAll: true` on the string `track_artists ta` → +`track_artists_v2 ta`. This covers 10 occurrences (buildState, +computeFatigue, calcBudgetSpent artist, calcBudgetSpent new_artist, +checkRepetition, rankCandidates, buildPlan, replan, resolveSeedArtistId). + +### 6b. Replace `track_artists ta3` with `track_artists_v2 ta3` + +Use `replaceAll: true` on the string `track_artists ta3` → +`track_artists_v2 ta3`. This covers 1 occurrence in +`calcBudgetSpent`'s `new_artist` case. + +**Note:** Do step 6a first, then 6b. After 6a, the `ta3` occurrence +will still be `track_artists ta3` (it wasn't matched by `track_artists ta` +because that's a different string — `ta3` ≠ `ta`). So both +replacements are needed. + +**Verify:** +- `npx tsc --noEmit` in `backend/` — 0 errors. +- `grep -n "track_artists " backend/src/services/session-director.service.ts` + should return **zero** matches (all replaced). If any `track_artists ` + without `_v2` remain, fix them. + +--- + +## Step 7 — correct the session log + +**File:** `SESSION-07-07-2026.md` + +**Problem:** The session log overclaims: says "Full Stack" and +"replaces getNextVibeChunk" but nothing is deployed, v1 is intact, and +frontend is not wired. Also miscounts files (7 not 8) and says +noveltyGenerator was "skipped" when it's implemented. + +**Fix:** Make these edits: + +### 7a. Fix the headline (line 3) + +Find: +``` +## Implemented: v2 Recommendation Engine — Full Stack (Systems A–E + Phase 4) +``` +Replace with: +``` +## Scaffolded: v2 Recommendation Engine — code complete, not yet deployed (Systems A–E + Phase 4) +``` + +### 7b. Fix the file count (line 8) + +Find: +``` +### Files created (7 new) +``` +Replace with: +``` +### Files created (8 new) +``` + +And add a row to the table after the `image-enrichment.service.ts` row +(line 15). After: +``` +| `backend/src/services/image-enrichment.service.ts` | 105 | **Phase 4** — Image candidate pipeline | +``` +Add: +``` +| `workers/src/mb-spine-writer.ts` | 136 | **A** — MB artist-credit → claims writer (wired into enrichment.service.ts) | +``` + +### 7c. Fix the novelty generator claim (line 53) + +Find: +``` +- `noveltyGenerator`: skipped (no release_date column) +``` +Replace with: +``` +- `noveltyGenerator`: recent releases (≤60d) via same_scene_as/same_label_as/produced edges from trusted artists +``` + +### 7d. Fix the "replaces getNextVibeChunk" claim (line 56) + +Find: +``` +**System D — Session Director (replaces getNextVibeChunk)** +``` +Replace with: +``` +**System D — Session Director (runs alongside v1; getNextVibeChunk not yet deleted)** +``` + +### 7e. Fix the Verification section (lines 86–88) + +Find: +``` +### Verification +- `npx tsc --noEmit` — 0 errors +- No git repo — changes uncommitted +``` +Replace with: +``` +### Verification +- `npx tsc --noEmit` — 0 errors +- `npx vitest run` — 30/30 pass (mocked shape checks, not DB-state) +- No git repo — changes uncommitted +- NOT deployed: live backend container is pre-v2; `/api/v2/*` and `/api/graph/*` return 404; DB has zero v2 tables. See `docs/architecture/v2-fix-plan.md` for the fix + deploy plan. +``` + +### 7f. Fix the Next section (lines 89–92) + +Find: +``` +### Next +- Wire the v2 endpoint into the frontend Vibe page (replace v1 vibeService calls) +- Build yt-dlp worker for System E acquisition (download candidates) +- Runtime smoke-test after redeploy +``` +Replace with: +``` +### Next +- Execute `docs/architecture/v2-fix-plan.md` (MV refresh, decay job, bug fixes, deploy) +- After deploy + verify: wire the v2 endpoint into the frontend Vibe page (replace v1 vibeService calls) +- Build yt-dlp worker for System E acquisition (download candidates) +- After v2 is verified in production: delete v1 CTE (`getNextVibeChunk`), `vibe.routes.ts`, `feedback` table, `artist_similar` table per the doc's "Retiring v1" list +``` + +--- + +## Step 8 — run typecheck + tests before deploying + +```bash +cd /home/kami/apps/muzick/backend +npx tsc --noEmit +npx vitest run +``` + +Both must pass (0 ts errors, 30/30 tests). If any test fails, do not +deploy — re-read the relevant step and fix. + +Also typecheck the worker: + +```bash +cd /home/kami/apps/muzick/workers +npx tsc --noEmit +``` + +--- + +## Step 9 — build, deploy, and verify against the live DB + +### 9a. Rebuild the backend + worker images + +```bash +cd /home/kami/apps/muzick +docker compose build backend worker +docker compose up -d backend worker +``` + +Wait ~15 seconds for boot, then check the backend logs for migration +output: + +```bash +docker logs muzick-backend-1 --tail 80 2>&1 | grep -E "Migration|migration|ERROR|error" +``` + +You should see: +``` +[DB] Running migration: 20260707_claim_fusion +[DB] Migration applied: 20260707_claim_fusion +[DB] Running migration: 20260707_backfill_claims +[DB] Migration applied: 20260707_backfill_claims +[DB] Running migration: 20260708_materialize_claim_fusion +[DB] Migration applied: 20260708_materialize_claim_fusion +``` + +If any migration fails, read the error, fix the SQL in a NEW migration +(do not edit the failed one), rebuild, and redeploy. + +### 9b. Verify v2 tables + views exist and are populated + +```bash +docker exec muzick-db-1 psql -U user -d muzick -c " +SELECT 'migrations' AS check, COUNT(*) FROM schema_migrations +UNION ALL SELECT 'claims', COUNT(*) FROM claims +UNION ALL SELECT 'source_trust', COUNT(*) FROM source_trust +UNION ALL SELECT 'evidence', COUNT(*) FROM evidence +UNION ALL SELECT 'claim_fusion rows', COUNT(*) FROM claim_fusion +UNION ALL SELECT 'track_artists_v2 rows', COUNT(*) FROM track_artists_v2 +UNION ALL SELECT 'recording_mbid set', COUNT(*) FROM tracks WHERE recording_mbid IS NOT NULL; +" +``` + +Expected after first boot: +- `migrations` = 7 (4 old + 3 new) +- `source_trust` = 7 (seed rows) +- `claims` > 0 (backfilled from `track_artists` + `artist_similar`) +- `claim_fusion rows` > 0 (populated by the materialize migration) +- `track_artists_v2 rows` > 0 (view over claim_fusion) + +If `claims` = 0, the backfill migration found nothing — check that +`track_artists` and `artist_similar` have data in the live DB. + +### 9c. Verify the v2 endpoints are live + +```bash +curl -s http://localhost:3000/api/graph/sources | head -c 200 +echo +curl -s -o /dev/null -w "v2/state: HTTP %{http_code}\n" http://localhost:3000/api/v2/state +curl -s -o /dev/null -w "graph/sources: HTTP %{http_code}\n" http://localhost:3000/api/graph/sources +curl -s -o /dev/null -w "graph/summary: HTTP %{http_code}\n" http://localhost:3000/api/graph/summary +``` + +Expected: `v2/state: HTTP 200`, `graph/sources: HTTP 200`, +`graph/summary: HTTP 200`. + +### 9d. Smoke-test the v2 session flow + +```bash +# Start a v2 session (use any library track ID as seed) +SEED=$(docker exec muzick-db-1 psql -U user -d muzick -t -c "SELECT id FROM tracks WHERE state='LIBRARY' LIMIT 1" | tr -d ' \n') +echo "Seed track: $SEED" +curl -s -X POST http://localhost:3000/api/v2/vibe/start \ + -H 'Content-Type: application/json' \ + -H "x-user-id: 00000000-0000-0000-0000-000000000000" \ + -d "{\"seedTrackId\":\"$SEED\"}" | head -c 500 +echo +# Get the next track from the plan +curl -s http://localhost:3000/api/v2/vibe/next \ + -H "x-user-id: 00000000-0000-0000-0000-000000000000" | head -c 300 +echo +# Check the plan +curl -s http://localhost:3000/api/v2/vibe/plan \ + -H "x-user-id: 00000000-0000-0000-0000-000000000000" | head -c 300 +``` + +If `/v2/vibe/start` returns an empty plan `[]`, check the backend logs +for generator errors. The most likely cause is `claim_fusion` being +empty — verify step 9b showed `claim_fusion rows > 0`. + +### 9e. Verify evidence is written on a completed play + +```bash +USER="00000000-0000-0000-0000-000000000000" +TRACK=$(docker exec muzick-db-1 psql -U user -d muzick -t -c "SELECT id FROM tracks WHERE state='LIBRARY' LIMIT 1" | tr -d ' \n') + +# Before +docker exec muzick-db-1 psql -U user -d muzick -c "SELECT COUNT(*) AS evidence_before FROM evidence WHERE user_id='$USER'" + +# Record a completed play via the v2 feedback endpoint +curl -s -X POST http://localhost:3000/api/v2/vibe/feedback \ + -H 'Content-Type: application/json' \ + -H "x-user-id: $USER" \ + -d "{\"trackId\":\"$TRACK\",\"action\":\"completed\"}" + +# After +docker exec muzick-db-1 psql -U user -d muzick -c "SELECT COUNT(*) AS evidence_after, signal, profile, weight FROM evidence WHERE user_id='$USER' GROUP BY signal, profile, weight ORDER BY created_at DESC LIMIT 5" +``` + +Expected: `evidence_after > evidence_before`, and you should see a +`playback_completed` / `longterm` / `0.10` row. + +### 9f. Verify the MV refresh is running + +Wait 15 seconds after boot, then: + +```bash +docker logs muzick-backend-1 2>&1 | grep -i "refresh_claim_fusion" | tail -3 +``` + +You should see no errors (the method logs only on failure). If you see +repeated `refresh_claim_fusion failed` errors, the MV or refresh +function doesn't exist — re-check that the +`20260708_materialize_claim_fusion` migration applied. + +### 9g. Verify belief decay runs + +```bash +# Manually trigger decay and check it doesn't error +docker exec muzick-backend-1 node -e " +const { Client } = require('pg'); +const c = new Client({ connectionString: process.env.DATABASE_URL }); +(async () => { + await c.connect(); + const r = await c.query('SELECT decay_beliefs()'); + console.log('decay result:', r.rows); + await c.end(); +})().catch(e => { console.error('FAIL:', e.message); process.exit(1); }); +" 2>&1 || echo "decay_beliefs() not a SQL function — that's OK, the method runs the UPDATE directly" +``` + +This is a soft check — the `decayBeliefs()` method runs raw SQL, not a +stored function. The real verification is that `last_decayed_at` +advances after 1 hour. Check: + +```bash +docker exec muzick-db-1 psql -U user -d muzick -c " +SELECT user_id, profile, entity_type, last_decayed_at, + EXTRACT(EPOCH FROM (NOW() - last_decayed_at))/3600 AS hours_since_decay +FROM listener_beliefs +ORDER BY last_decayed_at DESC LIMIT 5; +" +``` + +After 1+ hours of uptime, `hours_since_decay` should be < 1 for +recently-decayed rows (the hourly job touched them). + +--- + +## Summary of what each step fixes + +| Step | Doc section | Problem | Fix | +|---|---|---|---| +| 1 | A.4 | `claim_fusion` MV never refreshed | 10s interval calls `refresh_claim_fusion()` | +| 2 | B.4, B.2 | No belief decay; `forgotten` never derived | Hourly decay job + 24h forgotten derivation | +| 3 | A.5 #1 | MB spine writer can't create new artists (generated column) | Drop `normalized_name` from INSERT | +| 4 | B.3 | `dislikeTrack` writes evidence only on failure | Move evidence write to success path | +| 5 | B.3 | `hardDeleteTrack` writes no `manual_deleted` evidence | Add `-0.90` evidence before track DELETE | +| 6 | D.2 | Director reads legacy `track_artists`, bypassing alias fusion | Switch to `track_artists_v2` | +| 7 | — | Session log overclaims | Correct the wording | +| 8 | — | Pre-deploy gate | tsc + vitest pass | +| 9 | A.7, B.6, D.11 | Not deployed; acceptance unverified | Build, deploy, verify against live DB | + +## What is NOT in this plan (follow-ups, not blockers) + +- **MB spine writer album + artist-relation claims** — + `writeAlbumClaims` and `writeArtistRelationClaims` in + `mb-spine-writer.ts` are stubs (`console.log` + `return 0`). They + require new `MusicBrainzClient` methods (release-group artist-credit, + artist-relations ARs). Not a blocker for first deploy; the recording- + claim writer is the critical path. Implement as a follow-up. +- **Frontend wiring** — the frontend still calls `/api/vibe/*` (v1). + After v2 is verified in production, wire `/api/v2/vibe/*` into + `frontend/src/services/vibeService.ts` and the Vibe page. +- **v1 deletion** — `getNextVibeChunk`, `vibe.routes.ts`, the + `feedback` table, `artist_similar` table, `genre.parent_id` are all + still present. The doc says delete them when D ships and is + verified. That's a separate, careful release after v2 is confirmed + good in production. +- **LISTEN-based MV refresh** — the 10s interval in step 1 is the + simple, foolproof approach. Upgrading to a `LISTEN`/`NOTIFY` consumer + (immediate refresh on claim change) is a follow-up if 10s staleness + ever becomes a problem. diff --git a/docs/plans/2026-06-08-ui-overhaul.md b/docs/plans/2026-06-08-ui-overhaul.md new file mode 100644 index 0000000..66e3d90 --- /dev/null +++ b/docs/plans/2026-06-08-ui-overhaul.md @@ -0,0 +1,1706 @@ +# UI Overhaul — Implementation Plan +**Date:** 2026-06-08 +**Phases:** 1–4 per `docs/ui-rework.md` +**Stack:** React 18 · TanStack Router · Zustand · react-query · Tailwind CSS 3 · lucide-react + +--- + +## File map + +| Action | Path | +|--------|------| +| **Modify** | `frontend/tailwind.config.js` | +| **Modify** | `frontend/src/index.css` | +| **Modify** | `frontend/src/lib/theme.ts` | +| **Modify** | `frontend/src/router.tsx` | +| **Modify** | `frontend/src/types.ts` | +| **Modify** | `frontend/src/pages/Home.tsx` | +| **Modify** | `frontend/src/pages/Tracks.tsx` | +| **Modify** | `frontend/src/pages/Artists.tsx` | +| **Modify** | `frontend/src/pages/ArtistDetail.tsx` | +| **Modify** | `frontend/src/pages/Albums.tsx` | +| **Modify** | `frontend/src/pages/AlbumDetail.tsx` | +| **Modify** | `frontend/src/pages/Genres.tsx` | +| **Modify** | `frontend/src/pages/Discover.tsx` | +| **Modify** | `frontend/src/pages/Vibe.tsx` | +| **Modify** | `frontend/src/pages/Search.tsx` | +| **Modify** | `frontend/src/pages/Quarantine.tsx` | +| **Modify** | `frontend/src/pages/Settings.tsx` | +| **Create** | `frontend/src/components/AppShell.tsx` | +| **Create** | `frontend/src/components/NavRail.tsx` | +| **Create** | `frontend/src/components/TopBar.tsx` | +| **Create** | `frontend/src/components/PlaybackBar.tsx` | +| **Create** | `frontend/src/components/NowPlayingPanel.tsx` | +| **Create** | `frontend/src/components/Artwork.tsx` | +| **Create** | `frontend/src/components/MediaCard.tsx` | +| **Create** | `frontend/src/components/ShelfRow.tsx` | +| **Create** | `frontend/src/components/TrackRow.tsx` | +| **Create** | `frontend/src/services/quarantineService.ts` | +| **Delete** | `frontend/src/components/Layout.tsx` | +| **Delete** | `frontend/src/components/NowPlayingBar.tsx` | +| **Delete** | `frontend/src/pages/LibraryTrackRow.tsx` | + +--- + +## Task 1 — Expand design tokens + +**Goal:** Add 8 new CSS vars, wire every token into `tailwind.config.js` as semantic color keys, update all 4 existing theme presets + add a Default(Purple) preset. + +**Files:** `frontend/tailwind.config.js`, `frontend/src/index.css`, `frontend/src/lib/theme.ts` + +**Steps:** + +1. Replace `frontend/tailwind.config.js`: +```js +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: { + colors: { + background: 'var(--bg)', + elevated: 'var(--bg-elevated)', + surface: 'var(--surface)', + 'surface-h':'var(--surface-hover)', + line: 'var(--border)', + primary: 'var(--text)', + muted: 'var(--text-muted)', + accent: 'var(--accent)', + 'accent-h': 'var(--accent-hover)', + 'on-accent':'var(--on-accent)', + 'grad-a': 'var(--card-grad-a)', + 'grad-b': 'var(--card-grad-b)', + }, + }, + }, + plugins: [], +}; +``` + +2. Replace the `:root` block in `frontend/src/index.css`: +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --bg: #000000; + --bg-elevated: #111113; + --surface: #18181b; + --surface-hover: #27272a; + --border: #3f3f46; + --text: #ffffff; + --text-muted: #a1a1aa; + --accent: #3b82f6; + --accent-hover: #2563eb; + --on-accent: #ffffff; + --card-grad-a: #1e293b; + --card-grad-b: #0f172a; +} + +body { + margin: 0; + padding: 0; + background-color: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} +``` + +3. Replace `frontend/src/lib/theme.ts` — expand every preset's `vars` to include all 12 tokens; add a "Default (Purple)" preset: +```ts +export interface ThemePreset { + id: string; + name: string; + vars: Record<string, string>; + swatch: string; +} + +export const THEMES: ThemePreset[] = [ + { + id: 'purple', + name: 'Default (Purple)', + swatch: '#1e1b4b', + vars: { + '--bg': '#0d0b1a', '--bg-elevated': '#13102a', '--surface': '#1e1b4b', + '--surface-hover': '#2d2a5e', '--border': '#4c1d95', + '--text': '#ede9fe', '--text-muted': '#a78bfa', + '--accent': '#7c3aed', '--accent-hover': '#6d28d9', '--on-accent': '#ffffff', + '--card-grad-a': '#1e1b4b', '--card-grad-b': '#0d0b1a', + }, + }, + { + id: 'dark', + name: 'Dark', + swatch: '#18181b', + vars: { + '--bg': '#000000', '--bg-elevated': '#111113', '--surface': '#18181b', + '--surface-hover': '#27272a', '--border': '#3f3f46', + '--text': '#ffffff', '--text-muted': '#a1a1aa', + '--accent': '#3b82f6', '--accent-hover': '#2563eb', '--on-accent': '#ffffff', + '--card-grad-a': '#1e293b', '--card-grad-b': '#0f172a', + }, + }, + { + id: 'midnight', + name: 'Midnight', + swatch: '#0f172a', + vars: { + '--bg': '#020617', '--bg-elevated': '#0a1120', '--surface': '#0f172a', + '--surface-hover': '#1e293b', '--border': '#334155', + '--text': '#e2e8f0', '--text-muted': '#94a3b8', + '--accent': '#6366f1', '--accent-hover': '#4f46e5', '--on-accent': '#ffffff', + '--card-grad-a': '#1e1b4b', '--card-grad-b': '#020617', + }, + }, + { + id: 'forest', + name: 'Forest', + swatch: '#0c1f17', + vars: { + '--bg': '#03120c', '--bg-elevated': '#071a10', '--surface': '#0c1f17', + '--surface-hover': '#163024', '--border': '#1f4a33', + '--text': '#e7f5ee', '--text-muted': '#86efac', + '--accent': '#10b981', '--accent-hover': '#059669', '--on-accent': '#ffffff', + '--card-grad-a': '#0c1f17', '--card-grad-b': '#03120c', + }, + }, + { + id: 'plum', + name: 'Plum', + swatch: '#1e1029', + vars: { + '--bg': '#100619', '--bg-elevated': '#180924', '--surface': '#1e1029', + '--surface-hover': '#2d1a3d', '--border': '#5b2d7a', + '--text': '#f3e8ff', '--text-muted': '#d8b4fe', + '--accent': '#a855f7', '--accent-hover': '#9333ea', '--on-accent': '#ffffff', + '--card-grad-a': '#1e1029', '--card-grad-b': '#100619', + }, + }, +]; + +export const DEFAULT_THEME_ID = 'purple'; + +export const STORAGE_KEYS = { + theme: 'muzick.settings.theme', + volume: 'muzick.settings.volume', +} as const; + +export function applyTheme(theme: ThemePreset): void { + const root = document.documentElement; + for (const [key, value] of Object.entries(theme.vars)) { + root.style.setProperty(key, value); + } +} + +export function readStoredThemeId(): string { + try { + const stored = localStorage.getItem(STORAGE_KEYS.theme); + if (stored && THEMES.some((t) => t.id === stored)) return stored; + } catch { /* unavailable */ } + return DEFAULT_THEME_ID; +} + +export function initTheme(): void { + const id = readStoredThemeId(); + const theme = THEMES.find((t) => t.id === id) ?? THEMES[0]; + applyTheme(theme); +} + +export function readStoredVolume(fallback: number): number { + try { + const stored = localStorage.getItem(STORAGE_KEYS.volume); + if (stored !== null) { + const parsed = Number(stored); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) return parsed; + } + } catch { /* ignore */ } + return fallback; +} +``` + +**Acceptance criteria:** `npm run typecheck` in `frontend/` passes; no TS errors in theme.ts. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 2 — Artwork component + +**Goal:** A reusable `<Artwork>` that renders a deterministic gradient placeholder derived from a seed string (title/artist), with an optional `src` URL override. + +**Files:** `frontend/src/components/Artwork.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/Artwork.tsx`: +```tsx +interface ArtworkProps { + seed: string; + src?: string | null; + className?: string; + rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; +} + +function hueFromString(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return Math.abs(h) % 360; +} + +export function Artwork({ seed, src, className = '', rounded = 'md' }: ArtworkProps) { + const hue = hueFromString(seed); + const gradient = `linear-gradient(135deg, hsl(${hue},45%,22%), hsl(${(hue + 60) % 360},35%,12%))`; + const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded]; + + if (src) { + return <img src={src} alt={seed} className={`object-cover ${r} ${className}`} />; + } + return <div className={`${r} ${className}`} style={{ background: gradient }} />; +} +``` + +**Acceptance criteria:** Component renders with a gradient when no `src` given; renders an `<img>` when `src` is provided. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 3 — TrackRow component + +**Goal:** A single reusable `<TrackRow>` that replaces `LibraryTrackRow`, uses semantic token classes, and exposes an optional `onDislike` callback (so callers handle invalidation). + +**Files:** `frontend/src/components/TrackRow.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/TrackRow.tsx`: +```tsx +import { Play, Pause, Heart, ThumbsDown, Music } from 'lucide-react'; +import type { Track } from '../types'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { favoritesService } from '../services/favoritesService'; +import { Artwork } from './Artwork'; + +export function formatDuration(seconds?: number | null): string { + if (!seconds || seconds < 0 || !Number.isFinite(seconds)) return '0:00'; + const total = Math.floor(seconds); + return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, '0')}`; +} + +interface TrackRowProps { + track: Track; + queue: Track[]; + index: number; + showActions?: boolean; + trackNumber?: number; + onDislike?: (trackId: string) => void; +} + +export function TrackRow({ track, queue, index, showActions = true, trackNumber, onDislike }: TrackRowProps) { + const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore(); + const isCurrent = currentTrack?.id === track.id; + + const handlePlay = () => { + if (isCurrent) { isPlaying ? pause() : play(); return; } + setQueue(queue.slice(index)); + playTrack(track); + }; + + const handleFavorite = (e: React.MouseEvent) => { + e.stopPropagation(); + void favoritesService.add(track.id).catch(() => undefined); + }; + + const handleDislike = (e: React.MouseEvent) => { + e.stopPropagation(); + void favoritesService.dislike(track.id).catch(() => undefined); + onDislike?.(track.id); + }; + + return ( + <div + onClick={handlePlay} + className={`group flex w-full cursor-pointer items-center gap-3 rounded-lg border p-2.5 transition-colors ${ + isCurrent + ? 'border-accent/60 bg-accent/10' + : 'border-line bg-surface/50 hover:border-line hover:bg-surface-h' + }`} + > + <div className="relative flex h-10 w-10 flex-none items-center justify-center rounded overflow-hidden"> + <Artwork seed={`${track.title} ${track.artist}`} className="absolute inset-0 w-full h-full" /> + {trackNumber !== undefined ? ( + <span className={`relative z-10 text-sm tabular-nums text-muted group-hover:opacity-0 ${isCurrent && isPlaying ? 'opacity-0' : ''}`}> + {trackNumber} + </span> + ) : ( + <Music size={18} className={`relative z-10 text-muted group-hover:opacity-0 ${isCurrent && isPlaying ? 'opacity-0' : ''}`} /> + )} + {isCurrent && isPlaying ? ( + <Pause size={18} className="absolute z-20 text-primary opacity-100" /> + ) : ( + <Play size={18} className="absolute z-20 text-primary opacity-0 group-hover:opacity-100" /> + )} + </div> + + <div className="min-w-0 flex-1"> + <div className={`truncate text-sm font-medium ${isCurrent ? 'text-accent' : 'text-primary'}`}> + {track.title || 'Untitled'} + </div> + <div className="truncate text-xs text-muted">{track.artist || 'Unknown artist'}</div> + </div> + + {showActions && ( + <div className="flex flex-none items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"> + <button onClick={handleFavorite} title="Favorite" className="rounded p-1.5 text-muted hover:bg-surface-h hover:text-pink-400"> + <Heart size={16} /> + </button> + <button onClick={handleDislike} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface-h hover:text-red-400"> + <ThumbsDown size={16} /> + </button> + </div> + )} + + <div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div> + </div> + ); +} +``` + +**Acceptance criteria:** Renders with semantic classes; `isCurrent` highlights with accent; `trackNumber` or icon shown; actions hidden until hover. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 4 — PlaybackBar + +**Goal:** Full-width bottom transport bar (replaces `NowPlayingBar`): artwork thumbnail + title/artist on left, controls + scrubber in center, volume + panel-toggle on right. + +**Files:** `frontend/src/components/PlaybackBar.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/PlaybackBar.tsx`: +```tsx +import { Play, Pause, SkipBack, SkipForward, Volume2, ListMusic } from 'lucide-react'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { Artwork } from './Artwork'; +import { formatDuration } from './TrackRow'; + +interface PlaybackBarProps { + panelOpen: boolean; + onTogglePanel: () => void; +} + +export function PlaybackBar({ panelOpen, onTogglePanel }: PlaybackBarProps) { + const { currentTrack, isPlaying, position, duration, volume, play, pause, next, prev, setPosition, setVolume } = usePlaybackStore(); + + return ( + <div className="h-20 bg-elevated border-t border-line px-4 flex items-center gap-4 shrink-0"> + {/* Track info */} + <div className="flex items-center gap-3 w-56 min-w-0 shrink-0"> + {currentTrack ? ( + <> + <div className="w-12 h-12 flex-none rounded overflow-hidden"> + <Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} className="w-full h-full" /> + </div> + <div className="min-w-0"> + <div className="text-sm font-semibold text-primary truncate">{currentTrack.title}</div> + <div className="text-xs text-muted truncate">{currentTrack.artist}</div> + </div> + </> + ) : ( + <div className="text-sm text-muted italic">Nothing playing</div> + )} + </div> + + {/* Controls + scrubber */} + <div className="flex-1 flex flex-col items-center gap-1"> + <div className="flex items-center gap-5"> + <button onClick={prev} className="text-muted hover:text-primary" aria-label="Previous"> + <SkipBack size={20} /> + </button> + <button + onClick={() => isPlaying ? pause() : play()} + disabled={!currentTrack} + className="w-9 h-9 rounded-full bg-accent hover:bg-accent-h flex items-center justify-center text-on-accent disabled:opacity-40 transition-colors" + aria-label={isPlaying ? 'Pause' : 'Play'} + > + {isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />} + </button> + <button onClick={next} className="text-muted hover:text-primary" aria-label="Next"> + <SkipForward size={20} /> + </button> + </div> + <div className="flex w-full max-w-lg items-center gap-2"> + <span className="text-xs text-muted w-9 text-right tabular-nums">{formatDuration(position)}</span> + <input + type="range" min={0} max={Math.max(duration, 0.1)} step={0.1} + value={Math.min(position, duration || 0)} + onChange={(e) => setPosition(Number(e.target.value))} + disabled={!currentTrack || duration <= 0} + className="flex-1 h-1 cursor-pointer accent-[var(--accent)]" + aria-label="Seek" + /> + <span className="text-xs text-muted w-9 tabular-nums">{formatDuration(duration)}</span> + </div> + </div> + + {/* Volume + panel toggle */} + <div className="flex items-center gap-3 w-48 justify-end shrink-0"> + <Volume2 size={18} className="text-muted flex-none" /> + <input + type="range" min={0} max={1} step={0.01} value={volume} + onChange={(e) => setVolume(Number(e.target.value))} + className="w-20 h-1 cursor-pointer accent-[var(--accent)]" + aria-label="Volume" + /> + <button + onClick={onTogglePanel} + className={`p-2 rounded-md transition-colors ${panelOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-primary'}`} + aria-label="Toggle queue panel" + title="Up Next" + > + <ListMusic size={18} /> + </button> + </div> + </div> + ); +} +``` + +**Acceptance criteria:** Play/pause button is a circle with accent fill; scrubber spans center; volume + panel toggle on right; disabled state when no track. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 5 — NowPlayingPanel + +**Goal:** Collapsible right panel — large artwork, track info, scrubber, transport, Up Next queue list. + +**Files:** `frontend/src/components/NowPlayingPanel.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/NowPlayingPanel.tsx`: +```tsx +import { X, Play, Pause, SkipBack, SkipForward, Music } from 'lucide-react'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { Artwork } from './Artwork'; +import { formatDuration } from './TrackRow'; + +interface NowPlayingPanelProps { + onClose: () => void; +} + +export function NowPlayingPanel({ onClose }: NowPlayingPanelProps) { + const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition, playTrack, setQueue } = usePlaybackStore(); + + const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1; + const upNext = currentIdx >= 0 ? queue.slice(currentIdx + 1) : queue; + + return ( + <aside className="w-72 flex flex-col border-l border-line bg-elevated overflow-hidden shrink-0"> + <div className="flex items-center justify-between px-4 py-3 border-b border-line"> + <span className="text-sm font-semibold text-primary">Now Playing</span> + <button onClick={onClose} className="text-muted hover:text-primary p-1 rounded"> + <X size={16} /> + </button> + </div> + + <div className="p-4 space-y-4"> + <div className="aspect-square rounded-xl overflow-hidden"> + <Artwork + seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'} + className="w-full h-full" + rounded="xl" + /> + </div> + + {currentTrack ? ( + <div className="text-center space-y-0.5"> + <div className="font-bold text-primary truncate">{currentTrack.title}</div> + <div className="text-sm text-muted truncate">{currentTrack.artist}</div> + </div> + ) : ( + <div className="text-center text-sm text-muted italic">No track playing</div> + )} + + <div className="space-y-1"> + <input + type="range" min={0} max={Math.max(duration, 0.1)} step={0.1} + value={Math.min(position, duration || 0)} + onChange={(e) => setPosition(Number(e.target.value))} + disabled={!currentTrack || duration <= 0} + className="w-full h-1 cursor-pointer accent-[var(--accent)]" + /> + <div className="flex justify-between text-xs text-muted tabular-nums"> + <span>{formatDuration(position)}</span> + <span>{formatDuration(duration)}</span> + </div> + </div> + + <div className="flex items-center justify-center gap-6"> + <button onClick={prev} className="text-muted hover:text-primary"><SkipBack size={20} /></button> + <button + onClick={() => isPlaying ? pause() : play()} + disabled={!currentTrack} + className="w-10 h-10 rounded-full bg-accent hover:bg-accent-h flex items-center justify-center text-on-accent disabled:opacity-40" + > + {isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />} + </button> + <button onClick={next} className="text-muted hover:text-primary"><SkipForward size={20} /></button> + </div> + </div> + + <div className="flex-1 overflow-y-auto border-t border-line"> + <div className="px-4 py-2 text-xs font-semibold text-muted uppercase tracking-wide"> + Up Next ({upNext.length}) + </div> + {upNext.length === 0 ? ( + <div className="px-4 pb-4 text-sm text-muted italic">Queue is empty.</div> + ) : ( + <ul> + {upNext.map((track, i) => ( + <li key={`${track.id}-${i}`}> + <button + onClick={() => { setQueue(upNext.slice(i)); playTrack(track); }} + className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-surface-h" + > + <div className="w-8 h-8 flex-none rounded overflow-hidden"> + <Artwork seed={`${track.title} ${track.artist}`} className="w-full h-full" /> + </div> + <div className="min-w-0 flex-1"> + <div className="truncate text-sm text-primary">{track.title}</div> + <div className="truncate text-xs text-muted">{track.artist}</div> + </div> + </button> + </li> + ))} + </ul> + )} + </div> + </aside> + ); +} +``` + +**Acceptance criteria:** Panel shows artwork, scrubber, transport, and Up Next list; `onClose` hides it. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 6 — NavRail + +**Goal:** Persistent left navigation column with two groups (Library, Personal), active state via accent, links to all routes. + +**Files:** `frontend/src/components/NavRail.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/NavRail.tsx`: +```tsx +import { Link } from '@tanstack/react-router'; +import { + Home, Music, Disc3, Users, Tags, Zap, Compass, + ShieldAlert, Settings, +} from 'lucide-react'; + +const NAV_GROUPS = [ + { + label: 'Library', + items: [ + { to: '/', icon: Home, label: 'Home' }, + { to: '/tracks', icon: Music, label: 'Songs' }, + { to: '/albums', icon: Disc3, label: 'Albums' }, + { to: '/artists', icon: Users, label: 'Artists' }, + { to: '/genres', icon: Tags, label: 'Genres' }, + { to: '/vibe', icon: Zap, label: 'Vibe' }, + { to: '/discover', icon: Compass, label: 'Discover' }, + ], + }, + { + label: 'Personal', + items: [ + { to: '/quarantine', icon: ShieldAlert, label: 'Quarantine' }, + { to: '/settings', icon: Settings, label: 'Settings' }, + ], + }, +] as const; + +const base = 'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors w-full'; +const inactive = 'text-muted hover:bg-surface-h hover:text-primary'; +const active = 'bg-accent/15 text-accent font-medium'; + +export function NavRail() { + return ( + <aside className="w-56 flex flex-col bg-surface border-r border-line shrink-0 overflow-y-auto"> + <div className="px-4 py-5"> + <span className="text-xl font-bold text-accent tracking-tight">Muzick</span> + </div> + <nav className="flex-1 px-3 space-y-5 pb-4"> + {NAV_GROUPS.map((group) => ( + <div key={group.label}> + <div className="px-3 mb-1.5 text-xs font-semibold uppercase tracking-wider text-muted/60"> + {group.label} + </div> + <ul className="space-y-0.5"> + {group.items.map(({ to, icon: Icon, label }) => ( + <li key={to}> + <Link + to={to} + activeOptions={{ exact: to === '/' }} + activeProps={{ className: `${base} ${active}` }} + inactiveProps={{ className: `${base} ${inactive}` }} + > + <Icon size={18} /> + {label} + </Link> + </li> + ))} + </ul> + </div> + ))} + </nav> + </aside> + ); +} +``` + +**Acceptance criteria:** Active route link has accent background; two labelled groups; logo at top. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 7 — TopBar + +**Goal:** Narrow top bar with logo gap (the NavRail handles branding) and a global search input that navigates to `/search?q=` on submit. + +**Files:** `frontend/src/components/TopBar.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/TopBar.tsx`: +```tsx +import { useState } from 'react'; +import { Search } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; + +export function TopBar() { + const [q, setQ] = useState(''); + const navigate = useNavigate(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (q.trim()) void navigate({ to: '/search', search: { q: q.trim() } as any }); + }; + + return ( + <header className="h-14 bg-elevated border-b border-line flex items-center px-4 gap-4 shrink-0"> + <div className="w-56 shrink-0" /> {/* aligns with NavRail width */} + <form onSubmit={handleSubmit} className="flex-1 max-w-xl"> + <div className="relative"> + <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" /> + <input + type="search" + value={q} + onChange={(e) => setQ(e.target.value)} + placeholder="Search music… (Enter)" + className="w-full bg-surface border border-line rounded-lg pl-9 pr-4 py-1.5 text-sm text-primary placeholder:text-muted outline-none focus:border-accent transition-colors" + /> + </div> + </form> + </header> + ); +} +``` + +**Acceptance criteria:** Submitting the form navigates to `/search` with a `q` param; input styled with surface/border tokens. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 8 — AppShell + router update + +**Goal:** Replace `Layout` with `AppShell` (3-pane grid), wire `NowPlayingPanel` (starts collapsed), `PlaybackBar`, `NavRail`, `TopBar`, and `AudioEngine`. Update `router.tsx` to use `AppShell`. + +**Files:** `frontend/src/components/AppShell.tsx` (create), `frontend/src/router.tsx` (modify) + +**Steps:** + +1. Create `frontend/src/components/AppShell.tsx`: +```tsx +import { useState } from 'react'; +import { Outlet } from '@tanstack/react-router'; +import { AudioEngine } from './AudioEngine'; +import { NavRail } from './NavRail'; +import { TopBar } from './TopBar'; +import { PlaybackBar } from './PlaybackBar'; +import { NowPlayingPanel } from './NowPlayingPanel'; + +export default function AppShell() { + const [panelOpen, setPanelOpen] = useState(false); + + return ( + <div className="flex flex-col h-screen bg-background text-primary overflow-hidden"> + <TopBar /> + <div className="flex flex-1 overflow-hidden"> + <NavRail /> + <main className="flex-1 overflow-y-auto p-6"> + <Outlet /> + </main> + {panelOpen && <NowPlayingPanel onClose={() => setPanelOpen(false)} />} + </div> + <PlaybackBar panelOpen={panelOpen} onTogglePanel={() => setPanelOpen((o) => !o)} /> + <AudioEngine /> + </div> + ); +} +``` + +2. In `frontend/src/router.tsx`, replace `import Layout` and its usage: +```tsx +// replace: +import Layout from './components/Layout'; +// with: +import AppShell from './components/AppShell'; + +// replace in rootRoute component: +// <Layout><Outlet /></Layout> +// with: +// <AppShell /> +// (AppShell renders <Outlet /> itself) +``` + +Full updated rootRoute component: +```tsx +export const rootRoute = createRootRoute({ + component: AppShell, +}); +``` + +**Acceptance criteria:** App renders the 3-pane layout; panel hidden by default; clicking the queue icon in PlaybackBar opens/closes the panel. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 9 — MediaCard + ShelfRow + +**Goal:** `<MediaCard>` is a square artwork card with hover-play overlay used in grids and carousels. `<ShelfRow>` is a horizontal scroll container with a title + optional "View all" link. + +**Files:** `frontend/src/components/MediaCard.tsx` (create), `frontend/src/components/ShelfRow.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/MediaCard.tsx`: +```tsx +import { Play } from 'lucide-react'; +import { Artwork } from './Artwork'; + +interface MediaCardProps { + seed: string; + title: string; + subtitle?: string; + artSrc?: string | null; + onClick?: () => void; + href?: string; +} + +export function MediaCard({ seed, title, subtitle, artSrc, onClick }: MediaCardProps) { + return ( + <button + onClick={onClick} + className="group flex flex-col gap-2 text-left w-full bg-surface hover:bg-surface-h border border-line rounded-xl p-3 transition-colors" + > + <div className="relative aspect-square rounded-lg overflow-hidden w-full"> + <Artwork seed={seed} src={artSrc} className="w-full h-full" rounded="lg" /> + <div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity"> + <div className="w-10 h-10 rounded-full bg-accent flex items-center justify-center shadow-lg"> + <Play size={18} fill="white" className="text-on-accent ml-0.5" /> + </div> + </div> + </div> + <div className="min-w-0"> + <div className="truncate text-sm font-semibold text-primary">{title}</div> + {subtitle && <div className="truncate text-xs text-muted mt-0.5">{subtitle}</div>} + </div> + </button> + ); +} +``` + +2. Create `frontend/src/components/ShelfRow.tsx`: +```tsx +import { Link } from '@tanstack/react-router'; +import { ChevronRight } from 'lucide-react'; + +interface ShelfRowProps { + title: string; + viewAllTo?: string; + children: React.ReactNode; +} + +export function ShelfRow({ title, viewAllTo, children }: ShelfRowProps) { + return ( + <section className="space-y-3"> + <div className="flex items-center justify-between"> + <h2 className="text-lg font-bold text-primary">{title}</h2> + {viewAllTo && ( + <Link to={viewAllTo} className="flex items-center gap-0.5 text-xs text-muted hover:text-accent transition-colors"> + View all <ChevronRight size={14} /> + </Link> + )} + </div> + <div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide"> + {children} + </div> + </section> + ); +} +``` + +**Acceptance criteria:** MediaCard shows gradient artwork with play overlay on hover; ShelfRow scrolls horizontally and shows "View all" link when `viewAllTo` given. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 10 — Rework Home.tsx + +**Goal:** Replace the current two-section Home with Quick Access cards row + three shelf rows (Continue Listening, Recently Added, Most Played). + +**Files:** `frontend/src/pages/Home.tsx` (modify) + +**Steps:** + +1. Replace `frontend/src/pages/Home.tsx` entirely: +```tsx +import { useQuery } from '@tanstack/react-query'; +import { Clock, Heart, Star, PlusCircle } from 'lucide-react'; +import { ShelfRow } from '../components/ShelfRow'; +import { MediaCard } from '../components/MediaCard'; +import { historyService } from '../services/historyService'; +import { trackService } from '../services/trackService'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import type { HistoryEntry, Track } from '../types'; + +interface QuickCard { + label: string; + icon: React.ReactNode; + to: string; + gradient: string; +} + +const QUICK: QuickCard[] = [ + { label: 'Favorites', icon: <Heart size={20} />, to: '/tracks', gradient: 'from-pink-900/80 to-rose-950/80' }, + { label: 'Recently Added', icon: <PlusCircle size={20} />, to: '/tracks', gradient: 'from-blue-900/80 to-indigo-950/80' }, + { label: 'Most Played', icon: <Star size={20} />, to: '/tracks', gradient: 'from-amber-900/80 to-orange-950/80' }, + { label: 'History', icon: <Clock size={20} />, to: '/tracks', gradient: 'from-emerald-900/80 to-teal-950/80' }, +]; + +export default function Home() { + const { setQueue, playTrack } = usePlaybackStore(); + + const history = useQuery<HistoryEntry[]>({ + queryKey: ['history'], + queryFn: () => historyService.list(), + }); + + const recentlyAdded = useQuery<Track[]>({ + queryKey: ['recently-added'], + queryFn: () => trackService.listTracks({ limit: 20 }), + select: (tracks) => [...tracks].sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0)).slice(0, 12), + }); + + const mostPlayed = useQuery<Track[]>({ + queryKey: ['most-played'], + queryFn: () => trackService.listTracks({ limit: 12, sort_by: 'play_count', order: 'DESC' }), + }); + + const playFrom = (list: Track[], index: number) => { + setQueue(list.slice(index)); + playTrack(list[index]); + }; + + const historyTracks: Track[] = (history.data ?? []).slice(0, 12); + + return ( + <div className="space-y-8 max-w-5xl"> + <div> + <h1 className="text-3xl font-bold text-primary">Good listening</h1> + <p className="text-muted mt-1">Your music, your way.</p> + </div> + + {/* Quick access */} + <section> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> + {QUICK.map((card) => ( + <div + key={card.label} + className={`flex items-center gap-3 rounded-lg bg-gradient-to-br ${card.gradient} border border-line/40 px-4 py-3 cursor-pointer hover:opacity-90 transition-opacity`} + > + <span className="text-primary/70">{card.icon}</span> + <span className="text-sm font-semibold text-primary">{card.label}</span> + </div> + ))} + </div> + </section> + + <ShelfRow title="Continue Listening" viewAllTo="/tracks"> + {history.isLoading ? ( + <p className="text-sm text-muted py-4">Loading…</p> + ) : historyTracks.length === 0 ? ( + <p className="text-sm text-muted py-4">Nothing played yet.</p> + ) : ( + historyTracks.map((track, i) => ( + <div key={`${track.id}-${i}`} className="w-36 shrink-0"> + <MediaCard + seed={`${track.title} ${track.artist}`} + title={track.title} + subtitle={track.artist} + onClick={() => playFrom(historyTracks, i)} + /> + </div> + )) + )} + </ShelfRow> + + <ShelfRow title="Recently Added" viewAllTo="/tracks"> + {recentlyAdded.isLoading ? ( + <p className="text-sm text-muted py-4">Loading…</p> + ) : (recentlyAdded.data ?? []).length === 0 ? ( + <p className="text-sm text-muted py-4">No tracks yet.</p> + ) : ( + (recentlyAdded.data ?? []).map((track, i) => ( + <div key={track.id} className="w-36 shrink-0"> + <MediaCard + seed={`${track.title} ${track.artist}`} + title={track.title} + subtitle={track.artist} + onClick={() => playFrom(recentlyAdded.data!, i)} + /> + </div> + )) + )} + </ShelfRow> + + <ShelfRow title="Most Played" viewAllTo="/tracks"> + {mostPlayed.isLoading ? ( + <p className="text-sm text-muted py-4">Loading…</p> + ) : (mostPlayed.data ?? []).length === 0 ? ( + <p className="text-sm text-muted py-4">No tracks yet.</p> + ) : ( + (mostPlayed.data ?? []).map((track, i) => ( + <div key={track.id} className="w-36 shrink-0"> + <MediaCard + seed={`${track.title} ${track.artist}`} + title={track.title} + subtitle={`${track.play_count} plays`} + onClick={() => playFrom(mostPlayed.data!, i)} + /> + </div> + )) + )} + </ShelfRow> + </div> + ); +} +``` + +**Acceptance criteria:** Page shows 4 quick-access gradient cards + 3 horizontal shelves; clicking a media card plays from that position. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 11 — Restyle library pages (Tracks, Artists, ArtistDetail, Albums, AlbumDetail, Genres) + +**Goal:** Replace all hard-coded `zinc-*` classes with semantic tokens; replace `LibraryTrackRow` with `TrackRow`; use `MediaCard` / `Artwork` in grid views. + +**Files:** `Tracks.tsx`, `Artists.tsx`, `ArtistDetail.tsx`, `Albums.tsx`, `AlbumDetail.tsx`, `Genres.tsx` (all modify) + +**Steps:** + +1. **`Tracks.tsx`** — swap `LibraryTrackRow` for `TrackRow`; restyle pagination buttons: +```tsx +import { useState } from 'react'; +import { useQuery, keepPreviousData } from '@tanstack/react-query'; +import { Music, ChevronLeft, ChevronRight } from 'lucide-react'; +import { trackService } from '../services/trackService'; +import { TrackRow } from '../components/TrackRow'; +import type { Track } from '../types'; + +const PAGE_SIZE = 50; + +export default function Tracks() { + const [page, setPage] = useState(0); + const { data, isLoading, isError, isPlaceholderData } = useQuery<Track[]>({ + queryKey: ['tracks', page], + queryFn: () => trackService.listTracks({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, sort_by: 'title', order: 'ASC' }), + placeholderData: keepPreviousData, + }); + const tracks = data ?? []; + const hasNext = tracks.length === PAGE_SIZE; + + return ( + <div className="space-y-6 max-w-3xl"> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"> + <Music size={28} className="text-accent" /> Songs + </h1> + {isLoading ? <p className="text-sm text-muted">Loading…</p> + : isError ? <p className="text-sm text-muted">Couldn't load tracks.</p> + : tracks.length === 0 ? <p className="text-sm text-muted">{page === 0 ? 'No tracks yet.' : 'No more tracks.'}</p> + : <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>} + <div className="flex items-center justify-between pt-2"> + <button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0 || isPlaceholderData} + className="inline-flex items-center gap-1 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-40"> + <ChevronLeft size={16} /> Prev + </button> + <span className="text-sm text-muted">Page {page + 1}</span> + <button onClick={() => setPage((p) => p + 1)} disabled={!hasNext || isPlaceholderData} + className="inline-flex items-center gap-1 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-40"> + Next <ChevronRight size={16} /> + </button> + </div> + </div> + ); +} +``` + +2. **`Artists.tsx`** — replace `zinc-*` with tokens; use `Artwork` for avatar: +```tsx +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { Users } from 'lucide-react'; +import { artistService } from '../services/artistService'; +import { Artwork } from '../components/Artwork'; +import type { Artist } from '../types'; + +export default function Artists() { + const { data, isLoading, isError } = useQuery<Artist[]>({ + queryKey: ['artists'], + queryFn: () => artistService.listArtists(), + }); + + return ( + <div className="space-y-6"> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"> + <Users size={28} className="text-accent" /> Artists + </h1> + {isLoading ? <p className="text-sm text-muted">Loading…</p> + : isError ? <p className="text-sm text-muted">Couldn't load artists.</p> + : !data?.length ? <p className="text-sm text-muted">No artists yet.</p> + : ( + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"> + {data.map((artist) => ( + <Link key={artist.id} to="/artists/$artistId" params={{ artistId: artist.id }} + className="group flex flex-col items-center gap-3 rounded-xl border border-line bg-surface p-4 hover:bg-surface-h transition-colors"> + <div className="w-24 h-24 rounded-full overflow-hidden"> + <Artwork seed={artist.name} src={artist.image_path} className="w-full h-full" rounded="full" /> + </div> + <div className="text-sm font-medium text-primary truncate w-full text-center">{artist.name}</div> + </Link> + ))} + </div> + )} + </div> + ); +} +``` + +3. **`ArtistDetail.tsx`** — tokens; use `Artwork` for artist and album cards: +```tsx +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { ArrowLeft } from 'lucide-react'; +import { artistDetailRoute } from '../router'; +import { artistService } from '../services/artistService'; +import { Artwork } from '../components/Artwork'; +import type { ArtistWithAlbums } from '../types'; + +export default function ArtistDetail() { + const { artistId } = artistDetailRoute.useParams(); + const { data, isLoading, isError } = useQuery<ArtistWithAlbums>({ + queryKey: ['artist', artistId], + queryFn: () => artistService.getArtist(artistId), + }); + if (isLoading) return <p className="text-sm text-muted">Loading…</p>; + if (isError || !data) return <p className="text-sm text-muted">Couldn't load artist.</p>; + const albums = data.albums ?? []; + return ( + <div className="space-y-8 max-w-4xl"> + <Link to="/artists" className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary"> + <ArrowLeft size={16} /> Artists + </Link> + <div className="flex items-center gap-5"> + <div className="w-28 h-28 flex-none rounded-full overflow-hidden"> + <Artwork seed={data.name} src={data.image_path} className="w-full h-full" rounded="full" /> + </div> + <div> + <h1 className="text-4xl font-bold text-primary">{data.name}</h1> + <p className="text-sm text-muted mt-1">{albums.length} {albums.length === 1 ? 'album' : 'albums'}</p> + </div> + </div> + <section className="space-y-4"> + <h2 className="text-xl font-semibold text-primary">Albums</h2> + {albums.length === 0 ? <p className="text-sm text-muted">No albums.</p> : ( + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"> + {albums.map((album) => ( + <Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }} + className="group flex flex-col gap-2 rounded-xl border border-line bg-surface p-3 hover:bg-surface-h transition-colors"> + <div className="aspect-square rounded-lg overflow-hidden"> + <Artwork seed={`${album.title} ${data.name}`} className="w-full h-full" rounded="lg" /> + </div> + <div> + <div className="truncate text-sm font-medium text-primary">{album.title}</div> + {album.year && <div className="text-xs text-muted">{album.year}</div>} + </div> + </Link> + ))} + </div> + )} + </section> + </div> + ); +} +``` + +4. **`Albums.tsx`** — tokens + `Artwork`: +```tsx +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { Disc3 } from 'lucide-react'; +import { albumService } from '../services/albumService'; +import { Artwork } from '../components/Artwork'; +import type { Album } from '../types'; + +export default function Albums() { + const { data, isLoading, isError } = useQuery<Album[]>({ + queryKey: ['albums'], + queryFn: () => albumService.listAlbums(), + }); + return ( + <div className="space-y-6"> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"> + <Disc3 size={28} className="text-accent" /> Albums + </h1> + {isLoading ? <p className="text-sm text-muted">Loading…</p> + : isError ? <p className="text-sm text-muted">Couldn't load albums.</p> + : !data?.length ? <p className="text-sm text-muted">No albums yet.</p> + : ( + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"> + {data.map((album) => ( + <Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }} + className="group flex flex-col gap-2 rounded-xl border border-line bg-surface p-3 hover:bg-surface-h transition-colors"> + <div className="aspect-square rounded-lg overflow-hidden"> + <Artwork seed={album.title} className="w-full h-full" rounded="lg" /> + </div> + <div> + <div className="truncate text-sm font-medium text-primary">{album.title}</div> + {album.year && <div className="text-xs text-muted">{album.year}</div>} + </div> + </Link> + ))} + </div> + )} + </div> + ); +} +``` + +5. **`AlbumDetail.tsx`** — tokens + `Artwork` + `TrackRow`: +```tsx +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { Play, ArrowLeft } from 'lucide-react'; +import { albumDetailRoute } from '../router'; +import { albumService } from '../services/albumService'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { Artwork } from '../components/Artwork'; +import { TrackRow } from '../components/TrackRow'; +import type { AlbumWithTracks } from '../types'; + +export default function AlbumDetail() { + const { albumId } = albumDetailRoute.useParams(); + const { setQueue, playTrack } = usePlaybackStore(); + const { data, isLoading, isError } = useQuery<AlbumWithTracks>({ + queryKey: ['album', albumId], + queryFn: () => albumService.getAlbum(albumId), + }); + if (isLoading) return <p className="text-sm text-muted">Loading…</p>; + if (isError || !data) return <p className="text-sm text-muted">Couldn't load album.</p>; + const tracks = data.tracks ?? []; + return ( + <div className="space-y-8 max-w-3xl"> + <Link to="/albums" className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary"> + <ArrowLeft size={16} /> Albums + </Link> + <div className="flex items-end gap-5"> + <div className="w-40 h-40 flex-none rounded-xl overflow-hidden"> + <Artwork seed={data.title} className="w-full h-full" rounded="xl" /> + </div> + <div className="space-y-2"> + <h1 className="text-4xl font-bold text-primary">{data.title}</h1> + <p className="text-sm text-muted">{data.year ? `${data.year} · ` : ''}{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}</p> + <button onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }} + disabled={!tracks.length} + className="inline-flex items-center gap-2 rounded-full bg-accent hover:bg-accent-h px-5 py-2 text-sm font-semibold text-on-accent disabled:opacity-50 transition-colors"> + <Play size={16} fill="currentColor" /> Play album + </button> + </div> + </div> + <section className="space-y-1"> + {tracks.length === 0 ? <p className="text-sm text-muted">No tracks.</p> + : tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} trackNumber={i + 1} />)} + </section> + </div> + ); +} +``` + +6. **`Genres.tsx`** — tokens; genre cards use gradient derived from genre name: +```tsx +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Tag, Play, ArrowLeft } from 'lucide-react'; +import { genreService } from '../services/genreService'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { TrackRow } from '../components/TrackRow'; +import type { Genre, Track } from '../types'; + +function hueFrom(s: string) { + let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return Math.abs(h) % 360; +} + +export default function Genres() { + const [selected, setSelected] = useState<Genre | null>(null); + const { setQueue, playTrack } = usePlaybackStore(); + + const genresQ = useQuery<Genre[]>({ queryKey: ['genres'], queryFn: () => genreService.listGenres() }); + const tracksQ = useQuery<Track[]>({ + queryKey: ['genre-tracks', selected?.id], + queryFn: () => genreService.getGenreTracks(selected!.id), + enabled: !!selected, + }); + + if (selected) { + const tracks = tracksQ.data ?? []; + return ( + <div className="space-y-6 max-w-3xl"> + <button onClick={() => setSelected(null)} className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary"> + <ArrowLeft size={16} /> Genres + </button> + <div className="flex items-center justify-between"> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"><Tag size={28} className="text-accent" />{selected.name}</h1> + <button onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }} disabled={!tracks.length} + className="inline-flex items-center gap-2 rounded-full bg-accent hover:bg-accent-h px-4 py-2 text-sm font-semibold text-on-accent disabled:opacity-50"> + <Play size={16} fill="currentColor" /> Play all + </button> + </div> + {tracksQ.isLoading ? <p className="text-sm text-muted">Loading…</p> + : tracks.length === 0 ? <p className="text-sm text-muted">No tracks.</p> + : <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>} + </div> + ); + } + + return ( + <div className="space-y-6"> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"><Tag size={28} className="text-accent" />Genres</h1> + {genresQ.isLoading ? <p className="text-sm text-muted">Loading…</p> + : genresQ.isError ? <p className="text-sm text-muted">Couldn't load genres.</p> + : !genresQ.data?.length ? <p className="text-sm text-muted">No genres yet.</p> + : ( + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4"> + {genresQ.data.map((genre) => { + const hue = hueFrom(genre.name); + return ( + <button key={genre.id} onClick={() => setSelected(genre)} + className="group flex flex-col items-start gap-2 rounded-xl border border-line p-4 text-left transition-colors hover:border-accent/40" + style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}> + <Tag size={20} className="text-muted group-hover:text-accent" /> + <div className="w-full truncate font-medium text-primary">{genre.name}</div> + <div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div> + </button> + ); + })} + </div> + )} + </div> + ); +} +``` + +**Acceptance criteria:** All 6 pages compile; no `zinc-*` or `gray-*` hard-coded color references remain; `LibraryTrackRow` no longer imported. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 12 — Restyle Discover, Vibe, Search + +**Goal:** Apply semantic token classes; use `TrackRow` in Search; keep all logic intact. + +**Files:** `Discover.tsx`, `Vibe.tsx`, `Search.tsx` (all modify) + +**Steps:** + +1. **`Discover.tsx`** — swap `zinc-*` for tokens; use `TrackRow` for the track list: + - Replace `border-zinc-800 bg-zinc-900/50 hover:border-zinc-700 hover:bg-zinc-800/70` → `border-line bg-surface hover:bg-surface-h` + - Replace `text-zinc-400` → `text-muted`, `text-zinc-200` → `text-primary`, `text-white` → `text-primary` + - The genre card active state `border-blue-500/70 bg-blue-500/10` → `border-accent/60 bg-accent/10` + - Replace the inline `<button>` track list rows with `<TrackRow>` (pass `showActions={false}`) + - The "Start a vibe" button: `border-blue-500/60 bg-blue-500/10 text-blue-300 hover:bg-blue-500/20` → `border-accent/60 bg-accent/10 text-accent hover:bg-accent/20` + - Keep all logic, hooks, imports unchanged except adding `TrackRow` import and removing the inline button track row + +2. **`Vibe.tsx`** — same token swap; keep all logic: + - All `bg-zinc-900/50 border-zinc-800` → `bg-surface border-line` + - `hover:bg-zinc-800/70` → `hover:bg-surface-h` + - `text-zinc-400/500` → `text-muted` + - `text-zinc-200/300` → `text-primary` + - The seed-picker list buttons swap to `<TrackRow showActions={false}>` for the seed picker list + - The session buttons ("Keep", "Dislike & skip", "End Vibe"): token border/text classes + - The `bg-blue-500/10 border-blue-500/60 text-blue-300/400` accents → `bg-accent/10 border-accent/60 text-accent` + - `VibeTimeline` is used as-is (it will be restyled in its own file in a follow-up, but for now leave it) + +3. **`Search.tsx`** — swap `LibraryTrackRow` for `TrackRow`; token classes on the input: +```tsx +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Search as SearchIcon } from 'lucide-react'; +import { searchService } from '../services/searchService'; +import { TrackRow } from '../components/TrackRow'; +import type { SearchResponse, Track } from '../types'; + +export default function Search() { + const [input, setInput] = useState(''); + const [query, setQuery] = useState(''); + useEffect(() => { const id = setTimeout(() => setQuery(input.trim()), 300); return () => clearTimeout(id); }, [input]); + const { data, isLoading, isError, isFetching } = useQuery<SearchResponse>({ + queryKey: ['search', query], + queryFn: () => searchService.search(query), + enabled: query.length > 0, + }); + const tracks: Track[] = (data?.hits ?? []).map((h) => h.document).filter((t): t is Track => Boolean(t)); + return ( + <div className="space-y-6 max-w-3xl"> + <h1 className="text-3xl font-bold text-primary">Search</h1> + <div className="relative max-w-xl"> + <SearchIcon size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" /> + <input type="search" value={input} onChange={(e) => setInput(e.target.value)} + placeholder="Search tracks, artists…" autoFocus + className="w-full rounded-lg border border-line bg-surface/70 py-2.5 pl-10 pr-4 text-sm text-primary placeholder:text-muted outline-none focus:border-accent transition-colors" /> + </div> + {query.length === 0 ? <p className="text-sm text-muted">Type to search.</p> + : isLoading || isFetching ? <p className="text-sm text-muted">Searching…</p> + : isError ? <p className="text-sm text-muted">Search failed.</p> + : tracks.length === 0 ? <p className="text-sm text-muted">No results for "{query}".</p> + : <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>} + </div> + ); +} +``` + +**Acceptance criteria:** No `LibraryTrackRow` import in any of the three files; no hard-coded `zinc-*`/`gray-*` colors. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 13 — Real Quarantine page + quarantineService + +**Goal:** Add `DislikeEntry` to `types.ts`; create `quarantineService.ts`; rewrite `Quarantine.tsx` with a real list, countdowns, Restore, and hard-Delete actions. + +**Files:** `frontend/src/types.ts` (modify), `frontend/src/services/quarantineService.ts` (create), `frontend/src/pages/Quarantine.tsx` (rewrite) + +**Steps:** + +1. Add to `frontend/src/types.ts`: +```ts +export interface DislikeEntry { + track_id: string; + disliked_at: string; + warned_at: string | null; + deleted_at: string | null; + grace_hours: number; + state: 'HIDDEN' | 'WARNED' | 'DELETED' | string; + track_title: string; + track_artist: string; + track_path: string; +} +``` + +2. Create `frontend/src/services/quarantineService.ts`: +```ts +import api from './api'; +import type { DislikeEntry } from '../types'; + +export const quarantineService = { + async list(): Promise<DislikeEntry[]> { + const res = await api.get<DislikeEntry[]>('/dislikes'); + return res.data; + }, + + async restore(trackId: string): Promise<void> { + await api.post(`/dislikes/${trackId}/restore`); + }, + + async hardDelete(trackId: string): Promise<void> { + await api.delete(`/dislikes/${trackId}`); + }, +}; +``` + +3. Rewrite `frontend/src/pages/Quarantine.tsx`: +```tsx +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ShieldAlert, RotateCcw, Trash2, Clock } from 'lucide-react'; +import { quarantineService } from '../services/quarantineService'; +import type { DislikeEntry } from '../types'; + +function countdown(entry: DislikeEntry): string { + const base = entry.warned_at + ? new Date(entry.warned_at).getTime() + 24 * 3600 * 1000 + : new Date(entry.disliked_at).getTime() + entry.grace_hours * 3600 * 1000; + const ms = base - Date.now(); + if (ms <= 0) return 'Deleting soon'; + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + return h > 0 ? `${h}h ${m}m remaining` : `${m}m remaining`; +} + +function stateLabel(state: string) { + if (state === 'WARNED') return <span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 font-medium">Warning sent</span>; + return <span className="text-xs px-2 py-0.5 rounded-full bg-surface-h text-muted font-medium">Grace period</span>; +} + +export default function Quarantine() { + const qc = useQueryClient(); + const { data, isLoading, isError } = useQuery<DislikeEntry[]>({ + queryKey: ['dislikes'], + queryFn: () => quarantineService.list(), + refetchInterval: 60_000, + }); + + const restore = useMutation({ + mutationFn: (trackId: string) => quarantineService.restore(trackId), + onSuccess: () => qc.invalidateQueries({ queryKey: ['dislikes'] }), + }); + + const hardDelete = useMutation({ + mutationFn: (trackId: string) => quarantineService.hardDelete(trackId), + onSuccess: () => qc.invalidateQueries({ queryKey: ['dislikes'] }), + }); + + const entries = data ?? []; + + return ( + <div className="space-y-6 max-w-3xl"> + <div> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"> + <ShieldAlert size={28} className="text-accent" /> Quarantine + </h1> + <p className="text-muted mt-1">Disliked tracks pending deletion. Restore before the timer expires.</p> + </div> + + {isLoading ? <p className="text-sm text-muted">Loading…</p> + : isError ? <p className="text-sm text-muted">Couldn't load quarantine list.</p> + : entries.length === 0 ? ( + <div className="flex flex-col items-center gap-3 rounded-xl border border-line border-dashed py-16 text-center"> + <ShieldAlert size={32} className="text-muted/40" /> + <p className="text-muted">No tracks in quarantine.</p> + <p className="text-sm text-muted/60">Disliked tracks will appear here during the grace period.</p> + </div> + ) : ( + <ul className="space-y-2"> + {entries.map((entry) => ( + <li key={entry.track_id} className="flex items-center gap-3 rounded-lg border border-line bg-surface p-3"> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="text-sm font-semibold text-primary truncate">{entry.track_title}</span> + {stateLabel(entry.state)} + </div> + <div className="text-xs text-muted">{entry.track_artist}</div> + <div className="flex items-center gap-1 mt-1 text-xs text-muted"> + <Clock size={12} /> {countdown(entry)} + </div> + </div> + <div className="flex items-center gap-2 shrink-0"> + <button + onClick={() => restore.mutate(entry.track_id)} + disabled={restore.isPending} + title="Restore to library" + className="flex items-center gap-1.5 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-50 transition-colors" + > + <RotateCcw size={14} /> Restore + </button> + <button + onClick={() => { if (confirm(`Permanently delete "${entry.track_title}"?`)) hardDelete.mutate(entry.track_id); }} + disabled={hardDelete.isPending} + title="Delete now" + className="flex items-center gap-1.5 rounded-lg border border-red-500/40 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition-colors" + > + <Trash2 size={14} /> Delete + </button> + </div> + </li> + ))} + </ul> + )} + </div> + ); +} +``` + +**Acceptance criteria:** Page lists disliked tracks from the real backend; Restore clears the row and returns track to library; Delete prompts confirmation; countdown shows time remaining. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 14 — Settings restyle + final typecheck + +**Goal:** Restyle Settings with semantic tokens; remove hard-coded `gray-800`/`gray-700` classes; keep theme + volume logic intact. Then run a final typecheck. + +**Files:** `frontend/src/pages/Settings.tsx` (modify) + +**Steps:** + +1. Rewrite `frontend/src/pages/Settings.tsx` (logic unchanged, colors replaced): +```tsx +import { useEffect, useState } from 'react'; +import { Palette, Volume2, Info, Check } from 'lucide-react'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import api from '../services/api'; +import { THEMES, DEFAULT_THEME_ID, STORAGE_KEYS, applyTheme, readStoredThemeId, readStoredVolume, type ThemePreset } from '../lib/theme'; + +export default function Settings() { + const volume = usePlaybackStore((s) => s.volume); + const setVolume = usePlaybackStore((s) => s.setVolume); + const [themeId, setThemeId] = useState<string>(DEFAULT_THEME_ID); + + useEffect(() => { + const id = readStoredThemeId(); + setThemeId(id); + const t = THEMES.find((x) => x.id === id); + if (t) applyTheme(t); + setVolume(readStoredVolume(volume)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selectTheme = (theme: ThemePreset) => { + setThemeId(theme.id); + applyTheme(theme); + try { localStorage.setItem(STORAGE_KEYS.theme, theme.id); } catch { /**/ } + }; + + const handleVolume = (v: number) => { + setVolume(v); + try { localStorage.setItem(STORAGE_KEYS.volume, String(v)); } catch { /**/ } + }; + + return ( + <div className="space-y-8 max-w-2xl"> + <div> + <h1 className="text-3xl font-bold text-primary">Settings</h1> + <p className="text-muted mt-1">Preferences are stored locally in this browser.</p> + </div> + + <section className="rounded-xl border border-line bg-surface p-5 space-y-4"> + <h2 className="text-lg font-semibold text-primary flex items-center gap-2"><Palette size={20} className="text-accent" />Theme</h2> + <div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> + {THEMES.map((theme) => { + const active = theme.id === themeId; + return ( + <button key={theme.id} onClick={() => selectTheme(theme)} + className={`relative flex flex-col items-start gap-2 rounded-lg border p-3 text-left transition-colors ${active ? 'border-accent ring-2 ring-accent/30' : 'border-line hover:border-accent/40'}`}> + <span className="h-10 w-full rounded-md border border-black/20" style={{ backgroundColor: theme.swatch }} /> + <span className="text-sm font-medium text-primary">{theme.name}</span> + {active && <Check size={14} className="absolute right-2 top-2 text-accent" />} + </button> + ); + })} + </div> + </section> + + <section className="rounded-xl border border-line bg-surface p-5 space-y-4"> + <h2 className="text-lg font-semibold text-primary flex items-center gap-2"><Volume2 size={20} className="text-accent" />Default volume</h2> + <div className="flex items-center gap-4"> + <input type="range" min={0} max={1} step={0.01} value={volume} + onChange={(e) => handleVolume(Number(e.target.value))} + className="flex-1 accent-[var(--accent)]" aria-label="Volume" /> + <span className="w-12 text-right text-sm tabular-nums text-primary">{Math.round(volume * 100)}%</span> + </div> + </section> + + <section className="rounded-xl border border-line bg-surface p-5 space-y-3"> + <h2 className="text-lg font-semibold text-primary flex items-center gap-2"><Info size={20} className="text-accent" />About</h2> + <dl className="space-y-2 text-sm"> + <div className="flex justify-between"><dt className="text-muted">Application</dt><dd className="text-primary font-medium">muzick</dd></div> + <div className="flex justify-between"><dt className="text-muted">Version</dt><dd className="text-primary font-medium tabular-nums">0.1.0</dd></div> + <div className="flex justify-between gap-4"><dt className="text-muted">API base</dt><dd className="font-mono text-xs text-primary break-all">{api.defaults.baseURL ?? '/api'}</dd></div> + </dl> + </section> + </div> + ); +} +``` + +2. Also update `VibeTimeline.tsx` to use tokens (it's used by Vibe): +```tsx +// Replace zinc-* with token classes throughout VibeTimeline.tsx: +// bg-zinc-900/50 border-zinc-800 → bg-surface border-line +// hover:border-zinc-700 hover:bg-zinc-800/70 → hover:bg-surface-h +// text-zinc-200 → text-primary +// text-zinc-400/500 → text-muted +// text-zinc-600 → text-muted/60 +// bg-blue-500/20 text-blue-300 → bg-accent/20 text-accent +// bg-blue-500 text-white → bg-accent text-on-accent +// bg-zinc-800 text-zinc-500 → bg-elevated text-muted +// The 'Now playing' span: bg-blue-500 → bg-accent +``` + +3. Delete `frontend/src/components/Layout.tsx`, `frontend/src/components/NowPlayingBar.tsx`, `frontend/src/pages/LibraryTrackRow.tsx`. + +4. Run final typecheck: +```bash +cd frontend && npm run typecheck +``` + +**Acceptance criteria:** Zero TypeScript errors. No remaining imports of `Layout`, `NowPlayingBar`, or `LibraryTrackRow`. + +**Verify:** +```bash +cd /mnt/server/home/kami/apps/muzick/frontend && npm run typecheck 2>&1 | tail -5 +# Expected: no output or "Found 0 errors." +grep -r "LibraryTrackRow\|NowPlayingBar\|from.*Layout" src/ | grep -v "\.md" +# Expected: no output +``` + +--- + +## Execution order + +Tasks are ordered by dependency: + +``` +1 (tokens) → 2 (Artwork) → 3 (TrackRow) → 4 (PlaybackBar) → 5 (NowPlayingPanel) + → 6 (NavRail) → 7 (TopBar) → 8 (AppShell+router) + → 9 (MediaCard+ShelfRow) → 10 (Home) + → 11 (library pages) → 12 (Discover/Vibe/Search) + → 13 (Quarantine) → 14 (Settings + typecheck) +``` + +Tasks 2–7 have no inter-dependencies and can be written in parallel; they all depend only on task 1. +Tasks 11–13 depend on tasks 1–3. + +--- + +## Execute now with `/implement`? diff --git a/docs/ui-rework.md b/docs/ui-rework.md new file mode 100644 index 0000000..60b8793 --- /dev/null +++ b/docs/ui-rework.md @@ -0,0 +1,139 @@ +# UI Rework Plan + +> Status: **planned, not started.** This document captures the target direction for a +> richer player UI (reference: the "LocalTunes" three-pane mockup) and what it implies for +> both the frontend and the backend. It is a plan to execute later, not a description of the +> current app. + +## 1. Vision + +Move from the current functional-but-plain single-content-column layout to a polished, +artwork-forward **three-pane music player** in the spirit of modern desktop players +(Spotify / Apple Music / the LocalTunes reference): + +- **Left:** persistent navigation rail (library sections + a personal/"your music" group). +- **Center:** scrollable content (Home, Library, Vibe, etc.) — artwork-rich cards, horizontal + carousels, hover-to-play. +- **Right:** persistent **Now Playing** panel — large artwork, track info, transport, and an + **Up Next / queue** list. +- **Bottom:** full-width global **playback bar** (shuffle / prev / play / next / repeat, + scrubber, volume, queue toggle) that's always visible regardless of route. +- **Top:** global search field + (future) user/account menu. + +Accent-driven, rounded, soft-gradient cards; dark by default but fully themeable via tokens. + +## 2. Layout structure + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ Top bar: [logo] [ global search ⌘K ] [bell] [avatar ▾] │ +├───────────────┬────────────────────────────────────────────┬───────────────┤ +│ Nav rail │ Content (router Outlet) │ Now Playing │ +│ - Home │ Good evening 👋 │ [ artwork ] │ +│ - Songs │ Quick Access cards │ Title/Artist │ +│ - Albums │ Recently Played (carousel, View all) │ scrubber │ +│ - Artists │ Made for you (mixes carousel) │ transport │ +│ - Genres │ ... │ Up Next list │ +│ - Playlists │ │ │ +│ - Folder │ │ (collapsible)│ +│ ────────── │ │ │ +│ Now Playing │ │ │ +│ Recently … │ │ │ +│ Most Played │ │ │ +│ Favorites │ │ │ +│ ────────── │ │ │ +│ Settings │ │ │ +│ Theme │ │ │ +│ About │ │ │ +├───────────────┴────────────────────────────────────────────┴───────────────┤ +│ Bottom bar: [art] Title/Artist ♥ ⇄ ◀ ▶▶ ⏯ ▶▶ ↻ 🔊────── queue ▤ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +The right Now-Playing panel and the bottom bar are partly redundant by design (desktop +players do this): the bottom bar is the always-on minimal transport; the right panel is the +expanded view with queue and large art, and is collapsible. + +## 3. Design tokens / theming + +This rework is the right moment to finish theming. Today only the shell consumes tokens +(`--bg`, `--surface`, `--text`, `--accent` from `src/lib/theme.ts`). Target: + +- **Expand the token set:** `--bg`, `--bg-elevated`, `--surface`, `--surface-hover`, + `--border`, `--text`, `--text-muted`, `--accent`, `--accent-hover`, `--on-accent`, + plus gradient stops for cards (`--card-grad-a/b`). +- **Drive Tailwind from the tokens:** extend `tailwind.config.js` `theme.colors` to reference + the CSS variables (e.g. `bg: 'var(--bg)'`, `surface: 'var(--surface)'`, `accent: + 'var(--accent)'`) so components use semantic classes (`bg-surface`, `text-muted`, + `bg-accent`) instead of hard-coded `bg-zinc-900` etc. This makes every component themeable + without per-component edits. +- Keep the existing presets (Dark / Midnight / Forest / Plum), add a light option, and keep + `initTheme()` applying the persisted choice before first paint. +- The reference's purple accent → add a "Default (Purple)" preset. + +## 4. Component inventory (new / reworked) + +| Component | Purpose | +| :--- | :--- | +| `AppShell` | 3-pane grid (rail / content / now-playing) + top bar + bottom bar. Replaces `Layout`. | +| `NavRail` | Sections + personal group + settings group; active state via `--accent`. | +| `TopBar` | Global search (debounced, ⌘K focus), account menu (stub until auth). | +| `NowPlayingPanel` | Right rail: large art, info, scrubber, transport, Up Next queue (reorder/remove). Collapsible. | +| `PlaybackBar` | Bottom global transport (always visible). Reworks `NowPlayingBar`. | +| `MediaCard` | Square artwork card with hover play overlay (used by carousels + grids). | +| `Carousel` / `ShelfRow` | Horizontal scroll row with title + "View all". | +| `QuickAccessCard` | Wide gradient card (Favorites / Recently Added / Most Played / Folder). | +| `TrackRow` | Reusable list row (replaces the per-page `LibraryTrackRow`) with art, actions, now-playing highlight. | +| `Artwork` | Resolves album/track artwork URL with a graceful gradient placeholder fallback. | + +State: keep Zustand `usePlaybackStore` (current/queue/isPlaying/position/volume) and +`useVibeStore`; add a small `useUiStore` for panel collapse + theme if useful. The Up Next +list is just the playback `queue`. + +## 5. Backend work this UI implies (gaps) + +The mockup assumes data we don't serve yet. Each is a discrete backend task: + +1. **Artwork serving** — `albums.artwork_id` / Cover-Art URLs are stored but never served. + Need `GET /api/albums/:id/artwork` (and/or per-track) that streams/redirects to the cached + cover, plus a placeholder when absent. Without this every card is a gradient placeholder. +2. **Playlists** — the rail shows "Playlists"; there are no playlist tables/endpoints. Needs + `playlists` + `playlist_track` schema and CRUD + reorder endpoints. (Net-new feature.) +3. **"Most Played"** — derivable now via `GET /api/tracks?sort_by=play_count&order=DESC`. + Wire a dedicated view/shelf. +4. **"Recently Added"** — needs reliable `mtime`/`created_at` sorting (currently sorted + client-side). Consider a `created_at` column + a sorted endpoint. +5. **"Made for you" mixes** — map to the Vibe engine: per-genre/seed mixes via + `/api/vibe/from-genre` and saved seeds. No new engine work, just presentation + maybe a + "mixes" endpoint that returns a handful of seed suggestions. +6. **Folder browse** — the rail shows "Folder"; there's no filesystem-browse endpoint. Needs + a sandboxed `GET /api/library/browse?path=` under `MUSIC_DIR` (reuse the stream route's + traversal guard). Optional / later. +7. **Typesense search** — the redesigned top-bar search wants fast fuzzy results; finish the + Typesense indexing pipeline (collection + reindex job + index-on-enrich) so search graduates + from the Postgres ILIKE fallback. (Already tracked in `progress.md`.) + +## 6. Suggested phasing + +1. **Tokenise theming** — extend tokens + wire Tailwind to CSS vars; migrate existing + components to semantic colour classes. (Unblocks real theming; low risk, high leverage.) +2. **AppShell + PlaybackBar + NowPlayingPanel** — the structural 3-pane shell with the + always-on transport and queue, reusing the current playback store/audio engine. +3. **MediaCard / Carousel / Artwork** + **artwork backend endpoint** — make the content + artwork-forward; redesign Home around Quick Access + shelves. +4. **Library/Discover/Vibe pages** restyled onto the new components. +5. **New features as desired:** Playlists, Folder browse, Most Played/Recently Added shelves, + Typesense search. + +## 7. Non-goals (for the first rework pass) + +- Auth / multi-user (still single-user). +- Mobile/responsive layout (target desktop first; the 3-pane collapses later). +- Real-time collaborative features. + +## 8. Open questions + +- Keep both the right Now-Playing panel **and** the bottom bar, or collapse to one? (Plan + assumes both, panel collapsible.) +- Artwork storage: serve via a backend proxy/cache, or store files locally and serve static? +- Playlists: is this in scope for the rework, or a separate feature track? diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3f29ae6 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.git +.env diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..54abf9e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-slim AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:stable-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9d49019 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Muzick + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f54f59e --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..867dec4 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3208 @@ +{ + "name": "muzick", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "muzick", + "dependencies": { + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-router": "^1.170.15", + "axios": "^1.17.0", + "date-fns": "^4.4.0", + "lucide-react": "^1.17.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3", + "vite": "^5.2.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.15.tgz", + "integrity": "sha512-GawYz7HEjj8rTUUDoT/SemDEVm63pZUO+2mOcXHY9Jl3EwMS5gFBnPu/2UvcrwRm1jN1k79fokc0d4aFmrLatg==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.13", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.13", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.13.tgz", + "integrity": "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isbot": { + "version": "5.1.41", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.41.tgz", + "integrity": "sha512-9WFV/Vhh0FEj6CQ7MoHweEL9/vLKPjeoD2I2htbAjX7kbW7VJs3OCpWOVyd+JraNTWVU6/DRx2MZy2KaUNXHcg==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..13363a4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "muzick", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-router": "^1.170.15", + "axios": "^1.17.0", + "date-fns": "^4.4.0", + "geist": "^1.7.2", + "lucide-react": "^1.17.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3", + "vite": "^5.2.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/fonts/Geist-Bold.woff2 b/frontend/public/fonts/Geist-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..870db68aed802ab1f13831f85a5fd6c1d6d146e9 GIT binary patch literal 46496 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 literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/Geist-Light.woff2 b/frontend/public/fonts/Geist-Light.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c049fcdbb071246df3c332573a8eccb45b379e5b GIT binary patch literal 45532 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 AW&i*H literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/Geist-Medium.woff2 b/frontend/public/fonts/Geist-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..3a62ba439e60db1f5e29f6b262012d834c19f061 GIT binary patch literal 46372 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 literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/Geist-Regular.woff2 b/frontend/public/fonts/Geist-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d98d41cc29e7bab16f76d024e265d014323e8edc GIT binary patch literal 45244 zcmV)5K*_&%Pew8T0RR910I<9O5&!@I0qkG^0I*L00nE(+00000000000000000000 z0000Qf?^wipAa0WDh6NxkpKvRBng}h5eN!|);NTgSOGQyBmAirLvqnb=Kg`qpbAYR>vt#%R*|{PV>;4 zm9TzT?FVK5|NsC0?@1~$X47H1ZVWhrD&hsN_+N#JnV3a_yQGK)X1Ok@VQ#IXI33DY z@8bJmtW#%@D6t%P98ICdbi#rj*a8)rB*mImYB6ljGMdT$e3lvpmub-K0yCO=a0K*sQWWu#wJHNrKw)fyW^;p5Gl^;1MZGi2}`nV?&OiaoX zQrygtud9~D?W(vw!lV#-Y+h4T{L;8j+^6dDS)Hg~^{g!NYg;$OEKLq>sFJE}6=j$Y zk7gz0@HYG>*rm}=Ygh0OiNuUVW*Po@$SFy!FsRexQcD&>zjNQ)B6lAtfse1E|7{50tpWN0s-L{>DXi_D}P4Gfa01Jy`UC4o$z zK8g!#XzYI~(#=IVai!++bv1FyFCis^t+97)kcW?(gUg#;EDlZ~?4=Leb}>Ga6tO&B z;14{wgukoIFoq>E^j+CQKs+`_Rf%ZP#)>22di8Ip2>0e!^{k}l3$sIvz7X&S1n~Ym zKes>U-1pup7%)bS9x`gQ_K2Q^LCD7pu)!$ummQd3il7>X8grQZt}H?4DK8YI@PF=OmlF{`0Ym+R$X%xa8U*V7cI4DfF2EyZvr z&Iza>AZy8dzylr+cs!<{2sltw)NriIY+hbn+E{i?>+YJ@ST{3ojk-0PH|o0IUH9v{ z0I>ho*hkFvW@hE?P)nknN2)}z1d)&-dZa~2B#-!?wsh?o7gfRvJmm*yH$Vo|QNnj; z(ho|$^%eCMWky}~B(q=U>MK*SyCd>R-7|dS8^Qvxz-2WTxaZe0lw)tM>qiDZ{?q`T zz~>B<($O<06&GPau*74?J%1MI%BqJn5|4nFq+`;d3;>(i z{tx0yg8MA_AhZQ@`^#w(l9++dz{kKNkineS@-hY^2#s`er1qiy$Dr+>hc()Q++h+G z)aK!u7Oa_*Gh%wWYC!6Bb@UJD0rStjmU9J4fzomT{9mnQli8{$0?C?X*hnZJ7j~cl z9|rEacV`Y^DGdGrh55V$QhR7qrS()m6L1n!fF+$NNZZ&>$P;V;^k0X-=Ged72RsB} z{-p`a;%-I~D~88Z{lWtXf)pj&lG9KAA?@Dja??w*O>tFuMcKr_bg(-@^3tg;)57S~ zJ`voMbn4mLBOW1BgqjsRj9Uh`m5*}|5tHa|+R;4>8+H=IPtBpp)it(=W1%*+tFR+F zzdARD5W(kTnk~Km0Rl^ow^&cpO@QD~eNvYhY|~Q1f>e+~P%Tx-wlmk?J)h?~$5x}mxaY(CtwhOlI5|s`qOyY= zc1GS1W1T~nVpB!Bo9$e>DYq!f?TaOmHZ2z;N-KGW{0 zsf7{T?rA=O4onba(wfFRHbG^6Sj|fcR(#39N-h;x>7@m$qL^S!t#%k{1dNynBbMf*fC$`kzXcavav6jJ z}vsYUmgr#MSu+fc7ULoR_Q=JyYk^dgj!lfRa_-iT5S+~O--#Bp=zoD zWo@fgoT|OraRC9=M@lU0%8P>-Amca30XKj9{#p#O;0`&=!40Hb_$bI9WLz3_?Ka4` z>dIXPxJyZy$)agW5;oeCPY(t#j4{kr4G0JTL5_mEfUf}>fIKAMQ{PM9+lr4MeP7Fd zt}ggE86Fn{(VjM8qCM9Q!GM4|ZwA9xeFp+q0>M_vHzG!Y6d5T<4_tbRRF((ZxILn) zA8q3~IM(KD24bVM@Ev6WHE7jfHvsGs5JJ zSLIu~(NkE}yn|Wo{HXdJ--|E)3Q6nTl>a1xRP^-UO3TdeqgUDLteoT~PkHOQ669~V z)dR)>T)e3ysl>n6U`{X)jigRWs?Q+(js7V?y_BOlr6Y~Iz{I))sjaJu8W5=eh5GNi2+kI z*1HMJ7Lo`|IS_z{ErR&t9r@o_DoQOTX@iYO;&Mm(sgnYNh^1VDGu>V4X)eEAyg)c@ zInp2~98E&&1y-AI?Kf{mg2OfrwT;r4L`C0 z`k8exvq0m=?3Djlu|U!Ai^oOEPIh?EF7?GtC3AI271U>!-BzYsT^A%Y5_%P2OjUI` zjz%p`%`L@DwQQ?WEn(sdwPONQUJXcfMRBZ9qctU@5V~T7mx68|t|23qs%sn*M&3|( zV5ZLylJLS{1U%|+^G+CM4lZ5PsJz@`7(^P%J;4}7$h>I+tgGe~R&9OTzVuHH4n;jx zD{5duXkKQx)^4G7lIF%caJjAwV1>|Y9EpM{(G4NHPVafUqk}X_@TW^b&TpsmiGoWQ z6QXNtevv(}mNs2V7d>BY;0-e}VhqP!y{Wi@SYQiUSWx$rAVLTOCumZUabiWM-r~b+8sGx~ z6+)+`VuXoO_WW{`7dF9op@jKU2%uSl1QZo!L(>6?#GKrT_o-JVlOl$bT7O0+$PqKC z39|BLsNz|<0vbvkA5k|T(#m*x-#oK|o}5US%+|D$`X8FryL}ws1kT)oN<@wa;mbAK z*G{$|nyyNU%3m+OfQK}HtOmxY^qWbHaF7CVmkWp?TL-sF>GX!R1+B;l-2mobk8FY} z5lDFigcd+KOb|(c_E!=C%wRf!l-C!vC00sJ$x}L!SNnUAcM1cjAfrcFVHTESX*>n$ z619i2_dx2Z-a1BV9(*Ni#b5LPc}J z6pqQYp#c6f>;0%z_ML+rICO&t(=@NyR!)G`5Dx&o(a_LGganQ(h*T!9HLhyWm zMuAXTq$%)9D`zrI0;X|6IrU=mnk#RE4!EKW)*=-_ZienMT?^>3(V_g$vMe0Sr<>-T zc8a5pCu(#aXkY9J99Fd<(GM96K1W-Cv`*{!Xb~1e_@=ze*Kz7$JjO+M30k;|xtFL! z@3f`sneLK=hMhh;_s-iZflFmC$d+&^PtFa8!|&;4Vd^oFf7f50gDyZRBS9nMD))Hy zgfOGhUQ2BYXN3+dIq&Nd1oO1%qoGjbM+G<3A8ujE!NPLUdzz0IPXLDPU0tuEXUAr} zD=kR$LMKKy8+q6-Ftm3cFXjVs#AWV`-knEigvH!FUr;W3ukY~&kp*uWyR8e;krprf zHo2+He?=lguvUOTm@tHfcxu`FXY^LWKTB$M71%-fT%5#)eWb;^bbq3BmHrWf`A-=) z|FMEy$b{GgO=(`s;hKJ;MXhO5J05^=13gmPuupJhaWD0))eJXGG}!QuNwvrgVC*Lm zk<7nN4OF$NOZ{wF0SfmFzXoSxf5EW2^p!9NF!}O67u?io@Jkm}{EgJ3TeT~RJP|Pe z7~qrOGvM>!OW>>QHyA}-P+$A&!K45yg;uf>eTq29Qch}B*PO}yn8Sx%WsKVR`V{k`TGMOjD$Ba%Qe7@3l&QXEQjD2X;&Mg~pBZr&r^^rW}-*))PU zFE$}^yl5p86LtTck=X5%%u&a+=A<*SIOl?nTv0EVTkgo`f$v-~7M&SsxqAGarw(Sv zaEv*n+eK~t4sfZL4pZN2|KesM$GG3LVji{XDzEzDSJomUR%$;g%IT*QNd2OR-LKXp z{o~2e|7*M#l|Q|=kVYE5X!y2kH+;yc-Y|9;e~=FJ$1c*}%)sG^oBhPwyLlk+EreD0 z;qR8>S7=h=H*#?I1YYj_XHp=Ryi~$c5VllYDh0GYTusl2!hA7$t){R@`XKl`r;x-Z4IuimY`Qr`_+ zUxWkA^4htW_wf*NBEFd)yeYHp?|k{Q0#R$o0*~(SjF~+?@dGK6lCcS=T1t&IcN<&; zC};J)vpuIsU6i1{OI+wHwV0)0ZVrn&h7_fwlqpw{%25Qa@t7rtPnn`zMJh*7ltY{Y z$8j9DV&$_E5jbfwLwvC{>t)OvHStExFJhStQzPu&hJf$?;l(idm3Gzdzm57) zFWOfl7(ZN=^NA2K)Tl-BdK;9K(oxo|+$uulP1MIj12YUpHwg-KQ8wa@SMf;bvrPO# z(1qIW{2M>mgjx~DhcDdDafS2O4Z|aAoZi85a-^td6cYD4h(V5RH^19Vu00000L_~YhE&09G|JAR%%|8L1o8#Q7%TW#aOyfMrC4`&w zs|-eW^tj-QAN~YjB9I0IVJ4UmLJ1=r3lT&TO&Y_Pq*R%56)H_N&2%%&G|Ne+oOVY2 zz?FB&*X;*kJ%&d_MnyB9vSQ=n*__^Lt=^x`=4y>jZ!o2y)?D8CLYmfSv0Sasf*A#M z=+w7`khoqrcgXKtd(pRbk#?{r0(*HO+=Ebj;au%p;bKGcL31L_1Er{}caRBlF76!> zcMggV?bEEI$MJw_Lchv3`A&*EA3?*5lN2*py*B9E_XSK&Wh>!E8NiLec{A zJ_6RYw|K+yYYlteDRsD?i|%#l(jj;O0+;pc`aoa-0(+`7Z&t!U%ay>8(f9~^w=p@m ze>gTx<4iKcJd10UYn?oKbs6EiRzID64*Htr_m17KP6cJ(j0jGoVj-M?SbS*&u`7Od zOU#t^Zk;6kpmH0ka3twWu1Qv=8Z(Je!cQonMdodG&0`Fal)R<}`a(Y`uwzz!_dW8s z@cNU5mBx%MWD3m>!Kxah)-EYS>8U50Yq7O9+j$^$7AcSFD==2~M!5;~=VsM)-7D(OzE?>c1K`#n_jcz%47Js%ks7hT$5XpIPg=!jpci zcwccy`PCfmt)-pVPxwkmzqyIcy%V+%Z}G#pX)$L%&M=$~wnpiO*VyKElBR4^lr$@}9*6kdJgSj>h#qxJpTRuF3a&c~&2uY7{tF{`;_bJu z<1h3r1R>&KTe(o3EF!-n7Q>o&z^=D*Sf;SIJSe9ozu!c=sM(C(l@i}e4bP0~Wipxi zk~`vTA6l%Y5QtL}K@I%Zg4H5qTwNa5oSE1cVHDPOWbb)#{}$=`coXHT_8Be1yV*ez zd|{%zXz{fdn#w`BmGZGOild_KW?YnLF_@lWi4`Yap+!rUeX?T1erADN)Pe^aiQ2W=DKzI{N+5#tTxMt6Uam9FU$MOG=iT<~+xH$fJ&+@xR|3{Ag zzbr=dZ6EMv4IYp8DbgXGH#?w6hxce3kGWqm>hVhy$=In3?D(I(JrtAm{*OMvyNC2y z==Yp!rfekm{QpYRACv4S5%3E%DTtAZTwusexy1-7!h(cSzm0*Z7#pCJn)PSmkjuIL zh=N;?Qb;GWuq9A^Mma6@i?13N&zH^$W4Yp2+LF7Kdy}2x$rEcr7WIbwZz1DpGy^Ic zzs$Fcj8Cv)q~T4Bs@^)($upJp1E3}00pARIPzZfoH7>wbJ@R5x4Vn+KICQc?H>_AJ^;eEZB z1(s5?@}+hG59UB-S#urBaI;Dp3_!3*RpVjW>&CtIv9{CY9h+@Rp5 z$+<_okZ~7h+;f5Z5_gS-=zL$PVEptjq+UfFRFVYgr5?;e`lg)6dPT}JIESMcfr zW&~gNz&aU^f%c;pP+jji%&%l{ZvU@|raS%*%wO&S3y^@c5fMRQ#DRF?p$H_v3PdD^ zb;Xecij<_VvILTW6r>3b(z=!|y`H12j9zwdkyFoAUR!SRgNMSlJQdY5DsEDuB;&2L zfse8#@oCRIqmjGPHU$wXj=XzY?0oK!4Pt|>{X1!)H|vQ#`EmqOiz2AE5y zO=ZyMY@k$2hIU}asy0liw#*$MkX$>KLVMO+Hb*X(vmF;#7j^1#`RY@hd2?MgcI4v= z-T8C9G`H8HrGr+jslI|${j_z^E))jptSZ#iL0GsjSWg(LH#N+X+z5SDBlVXQ8%&Kd z92m%BGJ<1%&nQC1=MMy_$CXH8i6NXYT!|)>2wc%%K!b;TMIJ4vbM0)YK%T%t01I)t z$$!-XeCqd`z`GEP10UJPLKGVO3BaEKJTc;laq1_51qbmbh>-QTawsQK4KS0{Kktly z-d%)?lyVp}Bj&Tg@G@^__{hr|PZbet(&w!Z@eE5yAQd8QT|_#WkY$?W)7qjUi+1Q~ z&w2DKMr{}MdP|u}K=86mQARWo%&wT?aDFew0xC2ka&0}!-Lgx6KIlK)S9ldC& z6}-iYGgR=k4&JgAgSTz8pZE&{h!{u2xCVaTN)0DHjQ9JSaJSFrkgYoJX>f-?#HYFj z+)~EV#SHnZz?&F3tcI$TB51%Ya+Ta2c`-@VM9qz>gk*Y!V~>G@>DqL=a017K|8i z#zW4#Fq-ILu44uXmBSEqcnj86)<_^G0u{CY6D?=RY>G0IeC-fFeDNa)PeKXDADQ_) z2ea=%{cW>3kX^u!i85PE*snh9DLs#P1ovY0j@{1-yWDU=_l0IBW^bEwGht#x8o1Pc zZ`ppaF|n&RS(7qOk`}LLoAMF)>&G+>CUpSDemv54Tv9F?}nlS*$XWXI`_yd%dHyb952(@$$tU#dH9g zmx%p~mrs4XC^XF(^5cy*B_IL@PR}9X+0a7YbL5g=`Q*v}2o%+RmN;}++taq_S zqPo{MLB&kcuakH+g9c2N%6-gjjRP1u9YSq90P@LK@~Iq1$1-LUry^E{Dwi4Pw(dJ^ zhD7;Q7MkB68`!>(O+Chy9#HDMWQlmXPz(i>Iaq$40JYm@_tGXO2^4TSc#dsEd9m`I zX)5Hy5~<1%qfD~G(WOxAW4~HYJmm(tU2tPAQ=}wBG60)r0WyPLlwoH8VnX7bI+x?X z+GH}=Pa>h@(w!m-W3xp00ZPbd1rbdUx;V3r6$x(@0ctVf7kz5VTnnkTD~Q|xM9)I; zsR;@C9~~OI4y0pcYsh#i1L_B|sYg%$rBj``l=AapKnB1zdRCG6>EKqL zrFTM$9#uA{+C-sPbaWdA*y7nij;wo99pZ&4Zzo6OmY68iDcLT3RuYzcG60L^$UTNT z?zqd9G=y-(jkzdLD!vCYgI<);glGz|Q$!!cgv2{3mun|up&PfkMj|%$(~3}X=}sZB zv3X7TPJx+I=Q<*hRuDnxm>CTvR!>j*2!S&i%1Nho<~Q1K;(*@BB_?mi6FHKZJAz!I zxTZ=I*geDW;IvYTF|MdNI(hqFae%DtBj&6YX{xBCbEUcJsO%6Rf(N11^S3sxm2WK!j^&x08IZwRG9PhsN z4zmvc{CEOdo+lk6_N3AybRC@%Ha zGxdKXX=6%I{l6#wcD9|;k*op88@|FXLJmfVnFu-9!yrNuA|yLPx^b8G}ZJ9TyT`cpee~DVyT7hp{SEc( zZ>mRsOWph15Vm+=hhp44Hb=O~zy9hwD40-y!Wlg-00RUEj|d6S$MsUbj`EkuQJ_SH z8Vy<;(BX(v=BFEQ#SM3GdJFRPyNH_WXxdp0sh&mKg^dQ8@9K_QiCkK`B;(#bGd&FB z;x*;w?=kn!9P)K|Y|Lk9hl6qBzjs=SjS2N5JNzAJ<}sr_;cGjcn}qgxQ8uwS{1T$2 zjMbh`if{fb&D5~kTus#s&Gc|gRM`#Np_voh-=cKyQ3$&Gi$o31v_sR0sbLzK<}Z&0 zo{ct@YFZUTvjhb8JK&&04m*N@(;9NX>3FGTgHD}2fugedJJKw}wZf;qAspZepWs~^ zoY~FzQ5#?4`%hXI@Os@zoiue@CcnWp5(+S@$_g$c4;BE;+znN#GMM7s;4NTWeN;-@ zP0RMJy~yHY@T^@%vdhSJ8R*d2N~^54 z##-yFx4}l6Y?fz>t+v^2hkQHjN-zw-W(7wJKtUsj{{^f?0`p*cKz;6r4^1U7m?Co% zegUcsj##xXwP58O?ylP=0K6Q(_)~H=d$P~y@1wi+<^pG16+MbQDpP2SZ#bV^A(2TR-nZP{7X{GbQGANYMz05$*z07n2P z0A~Qs0k{NkR6=lTYVED32?n3`ZX0Q$!LNPW=G$`HXp$kQJsBXrcvv<_@k(Ad5C+nq zHfRk7gV|s`d>#S+lp$~64!ogncs+~_;(;~T58wa~=-`4z`8bw;VlY|DoyYe00-;DO zG2JuTEURcnTXhZn+-{|)nr@hu?YPwQgD{Gdv;^hKg6L&8nk|OyPPaGhZL=~s>BMNf z)PfZAEif%TgD%3Ej4_VctF_vyy$0(gG}SV-Pu(4?AmGZ8mgJk!&{FD^6%@&vZL!rh z-N*w`E)wB_yZ=n-d2X63h_LF+AF7k-#lR0fZt|&EliJj!J`HJ1Q<}p|3x170Qtqm{ zu%%Y&b*%z5RKqn=qcv84RCq;HWJQr97b_l5Qa6J6{0iPxP7A?R^j!sOc_sn!C#rS& zJ;MY+jyCP-@E9Co4}(~Hh*SgQF*bL{kCZq!yLwjV_(J-s52=^(Dw(CNU6)=wq`<|* zYEEU}@W%_W+W?z20i^0Rb`k?ONk!^OVN!vc^b>KSOq#ICI;kfJHjSHtCLIW?l~qw( zxT#`#IE_s=K{&>%=$dUH97q8VvIpVvH4?A2)O)KitSx730rI7#2REr8UP3_c7*7W} zDbS|&g689EDlqHP0w4l0#L|#PG^PnSanBjJaK36i6-n66|A*b_CT2qta6e8qIL}^QHN>T(8OylaVs0SW; z{%!6~B zkml-5<~(Pex4;d8|H_1RXf|qu2sqSR1G<1s%a@LV~a^OwbW|OOru5fEVXSt z?{n>(3pzS4Iq4d3W(xeF^SwWLT|o~&moDlU@MH+m{UP{^H&&#VxAH_!M)SRre9S^T zs-p68sb(raBUMz~{8f$BoN*OO$y9aJ>)r&IIv|?_p;Vek6)#dcHjxcez_R3GN*Kjx zDbIs(IZVsGOk}3H7FucJ1{f1og85_J$rU^JZ`J=LrDf$6+{&uz*0%NzerH#A&o4i1 z$smCig!!g~B1kNe%H*(uy)s0Ll!#;TltqJ?klWltSh@e|_im-V>*(y7S#`k~RUceb z4Uq*^8S++nt35S+KoUUU`C==9`fm&u6j@r-Xt<=sW{FlCuqpT&1eRYX3%sGg3V94G zp0%kesCaq=SNv9~4n;_?Ka%+m_MK|5G<^rO9OyXm7ohvT#~PvnCZW`>o=c+7!UK6s zJOGZqJ$lc=K%IG{5P$Rl|L^2!>N)0h^uCvRN~H^Xw+ zKmOL(5<6pW9FA|2MSe65*IW1Q`d|Og{ywlFVKij1bt$N$xi*5rXdgb`T3+`@M@Ws8F@fOH9~i1?$+z`R%S$;Tybbza{b_Kla3-3iJFBUU@wAoWgYW#aEcN zIqZM;y2Od`U*0@HnXv#jEOz2!JjPud0ssFQu(%wU?*IQQ8@p4%_4h5Zn;HCbDL9Ak zZNm@Px$qWue!bkM@hWCuDkdTaSsBa(ERSt=iXpQ#DiKTB)_#sL!=qdlNG{8{h(}!Epd>*~k0)T2Jd| z3Afj{1-&kuyAQXQc=H7B4nv>F_;n>1zr0{sx$hrH8wypS|Usr7~d~Q?AWd_hxSEd zx(a95ALbI>3f1O%-as3+frF6Zj2b8OxD$pS0R)q3{r{flLq2`zOc&;L?{#y7x?4iA z#mBCshnm|ozq0MKEj#vmq{HbJio_aT5G8<}ZZojy(VCPs#;QSmKa4d1}OrnReD@nLS_O+Qe{b$rbkVyIvq+X_~dB8n3-|1;AJHU#m`2V zQ*Cvm(Mh^Y-W}G0pxx&vgr_GOdaSc@brU3@XeN${ zu!Nw5lyuZN$$dqGJOB?UEuDzWTs8-7I9Q;2}R$QXC*D$v!@isU)pb zi)Gd66=37w;t>&(l95x8YGQ`4e3b%p-W0ofegU;4n(%qJEzY0!E_HlVl#FbOG%MDk zM5|K4d%*JkWTi}-a_#U!2s#jTs?eoUSe0&My{aui5kb}Gnl$k!ON(Yv5iBVR4Ihx_ z6EeJ_;0+~jk>wp|UgLO7;3*N$hbdPC0U`CIaSunE-VR{c)T|BFb zV@+&o;xrIZkzDx-A@unSq`?uTLko6t}a&1_K7i zMSsg85=*c;w|@&ZL0m~vCy$tY@zEb@$y4=cKqT;BMlTT`FPcPzmB$Hg{TuQ?|QX7>Ns@wxDfjPx# zO$qcs;jvvDi$gCRbJWr@*0#4+HI=H71kshP#vucXDx13FubGrpZ$eqDFp1oHMH3$*#HfHg7DOSjDlh~7oAW=d)&lUC_%8{jlC z;N)CLQ)a9^RtiSwhR?}CvFC(6_tY8Do9y1yhD`)C=vdIIC@$6%V zGUT8`#AF6&sC=4lnHhMJE{Vi#p_Dc&EJKxHM?0ReIrJEqqi>XzLpUop=D=cgsdCOq z57OeKI;RsE|BLN=D&lfvWX}R9JOoM)1C@t^+9N>Yk)ZV`F!5Mm>d|24v0Jtwx*wCO z`m*B%4e85MP>1I4PR1aw-^tyZuiKm0F|LsSRqA z+M@S8VAWW&{~cIX=*WKGX-@JGvf`e-st2f$=t)`0VZ-Uc04*7lxG0@^{<)%&92{{U zVmTN)vbOPfSh7s3J^xZAl#W$1ANj2(z&oy+Pf@`Fj;gas!N=gJDUlxoz;#^~^GTBA ze7u|is~GTZ5>L!~QO$ES*H&v@;Hji6)0#dYGg*6pM%zU1DIK5E!-=`B2!CLD+;ns0 zB2`u|KNX3WAaWKTn(#Y|V`fkGMO{8SOnU~brVDJ(RR|3$l`5(yw83BfN&0x_kAyDR zX6eccnz~-L+aXdQE4l6j$fQ2mmDN!G|AuQ?94~St?d0OUIb5y$kdb{hJBj1a1A)9rSmN%nz=XJy|9|+ zAH^`a8BpB%<~2tM2Wt>{_kwL1GXuG8%314Xq&IM4<0m5Rk3=Jjtexk`4o`By(QMAY5CaRw z^58z~;;XV~tgVEt@(5nE;PRP9=*t5d!&a8OB!c+xm7KY}Uf|v2QqF%?cFgOJH^`u! z#&b#D*6Hv7@17m0W}7@BKTSigZAf>c!BAhrP-DM? z`Lq41( zH%YZ!#9(;j#Tu*Xrag5}elVO3)YaO10rJyQcZF4Zeev&G(+hyy3T*Tj13MIe^{y~j z`4EG9?*cs3U*+$&H336(`)ihkbRdVroOnvo{eh#p@ug`S6~jNwVmjzTE_8?V>on0+ zj_(;1^gg@^A3h0O z@XRxrO!@`0mvNY@ksn{+yF_F>Owb;46X=S5$7wQy;2Vw>DB zx6CaMeWio6KhOI#H8KD9e5eV zEc}-ZlKu7_E4Ly(IZG=BqV4xIZCdcb4kCZ!$*Pg2@}iW>6qIEesFOy#rcWc!k^2~O zwvO8@yvDEV#Hi?$wkD=ApU38K*p-sI)mmp+A@WDrccidtA(OdGeW&DX#KYksDK6$^ zTBx>Go^n#cYk;EP_1tu%zX}UlX(mf(<@2`0G$th&mr$Z_$h2SwI6+KBx^idQS0zqB*zbrsbpZqC>6gtbO z2#-H5X7E6zRpo4o&l%zlRaFeQd?$xaVXGwU1vMGIdYKRv#){ph6dLR?60S0p)yPB} zwTW3dLq@V5s-XtC4^pAimC})DziN}y?Vb6AOl5|C-Z#pEEfN_Zj3Gxk&f%4iY)Fy1 z5*uE!(Z?8Jb&F?l@KWGf(48qtn^n-d5tNKk61BdR%djzqqdj%p&kZ%Lgs0`T6MT0a z8th)e>Xxd|j)M4VExty6tZtKFAU~2uWjf!=EV&pfHcw+29Hr^W$c4vAw7_aJTdn%> z1xmE$G4;UrH)2AjCXhNW+AH?f2UQ+Y%eRuB| z`7Cm4Ecfb~M^^gETg+|!LcBdpel@-@hQ_6}6x41kpd3iT+MLWutQommHlAr=a2*ms z($FM{{WmJ(s-_a5yt9xX8ny`1n^wmsdE;R=65;kbUlfK>a&2&vf;y1Bi7dKKI?K+? zae<<)S?a-?m$;UMV$&kVFpyHd)XaEU5h=K&+-B4i^!t?_%?R!LjyPrQLFK(ngksjq z9|bLR7oO`#6f!@eC=9idO7{?bvmx7wQSxiEcis|E?l!yCqgz{dYFBwf_;NZ6kE&fQ zxpU@CjZ!hIXv2y|_^na{f`R%GZUS|vZ`{5n6Mo31yjo3s6I*a~mTVuPQC#0vANbz<1r z8*%j%AQv2qMJ!}~E*PJDL)>DtcCw|wSle2ZCO966@{ApeHQ5xS+Zv;2b17Extm>hw z2b)bjCA!1^>v6<&v;H}pXA2wpcwr%TbW-wp$&vN(uK@p#*a&iFE_8Yo>x#`JHwD{; zIY9d5W~lN1M%1-EzjNg*D=s}{EU-X5bq(s~mEW?%@^5%-Kur5hpmBZF7RM>0>lzB` z4CLTveYcu~#Yh zKk%(WHk}#c)|GN- zqx@G0JPpdO3Jl3kVi!QZP(QFox7A|OCNuVgiOd`dp2a-s3Kqu54RxjwUF03UEUW}9 zprO*~oRV{2+NKtPS`WTxkgmZZwY$m@%E(sIpX+7C`_#bB7H15r{nVuiEc3FiQtHiX zeKnzE{XGVY7{=7i7)on_4q6~piF9v@D7q5-y)4l4nC+^j))^2Sz3tEd>T;7MIK?_d z@sH-EGDqLx{iRxpwSEaLjDnO3?j&$pXK54p6$L%pX+rsGF=(mLQp&SzEY#6{Jz3tW zecrSgIg>L}?}WG*FJ%(=jgU=j1Re~CoRz>6iGuS`nhXkr%D2(FpT<{T1QPBPTF$L| z%nbfBO)w|O8~f4)@p31NW6b&)gR8-y4hyU2lvo@N+8eR8*C0N5sNVMy7F{I#Vst$D zm@VcMyv_c6=(#NzbS~7{a!w&F#pqaEW5LV3IoB~Zq$Cw?Xbx5zGAXuRk26) z@5Wm)t+c@RI-8HP;YvTRMRQggqhxu#kR(gS?bu*1h1MJ!)pHQAgr(pPCOgir~m^m(aIXw~gdr<66HjT-iKoBGw+D5?*-< z8O&_qxRdIL`>9Veg4UF{yeB zw}TIkCq{KN=5H@2JKeV#DMm^Q*q8_NlG866D4m2vLMO^@W+$$?wP@EfLk{^6agk)J z(3wCODtz*N{#Os(j#O`DPvg;V0w0AzF0O_{MlGP29D}CC$(q|%tolg;i7#ZYHF?6j z+Sk&%mhw@aU~lP@@T84wm^~p;$d_mzwJ#Bq@=u_RAbW(C}|1bP~R{ja^5a_Ww-+q)@`Kln16C$`GPIx)A!hH^cg`vX@ z0ZH2;Tl#E0jrfrCyC(fwI8M8w^vyH&_%Xwz<%8(#Hy(-;#zSZabT9X=_5dU1DmXQS z>oV1~n2!~L1dyUbGvTR7%HT)dkt`p!aI0o5MBXO0CZo`s8J-8p6*Dp$^7spC!l(og%sczN-jq&c#I#8?t}x{w~} zt}*OE`f6EbYuKbK!vIr2tiMgLK!|7e-;iE~&eQbN*M0qq{>lE!b^88p#w_q^c>vm4i8-E-LuTxLwrscF14(9o2As z3xE8D+a?gaPy9S=&7mm+ao8n^(}~YbKh{`#5^8YCNZ9YNK|9o>d`(@asR8q)!55XS zn*BLJSAt{9(Rwo%!=H>YV747yir9W+>1RC9me}rSoeeQK-u>kqqfQx$yN4#T@B^cT zQLeo3pSWcugJcn6&BEgqpBT!?o(Io{>=Lwoh2e#XJl?`rs2t6CrtR@lfB2$aNCMfT zd7Ge5qN{6js7W7g0;t*K)_^RslVlp-QIGbM1{kY5BCqN1_-SS&e7kL_Hix}nHfeb7 z)>%!=4HWNi48QX67ta4YxQgSPa+NIyqWz;ZLuw1-Pma~HDDg+!p5s|kJ#Ctqjh|XN zY%f$YTh1-vOj5fsBM(RcNnE>fjPPS(!GpE7!OR$?1k-I+e>>*=3-UKRvelH_4b^P@ z_z^X~Pv)^ZH4BL*J;YSuNIe>i<1wDbB>mP-`O!*NMAp08U0<7bxav|biso$)&^+m%#g?)%MBHmsw-*^8o?oIfAsxQ z6`~8B6fv_Md%x;N-*!@kdh|wL{i2gnHHE6({dw;bkBJG`7zX79gIx|hvHFH}5VmNH znm=OkKMihD+^gSQVtK{FKWp2sjBUGq_|qLX=NIBfi$7^*7ZLiyu;x%Ky{va|74p?+ z9BdzjC7-ooG^;B@lTue+wop+{klCDSqr*V-*-aKOgGShRZzT#tpzKrFB7N# ztQFv=BU}R0rkBtoiD58D4h*lAEjZKBe}H&JAdxs|RL`A%n`MFxB}Z}J$0XC-w_jmP z)3Spj>iu($F45pH2h@!85+CdOWnhbbvd1A_2}t!#Yx&VGOxcLNae*&OJhV?7mDW-4 z-5-2;>xXefv7fxj;}nu2eOzNc7bCbdK)of6rg|t300v zz`^5G@=Rr^{h;c<U6B_lZde}b=Fz$5 z9{{=fE?d1%7yMoDLCO0ir}BUgODk7VcLzAtu=~&9MOf z)%3wDkKNQageKuF7&-9v;-{n4@studxKnaUl$)cd*twoXZEZ$PdvuRJ)Tgfz0;SG<{K`P>sBXX!v-(D z?LNK6{gYNVzm06M)vU15)1%?q4j*4w>s96f4s2oLRJYGN>Chi=?2B$a7}P#s6?&QC z<^gsu?%z=pO4qlf_@42A2ZBNE!S(e%OOHs=k|?!f5*2pTY4jN=%k)O-m|ttCb#iMw zi{5q3Z<0hWIT8U+RE{pXH?8xk4>mP}2PYc?{cFe4n{~fIGN7*znf%Mqm-;|RU?y$a z54_o$+?POR42c0#5wy(ms%sAA_&Mm3An*&+aoBz198Ip$%2Q;1F}EX1ZN)^1DyvkX zu0!8<$wtl+U#$U4HdTYTzCD9cv7zN3qto}B^e`^mhzPK8&$zxHn|^5OJEjrd#&^Gs zC&ui{c$nBIW#oAa^=vLP`hA6;dHPG$YKW;BP!C7(llA8Je9(?mrjN~FQ^V6#`bS6X8Y zrLM|MzY-X8X&W2#hYa+k0SJQpke~*H_-c&OfD0Zm*^$&Y9il5R||uc=b8gj`1pT?2LS zrT#?{I|eI3O|VQaW|Z7D6oWWhMzfG?V2EoS9W>j10lOYH1Up=1t`9Gb#e}wc9^ zN)?Z{E*Vi*++Q_)+2oG8!vEpC>Dpj;*5Lg@JfqpR7VktEK?y0huik73oGmzA;JfL$ z>kphS!M3CiOaH$&@QRiMLa0?sJALmA)cZAQ zvsIqw3r-b=XCaeRBsR&QSvwY6jSLq?5Csw`97c3ltd}Io9UQ7V=fmaM@!U(>u&;lz zek}vdNkz%dBq6l{v}&%G8lF_0>QBVtCIW-@OV1qXL}5{fl1fER=bRFq{zOi z1h4)BJ0s)lSF5-bsjg)Ih3qJeK%wB=*kX%K(RMkl;|%97ZmLMxBH<6(bC$-q!4@S` zM!RZ{O$T}$h`L4m`GJt+vI39M=~My{1pcd#EJO{O{398sDFwte8)OL_3$1mbK{tmy zrJ0!=z%Sx{x5KV^H=f<@a;U@)kpj@MeaXq z5=9Ot6G%^p#p5=#^d)-n#<6hnBS~R&M;R9w&2A;2bDK<%`%F>wIxtHg7oS|9173{jJ^eFIb@Np>wLhlOznjuta@o|OBjq#UULzEBtOZreu-R60NW zepD~;!rwRA*J!Y3b#^noh_j@G0*bKw<)dHVqUkvdp^+C8W8m>E_U3vEBHz%#kr`AX zM5L-Q2srYW*pK%q=LS#)M+C754z2|eflQrH1nEFg8(MUY1cx_*=7RDfC5E`ljIes zoGV*6<@U7_Au$=duo22|6b#*Y@3f(@!K)Qz76007fN|i$hTMimCd}wtCQ&XwX$Z0+6XW=2MRJ*5@qv*zl=Rp068?t?xXu5G*~4NQTe z{26aa!E1}?JI_rsN578TJ;Ul|AW*jw+_QWHTS3U#l@JY^;DqR$FS^sm2GA`)_yJZw z`l4EyAfxkYX~SBOI_%Zy1g2^$h~RV!OM+bJQ^NY_~WpIlIWlq8B>koDHry zWO_s5=2wF4513C$)nQ&JqjW(*{y!>9^tcnZR;>Z#!`ZlaVeuac%1x4m=Mvv0=HjzV zsZ)_u0`O4;a=KyI>4x-9k0BJ(Nh;4V8RsjjF3|O@uX4?uLmFS5jV$kO@-Od-95T=D zS-x`5tj#rd3UP;dSUB$O=slCD*h2QfJ}~hD_-h`XCmV@VcTrWgoWuBSCXC)ir(@GD z-a!kWR`1rio3YpwnwQ{h#i3TzibGpRLNlKH@9+7jrY6PQ!w}mm>=?i(wHs{WS}`P2 z4k1G-kSb>z0mP}7wAeAIz|qA<4Pmy(#g~a7`QqioF)pXBi!-qn_1PN7=Fm=e#{e6f zo)&K(bxTK6{!FOlh`8oZo6y<@dmY|3z-p_-%F*n+r(6c{)EpYdRe!PQ2cA-7Z$dNQWb z1>gZvK-P1LGJEsNjPB{0LJrj3KN8=CfeIz&c7pEB|u-_U@G` zKpPSW+QHscV?&=7&zNR>sPAjj*A8`mC-+>+uO!BLTH?hyq!zjOW4zwc(hicfZow-V zg=~RUz$u(QUbi4-vh{~<&xyl}<@Z-Abs0c`B%_e#iy0hT)6&q_ml#~Ad3#^5zOOr} zIXWYfS)$BP7icpGIZw5*{R0!LXR;q!|I7a|kW^RGkdmB(FJ99^j(F(X$=C0q({yK? z)8r;LHaUc91FSZe5ped0-(~f4KfHGw-23agxh@Kq+zTGRXPOb)CsXYO@vL}Z+;*;o zt<`gHI(~)HmYf$X999pd>33t^N!{sR7V!0retjv>KZd;<^@)!RENz(8zTsphboOuA z(3m?eGPh9liKoNyCh-01?hT2Ky6A=?8=L3v85>kHJDO@igG_I*S@npl7Iwlmy}=FR z9w1J=qTzd)uBgNJXM3);_0>Iv#a+M*KfJa`rfjfI9Sp+e37ejnN8ii?}N9)*3&_3t0= zB2T`5eBBaYJioqpWeXA-+8yzrHQG_HRD4Myab$sr6GO2%vDKK zRa1WYdV)a%y6g~)LpDB4bbO^^%*=oPi`JOf#qq6CPcuhgb~;KUJr}|u^B*5KxBMp2 zz__+O=NxH^oT0UYu2CD3op3&XB0`qL`%mtxVcb4mktc)VyYpS(hv4N~5zW*zIwk;m ztyYhQbD*azr}opQkvpw*0i?d3loLTpUHDFWXd44Ww+TF}JMi8~Z7vgSgd1K*Ugy48 z!-C5n+(&RmxQu2Tpj}-BtFMp=E^Oisg$Rc3*4#30lU=84vU`_mmPw;Xq$ue!!>Rc` z@ZC9Ic!4NAAJ4(?tMevh)t&!0aqei^15?uwiGg9HPPRm z|8~&@$Sh-Pc+0IAii-r)i3!eyCNaqJcwq3e*MX#dE+^Qg5DX#{z;`qb#vwPH*wE6Y zfqCJ(ND~+viwki6q?OMPSLzJB^v!$LmRi_nZTtp@TpH=S7+kcrXDv@kp-4S204M+< zBHXChxRQoUlqU-UU#JLRVeah`!-Uy=ux%a!p+2N<54dMtpSQ_+0r`@>5lx?1P*KSj z_XVke?@Phjn4{Ss2~eCq z!JGn5+>SYoPWLRlB$=J>4%6vNb^+3^haG6H+t&(8-zHW*-+ z;!BIbBBRP6V_%xD1XIvqyJCMhtT-_2K&>8apquQEi=k#$ZE!u!mTych&tB;04rq2#2Na%t!;w=dw2k^z(Ms2 zh0N1ib7AGS7RdXJ>vJ1o+lKzOz|F!*>0BOKWVK@kQK4LGMYLMf3e^}YF)qsb^+;{b zwHM(+iYh1;gjA~9P?u<;I;C&<-YvHLt&*NrnZeWKN(b$W&J_sYhQav3*lACO2xt-T z+cZ#ZXQxnQ25dG1Xtu~{S^`N`qe0!9PJ{$scD@HWwjqxon?F3q`nXS4ouXSpmDMSg z8qbX%P1QUu*XpO6xB#7FoFI9I;rsDD#N`XQS2pBvSSL!mjByzC{ zwA;*}SniTcC&=1DXqwa!d-AIhT-SpgLk(Y~ag{A1VFw7+c6JC=_D&;fqtBihrhB5qgx`(5e$m!+Tg53PN4}@o)drt-5-Kq8pVKJwKk@pY2l=}Z#zEt4+GcB>G z_?xVpoctVK4#`xIUyrY-&U!$(Vv2mFkqW zT!B_hUmJgQhJQWpd(rXo{IY~%{E9Nc;PryF!8!bnf`Vy0VPZLf>l`i9z!qt( z;?Fkh`i5m2kWz?3|=EGS!nFHR`S zFF!8I`~JGcO#Icgbg@9ot<@>@XZR9>fUBTCAHbf^if-2td%M&rIKWn&yk@MJ}UY5em-ra~?PA?+fS9fOh9`hz@;)XU&Fg|$LRQrZ6&%Rk>W zTU!x^(95o+kK+>5ilBd1pJ$@QSL?aGo3dnI*R4x?Qlhvv#%Lj|Zn>gNq7rd3m_P#kuCj#hH~-Somq? z$;m-d%)orA9S##>7E_Co3adV(a`LtuLG1XcKUC9jjXKRRHqNP}$HinrA?XS9H}n-U zUg&!08bmO@Teg0%lwrs$rW(mlcFwD+6*znulHJyQQq0HSy21ulewsDb@$t zkguSuU;A z^yPvjnnmkk$||7+kO&C(n;I5KE176A?<^RrAtJ2(iZCpMxUk)$GfP;i8gmT`keYRN zk4W2o-*q3D`EaH*x&0dfP(lS^!~bW?9(~LG`pSo?LDc09+<|=9h*>mYSuE+UfdYNI zfMUNDxrxNMKHS8$oAcPq6QBJoHfaAz$Rgb~QfDTQM~Lw8N%%Xet#BXCZ(%<&`L`&7SYwptU*;|1Pmy)K4E7UfveIC3Y z+;rq>@73=^E7HtjlJ-+Mf_5QDE~K47>bcH@^93%PtwR{9>7n`*h$mEaGq>(N8~}=wP#1?2eR_ z-A+5JVvpX_83lRZsDyuf!$1CExQDbTM2mYG)AD`P=MrWOzKO;48SiGKzcZ2a=g_me zG{q;Wx;BSa>$jDvQ%A|^)z!bOQNg3R3_RvIX##Vv@+2&0vCwf6=oOlyM;mOZ3RF$N z-_BbGk6a6rBl5<|f$rxKbWc~H_TZ9%Ju+|CL926jJ5232D>xPX%VO@BY1OtmpHa?i zcS|H>Md{;(&YF~ukY{id8iN;fuiG)TF|@zI^E9RXDp@R*xcdDf z9fe6#>Y`Huyail66P1{4E^yjpo9g$v72 z%&3wpQK(9bB-K!gKLb_rtaTO63YDtMW>1c9OQ*U@m@c zkV0a~T=*gmwY97y8^jSRE){3K4dm_I*-Xcx(@*$T)9Z@q41xgJ0Fj%8LP7y;y#hfK z8Hyu9R7|WDI9ZH(v6$h;3<~HFp++gx$5@`Ntx2U8>@K8e)~+V%%LaC>Q})ZpP`+%cd#29r+gxFkU`3UR0`YRtlqKT{MWv-h1biWn_(Szi7WPj$&UP2<%5tu0gc}WyJ~OFL z=4Ny=r{Ghna!%(wqP!pHw&!>Oe!=%gDaZTm{lD2+sSP*5Z1D0Y2~v&5boVo@FuSi2 zkVh|a0gF69g43CxwPKnxG{xgMxnR}r%Ws8!k#nk3UE}$vXlPy*IOcWG+F1Q;ha{JU=7ScffSv&8}SGHGwyH_qy zjpqq`v^%F{Y>0gqL^HBdk}($0lLF~`!%}svpQrRCOYE2wi3R5CJl<5pFY)JoiNa-N z{1AWc?R2otsS#V9NlGUsL7`}C5h;OAvA#1E{E9sePOc)-erEE-73aL528a7kfn94I!c-*hJ=28vEYw<7D`hZ zVCC7l#ou02rqFE#_X{p|FX5WHgx=b?w45;5FqdLi#AVw&458LTV;Dk>v9Hi3+mW|xLExTWOQ7Z$d$b|ISrfxD}L+vF716GZypajS9+u6 z@@44L9Za|dz72X)J-?J4{)8LAB(Q@9lgiI0ytiS)^J%PSm-eO7V-9fN|0%?gNJ%S9 zljFL4B_)39rb3kzh{K{l5Q#dZ8`xzR$ZH^KHB>{ZH0Y>oem#S%5;lu#v{icg_xBta zBoe=p#yBz)Pp!nxxJ4=a$$EyqdWsUOq2*ntNzBPBnA;3exK^n-R;HGb(xFF@&c7p2 zlK#Tiw3@-GrmqSi6SV;^9lGfTDxh-Zec-v(vQAxHYh$yJREk;*SMnQYBehhPUVxYNb@Bw!#Ln zO?ahUjyfzTYL_c4cGO~VOtrsDECQ?4Sj%Rwt*qF-oZ9wFCou};qMRjgz5Gbz_^^}g zhHlx-dxRaDM6HwkV;6dqUMQd@C3Ub_Y<5RdVs##Q;ktG0nh`w|b^2^E|B9(PDe+=}&1N}C8H|J0JR8OPBqLR_l@lUE zgi2{ugMLVV_H@*zHL_-HrB1OjPWv6`NK6{jB~z0U$0#b}oV{RUyJyoEb`10w3etVY zO)WH;oKQv;$!w}!Iq5zB!qjWtj9*duYkIbtylVoQxKZ-z%quekI4G6hwF`QSXEY9^ zLbw5QE9s)%)&mFj?GKKPyj}6YS&nx;s31Z_v6sVl3j}^*FIzm{4EK(X&Wjq^U^Q|? z)p(+$)QK;5E-LZub0r7&?LTm!LN7uSI@jljAH1 z#{4HU>mRcjL1M|9f1*rwXKg}tK|wSh(s}mF-Wc~9`CISZIw)T23fRb<5NMO( zz??9`F@{CU&MjsD(A7G_3e;+t2qL;OC#Qz+~5+_vBT>ZL&%8Otow5xbjmVZ7LftDW57`iIDXZrYDjPEGJk}JFR;=#c?QQw!9 zabt62pI)6a1k@?7&(^{jx_(IN#HRbsbJB^YQE*kw-Ew8?l?+4FTUV~e=3HTH2`T#Z z3$FMY7MU`uFHQZbJj;ySoOhv*QW)lgl&^A18J?vF-y5gh;VII|o$4}TX<1g8+NqVx zNj|U5i+YK^jSyB?{<^{;kSx*w>}fS~1y2#i)10HCJHdvKR&0%Y@QA6Uku`HuKr_Y~%SsayJk#?4L$tfvV@V$D~Pra)?BBhlU=meiF zrB+@_MF0CobtM}P3KV1oAq@u~f(b;T>_$WuJK9&R7Vn~>&0kW3s5Dr%S~%iK0_hG> zKh-{-H%VQ(IQhOOJ{QCczmZPrDlNKIwH)V5Yx}(?l=*)(l2a9_%nS}8>&cT}zy#c1 zrOfm^&k?fXzr@uWj=Hs@Wqy0V`)&dsiOWd#h1Lqex`<>)}t64~hS^!x^PlIoo z<~<40+d*2#k-v-0qn`(o^!y9ZFRpjZzZ72;CnOWyfHoJIm7aH~+f939O&HIV!ijlR zP$624Amodh9L1}p9R@JiN7|yVrHFI{q@_w1k!Ho$QalGB4K%m7I3eZivFYVdhX1VT zA(T<66{a-8?1pvQ_Vd{m((8@|F#(<;#b+bte5CHe0#YCYDewX*5Q`Lb-GCVGfN9?> zmWccobb#W?1m6G_fEUwJ(C$v+DEt5BDPPqo0^4ig3_D`mUoZjNRp=u@nsrgk(alh! zw>_stpG!xOUDf^Xj70h$E!8{0X;m7}El>k~h>>J!AC^70+AS%b4(kON5e}MeZ14Z> zjd&IUeeu+WSE1*I2vleWk5VbU?pP2LpcB3K`jo@QBRMogSe4Lm zt68QPc|!IBIT%WDQUIZgs^ZZ9nWubpnWMGTl7)TpV>ms> zW9eyXv}R}s)#;g>P0yikJdHxKVK|1>P=eLvDo|C>KCE5lDAIH3+M3S3s(G*}08BP3 zzz^J9&SJ`e8)6r!NlfWE7}L}6kEb)*+N*bWetHIB@j`s;TB)%g?I6Sb{#K6XP)8N2 z-v+cu;1KTjcWx-Zt&P{a(8SK{bU5*tSrkXGGZUf~m| zoAw}P?&M&g*CU|~BMQm{%65iar8O$o9rq4{Grkkhww+o$kn$bsbQ9i{O?F*g+iq72 zCzd3{9jg(_Dog0ABXw%2G!Obh@(H|T9sa@ukJ7;rbDrCA=*vf{c4niqncC1_knfJu zgwNv>a?1pKym9!Gs&ud!j5(goPKd83A~{!oDkG8p)t{+10TN%{TmzQdsi^`6OMEAv zkb6e(b!Xg|Mi=+A#v1Tyn%$RZBf+vig26s4z2VaV50GrzypQS&-dJL=kEZ2!XRD|4w zI=Yoc>?JA2%)18cPxGrMW@dj3sP-DHvSPRN8nkz#Pv8nsVorP-fx6O1;2lGW)Hx-@ zXn2sJZYhs4@BSEBvEH8$C2kjnKKu!cCG#RA;L;^_HK*hB*Tk*VIaRz%a4F`musN5i zMp&;3(yjhN`m;ng)k#37X)uQaG9klV;mOQP!ziIl2`u!Xb;DOY2WjAAn(?V6_(DZT z@L_uxoDnj;a|G$Q`ZaMG!B34w>R?Gwg-%FV!Ug;qhj;IvWuqG{=}%~d^d<8|j|F>+ z7uTlzB6XG%cuFUL&05pRcPQbV+GI=1DTkv=;v%Z)(`l4I#&q~2Ob_-CD~}oGY1{HH zW-Qn`7x$(7B6XG%cv&Za&05n5oxJVf@~|?v1}mX2tn_ZlPi-f8>Vw4yygWi6-cREp}h_w1{#E;WOnbIFv9q`SmZ zBhP7<<*|Xpb{^Wy;PjA5HX#XpfX$L#m#n;}Gp?1p*0i7OrW8+S7G%e?&$w15wrRgq zrqt19Dh9XIOZv*@9{1{=<42`iu|9COXsfr?pUMb$+MdhCl&TxqI`8|pq0ch!Z&7RR zJU*2bW4~p>ADw+XLbDSJVEDk$#5Se><9wIvMv4c^DvKg_4Qn766Ig3lTig1~rm)(a zDvImbTF7QrZ!M(E*)>A@5>2lBr&g!}>(-9GVQFgz@xg=TnA`YoA5qxJX^%gQ-Pk5m zFRXkt<8C>f59eLN8RQjy-EB(jo{vF=6Z$L=dKfO`;f1-+tD*qN$_2}<)qkk4Z`R^QHH9mmF_YbVcYj_=R z;7z=hnccPzOz(t!P#U9`daiMu8{Fg;w{>TjCOXB+=IN8rll$#oyolgBMu~n=P_kz? zOv0EsU_;G=ZvuC+Y(-Im31UEB91I*&hC!}XyNU%YU;ztQzycPq^pgc@S=>SK%WvrU z6m|EBDEhnuaqA7hm9_~?euH3D0r;2x{$>uO?QHm;#)jQ2?ICTWVjF(Ne;?wA{9hGa zW1-sj$huUoX;AbzBe+CKR!~)%pZabHj8drAF<#t^%C7$c0Iq9Hk6s2#7SYPVJAgVp zB#Dy5XxDcUU6qn%6`qKYk=Rw(Qy3g!^8K;^0AgZE0ZocDM;wR2e`5bWj^5hi|C;|6 zeQ&Sp{hW6C<&W{%_xIQP82EOO_aFMYeZgb)huwYT-FwTg_Pu}q!5#%AOJl^1%>}W$k^(80OcqU6w)_eyaeE_ZlfbZVg9GQrC3-3_-1i|0pLMn2D#7t z9YOyCq!b&wE_3l%kQ=SL-%7=l-!^i{k_k6DQBUy@#!74s#!76FPKxRki zeL3C15bcNTOJSx1kqUeZd33yVU=2SEIw@8PA{@r4bat!|`Zn zq{L-#FOAD~>NHWg+**Y9ZppA6tQGdvrgSMSy-h{N&CIx~lmFvpNEe$z%7NYMa2Wgs=Rlx~~MfyHpk z&WM7O3%^=Rtb{~!vl7N{LKp*59Kb0%(+KBMWPPZHa1A%Ror2}=&D|!zrW37gc>w?J z$+7hkS#XRjlEO0wV)$~0CApPIhc@5op7EWu zGiOHcc*bdliA69w#%V4akVU0Vqa`-rm?M?~_#IZ3YIt)vZN&+!4|em7*7&AGf5yIa^@JQMAXd5G>hT>~v=O0~U@gKX%0x4L$yc%%7E3MQQeKWg{@$!OZTepk zKsb)9RAbF3MLnkgR<(#sO7~g_!R`55=`L}F(Msc$bY~StxKPXLO;lRiG|Q7&ppqI+ zv&^&VJuZm{@T1whN^QwS=rD|^g+Uo@s9I$f0eVyes&AB$9m`d$Y?_z5+ z-(h52AD|HSUUN6r@q=CfR$h!E4tuCRW&GgfgfDq_s?hC5ok! z&ZjKf6eA>hXi0!3hqO^f*@*=VQIu}0!EbzV%0_O)T^%W zBvo8BLK)Nn(yf|!RH9fq`Mk7TMM@N;;gQ>)cW0+&T}>@OWXb!QTjY8R7KZ!jJviaM zm#d@4P`F!-|ETg;P2*FmwC_gvIFqa{g;t`4oy%`ru5DD?Hva_-oji-FfrIl-betV8 zW5t~FcxyL^P(MTkI=;M$7p=JGJPVyfQU5b`UCST?&My+D{DyO|dd~Z{&UnS8?7C^@ zv&F$18;ksw*=nc~Vv*i-W*S^le4dBU18F!{t6<^^^^M&2A@ zT6gEC`scUt9harSFe~%hSpR`AdQN!mS940Hp@iofLt&My=Rxu+^#c-}Tz^V{cyuci zapG&@vB|9z79mLtKG|Y@^Yyx`+Rj5Uq}pGX2}QV~$422wb`2ee%tU3g_7C@L5{Cf? zPu8$Hj{121@2B+oqMQ^9$n@^0jug)jx$E`H5TAy!dWk8+M(LPw(l<7mS}R!QRiCDP^kl|}LCChMDRrv7glu(JOhzp{ z!*Wc=+&CKc(7qShNFfAcBoE!D9LW(-&o9|sp z@}N)aU{KVV;AHApr@7Wlp^oDO3$Up}`t+;UkjWF|^F9go3yp48r#RC{gSMqjUUG_n z3?mAMt`ivFk;j>zLi$DbI@i-eos7h7%7W>PBb*7DMz2n4aK7sicH=zp=Ri`+H{YXyL6xJ&*)3h+jPj?dvw#2e~%fc zpqHaD2aVN?M!RLgnN2t}s8PTCG_59_HVJ}z_gMn&!d$aed zl)ZhJ*WDS}+bh?68-?kg`L`7oZi`Vt_jt6zF-gqu%@E$mB|A02}n3ydo;~bBUt+@{SJL;?eV_O$|w`h8u|!fj~VWn zIff6UHn#yYJapZ9OZ6O7O>Q#)>YCoGce7v@HWBO=)hi8Q0C1(5nPD!nfUyZIg?#J4G1d68MA*FS>y&+th%4Vp3fmam z&%|9SqeGoE%WkVf8i!)%uHc9s3!wD)lJmq?>J`iU2`Rt%>bl$X_A?wXoiLlS_k*hx z45tFCVju35fmwS5b>Ns{&!ejB@9Gj;r*}s_zJ2_=L{B@Ge1qB^46D9RJuH5n1n^6*UjbJ~zGI6OkJ7m#T9ia+X{eC1-~^rH*|Mup>7 zT=QJAIn8|L$g3;mzS^7Qc%Hyn=9q^E1Ib-Wi@!n!+s0(6bO!2rOlNU*yYHF5CQ@e? zQPGN7z%yz6D|;KUF$qt|-KP|?q@Ex=zGrjhTlBR)Iz08Ysfq$T_dE1u6{XOowv6B* z&*XjJu6_=CCKf3SB+aM>Tw{K9o-QOQon#6d#3m9DK6HY;VNwI^@xE@Fa)-;uW{O*w zGr^c_V6)F9WcFoA%B8w$eLS5dC!Lr$T|zX*KFpZlzBej_@coAy-LVd}qLZXaD7>L0 zx7HNv&cIxgn}#JT-7Q15IUqfjNS=;$MF^Sc-NA72QSKs_LUhrTtP(`?99MI@Bn|#`Q(>&I3G4qB^drb zu)tH#6f+tYwXH)gs!mVmR5#S0uW^@exXeL1p!zC~;5HY8e}RtV0(5HTExcmfUK}@& znEm@qawV|dZ;QKCS?hx^&8W^5Lg~AB=6H{seApx7f=>zPqRuoQ>{A%fo=Py7f>Q$% z69n#p(wf$e{BhrLJT_3*6yPj|b3VmHnu46FIm}OnbpEcn@rh8~ug-BHob~$KbhmP^{k>FhG5&x2g+ers)5(k{_T=E8e z7O&Mb*F)TQg2`=&&zDG?OLmcJS)l=&09V>mARRG$XhA8GPIledE3&XRh`3!x+t(A3|q(v|$%G*!)V?VP~-nI`TO2(l0FfDZ_1(!nybO&HBf;{=+_e@L( z&Q)3@B3iBhQ=ScGjQ)m4+tB~8d_x#BMZd#x4KQE8rl>O7q&Bh}5Z_F=f128&?$^)c z45J1F5sN_o8#2@05w=?+lcDq#%XPZkry=zc*F*Yp+Jt*Mu)82?As~NW&+9=b?ps7c z2EDXSNG?A`9XqnyCt7eN?-lIOmGq%hs>n37&#N%+K1odt%$H&%-$!4V%OYT!hg<#%gX$%trQLIs_yTU&xQO0$SWbV!Qz>XvQPV zVizD$QYkEvMw%Dmfr|$1h+a};+BM_KC8`7f#YQ>NwN}6kdVXxwnuRF^HWDupL?R|! z)SlGAMmS;5=_3H|d@GTZeXJhR$OqGR1NDk`SJ_(#6(F{Y=oY-sVFYDK>AMWA7P=lw$}*Z>$qD393iaG-;WsK>*+{ zdW+^7+usp^UNR0EW0?v-1Npff9h-c(<^T3b{c-G>YV9^Adrl2>z$krPEXZ|4U0Y-A zg1^FXi{ZV~j(B??bu^>@@w#=?!Ma4-4F@%}GOfkpcr12}T>-%6k>bD!SB8A=g$Z%a z34iH9l}y+_-_Bly zM8z)a%$osevjr>eh`GB5#RT`KB9@8PoK3-f8FGYAB)8S38J)YJA{p{UZ|vaNHt)XO zre`C4qz^j?Qw&Ja-b11%lqGvR(25C4q!{bIBmkce*9k`|xrmGcG2yaYhDz2Qgk@Bc z6OP76CYPn~;6AKo4$Hd}Q5by%?|#K+Gpi-dp5YWl7g*mCvQ_|&TOv|qEafSgFc~A4 zP13*vi=CJ=uZc*J6UAN=*{4sk#M6MLU~JTkQm8qa4nu7#R|!saz~v))wC)=G0eZVUnv}qod(?hDd;~?N@<3jY9Z^!Np0uO%|2e3YwiZ=T9XH0c0-)3_nU{ww zoOm4@d8(W`&=ZJ5tUa9=Zh$!2p0+2r@@=f=u{)!1jpmJ{lo4jrR(sBXm^C!qqhsP8 zc1Nti-)v(pytpoh_oV5DM60!y;U=1$k{Syo383=8t@HFabRvizgOa24NM$;nW)pzX z0tetl22vd_A^~e*fdll}8o|hf@$P|YX8ZS7M-@K14TFJ1H~7GnRSeSuvX`@KdjHkUxntJGHbPFFV^1T6r9oq*4hKtrS$iQZ zYCPaeEGq9iYjqNQN8a7pHS(&iX&i9Bf=Y8`H(58*H)%QMvIYX1Y_!gb5U~&v|31goQIhGgBG*5A{i5nu27*ggLx#AZQZ{^cFC=Ics*nY<)5U8 z`Oi6W1_@jAmh_y`il~hAMkBExDgrYL;*+j5I|6j6@DT z3t@`IV8d$qFco(I_+Ay`>0dZM^0;Xwl)$QBQc`v!NqJYlfO8qs z({BZTdJh#(`W-A1=t>&rJMB5t^vcbO1|2xEX>oJBO^oQ5MlE82D)JPDB^0O{_qVTk zhCiDZc_&(Hq+K*@5ew5S|9ESSOsc#75=b1d2SxF( zZ`S~Va-P;}G%OJF22rF{5}CD+y*VNQYe|_vi5$@l!o#X~(wKF@igni^JwOsuN!yFN z#d~>JsSzGNbqd7LE0QdXx?yH&!FK9wa=X_8G*yhQ zB;C;q3d#68;UwJTG3%t%dWyHV8Y@rglqFyK6z2H_+K{NZgi=+D$4T6>+-1?V;efm~ z6f5`3b6UE6_3Bgx`cvlva%A>+mF7!5JK7XBoFr1odxLel39SDhDB*-%nC;pmmu$c zV*vtD9p67)l(<@0X{KdwMrK1|i;MzKJ$^{YPrRaWLdWU&fLx0PVIMzO*>HDpLKBD95 zA1K$tn6g0S*XiTo=*SEdt@qZ*5*bhGd3}3CiI`p4MD$qYt%n3^Bxby^Q%l~ZKr-60 zwV0h^VV-TE$pjWu7E?w#Fiw2PhdfbX2Mh9beb_fLZIK>ct+&?pAwqVb*F@gX>}GHS zFhU1mS)b>uaO9P~*q8fI_}iM%(xp`0)CFL^1^RstXz7r)xd&8zA482>II3K- zc;y2Y=CY`~*$;^{fFQIl7U#~R%{I>*d2hG9?QU9TP2P9|Ms!=vkJ{10O;iSa)(>km z=O!(xKEl;7LI+_r`{tF)BOGJpdwC|^tViyg5)1QK%nVuR4#K>cV_-KArMJ&ixY}43 z+>zII+s^7V=A|fCb&jE3^GaOBG84PdV;^*lT_D*a0j73j#8~t`1nS`lqEEIvEH?+F zg8Bg^tfRcKZjGu=u~K_Yqez_Imc{pAV32a8fuJg0^(=~V8IEFEhvmTWPE3Z+kISh) zwzbi+ef7pJ_3_TKDHboZYS<9)VcbLr_Ib67-6nUD&QAVwf>D(|R z(56^+N-LHozq@}7*2zx?8+b5vBKhDAcQBtI}`zS{uu2E}2&BBua! zSg~Ky&*ilFTv%T5+6SL5D5{UXlW#J2*||@Am8J4B@F1ruuQFsZ)_V2HezK9`JNfj* z{^^6$o2$={bKm|+9$6FKh$h{9Ga2do3!`JT2O-M6R8PPR8Fqk=(F+eC9?D~&^V6^}@Q0+xg+nI2KJUnWhb7 zdZ0xE{h-ueEUzE23#qS5`!=H)JQ$I)5(>xi!_MaA4 zu((*0%SrQkVNJ#DaJW}3z7iRDIo93G)2ij&rePIr?S9K()?fBB_6hFVqdcfZ0^U;D zgV7e{{iG9x%zU$KiK z$nxlZ+&(67zpb2LxM*F{4XS+?M}o&WRj0cmi)1V}4Ug%Z(FiT5^<(17h?W)-gB;83 zG~M48H(UeYGsj?;`x3~lDi$)SzrQ~&bx)W&(%|S*z}9)+PpwPqQ!*N`DvZti(Zf&q zN=qOx5^k2@h4O3k#E+r2X9m4>*S)riruZ=L<;PK8PJ@3P1N#&olFWQ5a;v^?B znY-??Aa@fz-OW?Q)5$&z0ef(pV`YN@ZHw!hUQu<(z$v55P3MBG6>MMHtWeaATCS}biBUC zjiQbd*W;PxFb@)2Z$5y?o>M?gbM>a!P0YM!4xpk)^ zb4nFTK%>2izQZ3&Uk}0L?&!8@el6)t*WF|FhW|a`%ONXw_c{}+BtX`X2a%iK^00Ol zz;?Xp)5yc{!JST)sb zf^((2ZZMB4|GKc^^~B2SuJDj^PFXNsppNqYWnX+({TIG_1rgdW(ouNVntZwf-lS)~ z=o-lQ-p(oq?O|+c!EA~^69)|N8z75oJrqndg_2Aew&$RB`urT!53H<$%W@7Is9szU zI}1$(vs9JjXl9&(zP|}$dWx6LOPlbJ-Ez}1LD0j;J`!r#oM<9K^v1OXSc0qvyRSk_eqL5w12-1m6*g9yd+ zmfIXS34Yf-nRGnxDD>kBXjzm##?=hxIG2SbD*$WNK}scXh|QU_=hPfu0gU*rQso6W z4jad^%sKj+Gd62<2}dZwG6w$If4T4~XWYp$i(Q(;%y*22nE5(Eu|uG&?@gXev@99> z=p_niYXW0U1p#-%D;x)Bw1xw+a@{$>3tJg)ks(5@yL8dyW!}?V%Vh-a#~2~4v>#r@ zty63jvBo$aRlu6hMqwxWml*lUf|Z?1oDG%A!`=WVLwJ92b8c8A4Jc>4IsBZ24CTdg z6i8t2gxu5BS{ksc4F|=n+&q>oHa}gq`6}Q1gnK4JR1xK+5S?kd9Sq;)HA{4kwO!pA zt>i~9a>UW>E89E*Btr3HJjxQ`xaWbfS@x16%X;CR)o{-*sC|a&avU^HIRWk+FNJM{ zercdcUjTGgeAaZes#RB7=;Ze9#ClpYFVF7v*ztPUFV?oaD@HsyF+&ZWy>q8MdHm>+ zL;I`Y{`>LU0InIMV&g~u3{rXRReh6K^}c>KD5{Io`pB7f@At!p@Y%hyXj>9H$`T`^ zWP8Wi#on9IB4eFvPWIz?+^5c8Ke@U7j3S!`jyicg997AvlJrLo(AWm&Gdqs;>*n8s z&Fk&!V8wE4>qf5O+wHavPDvPWHY;u|>bm|q-FUSINNbpN9ac=^ZVJ?WOlzF&-6Lp4 zYZ{xS+kV~-SeYbih0CDD?X8aX+c5j!2iQ28t!k7SuCpinEl7F#Ru7OOhvx;6BB#4a zVkF*e!hqY|4E!*@0g}u9%*aW-F9Kp#5#Pj+9y`(TwPtd0{eAnr)nrE;c7H(Mb`tLV zzV3kLDyM2eEx9UpT4r&jk9FWy=qj#NmB$S~d$hax+8yhDIko>L&m1*ep~Nq)atAr( z!s!``q!c>v%qYXxZQziiq`t7SVx`8l_p7bhcx91&vRvQf>kQFQ0U%nLIke=RgfRgfVkKL;6E6)h|SD% zwO3gbzrLqig$!6>3BwtS6Sye=3Zvl^pSPPs1eI+o0wujz<)!K>4!&7WBd3e;*6eQH zj4;CuPoqFxDHmjeufFxh+AAl8qG7N}3SN6Js?2ZhidF2x6b6iiN^t*NiAQSaCHWu# zo3)F@Vj`AfsoS+S-%2MRb?cx|!MM?haI2l;`JD=bRR?sd?lz3h(?Et2yzXH;&f9jk zFz@>CadH)hWGv2&=bS?oPHYaLlOBF7@ZK(1!p-X{ zq>CL^9=gG8OgV?}M*wZJQo~+4SC_qXagj}*;$?N5w~b?bnuGCa-C542~_T>IYx-^V;trqw;bwF7r z;h^U2!F#qaRXO#L8iA8MRm=|}&Z0Y`1UK72t`Ti2jLHzU$%)b%5BsPmC!8{CU=4gH z4_%k7CNoZFe+he|@b%N;hpJw$`bX+lWUo)&rA1wR(ybYSP4+76PYWXZjCt4E>OvKA z)!#&$fSEcWudw{A%Awc%VI>S{l+t>k<05Bhw%6Om1s*lubzmu^C_ z$;6i-%{)Toi=q=o$6^!Mh)8bibi4$FVl{-zdH~k7*k(tfRGsI1-a%8VR4Bb;*0SHE zle%Uwn~WD0m%|l{0LDWD2=H0w&oqW%?W0hKbn5FVwCRZD&97SgaC6UzOba8zXrbFJ z-!}jUwl`b3?oU#?E;V1gn%W_oLzS*(IQ8EwD!{%CEYYBv>k_urEe-7^n|OSglFNT@ z8gyAMzLG)5vSOf?<-C4moL5Ni9=Xo-PKwiFF$Ssg-KKCeK0v4~3!hvVNzre|?@i@7 zbRfRh$zvAOWPAxah~b{J=?}jKK$zS}_Z&!Kpc@AQ=;imtH!KT|uXpjf&}i3FhYJfZ zTU1ckVk>u8OW=>|W(!d}0zp5NByw2lgcWyR{oMlX|@3os+hag-Lk);i!bk_7# zgy}m08tP`V=_Gs6X4Dy24O_Ike2tPS zwRJi!&v*)5i0J-P{^k7O`DCD;FmSB!MzUCEUGV(T8afgYe42rdhq#j4x(pz*6;J^Y zQ%3}g&QZAWsdk$8=T|=rA>XRQYUwKXNg5$;(@(PVt=0siwUj{nTo$irfBlJ8m$Z3w zEDK3|Uz(gWo3bbP-k3!}V>%3>uIJt^?e7h1%AAl*p>c^Y++&hmuVXHlvMowsc+f|| zIdcaAFo`dK4BW01qddoEh07$t*(6XVp{xys>GvlmD`(qqjo2;zLPI2(`HMyX@)klq zN)wxheSEqaHu9h-WL_lEzyVdT!XfP z=NT$6Zr$E0U~9?|38Nn+5~CQm@cJw}OEo=OEdPIi?%Yf$>+U{1!MQ>-$`+W@{0@Kw4kW^Fh{wDW9#{V_ zM#ulZfARj+%k|am%k2xWdES}Pv`cmdL+5#3n_%S^GN|}M+?~fNscR}>DJUGeSX*E? zRX|u+Zli;j;hJv?t$Y%ddz)EUY^2&QOeG0ShXvU=Qfvj1W8V_Gci0V8ahk?Kv;MSu zm8&7)H349yIKx>PZ_@zOPj;!zsw z97>t6i- zrX*`wa!oTjs{%p=(_YOF)zmrgfDUxUVjSn=eR|MgJYOnS*a+|~TR^>6GC&|Rjzl0v z<;~Axa^aRmkp}i8Q?BpBEaMm)Z*|Ga>lVQ@PtKYru5Kh;Td7O-O&`B3fV^-mg0dr!`E_VaR)_gcEO;#tL==J zMV3S%mqJ?WFV5nq-la3+swFelUc*$<_nMKyaioWbxsahmCFF%w(1brlctbTI`_OSl znohl=f?V#RWRoo1ptOq6Lsx}ORvkO?=ul0b#dT6swENn$swC%q1^c$@eukE0w0lI> zqF9U~xY@ee>vH0e8;J4l7NJtAzuPp0h%bfN#g7vr-w~{6*dfBWJ9R~^Lod2(O5$1o zoEXsgl;JmMg7Nhfg{6cMNh-Fw3yjRg75g6{cHa{TS;(=RqMY)5L$ivdJ^qNM<6Og* zkC;tiuNj|NHI@D_ndNCqK=-Jn$vbqvD@c$*8I zxjQDnat(B)x={>Z7oArajg2!G*7`zAbArCyQ(Fky&)(5vohlaUk&OrjOcXm2O^<+$ zBknZo@iS-$;O9QSI{wD$a+~n)^|I_K06+iBwgJE|z9rDjy4)POkpV^s00jPriw5V; z+t^PN|8EZ&+V}rs)th*rCI|exoQu30tYN;+DTkhJv_FRj-NHX-FQIRIiFTae{7~SF z>ZQ!Z(tU{`x5^pF4tg7@CvI|{uXu&I4-)+I6T5qdLGRyw3||2>vw(Zt2eiN0jn*qH z=<#n(~)V1&w=#i{rPo+2D(YYWRAO7g=ch<)S(=(DxIZ2atYZ8 z{-c-X`1hX$`uKV?7OS|@;(=YKxG?EnPqB$p+(m>O<2ixD9G#3?hVSl&Yp)|BDCmw8 zm((4&VP$B*+E)npOer%#+Y1h*>gqkBN6Qc{-15a6M~dXVFL05GY1vqglX&5C5|`z$ zGt1tAA_`@QZ;+g$8zmI_7#8{5Uv;{V_ZS`233|3*aH(i=i_^N$WUq1O-podxY390? zO)jX^rEBB#CcJB0m_2Re+9>8I!=6QJZDS=?`MLA(Yd2$RB2Pr$dKHc=G>y7yCeQJ6?xIs=3cIz$Ujl*iSZjLB1h9AU@1b%R*s}VAU9GDNdM)Qb z4R+DbPzo3|KX%P|t&9z#-E-=zv_Q~`>{Ys;;oim!tV?Z9t|H^SN4BgZkdv+KOYD*% zt5jjBvUPYGxYluHLS4Fzs!M6*cpyhhaR2Lu-yb1rP~(d@%ALxCZV*}4-iQ;{Tl`st zPe91c;JIib%`^W9GJv3 z8tUQZ;r;?+rk--s5+$xaqVLkr+oFs)4^^y~wp~A5E{@f|zzoMT>LQjoVOYP&5H_t*(M-FT0f{3D*Zp&XA40ruKfm`Wot zR^;fcSt$)E8TBgfE)o_W90GBfHa8j9F3azw!9X63E>6g!!!0Q|1q0?HV{SD-ut@-T0q{MDl!ZNnl2tv7Ft+t@7O3o$poKJZ72%u|PJtA;wG%ilK_edbN z`G4o65YmcYZWR5XIIE0zeQ6tSy;#A8`Tgxt70zpOv{hQvQRjk+EjUt~ zpVp_8s8ebucZK@Dc&}AbkXYfTmEpFl5NheTf_f@Aaz01JpsQ>lw3LZMcqq|JhtD0% zgXZ&6>Vj=R8ldn5(N6U7Qn|dqMS18nL(v?NZM5)6PhN248QyM;$j2yu_i=jGnIFmg z)WijN#x^jOBp_q3H^pmQ$(x|(SPutKvh4q^>6JeR(&7nl$o-%a>b$wWIaq@Fs*GpA zL3zg?GmUR;TEkY-x`GNURl;RM5#hW51`e1gr_0CNoK6hDsyh6Nx4-gWIrUq%?Xe?c^1g}IAiPnjSe&{T$kr-Vy zi)bO1OSyAdFRfh3L%R;0x~#K{Uw^NS#L-Y+{RV8J5si7#O?HBBnXERN1Rr;I*lCyi z?vvkRuS<;BXTJjuI^?h;jyfhBA33HYCYWfF$)=zv^FThH3kmdQKI-(1Gd`;HyAXZ) zhF}2wSuBwxehc7QPuNJZr-Z?CDGc&uip-BXgVQODvm+*vN*dqrxb<>Ho%F^agR6!x zjN#P10)_$>Lu967D@4lRyA@cUP(1`|5P}LMH%)IwdhL#nXnuPdiE5o#G$MyUmjN&BC@}gHdCV4R<&r;*%=2)gS zZ-;GaRLNFRONW_ybo3z0d9G8K_gpr$%M`;@2qLuJnA*@YUGnYswPjT)J1M?QGv^iEF+T!1I|@x|6c0q*nya$ z#bVuJaUyO>PnTO1uJ7&@snd7vi|EsDz+h3*U|P4KBXOu6O_d^9nJX2uDM`+)mL*e( z3{nXUk?xwi?YwN-vTet%JukfU%4>TdXykSd2?I!3B)QbTbyTdj%K(*(tVZy*3W`d~ zYz~*Fjm#f1C#SG`jRS`7U0zXUV)=7;A=nx@81wG3y{OtA^ z%jFe(38L;M-Qb2b2sZ}QLMI4tQ{n3pH3{av%NTj2@|AliF0^{(4-E}%zVYh?3&l4% z8dZtKv1y^Gnl93`&$O!KRCmRZPrnawy<*r!fQ7+VYwyCT?s_$PL!N75YBVY@NTo!6 z0i;1`0K1Ea0v902o3{_$;o$;l`f#(&1`fiTBA(aumHhgG<#LgSFS+g`$Y8+-{XzfQ zy#J1HmFJLJA0?oZ`F7AdW@`Txy45jD-D5+1i`NAqN6BznG)0K>x{-hpbIlfFT*V&y zG>D5t#r)ePB{mudAKenK6JCdg948$QyqgYp&3+PE(j1gb+$R;PbU@N5U69o^k^SAy z0t9b;khIXC%VFi7IyQ9NV?J#jXhLr@`Jm3B0(PBI}mR2xNL2fsN{m!R5D(> zM;1&3Mq3t!Y4z<`H>;M)cmC0l@&nm=Q@-aNT*_vUU!4u3D*l@9{aTlft`*ulbQ@# zCeD-U4(Id6Kh>Xbx?h3*!JkXtej?9j?M*9r`vqXsz(Rt7Ltr3aAfceaEVu;#F^#Ad zBOETUkYM083Ts4JXap(-V+$n`^VvpPGxAifs;IhZb7z0~{eB717RrLd1Ao5Z@$s+8 z1`tz!N6alX(#%`xq+97F!l1w*AYrUq_W}(L18d0a1j3Z4xh3uE$G9>BhZty3lSNpU zt}O}-8phD-TP!wMv*u#gIdlFn5Ym7&SJJ^f(7e{^1ZH^yb(i!|;AHE!UpG6dBn(3F z;S4{HZv<$Vjf6l9k%D%+2emZl6Nb~IjSAKK3)C(k!{r(jU$K++3lzy?j8RZfP*BiC zK|w)52L)pk6pS#&DW+ydo2KseCJjb6)Ka(W)UBgEE$of3!D#EbhYrmD2RbSqONA8w zKj7$Vio>oGFW&JS?sb(-ML|2+c9QvVe=!vrquuyyO0O`k*NR|_<9Y`ZoWtnkZF`A5 zZz*HAylDvn^F0$E`>=(;lrjFJwEswXH7~m@V>fTofsS&92pLEOZ~-CD7)TsIU;#A) zxCIHil{lJJ2oS&q(2NKXGBEJ0qz)MZg_#F|r@$HwHSicnLL3sRpbh{4>cA@SW)leO z8c}hE#4c3xP^<*8LI8v+SPO@`$AJNSaP<7t&}0whORomCQH7%6l+8MoZX}41fkY4& zS>uryNSr`m0W|}-1qr&9IGR-m5Woh|j0_PnFz~FT>8t>O!px(TrDjIrmJm*oI0x}Q;hCpF$UtJ(OmSH)TB8Qvm z;_%(Ck3jRcwmHYDt?N+D>k4ss>Llu*Q{GdJE^-;}cq7-;jyIhS754P`qu-*LfAWr1 zJ63G9ZDrs*hZBhPFR{Lf6L`z7qnUp)rr-oRFvet1a0=$ZHSj^yHJXJY@LO4lqL8+u zUM0F~*Inic)gE2lNSRS^h>(FGFa!#NQw$iqq@e~-S;1g17%Xob3>Apz!9(KZ=l?|n zb9$=&4-;DQE?ObwdH>({5IAkDpdd9VhRJZzMO)yC%Y39A$BT5@MQle47@2v`GG(N7U=y}De>-l${c2pOGXu$1QrI!d|$ma@{~O2qvVstTWv z1@jMK>$qq`Uj}`}wx$mhtV9J;zC?f4rdAIKYdvyH)H!wkXOhAa^8CSoBSeM*Qx)OF}Y0 zyOA`BNi3jktCmho_e!kj#X%0LKU>y%PtxmYs1x=z3J}=mb}`>;uX8%Q&U;?}6S-Jh zMDd~d_QDlbbrU)hO!kc2sGp|733OzT{w^H*De4LK-va4hTRFvb^$;EO4#57~vj#MP zYd8R{Yl_C4sZEL#QhGQJ9Cd`F_HcB#Te|$94T8m`v)sY5|OaV<8%|CK5<-3gm zhrD?O#yrv2jsJhfK{wAvQ>zBcBsgS2xjtXVwd*jjaT@>GNyV=v)6h_Wut(}Z7?8>a Y@2c33dQ*#kb4tl?mA+$C!XW?v0J&JFj*u4!LLJa>rU;C1bOkqZcXB~K zQP8}cae>-Abj&NZVXKzQl=zDryP>O+Z}v19B25rG?hzCeV_+^#b@D2X7T5}vXzN;O z;_B5pAbTn_B#rbil;D;i3i0amQwV%J&$X5GLuOa{k^KBQ-Q_i*U$92DQ0h^BgNiz7 zJ7?(D&H6ooxA}4U&<@A6+;PMLK~(LFOv#jz13xMaN84U>$NWxKy3+d~iFSwM)v3iO z>*cglZ?zOIX*WZ(8{g)&DXTS}f^yf~|LOC`ZZF|71j*RhS#t3;shJ`#j=pVEIUWaY z=0m2-blJ){&@)Y1Q+u|DfJ|;rU_>mnMbuGOJ@tkE=lT1rwf4FHy&tquaZ4D~G*sgZ zWF!r^uK&DU-~U>BA68XY7n-=5*K-H%VUM5gec%TW$=UnSj~wIxf|w48Bhszey(A(f zU}&L*7ED5rM~WhEMIJlGj@Yo&_j%t3R1~a)qEZtiKtc;q0U>~r(24~GHR zYyZf`|69NM-PeD8{aUT>dkSjF>e)M1A~c0@xadL_E@Y=s79)`Y{tql$klE$wy+{EN z22k{X{6Wy%Kz+_-ouhkFw}x@$_P4R)`{Y){tXOD^=YoY|Nq`_WKc^ph}3h*-6Y#ffs}4-C}QyoA9~8 z1(H+B8Ir-^uH>q(2LJ>D&ZqbK0~*G@9HP+`_eK}`$2R*yrwT0)5;RzXS#3o7*3^!B zy?yHpQSELHKOimemB4Gi{Zz)7vxR#Vrp{nQ3M1tZjF_AQzzjLXPh|fs-H@Y{x^L#+ z?O%FVbVc_FgG835z=$}CP;m|$Q3KZZ3q@)#n_gcG;x}};e!wZsI z@V2MoGn3l6J;C4bJrzu4O9ObD?LTMtiJeN({0WJdz{1TnrFrF6+1l)jLu585voF(0 zMAt=#MfmQ+RSt2aAj2`1GhAvi_j^sv8DRIqeBeT?ZI`JiT{Q0e0L*eB9Yrx+&gQnu z_HyPqq*x{C)Rn&frIqwo*RKh!(d_7?%LyJ}J$GkYcc`D25$X4@ElJ&_iJA~ECZ{|t zIecmMJ!@ylg-9JDGqPC@Gy-GmK2@OPyqEcDBw? z{`}C|)()yP;XtA=vNZ?lACyBVg!LSZ@cjR!YW?;-6d@K#bvI$jHsy|*NOhDZZXrr? z+|2wC@-Du*uP$Cyy($nW05k$AQy^tCNHz(Q7AeU#stTaNAPptUb7)Pn-AN7$%JvUx z_xXpSLeo+Qg?5LiNm)MzPyZC${c||`lr_6vEN8dBZeL9+7Ql$mo_)Me{MSw2Vq#?-=GGMbiNZKKz%Y>1YY-#>1*a6`B&D%-0`ekp zg8~qhOY)aTLapJcraukYM?b327@tiP(*1I~ftkPbqZWl_JVEgt#TW?G#XRff6$ZK> z3`%Sj>i1Rx1_Dw5|Fn}Id`koCVRv-_Xe7bJV;WBf72SXW2@Gd;rL%u2c%b0lS)}{E zh;$i4jTy``b&YD2B*&g>ZPULkwjejEC=X7_qN zcqLm}enCV;WRPKa438kU>}U0x5Wq5yBI}?d2nf-;SNdl%81Nc*s#TAy}m>D?wnqNu1uajm55<1eoNnGhidd>^ilFhB_G-km!4I?`oQ-8s@(}viQNF|9c4gX}*MHq;Lzg%l7F00eDx=mYpOKm(8=L!&fKQ}y>u=(GJ^6^Qhx24L(-y+*`ZZpQO{}v>GMN1C1E9PwyQ<~n)W;c&b*$JQ>UmCjm@Z~T3^QA+qYH|~U5_89D zxB4VS4aK0n``$YRv-h1&dPZAk2@rv;1LCf{dKPfpggyGofJ{1h@RyAC%hKjlIr&X} z;gq6h10V1(kbndJx-Ek-<_;9_U>$@XqO!!O+8(r_oR4B1FK|<1`w|(Hjm69RZDsY0yNG#KQKEXu!(vGs0$2)< zAOYR;X>p&Ci97?toc*EpL+MxCL4~E&(~K`wJ506aS_~`$RHFg9bC| zn>Mlutq`{hr^b~mSulVD+9Q`$`|N0~jjN)sYJ|l0MLQN0MRnd|hZfG1J~3ptzMcY? zZ!&yy-97Pd@s_EyZaNld_FW-i9qZVu5^gHkQK1v(7d)oKwcYVD%}izi41!ZYc~I)S zzGXRmUyX8Sf#HRvEdE!mcN;aLyj}rnh|s5!Xw9`wt58~e!d%~^xk8xGO14Jjv_k;y zp$JfETA>`F4dMq>62eGX)zzSYowXa@D1onWC=^#4sqkt~EUBBx=a7@%g@7bj@Woj4 zmbgnLOhqY$e6yL(mVJi-BXiYtRi)j$>0Lc#1DaZwzOeV$DT4>D!Ka|4E{+Ktn&?*5 zU9$Y^K9x=>*W2{7=In+6^mU0mQ5Rv~IakmY9C1)@O0{Vup)xJWB`nT`f{Lu!rfLhK zi5c)p1P`|7)vP^KmI7eZW-oHc-+_S<)E&G*LWNP>s2cp!7n8|4Q%J@WHKQ{3lTTOa zMhNIDGh*;|EozAuA$HY^aOB`{qOL4iT-hlXN{}k)H<=msc$~x~rh}C%QE*svFB2l7 zpMmYG^0br-fSU6m+mKPp+Dyxugs1Pw86hSL_Udrwwsz#shWjM6B2!3(wk!lzR4J@o zTawxHb@QO#4q#bfXmJ5t!Ed#<$mOLcIsh&qhGCGBD3_h;##b|v%B=f*?JZ6QXc(e0 z@~*86V9&R-mMQRpiMvg?3pq4C%pZKA)b0uICk=Mw|@@s0ZZs~-*kE4 z^2CpnGxc6s^vM#{O8p)9B=O)!j=tppnfOcN9|Bl$AxJCEbwC=F*kRu8Jsw`( z(=$Efe81)io(u%(QI9Np@DsUNxsUs$x(De&QP2aREUxsP0-pX1N6cJtw2N2^xYkX) ztl*-*$itG$-#?dL4OIKOfKrdu)X?7|#r-_;sduir3N>WV)DMRXGk_IKIHRj4!~21VJWR8Z(L%apVb|{9+U$ zftzG2ahI!+MK%~>v(pMW?qwVJ!%ysDcx)CG6%}z>d{lhI8F}*Rf`+b}n7BWprtXh9 zIJ)KG=})w3ZMF^_{b^$xD@;`81OL#Zfe-1l)hBe@X%_?bI?PKaoM6Hae&UAAN-}Rk50tmzrj++_3W*O{946o6 z7bDZz8>? zn&@-1F%^A@UQ&D+gTVa%F=80d+e-q*+>nwSas+!J?nzXnQ3M^;LVPsQh44I)QxS%` zvoTy7;lo6ZmZRqwIL7=zqb>{VWBj@ot?bM9l`hcueJR*r|Hl5#+HdIWODRWl0%wh7 zop;-aNZar^dN4uG_w(f~pE8xAoJ$0}$pl*pL$(8y zH~M#0_NB7wFHod++YzHt^{OdHnu?uZN;oQfil9gp6^24)ijfDKw!;jHphy)JhC*hF z(FGC`5)u-+j$~HAaD+zdCO1%Mt=y0oyx77ITt$ykXHfqx3VI{&qeCAyCi^t{N%Ohv zulh0;3VrphJpYeviW7l%3_75b@SVitMPVl;{0NgSKx80O@}l+mF?K14Ia!$cID?2M zSr%U!gG%sgn6#uV<>^RY@Cm)luku7C7^nhsb{UuVI?!2PJKp_lN94c&c8Jkwy*pU# z@Z=FO)E;cugX&kl=aX$7*2CTQE$|!$m2d{|FA*XW)rf^54nBk?ATUUV?9m1zeG6!W zZ=3K%h!i!6w|ykiWyqA}xvsouLYe+?%rMI4D*6{N#u#Ia4?ZRRXg54&es?^7gnLd- z!m)K&Y&vKCGWsxE!J7D*8wki*(-+2H_|Ahz3Mfu8)K|>zOdUKN1XJv zQ@-_`AN=Sizl5UC4N3honVp>ALkTssR7M^kig?qMKsqlXMsd7t1T$W^ng?RvVz4=a zEkCxYjmjl&Zg1rN%b*{*6XDX2Sbk{ykq-_%*T;$LL+J)(P<4rqpN3_Vc2T5nR4?W& zIZ&48CuI40&j(KOy-#ufFrPjM5R5Qsq~tJ91QAq{ntEttrq$_pF!4m;wgV~#uFq*G3N-5GB<>zwm0xagAW zZn)`P?|I)R?)cPq9(b7V2Nf8qFtLfrD4AK*@o3YP?!Ajdz={{I6iCToo(LkSBsKNW z$V{u#8%<`5)n<429{g+JGokY1Xh zKPZ(JaW$Wp_K`gm0a50;u#G8Xl?=79+KN8wZzM5BXI)AhGVi_7-P76PjO~2KN7po$ z|9a3)`d<30p+mG!uO@+4`IMnoA$oVF@VTo=BJG;24s@{3c$<(r<$+;G( zoa43Uub`^qEVR>!nxUu77*lEFNjlG(G3qTG4}|$C_H?eV8r3LwXCbvup~lC6O;A(H zsDZgs(V5&R8Z=l1gCWe^u*;HC2JE6x#S6M)oilnQ!x*&HtEu%3TrHsXT9XH+FEs+M zdi2bcS7e;1CF8!`smJFX(cf*?KnN=!OkIa-2!s(3CRCmDLKu9K9izEh3CtOE zf;=)7lGV$AWSU#~hTYo1EG}6D*=k3Vxo7!F0wrqdS9W)K3iP30R&a+p2niN>sbRU| zBCQaUfd9zRl%M* znVtWBr0b^1m^;0BdpaaN(J$=A?h#gt>!mDIu;cr{F8J zJDi+%*uHo@UOKx*Rybd zMB@5W1X>2$)wr0IR=N|=9ZOlp!IABclkaCp2K8u~dZbrudhzr+ZU#7D&odwgtNOKl zjcai`+gP|J((gzx-Md={SeIMB&U7Of@Tz^g(VLk3icK1j*xxGmZViA3A~a~aheD+;3`#)@h}uW&q2(T`PY7$orOi$;uXUn*P;&-$r_W=l#E;*p2QB6{Z49re0do!@ftt$YTT%y5gD}k!Zr)xOxVXjffxU6xTjn!W+TU~_nUp~4`} zYTLsEA~7MA**f(GN1e;l(A3h_(bdy8Ff=k2i*7JB8R{Ss4odhi$|wvo2Hiw~nrP@} z9JEY8C)YtFD+peJ=7BRVpiE)RJ1ojaY@DZP`2zD5Nj>;K&;Z^7Rw4Yz5oGqT1dK|SMd8u>b#yR5A2li{dMMl1?*9+djQ-2)XrMrUXyNBJ32tGn zUxVfX?5yXW8k&WX2>LaJ461e7-|~}3!3_2@eRWQ}6v6fQxKrLz&)VkE@r>}5zvWF_&BP&pE%2BA^ zmSa$TAZI`w%Q+aG$a#pqCRbqfnQTa3v|fw54drWGcp8ntQ*W_3wDYr)3WCOdoN;3&AcTi2kW z4IZAv%Ui|A*M>&^s7atoP_PZnn#)?WMs3=XcI|~uomG}umg~};^yrQH^tGv9f60J> zK!yyJVc2j4BSs<{HClx+V__|~ymBk7D8sn%NG42#F=;ZqopzStvEP#Y=s%dBNy&cE zEZc9|75K}$T2Fke^wg(1&wNgJ?n~GUU-P{5J?uX}3jGh$;2C1V3!!E>3S*8J!dl?{ zNUevDVxU8mG13usVyt8A#zZIBiK$Mp8#A3@8grdv77JZq8cSVb7F)WF?by>j)Nx8r zVHIcfERMwkF?V1eQVmdx4W z3|PerCt3V9PvMubqnsrA?c#Tk zjNiR-#9Q>U`37>T{`7+){?dnZ_^^+QT8btL>Zg((pI$8ZTc6j`RbQ;M#Rxm0oSn z*C;y|uNC${1hf$_RJKn`iN*Kn|EIpm*l!mkx+<3c40aQU(0r(vN}KuzP53X4RaXoe zckhnn{BN8}W1Cm-Xd)*$w$X+_aeq8eSh7Bx!Xj zmr*RB=GTW~_xN_lcK4(^}7-M976}F?@9neyRw900- zZL>>oJ8B~iHcoEWfAca%4yu>~#neM~nCx&_`o&zcY2hK;yD+^9JnoxLg7_D({TMfW zAE4Ww_nyXu5y%ae?Cm5$9nL(|5j`?W%4EQIXrr0` z6$#06qZpYrvsa$`qP0>Aap0?ZP#0;a5i&eb9rd8tA86M>jaM`e~-6SDxK*IU`{&k{VfR)13l}PUYaBO|GwIPJ|3A{G~Ma9md09Y=~(*6*G+s{ zn;}@JP@zJH{dSQNVr#pSb4HucDd0GL+LYzCt#(@<&`E~>BhSxyfqXd@jp*>5bMo|Q z9oX=r-mB3`_!qJ?O@==g=l15Ajb+&WSg@79(#b z%sMJ4LtwF!X*;Gat~`eGH<6>bI4;=iIWk9oaeZ$~9`Gl3)&N^5!2LMi^+PU{bQ?^j zF`Z*^_z$@gQD?IpS|*xYzq8g{`*gMduKk%{&eTe;^C$p70aNATZeXZ<@&XK4Ab~T- znd8jyFzO;0R&8?toWvaO09eR5=-@vX)579E4D&aGWz5yAGglI0K-L?aod(2>Nt^PA$#yNv4n8SN(KYVOZERD|| zck(Mf$9(*6Uoq}J=_p?s51#b*Wo+q339j_J;7d=0PN(PIs zlxMjwVl&}?^r|U`Q0s+dPrlc24tD`Lo6r6so7{$y>ulU(uBXbuz?hYcnpw$QPk9Fd zT(A>j-aqw%bf9Q(`nDD#6K(f2VMyh4?GME#4_9&^Wglw#f(Lw9%W78Qz12aBK;RnA z*lRq^gowtZ9%bSL@lDYXiWe`msfEZFd_Olzd06Ky#^zS-1GOn0cO0sFF}bjW_mwHj zzw+)>wbu2S;>pq=OyfVY(!KGl=KtSry#D>ZKN8vC$X9n0S9w!k@gYuf?YHYXK5dHk zXa9!2D*!SBWDZCNkOd$KKpFta0FnZv0Hh7b1dw``LzWW_*_vgL?TLi!%wovyL_qdt z0p!TchukxBA*-1UISNPxNDGiD5P*O%G~RhZ*moQ#x_bJ+1i@hOM9=~74~+f8G8*JG zDU>R;mbOmfj~*Br8RIy`+1q`Tb%Pw5!mFRH^ff~Q89pG$GC_F@bMg`<|xly;x7<)`?k z{b%TJ5U!ngd;kNntA{8Yg)mui5$;V?fvkxcHwb3T7Z`7{AWDbNFC zM8Pdh>qCb3Yr{AclWz^^N?v(Z#shyd8*I$U0Gn_11TE686#xf+LbaH|N|a+6tLi|F z2Dz(P+H#-69GrB+#2Y5PVUjKP|NiC>hxXv{F+TRk|3shcQ-0dd_?bWJ=L8Qp5uk&D zQkilUDpjdgqc%>#1_vJUF?7~sMW&c)nqnoUn_;F|W-B#EnK!-VZF6%5Je~TNSq3t0{g${{Dkz%U zBmt?D)_Btxd-tV#GDaJGa{6*MGj89(syilI&&1G3Wz=w;sPFytMO($$Bq%0~ycU@= z6FE86@mgC~vWJ+_%1N4{3w9EQ+|)5zTN|^^gytn#oY3VaE0Ht5$E^+X?)H@h&5z{o zNw@h46$>-p5l{veBNWPL+kr@Nr|8Cq#@2@1@H^4!rT9gUfEqAs&_*Y8pd+2q1#)56 ze&^X=PrdS2`&75@z4zK*=N)@@-_RR-Q}4+&zn2$94!Q7Ykf~&YgI(?+uIz9}KF0m| zMc6l<aX>>Zup|l>qG~f_DtYG8>nmxO*Hd>mfwo7^kdrimv)};l>dGPU-R5^ zx}15g2ep4|_u1%uRW{k|16%yZhd#2^$F}*e?LP6T|JmU)JAH1~Z&f(E+FtwYcfdi1 zeChD*cb=H;yC==z!G-ZH{u-Dc`I+egLV_SjgrbyHR^`=P55QwS3H{GjD7d3KlXx0a ztk-&Hyo%BicimGuHY%vdTZx$94nXrA4JJ8W5e%K`s+w0G+#LMql3wSv`Vt@TcL_?znkVefJdLv}agf!B}?6F`(LX4iHaFjue z7sivL1O!nx>vrWr8Y9QkV`!vvM?e_fE3$+7Kp4mi4dw;Hah3SK`q zel^Z7Bo~sECZR=W6FLNy&?T(Odu~*4Ek z?|Gk6s!|nYj%7Ac`CJr%V4;BcAtPizP(Njb9R%hVAVLe=Z%~vTgg=lNBS?Q?ab|G* zlamk#uK)6qtdurSifz|+O2>C;BwDzfWrNsvxx0VEG3YXvyTX;Oa*b~z?xUNg~DGcQ4fLhoH}NF+JTBQyf4Kczq4HD+Q$^)vAMSCvkM$(?8E-A}Iq!|nQ!EZX)uL}?;{CofQ)tq_5}M^&irP8y>2<9V zzUZTjuWq%OZw6|^bbB1v#L1ByS1Ho|N||$W7bEAp$2+=T8gom1gAF}BXM@X~i>&jy z^cib(J#)ID|EXKNqlmAc$?ao|`bE_4WJGrPaM|5HN~o6f=bj()GRt`1Dz2^BQQO%q z?Uh}*dGbd)xOp*zMnqi|)%tT2B*9lqLMfd^T7@d*;-j(|{6$t8!dcD{s^t|3&Lgz) z>11RnQmvFFmtYW7C%XCcl+J&x-nF>)>^q>*8B76|l(dX2079^wygc(Q^(RFsv{2^f zL%wJOh@!HvgNEh)LTMD7q9(%faK2jYN=^n9`0%%C6}lgS>LC22NVo^Kp*J z=P&&MxDOxv^WF5oTiXG(w*M79@&9w69WKZUybVA$-5jvJ1Q0+0Hl`2}Gd=#Fn>Tqr z3UBm_wRg!+-%=}NC{${nt4uIcxvjpM*(zq(*;TH2J&_1)f>uWGiX*qF+f`Ouy7r)% zHh7~pPO~&y^R~9OuH9&xFwjLVtJd=~L8%UC62nQmm(28orcBHhzVjTp`!^4H7X&_*rTjIG;=)tRGk7e5x$ zsV_xEtcrE2Wc-eCzM8yZn~v4s4GT9%LxUNVF7LiUg|t@);KT$f3~RP?gRC zVpxSJqEhkE0copN&}fa@tj*J!+v=9oQd(9kXr-&2gyDI^4C-^3<0PG2 zwal%r+{zF&T6?7$t$x$nlB+~R8*i}6Ues;)#aJ3EV{L3%wIDsv&ya`D+0eu1KWdV* z>%BXVG!wu2^ZK>;&I3GgerVn*D=rD#e_1fU4$$Y{|L9*1k=w56b=O=0u=ZBvIoAGk zpkmNvuzBF>fv^GB$M?H*VB0|QfN&swAa)=cVC|*=YaR=*=Fk2A^sD;s_s8@L`uFtf z`sDy?j_s%SFZIv&(?8w5|A7A<)CziH0pDBy`R@Mk!X*EueF6*j_#PpDgo}~D`~`3O zidVUowE@(skd=jw%*Pk6eWS)r!dI*_N!E#jKKsHyT6gOD8g9JFruws&y#m<#UxW#; z4v7S4i=XjtzuMP`$+S;mrce95$4vX+oPF=hcpmH^RBtU#|E2i=f~eEXM_k7E}ED`HmUrDia(V^ z_ys1a7<8}uib51b7iyjbVkJtJE<>(0)@s&jgMJ6RWQ38%rA;hs5#hUUH`%g8u@gs+ z(D~f_DXR>Ty4r%Y`+Qwh?@})Nqq%iDp7rL|u%=D1At9lw(9tt?!nR%e?z{T`-#lB- zcg$Y<1Ua(E2o&e4>tf_MotyVAbDg{0;x6~-$VoF6%|yD0E5ag+jX|EuwSp#vE{!&oex;gKYO|O!S#q@0X|C1YsJlh)dOhp(w+VL| z=n!f*)@QWWNRQ1!HtaPqVAFo<_t?14_CxkuVXwp2IC`BU*YX9d-7e1m7-N)-GRa_< zs%tweb_BRnsJf38WEsgWbLQ;W@f{uZOjf2ZbB^5QzA9O-%o{vEt2d64bE>srg zE6#u3o7A!s;8D=a;g+jG9*=z9{zU69mEe2|G%DnW(4!gVGMI14d^Q zSY!@a@k#G8nT0cpcWlH57BPViuc0!>!1%pBt z%3%VE@oc_Ck|PkeER#)v{;1sPo!E49OGDqXh!t=TuOXGEj@X%VgH#=|yV+(`R@}?n zz9_3tW-r6cxjgg!diEl4RDmaKD?}eIkvBiU-T$&UbBMhMH>dD~g_yqz(gWslprumbJs0kjSQAm(0HO-Ho@N}PicsQCHri%-&J1#s3MX=3}*b|sxrO*lM1QPk? z5SmkX#=l8oy2zTrt>iUyj-1Onk-9TL3>jy@qZWAJVCv9NA~Y$Tocqf{W`*6CRt)Yv zMlYa?U{B;6&>gym>-vJ&%si6We_N*w5~VOsdTJnM0_CnwV@m!)mD9nKNDe7 zJA++mktGFluAf}?c%q-d#EEr-@>8)aT87-_t8FaB6YR&yN_ZeLPY@B4@Z7O7Ou+KY zL)LTf{gH)VwfP9bba>9$r4i#?8}b_`Amy6fyGoGaCZyJ6T53q@l_EQ9AxGz2H;lPp zXIN$$8SP>y47RgZ`-WLrs1@y|`Pnq8Cb4ycA}}F25VPD#V}{$}V6|c`nU>?4h|;-@ zmvXk1D9{=N`6l02)>c++NCq{vFykUfvId)2T*$N-N*=Rl* zd5lKOi-^>{6Z_%oO;YJrsav-YQSrM`ExUo)pmZT;K+MGEEiJ}@J4{e}Sdn!Jm}5RX zL)Y9^tTbPVz1ymYh-UeHpP*3+A&M z0N@vG=4KYulG>uSsU2#U+M|w8_t5(oXh&LeoLs{ldWmZGWi3vFgLO<@6DLqGyfs#v zakAoTLp=>-k^Kf6^~wt^ML9X8AB!;`+*Y;|;p8gX&~EKhp-`S?O%=sGXF#v8?mCMl zFhkQ_9&|i{VP{0=AHe4xZ^Sdfr1Kd$2il##bx(|y9ZX1JiJaxm>1jDuEobR;kFnkzU0Qlo81)50S+>|e3y1mQ?axoRXh=B zUfB5hSDvb2<{R}YMt;&a3taT88MjWmFL{_npk9X#fLx!_vaHvFyC)0 z7yEln8b+L9;_xGGLfU>D=8aY54rYv>!qMLO3irqcg^&3uXa80yFj^9uAG8t=hqL*a z^BWM5$t246qI}=uTjMs#&Sub{XI#`iMR7gAnPh6jxC6E$B5pf{5wDkWgxR1loyH;2 zU=Be|KNIMT&3zC*Ccu!u7$n41sW8qXR6r8xNo-t*AwH{0WmXNUZS4<)*oKr< zNg%}`QRkQ`&sq-878wQl>EE=zP^v{1VT?Y!{sd~^s*n}4Myc4$wQQyt6ecty^#dd2DJpdrm=z<%Zv<}o|70v zScz6NL<@NUSODi*(;vchd9l_xW*5cDvV@^iOC>phY%=3p)$ik|7Qd?AOth*b%y=k^xIr2o|`~tVNSqEnU%P+&qgxEoQBkBG4se<;ECa9y|vtmmaMp~5f>TM3-56~`r$N?wH|MvQJl=^HnWq?ly)G@0V{RZ=OsfdP+Xw_I72 ztToMRGIS48NPic+!N}f7`aqWOO@_rIIl-80vgoT+D zevvT-L#YE<{*OD~EpJpYJea%MF4nN&M(*02{NS|KEF_SPFvrU zfmC1}p!^4a`!w%|OCNT90`|yfI!R&)dS3w$ z%Q)AU1`%Eke`<*Wg{0@zAYpXodgR?VQtMY=^jA+VtMJbif)ZbBZ6}_=1toWGQFyY- zjsEFXHCxhSL=%m-UO>0T&`@I;g}m8ADTnYEhQLC<5};gC$pSjtt@Ub~dW7KTRRmEW z38wQAUa7bov|~}(F(Mop_ip0E*#W57=G-hb7lG5J&_)=+>nK7;5*s-r=L1O@YaN(+ zuP&UVn?fPN1J6<2JFwVRtnH5KNbv0;NuYT@)@sm<##tmaeoCvA9`474uwy<`RU3Mh zx-VFsrf{S;hh#-#e9a>BEkM&Pp>n;@2SB>gGu{Y&1ap0wzTydnAk}cXh?hdFd~2Ai zyAntl{F(pamrSX9TD(UQ=FrozH3Ui?q&mMuJM`S1`+KlX-gT!Cnb&*ZA1r0&e<0By z(0aydG#kC-(c$2+y}0<3QLzrU6t^xV`PFU{&AFbMj1Zb1qn4aqOtOfm4v|Mbk~sE zUXC7A%HF33X*i(Q8Mo{U*|Z3MZu--WkXfGiAXm0RQt{e#caBetx&xf=b-xli0!d=gc|=Mq$CCdg5rI+S2r7O@pVD~BzKD(&1CR{`V2hxD?d2rA;+?bm5FV0ID0EBFwQ8x8`KTl1bJRqzd^5j~*O;)!9)oK#H zg{Y8(jxIq%;BxPj1d9s^BaOI6%zRA16uX+So{~(1;r)axmFAwIL(cVUB!n)CI@1S< z!1`50d;YZ8Tf{Knn2K=gP{FM7u{-9#5hW>gob$OSh!RgGAiu#Vi-)_=xvCTVR2py7 zv^|#L0!#H=0a(+>dI!cEhi)bMN|Oy{7@8>d^4H{+WsxEB!V^Qb`2d47*Xv|qr-%NC zgdTR#V8>(j+(C=5Rq%TEvSIe*!hd+6c|vKXokNhbUJh-3vE%iW$(TS4S5d^!sbMkq zU|Roh(00`1eK0?yxy+FwmiL(x2we2mygf+pHDK9ieOaEp!b=LoXAdhAfI=v^r)O!* z==`sX0ZLYFnDEZiffiBoIA>n__CnSpj2vFEP`^mpNM)Xc;U~8kCV2*H@RKG=N|fcU zG1X*o+3er>7690ukz1@trVb~zp62ywxEzSIj~WP=Rh z-t2J(ma~BTb~C4Qd3svAHaH*bjQo+D*!y*>Tyl=IUB@lbv^wTQYf94 zGJh<5bnL%^w7O+--}O{VSv&H9{fdwEY4)UdUZiTTyN_M@Z!PY@&Q`oJdUh+%tCK2v zr)qP;5-tq$baclA(Uk_3fRs-SF>JG zSdsvC_CG?R>54|cEhIWM5_zs_x%Mk}@tK6-K=h?o2qmGKgC>NgAgZXDlc$SkaIz<-5nD8FrUJanVY2Iw#4}rx zUPuop=m7+!stJ+gYF=-J8m|UbKwOjR4F15Z&bp|AOTOY= zQIW7c7XW(7X_Wanw7gi+EeV-Dfh9J5Ho}I@S2#n*AVH=7YT2ZW zW!Bjm5qhIqdbhjXxhpTCgZs51O9r%8(OoA&cPd%_1K`3og8Y<0ClnWbz0ak4!rDS7 z$g(hm$$etO5w2{4Km?}%vVOq>mMN&zqmeoMod%v!#`pW<9hso;jmy&cHMVqMGfrT6-TwiWcNw8cFF5ekz~vfcYdsu4Dq;?W)35c(cQI7lL&x!3gB`Ngq8 zwxA&!A6H{lwlJ0T%pGPhOK4gkzEzmbfi)&oayn{f1FQ(kyzVJP*&i((FD*ko>CL~G z{I))eEZu&Dx;@UCG_A*SYj_$4p%~SYp46~y#PK=OBn%Tg6n^yZ7XWF+eCcHdHEd)# z)V4p1ofBBgpU{B@)zKrujB9FhR0w0`ivqCG{TCF+L0BKEo-mO+bY?$`UE>K6^}ym` zq5-AuiZ!b5bQczv1kB_MIK*mbRnnEM7>eVx|K4PysmcIQ0~$8?3M1#YyU!v2p^h=#w#{YCE4=Z-#g z_cYVh&;E?<#K+SmU09;uX5y~yW2=l*b*E#m>;ZV@WFJ=+P0m<3 zn0(XCkiir*vAlc zw2Dfl{kHKkXx&~Ra`ATB%|h3#9;bel2HEprVqZr()J{K82~2+>N5b`<7IWPTL_>y`1O&Z-K*Jw&?*C=zWj&T2 zCyCl!b!p$i7vhoK`f@f}&F=O*yTVtlck9{B)>*CR(z1o;bl-7(`>a~@Hz#;a*|$`k z9G}paSFCFF>m}R5OV@kaE}K?$dE(iP_Bs7XPo5kccQz#-C8GGkWhJKmd7qC_ozbc4 zs0n*^#qv;hW>{5To z85Q!(f+U zd~r;;{{0_VA7<(Ll0Ibyw37xKCj^Y0QkA#9KR9m>hU7keyiHh$L0j*60{%k~+?Pnj z0;^W{BzfV@wcDcY-NU5nVQ9b0qNEM{xyX@`k#OA5QPG?Cp{$aM_rT{x{Fouh5?_-9K6NJ1myM%-vP@CvOLR`J4a$r9(?(-lgMDZ(`ez z;j4|i_rFDMKK>zXrtr!A>C|VR5B+}GNROw}8yDW1*Jb@?U~XY}#AK_gma}4kSiP~@ zI#SnjaMKV|fI*k@Q0$=}_t5!RES8U_@3FNc)vwd5cC|Ju_N+77$EHUhoui-xo<=S* zHjH<4ddBo}tu|gf3R=>$b=7p27X{4yeBkvr4jwppW^Yr#^W`7!zVrw$z2Lh+(^((@ zoVN${;O%d%=dGQF-LqgYw z|9IkYPoMaaJuI~zn+6HbZAO7&8I06-$9+$r03^%mtYi{6C%dS&C#hl90PJdM0rt3L zgJ-9E=&nTr_1=-LF7H^q#Wx1?eIs7h8;cED%y6;pB=?L_u{+qT*yAplTi8@?2HpxX z&k_QJ^TOawxv2>&2=I;U(iV}ZwAIMr^9-EQ*8M-S%UTTV|6O1%wP&KkcLnrS5Ymh;-XO*8>->L#k$l6 z97aNA(9J;tplPpUH^g$oF%4n?hs?y6WbNi<{9<0xREbd#S!{DVZW;^*1K%`tceGI2 z&+ToB54d!$$+o`UU~iRxf+I-DY`f*m!L5z$-`Kv>d=|t8Kh-e6rX*cIi`%KX9=(}pkwf)uXPnKUDv%8~UG1edHPY(c3!jUGQEQ1?I?{5@cM5C|&6 zp%wCk-$r^yK0!=>YpHKVYXhI>;y`xfPp(Z^yQpuRDI^kz!V2cN0Sqn;c0Onhx`NFQ zI)f50SV#Ua(HP9p!lMp!>7E^jHiDmjjk_1Gc8K{rja0YzSbnn$Z5$nlNm6y!*qU3L zy>F%cmXur@on3k|5J{=SBTP(DH9dQI#y)l?>wgBmAFp&*3yEbc89e=1CWAu2NLy=O zzFjreekw1y22Pd)IgEZ|R(BMS%cts=R)=HLq#;AC&`*hayOI2@@JTe2$-ooXtVKvG zpUs3VPrH+rnS0`Odpuc%F-FO{=0YzyN_+*+&L#C|G?uv!rQTJ+GAUKenhFNDMy2GM z4x!?KHZHSQtp)~*1J03#c=I5r`2m{2DH$p}M+WAhdoX69Wh6k0j^|oA#2&4N)Qgvl zLsp{2o{SG_gq!QKNGhfL}}d%?jYlchjGZ>qF)?3p#GEY;KG$E-w#gZQ|+{ zuH5a37DGszUm_jozI8N{#lR8Re;-2sNr_F8CX_4HZW~Zpl{jo0KB+6&(a@nv<_v;=p`J2vQz3Z6juV zmY*0(QC0JeIh7ygjxTSTsSv#P!~n8ntQ+QD1xA7mDT<3kU?7fQ$A) z-(?L`rx2S>oUF)9Wd0&!sCaohm)WDySmwIq^0s#doiZ0)AsxD6UTPGWH+`r(Qtnfs zi4waA+b5OcdhKEfURkF5ruPpj+oV*oO;jpJr_-@@zRKg7!VhKqJiOE?!qa);d!1rw zxgf9cX6w_keyF4j4VCtlZCNR*Ecn*yXCtOV-{_QOc!^z%>y=8eeRh$As6zW9<=y|C zrLlE79Y;sK!oQz7`H;>i*FPWNuznnUj!xiZ$9Hu&Vx-}i1}PQwb52TZ9%WjNHCLj_5FtttH8|t03O(r7Z^Tf7V%-;${m(#-ol;{8Br#SgpZpLk_$L!CZE=Dv@{Q@Ct=2+|g*EwT1}_Ey^H z=#*h@QR?g4(*|8bo}qwuh>St+Z#Rb;9Bv02eL3lknDJ$&J5EoY0bNQQ@4MW8ndP0f zabc^h4+<|u42O^Pj}70JCNoQ^DlpDN5SG}kcq)WdHks0v5N&QZCqzTPh*#Tf>gq&Q z&oQ|E1zK7ysnC}G%YIvQe06;_wofK4@2eMys0vhrBn0V-0%}y{OI)a?rE&~5U0#bS zT?vzyqha#UK4301R}PK?$Hm2gcXU*+(WU6^9#2~KEok|qD|%E}!P_#?oVYPAj>BRl zLeD58&c;Dld8gqi{G@X^c_+T;P+81?++tBFEfzUoF)KuHPC14PFW_RzIY?mcqo87T zP0xxBw|hlf&+5SVXrq60To25>7ZhnIU|>$PDV!kEa3^x?ZhKYYJ7)fh`$~=iJ{_wg z!dO!L{M#CJYCar(Z>V9|9sA2;P5(%nILIM=N z>lRwzK6t#v=>tS{K!SJ_w6uHMiZT0ijbcwQpx8ZK(+|F-c_)Ys{`AHJYYwNq&*gs^ z{;}!~k6CVhEq2S<7p;D6&kDz+LAMZC+MqhrNuyBDpEvXyPC(_2Qmx-4UQgL%w- z51L1|lC~|bPF1=!Eo+;)Q|7|a{p+FzTUOm)#S#!{Q~|N}-=tKq=REc@8`v2MC&thG5f*4#4{nB;IOXzinu z>lV2R3wB@@9=;Z|1`k~WV~!WN3J2srjCW)fX7^?nWwdYhp0Ph;_r0TPoGtuq;tw|q z*{RP?W|btxx;)K#xzm!U`5Us<061$CHIWY%q~`p|g0fTcQiZXxu7>92OCo+_*YF$9 z!o*TK2OrFXesT0hNyEq+`{o~KV+UtQqp#hf6{q^_kIS*)8FH>nt{L*A#&9qV&(Y@U z_uVt!uL^CMJNmSH&-~!LG6XJt?QehV;7mmrJPVy#zeAeZ*zk<&gR41dmR{;-2WG)? z*h34}#@D$$8kNg^ZliY1FRWXab?d>;|2b9~-#;+3#g;qB1sYM3c-}y2H|82Y`xzT7 zD=6R=N0n$XBxi4^BQQMN;i51(A|X>iuHcGzA{NKYhb*)i#T-=SjSX}zUS#ImHj`UR zm06k0f*$Qk88deZIcDQShV3yYXne>5&%k`l31`>4CWqJii>-D`d9Gx3Dficeho?pI5krxl?6&ouNdUlXI0wx-1%45GQC+_*#PoE=DajT_Os4bfbJoNO@Dy z`^R8-muTSC=l_X*kAJpj!~N%9t5Timscck9cp4HqaVU@2RK=<0L#9>@n4wdUepV~9 zPL5}>`H;}f`I{lp{doSdP?+<)MdnX1zCQ7~{b&2@>p1oF%*2ktu@Sre#Ig2cliLSJ z#ulHPY5V^!CLY2o#bF z`~NYBf`3kTGhkjlm!tQFJW77$EM%_-sekB&R-fH0li6=AUyIF;X)S=aVf^x)Cj++M zFX+7u7OAYk?A6&cn4yA#A&ka&ipDsJ>TwrZy$-WX=3qoxY>hkN!#^P?UlE3Sv?w!W$tux&+^VhSG44Fc}-MU zSM{yAo!@>wOwlkjjkW&2Afdx{_o14_!5u-=4Mfom3!lwYF!3S$gkqxA7#y=3rx5WC zSQ{+!aejG}d?~x#C)GA}cc^=dZA*jK=dP(1xs5)L)uCgvbq+7u_{mcf#Dd+Eb+Wq8 z?q~$;$>E&B?7g#L$fA#yeUu1ko@MWapWZQHX4ezWehVEj7ryv1Kk4lSLN-7t|KzQx zk9<$cZhaM956(T$gq0*iKpa-JCgl2q>>?DmW6SIbNsDc(CU~P^< ztN;Jsonv2P=b?g=5zKtqgvGAdIIVNKW^}+tyG1&|%%6-1<^_-QIVDZJmWJUvi+i|* zSKP$s91o&WKl;aUerp!dCTH?FnS!0`=le1WY*omfi9E%-lR7kS`cTw+DLbI*Rp#jv zZoABGi_eYD$+s056j~tuwG$=kq?$p4V0Uw~V9$`zSTnS%ds>X$@nSUrnJV1Zry7z; zt>*q0R@i&#(Rv%5sTR2A*fcFB6!g^Lp3iQ~M$J;_d~W}6Y@C0xud6-WY9SR!l}uiN z4z1JX27_I+r2ge?3+jhrgw=Q|O@XISq+}Mu#lUlsc+^^22=d4c?$`12J#1EzHiY4ucsy|( zYe@=4T%jHEb#x5*v=tN)oV7$^;q&;W5Jq#Bd>2!CoJu`jin&`M;3>&uqJ$|HOPEBm zl01`G;g(x)!a&#PL7=1(M3JkYc(1f;%YWa094U= zGB%4R-ZvXB874a%1~n0_=Ac=u ztb)vRC@eSc*%D0@@kLEze=_49)GP1;{7m#4|$@N<-jNg9tK zoA5H|xb)E~5L&t0`)t}N>bHI{@mG6%aDBTyJC0^m5U5Dfb{kk%H)QSUHfnWU1{MpM z>(&N@JpRx=-jGnhAA%F`!`_qTZ&A&|zig++qy2BZSPF#Uatkn6 z=!tGRn?USj|KMQ5P`@JiL+}zdE{SveGn5ZBi5^NB5B$-{^H;A!_AojwN3j)iYC@hlw-Ewx6s$~?_nKg$Hf6(V&g2a z_Tn$YUlxPxaTaXHm*s952EJA$7Eb+k*U?y^h%qclJ~`OHU+uGG^0=NjTl;SOUQNPx>j_fH$v}Y*=5M)va{)x*8+xX z-_x%(!G$5w8Ars44r%(dJkhDhJlUFc(x0_4B)%a_7(lvPnR%pTCjR52miU)W`+M>l zQwB|avbgk}#@@FRB!k-*7M#z|KVR@L5*8OH`x3j5W@~^iM}jRZ)c3jb%i7VIBci|8 zvcM(To&C_jr@4&p8kp}ZU}O%#{vD^L%Gqf#R^^5ve(|Y&*^9D(GQR^n-fz7K6(oqH;lMvCxKB{o{rr`EWB;Jsc!bPd1Ky_~n;sv0&GycRuO*EW59< z84{n|36jmDx#iOZ%ZxUsLRIys+*AIjN~P$&2aJ^gPUIUHnUD+IS@4LX5K6IUIgx(4 za9O3F6L|wG6|xnN7TnNrB1Okni9W|@e%X}N@EN(CxyYv~+hcj>+BM5tGmw&cEZKLX zd=@f$WRJy>DW^WcZ>bz#+rv7PxGMBo{rxy@99O({e5Yu>Ywdw+Tduv;7GKKdBE+B0 z0r)bAsuN4DC>ud3T2M-(Qfw=woJb4}rC2t%JJ^5Zqaz>oAO3LYqrSr*X`|2khY14L zz#wWf{#V(|+E})-niCO-XfRZ0u^Vl>vzNrO71g|m<-EC@7HNG`Y-_!tQY3u}fOJ}6 z?MOp+WR0{&ut|79_{1!$Uv#E^(UU^qb>R|QeSg%o+bo>%|9YH6B9g}cebLm>h)fqY z1tL03K*X6k@Vh#2JJX6x<^s7^#dy_z6%)N}X^sihdjJ2b^lQmN zw^mf$pj|8kvNYO~cYp9+Ynjf!Dr(9r)ei9vX%!xCvpXbh0gD#&HXxnIk?nuGuJ1nh zNpsmO-;e6)I;1h~Y;m#ocXjP!yE=~RtXq9*d{+&p68*-`$W|4qOlP*MdHBX!2BVad zy^v>5z5&6dlwz=?I{k_*t2Q+4sjb}?T)ugF4b_>tOQd5aJxDk@n z)@wC!nHD*IDME+=+~)dvdySAT(8(~N?Pnt7p?GMq_wO3t?1$gGGDz=!*_4x=l#_?g zhDu;vMODA7K6y}ISbX_rG=W)PPBc_9C`LDymo?W~$E%Z(6g#95JYB|=>!5O-@l)^1b0sz1hc5nUXWk@@SzW{5{5l*#j^45fl@m7a|(%*hPj z%0`f~vbSy76wlp^M7mNc>67Rvc4VTv)Ip_TLL%8MN|s0(XjCFytWr$V<8h`+IC{W#DfU(sb!Te5}Qo>RHvOs9fgbi=tzVqFA#m!R3fk z492LK`?J)oJS%>P3rzz7TO`O34)j+SWXjVChSzN3(V7}4BR`)3Q(CL;_^9!t9nu5| zL7ROO~qImN7+~CYscfC>Q~KSuMm@0YEbQgLvr&Ny(|n3$87c9zn}Rl_W8+kU0ig zBo+sn83JJkLmfyFeMtz(Ry*=aMo5?|^x=eb!=TI7KIpGaS!t-0VJ8AZZhf+mQYkaY z)-|*zB939veB^?av1yvM)^+{>zz0wOAK3DjbwIvUQp;DwB`gFlQ1EM)N(SWC;72C> z{kVO|6@8#5iT^B2i2G;JTXN>by^o4KfIB|5cf7;n9q;U2!`lXI^M<_xG5=8WZvY;d zGwyPxMK*lyPZLF&0m~9;@bdr(80DT;0)Qe>v%J<{nH4>f2=`roFEcU|)l|-t1H46Z zG9Cc%CVeY1|HypLyPM=}tqjJcq&Lgz?<{6zQ7J|jo)hf!E>f%pjd==CohT@6=T6yd zq!3f*nQZx}KBQa4j9jIwy{cJ;XKu&_-Mf*@cbnr63=Vi7qAp$XUb^VHC>Og_RP>Yg zx;MLMV%@ZDN+g=<4KXs*fNc!EI4Esh{)XJ#4f#Wpc00dv{_^2q=bU|nIU8t2b)Jts zAKmqS?8Oufc=zPBT=zW6ayheHqnH+wztXQ z7fbqY%#PnktGiWK{G|8Io%KVEU&7?%vHu zq=_h_RatFgeD^dMB>_hFv!OTT^3O zqwlw#-*)_l%n0uM16lHJ@NT^PB#_B`g#;sTJO8A<1i_ zu?sorCt<4PH3Ml&9f717iLR|+gUL|Y?PCgR$tuZJ98 zU~~*pYZ|A2!xB#O-EKZ)9$vhd7Vu9Qp5Sgj->Ekpv2^^+cAPg)w?9kX6%2;*IiCar zB+|#4b1p1+_3YU=iAW-iKYjW#1G(SjsGV;#4zKlb<4>M4F-YpuCG+%2i_2BXsIqA>Tmt6JOJ_cC3SO_ItiW($rYD!U7} z7wk)CL%a7*%QY>C{ruEtr^3$P9V;Ts*1U?&h=FIHx+BU~Uj+6}MhwKuu81hReCSrs zI-B+CKj(56T$EX~!l8}u>7_6GmGkBLndzC&IZLglD{L1oUb<)}?Mr^U@BF=U_j}(W zA=4NPy&evQ!F4(Y9fW*sY`J&-{<$n-aaNR|(=8PBLE)ese{mVVx$o_sZdoQA7O{b4 zp&$!NIK*ZAhTgZw@OHxBaOkd|4vOD+ug~?edv6Un*UK&0dC`@ezu(91JKxN5T=;A8 zz!=GCB@Y7%@(8fq2C~@^1rP(+xAelr~d5!mCI zsV&4*Xqc^C6PmNNOg$Bf5Z|55+T(|mcTvd!L{Tg$)}NDW#!91DfC#&sfgD33M+KRE zt@WFdHOY&O#p|!>6i`iCCi*5cAxWFdNhI^j)9L8Jl*ObJZVp+n3SqC$t4C~?0PvRN zMZX_`>G%T$P~zu^{GXKwrIluv5sYx32KAYb2)mY ze*XhuB#@NwYwA)`Rq!uJ`4l|+4eVN!Khv9%Y>`Vc{<{HQn1s(}#SvI#$hdG)QgjYG zj>xv3Ms3WOE&inVC}E;QHeq(vj`Yr1Gebh%c*9Xy%-Y&j;KkAw9gM;)hUFx-1ewiI z5ZqKj&bgn3w%iN!z5IPi{GuD+_(Xg*Yax+YwzMq4y7;Q&77|zs_FG1J=4>rfLg#S6 z3v~^-vWsHO>bkO#t~Q;{a}zAskRPH}iVA8=<@IbPs@bqW$J7rjm)=|x6`N$xRT zWX6l5juR1OvZEj^MDgl$M$J_c@(lNq*$>dW4|k{0!QHwTftt22)TgUbC^WpB!#@C} zMHJQrYN}Pr$_4sb15xt+O{Ribra~_p>xASuJhiBy;Y#4Zsm|-xE1w6}Z zM+a^$3WXw(;|^y2&yk z&+G9qMLpY#PU!h+Q9pFjiG8ObfYrtf!R{t4>|g10j9)G3w+T%$>*BtFKCezP!qi3D z;)X^FvA+Ha6TxQgo(y*;yAg0v)aaT~ySw(C`V~Tk`sK!Q>lX`b>SE;TpAwooX%Wgs zA?19gPqPTm%01XHu#Eh%g#6Kh{IT8oPfOscOiLG7$(%=cBx4zpxeO^#Mp{ha;SBAC zD9}q1RNk~Wx;Fz}Nz6l>bP^vLDa3OZaZ1CYaFD`^^Thgtc@)Ne&Vc=SIE#&vJ1nQ0 zJ>sq8JcHx0P@{{Q@8YCWNl%MNZX}Y(ih(~U8jC499K=)bOnC13W}6aqr(`l(LCVu=(8sR zZYX4ODjp`Oc_i{8r7EHdy2E}to7E9LPwT2Tx~k3BpcwZJ8ts@0lW6efN}}#vL#575 zl;@Et(oi7LaU-)fix^U7Hk8yDQS6==m&39dIiMxR={0D zgB5`(;lHRUees!+-z7B3Zhw#X>j1s5QXHS^spx^YDZ$aEDe2@`bX7qd0+01I1Pddo ztxA5(4gqBHiD}Nz8A|r7F_!W? z&&l@~V9Zl<@E5jw9}gBaNeOj-!dM&cxU5cY~r>@ymr~tG`f6U+6)c z)`Qc#D^ahwJy)VdU(sJ^Om~$IQ_Y*YWwU0@nl)?ItcPTL$t<(;c!z?cN8uQRB_)FJ zs~sSn0)8}~iHxduXsrdfTDAXc1^L`fznHDdO@7#2KF6x({CU+EZ$ZMou88Yi#-9F+ zaT)p5c>&>94t5ordZbc*tZj*hu{9(f0juDAxEL+Ai2&kEGMd`~D^%NKau`;65~4^3 zbR0flev&#f!y=TGCbB0M{!I}mLYm~Y5fqV(nD!6*wRZoEk^hr5|I?Z7?|okILpSap&-?P@ zXup=d`l2l6^Xr3cb{eMhfc~PtJ@51$!v2R>-U6`Ck%099(EyNBZ`V%&HUjd+iV^8t^_*a5n|HsM2zCR<*cY`+uQRMysLBP1iHtcEoF^eZttubBMwF_ zrh~D29$ppR)6>SesDO9nzO{1s)Y6&t%XTM*eLd{Ug_gZo{ho)Hr2XCY)73gg-`b(9 z;v^|BFh~c{`Q2bO-fn1L~V( zBAU*BVodiSt3gwyf36=oD|5O2e#<;&ofVm9(f$6&l%t_92E7=r819Mih@GOYzoQzLWL|e0~dyk={x?*YQLPu7}Nb$x*Mh?R+e&n{mrsvG?Z7GDey~ zZMN@W@?cPOj}U_--i_P7R*j$ZI%CXgncL&X7F1u6mX7paE=Q_0Ml8Cm>TGqQ{9?W+r@`;Z`U5^Z0 z{gxyB0)wj;l2|`S1qr1R_W2w)&WfCVs*IS2FtOoVb5IhE&WMsiFhljHt`CXK?BcVwTyr4SL z!Ii8Mq?T?q1ZvMrLFrVNF<)M>1GgKY~MVw zXN)w%hCLJybvWj&%%J5#0s1&Mv=*q2bP7{df;4or6!(NjVM%+kecmI`MUj@2L&nz6 zQKllP#UqbI!~&L^>CDChcvsJOHbYxWilaKjk*s$#mtve4ec{IfsQDvE?~Ig>@93|9 zJ88#pM;;}4#|UXWN(YygtL(JDiX6Fa#{}#fMVLwNM8`3ZLa@_;O_ie%;L%H}uxXil zrXpfN(MP%f`BPLX)VSJju-H zNLOnp0DwJQty^DOap>5WZPng0r1#A8$7kt)3)P*kK6{pGO`qeEQ0ZIIU=+c3ew*@4 zBt0yRU@>Kz4SYm+)WNSDM%-z=_j7J!v7lOyw1R6(JB{!q-tvfOe+3-t#|BdWPK*Ha z-NkpA+(nyE?!Xvt?pc4YVO?KR=b|7rCCZ*_9q5nW6U}e$Uomn|9QY=rhxN02C##rf z2IPU6!+teaKpoBH8FixvQB!lPOb4Wwr_}Y7;~4bR;uScqN3mvf1<~yBLM7|5Y5O>mTwqgh4;M6*Xx=GalOH0N?`^Nlhry)FT2lU%EvER#t& z_I6ft1JA8mnR&;~;@GpfACpbt&&=tFQRdc8ddi}qi?nG4hm^~~GP;6j_PE*{i?fOC z-PQ(eOopiMNFKmif6U6j>#hXyFs`lpin4;BQR%w8ZgEq{3btl-D}NH-ihi05O^UZ% zO^;V9nP_b(bvD5wQs&yyB|}8vy4lN7aRXH)9M*>F6-%? zm04&GYCf=O&Ikgck`Jw^H2cg zs&m_`W1@qjuSdhfpMnVk(e)MJTscvZCp_FKGF&u31!6%)Uj4P+H14h*i+@!sT$i{~ zeQLk1tM^pSZ7QIASyAusVoM0DlC>%%uTp!E#JI0NN+9?ibc+_}Y-K8veOjI+-Mo@1L>!x9z`ecWFCqh=Qm z8N|XAGpt+qEi+D$ZWYr}z!eUMzr?i3!lM%=TKA?(1KE5O&-x4qvD>n!u4cU|qwmni zt<__0c4eZsP;2$5N={vcz=tAiFSxnbt5A>+ZQOpU-9*Y(n;5Yhku>mVD}I&?d95C# z=pE(Pb{6|Ri4Y21g>126XIrT#<6=Z4MoaRK26r{3X#iXZCze>edx2+Jbq3toQ^|{^ zFoc=RVzZr_aNkK2x}TWCohHK*1gro=RI7g{O7~XB1x=g^s*~amk|k4`;Sipp4enMS z^qPjVZI*WCd}5y(!XB?ah(BO_S-|4TZQ&sXU;7|-$KmL|srN@q*T!07p~`sCtr)lV zcfB>lim7}`19B?4olG@XVru$ZIlV_9!0UBP+G0HZ_Z$$ft%^+WI0}5`Io3IEdBasE z&hhSq8@Ck5$r%9PUqK*cq_o&Qn^e z@Fecy=whIaMWiN$mA&WqM3rM$#A-Q?JNV?DcS9cWkWS6^b(%53uzAk(9bIKJETA-W z_-%I|O7x%!@PYtFok}TH97{CUf!M;%9OnR;X+rw=>wBQlL&WoQE2?pVT2^E0Qfk8C z^WMcxo$$#CgrV;{PN_@SuImi5kCtUFrzxaUOpDr3P2`^4q?brV|HYqJP=Pi29t=mhC)%m&VwIvs z{o;HYcbX~&VD^KC_ag(cPfaVRL%o^)?jSLGf&hb3%u3*{kjMUdC zu<>KdL-iiQ%%61%y2HjG9BoE3JOZ%Na&Jwz%~CqFC5}#nGrE0sC6N7}xY?!%^PywY z!Yoity%6)bvjRC%zC_9tINo$*dn}{h{ZbnOoof2(-n5$H~uHV3VH}V5ZAD%v^G(P$}LI%uxB< zk)e2Eq=Sm92RV@+4cQqo%!jM{(ek!7psZDsTYL8f*%2gd;NNPY!`+1>8+XM(S27el zhp{Fr2e(=x+^0Qco2)g)@>lRNdAcFl`~rIISZ9%I+(S339YK|vrH0|N1bK7f-9AhQ!jVC zxc2)axJJ}g>i2S&&)Ve|9Ut!36*e88MLi*h&?%om(xZXT59Us#SFm0V#yCTZRD;g! z!b6!7q{F7GF)<=6f~I#5i{{ zX8e~5%CMYS3>>+N+Ta>SCbt2|L#naZn`*Yg4;^$In-Zfn8?<%HP72f>2Y(NzM(?Tx zTju+O4;#Yn(c(}SHcCD9G#@LFB0W(`5mv*Y*TBL z{&b1ylkD2gdOdA?zYa>SELqs}4mKUn9y>lOSit(4=0sfTp$VQ=gK&%?rn{E2FsZ8Wbdhyyj9DpO=pcHvazSpB zFiO?00uxnr5)ZfI4r9mFdsU)k5q|Aj#)C&)a0Lq@I0KP%upKn>aR=gWTQyngfa>v( z80UR!q-4;&>iO*PZQG>+B-Ok8gd--z(RS2eM|X*G33`V(&{&4kvE4h}PlD2xu-r2| z6wGBF6OT{Vg?pwhK&8{x`g&Wpz23ZaB`TXX*PFG5VbDm~c4ah34P*;oCTrY3HhX%Y zAYvBi@xkn53!18fa5WQcPxam@f!dkPI^FEk3binIe;9XW8k$_p7#7cNb6T%Y?B70d5i_b-@7t3y=>n(chC6=#I zPZJp$P+Mcx*Y$h=6o{|Fk2IWJdmnY7HL!r*VToscm+=%yF<2w@C9cH-chfY>yY;?& zPJSonh!Tj8Q)5%tQa}v)>O5VewihW-nb{?XM9iVdJvof@(cFWw3CorZbclA_(PAL(WnIkA^;V7B8Zq3)0_&j>kOtNJ$9sr_8BMAi z)c2cY9Bx{WpoUiFX|cFCk6mLl0%Y1u95@3{P}6#mM$n_sD)Hr9X=aR6tRef{QwJb~ z+P_KT+ugekMPz0x}mQ-rs8Svo2SOMJaD-iRUniOI5 zFz1rbwmdnQ)_*Ha6DjSx$r58L58k-ZN8$4Io9e>rAmvDB9z>)~%$KbG=x*&76WqT7 zF%LHtWpmI4gXU0Da(ulCc5c^2GQt6##w#5)k5++ST1T zq+)^+F2;J#83COhRu>u(r;d0c5ECw1eFrH4-*xQ9s7t3vIpK0Cte-+_=CC}Lh{9+q zcx)*?nNBTf<_0BY#)0h(Cbq8&uxkUMQ!_l`p^~ylxEZdXJYf1pyvFAPsHyR}E$84} z5Nv;B)EtXY2TAEE+4QK(q=xD|f8g}=gJZhr*=bP1%EW6GB%A~tJcaBTwnYHf8k093bln+T=eoH4D>?F*)KCHKTKla7Q#U`@`N z<_`|${U6b7XD|<45Shx|&9!5!s74+szm~GP7Q(n|ahsFG>?9Z3k}3DKREJ}pv$SL& zXoTZR_U$c3d!H&!Mw>ZM%s@~HiJMJO%$-SXQ$y6Ke5>mjTw(|LWGgXzFEO?+#RXjS&m%p z6QyOOkW**7yCH?8PWdPgCh78uck(fC`VL)35(D%7jpi}dCA_sQ^LWEI%(bW<;YbOY z?q!90NVU3J&BL?{hE0pRkdzg{(!X~bpa{(%WK{Q}JD;0l>2T}OKxviSaM&x(eX^lv zAAP4t!|vf_08NwIhly^E()P?%rg^(G5(TdS)TP@)L>o zLNcZ{Go#ZBdi^20(+CDn@pm4r;Ja!ojB$ej|0=UJ4&q2!Y$0psL5`cFrB##qNSKF3 zuBo|y%K*~}C0hXpwo!y~nZAj^!RC1b8`F5BMvUVOfiL43|CPw3up${f($6HY5W;I!2*lylJHEqLUEzLwFgudu`o!`xp&idBn-Mv=!O)1UPWH|2A*p;riBtzTD4w5mq@!Tr*n|sN~Bz+ zVDS{}PR5g~KxlIyys8FmkMOm6Fv^2t`WCiVi}1DchueoV6{rm8a-<7m=1)74P#k#3 zYu14&ZSpSA9Xn#BN`*LJ-6+!tg1rHFcO_)2!*bZHoi+x+zSUoMi(!!EITTUVtba<0 z9S)$%{XO(>3D-s)+GD~K@~r_i)Ii4+MrQQlc_?RXf{ir%T48r3v3zwNWrY^`8r!z+pb04nH{k|h6vO7)oIB^dM{JjuVAmOeETt)+w z#~|q9lvZgFX#%OyAb3uZMg8GFAV}Jm!Qw1Ccm5Ei@huSSUt@rDOd#i;aiZvTd`ql{e06$#2*yS1ls6BvH)q7G(W7!jfAXm!qAf7 z2ymoqbDeMS(0qiflF$M5Vt@A4gQ<|V3`#wD<6;3zmh}u{E60n_tf1*iqPX;_nY@;h z?Dy6J`8<2{#u-niVQ&R;PU9Ml325#3iyB%ba39=^RK8(&=e}vVA@nODJm}g+@ecLI z88mf*b9feDOb&*mMJ||6pUE4@$VvW%h94w5boFE?)zdUb+SV52=3S%GFvpluC!CX1 zW`L_JnqpQFu$>BwqYFTxJ6GyQhG=5>x)UaCVbn31a<<`u!ip|@>?y(O<`t%P_>+Z- zW+(beJkVC8@&!3`+pF$Hu`tLDiZ*~tEK3>WN``#U%4{1g5E{NB@l_pa z9@W7Z%~UXCC&)0*)=5d*&di6YigF-Xon|uW*iT^ zARcIIW2im|6$fEimu+4E(cg`E0|O0nkA7T_V=_>-QO|oXR;{6MVb!2`UwhGG;p}zV z^Nn^`m`%;&;V=}qJ{T4IVsQr8Zq7Vid2_xgmW;fNWom|9`>{y3M=eqmFIwctsR3g& z8z)u>ruuBvX1&BnFei0kJc!leU?wlt9We<*Q63&*_U0GFqcMVq(rP+N4FydW0?wcTX4=b$am0L z?re`9G33{~gH4vgbS^#HN&QWg!hV6*mRTy|ODGKg)T2)vL5l@#Adqr!bngyT@v4jIvM}8`?o21+5LY*56H1F0J;`Jj z*_WU;5w@^iB=#P1NBJZljJ(&Vtd|l=H@jYHif2x@&E-WreeQ1dH|xIHwp$+2+N=xu z#XkzD`Di{eXQ`+}b&Cnvk~lz|_?Q6f4M||1BB#JEk)j2*Zl<%(ol>l;J>Zvf`s#yI z^MA2HdG?FG$WnRfRLH5yn^lPdYrS|Ks=X9nz%Sf_*Pna+dVRKE_vN8FM8QD@db(*o z5*Cv&F~)KXGCD64-GUjCeIK7dFMI+XQ$PEj*7G1B#746_06W=N9PA^msD)2e99-c% z+7+7alE={J^RVU*5&S-Txl>S`UK|{nwCL56Y1D~K5430)fyAVryq|ve4;;~JIl>hL z4`ADFIM*nVac%amB!qzC6DrSHO0IB_r$#*@X@4ZX3MTL|lW+FxNZ|v&x1AMkVNoc9 zo5|VBoiaofY9ac$SlN_SN&CIa<+FKE4>TFr4jfenW(=|RvQLsnV0bUE(KQ*?On~P? zEGj7H{1$|JK3mzjb?ctO?3l`{7< zjT*enyK+u0yw5ZZw`m;R9#;GbtUb>{wZMyuAhpwgw#K9Zlg!gR@;N1uX_;~N<=CSy z$s@rnho-jRw2_@|;yJm{nn;40q&@IFwvIm+tz3!+T<~n_*b-ivMrB<3o*g>jGQpOe zj8@am>mLqZ1%kDmr9q#|eO{(JpoVJzI;k|+rPU7o7?&#V;}-|IbR|%+%+qf^Ad0VBff(Ylp__jFAag4zOE$JX9 z!l=v_6Lk*W_o`5kzA`zKjst}8$h1Tu8WpCv+Tdti4Iu)sH9*nPNg~hUu1AB?7x}Om z2q`0U&D^uIh9v>&Bl%ZXlfHg6#~ZNsR0!gVAiE7#+0{O{v-9k^(CBy|B*5$3L`x|_ zT$CMbj5kkNMbu8>dVw|H)PuNH+&3}Va|&Dmc7{BP3$@TAT1QDTuLU(iepv0A9IiK{ z*E>nYB%qNY8}cEr-+C(=B6QqJ@Ynoey6OzaAm&5zcQ!xARWtJi-c%N&xKlcgT`ru1 z^1Si3O`j~j&xNY%!W>{|O73^v8Fsdmx7={9PkZ*Yx2aBPL!fkVK)98k9H-G5v0hna zX`~TwBe7AmMEJDV{eJC^UL*lL?^;JEx;DfEO0PYGM&Y+#MN{7kFfyO@M{bfYhFlr3 z@TwU#@ml$y>6;o zaZyfE@9XWENFb&hWjyubc(T5y3g2HsHU)f7N9|oTIH0hiiNQ7V$_PzrFF{aM)s?{& zyxJkfTchLMSfWgimF{%Rw?OJ!e)SKaGX|7(F~<#9yu=;gqyDs=2cVW-$8fHfN}qj6#-g-$D9>(C08l~6&_Lp0&6aWnG4h;I3&SJTqmc-|-|aq| zh>(HLY$)L*_}yWXNyqeCvR}Q>yhiPi$_dJDmjR~tKuRF(I!zDF2^w>t!|(Hs)(@QYGC% zSu2)-dIDO7AFv3{dDtvqE7Ot_++A~MD+5!>&RG|dWzX4onpahop?4@L%s99#EvFm9 z>sk@;<5e%@Bxb)s%cc{Dn85eqRO3&EXtg5Wr)Seevv67pKAA9K&>h({U+)+b8By61 zaDmi{iVNcty2UKzn^)_s>1m|WE7+N*i3ScP*jA`TtMFN;GXY$@BV1q(6n9fpXkuJ) zpd_xutK1$X2X#d70ng_|=5`0&d5!c5r(bF;v>O0U3%}~ARJH0#GZo$LgRz{J%*%^a z23lv`-LJ~6WW5_WJ)@UO+<(w~8eV_(m6u<9{(8MXoF0a*+*Ld6RH%H%e^pp{?N$Ad zQ1x`{6$aJCslWca`RIYyUU}(7c;~ry{=2iPBw>jOEoI|a$;A?@`NCnDOP*K++Wi5m zTTFb5l0IROjrL!}>_GWgb4y2^yzb7ZWK>D|o&p3sLCy**XqW8`QVChoub?JivH_^Pi4KN?L{w5&?o&s@kH-N@Q!*X)W=vGx6FMe&W^Mwp3O)&viERNR>#3b?2q&=vi z@T@dnS#L#joSA+|Og|7rXqSWCgRO32GNXhe>gr$>PV4;atGsn5;cz36p}vZ2PK|s> z6|gU;=8)d2XnN8rW9rdM@-8Wgn{wu{98Kdp*5%6S}a`%+fou> zF*oTnS=l!RYsqHXUQI4l+teFQtC3*8Ix^z(OI`zm43!M9_d!3E9exTxsr(Re1&a6m zpqmooaUp@5w|R&Q>rtOuu_?Ac_PEAMbi-b4E-rgv%p^nE=p_-HjmQTqQYSG~y>qGR zb^zDxbM>oWU4gLkrcx7UNS-M`-QMb&beHXT)J@vy_sE+?f24eC$D142+g==GxQ|{) zK4b(9GcbhTQer3`R|*$%brZuQIuyHfQ6_f=VVi{GvT(vLE7UZ_V!OE$D>dw1iCiO{D#n#pXeBTP1 z+9X7z$qhkf5r&voHz8Hprfyhtq{IrrU{Sr;{h}5VG1rRQudu#cOOh zV*jcRgA9GvMqBujljGoj_+DjIXz31^P;&Nc#cp+{10W{bwt~WYQz$11Qw8N}Z`Rmf zEt)wx5$zu`oZ!~rCNC71Q<}Hu2UDe8BE96e6IdJFKXm$XFdr$4E)q1FM=%9%L-^uW zjV2E58|vgakoS!{aE=rjz#Z#FyDf$DQIqN1OVoqRFVtRZ4Z^$-jCm0h_6J`vGQYaNJJip7@449*Gki->^q_mC^1a z4i{z+SIBjQ+^!&~=;i8P?7`BR<5^R#2RtlvS8xyDd~e)e*Sm_CJHK;Af5INGuP3fz zJajjyOrxNl z9jeaZLFp&V=rxfY9kL+keD8=hn8c2)vJ{)%u15uC*yDSTtY%wMq2Lio>SIdxyPAwJ z8xMgx>}a-A*BM{3uPxKSZVGV}ljt_K#~Qk<7f9U}bxX&J%!};Rxpx^tvs#l{ALS*K zt{In^t|Iwf6-|#GN{$w8QbM*d#3CMXy}sD2i}@#@Prnz3%fsnzeZDzws&vSPB0HP& zGod+3`j;mJ--;Blb(JA#vm}(JX~z?}e8w5D-_-If4II}G-Ur8nu`g6+pfd)5*#f@o z;7=y771xzA_>ieF=2~Ftqev=kgmi4ncmU-V?2R(LpC3G%`Sd0X(^uu%$6ss#fq*I1vc*@2IFU7FPUx0P zs|G49Dw)0k-8H41pE5_;#ZQ%ZW`1)QY~bl0JW=S-WXh${FjPuKIDG+&ow?CV=+0xG zut)UfF#4pX7Z`wjijXgIg(m6CAyQFpGv|=v4(P&daloMXFiw^~vWgTyN~27if#Mwy z;Tk~kDf&xt;&yq&u3;jYE7=Tz-&oxFR_cI4;iG2afmRoyi6j>elpyYCjRgYjfO3aq zT7iW^WKb0T*;DX~xJy-j*68rL!Z2@4x6_H)tCsx#OBRK?k!-_VllwZgnc`E$D8 zc~3ocjNphwNwDG$ZrD%s*-^wZpDOzSIqq4hl;a|K%t!D7flCceO{-Otv7Vbk({n8? zUM6=lMNdbBGY{RAw-SgQd6pqp_~^z~J{DM|nW8GM&Jgh5mQVzQ5HF|e3Y{WgEG)I`(Uzu~9~-HBF{unMYGJYRO|!a)DdDbV z>S*lRpQy1FquVIPLFD!1rWD*3qcq@TrS>GOb3ucFYRn3*AdJdYy45qv_yCBGyBQc3>5Hn;j;ZD+Q$%u7AfZbcvZkNA`i*=djWvAC58cQ|;+Fjl%OI)EIbQX- z7HRwn69EYw7V?xO)35@ut_*Bk_Uwz5Z1x9wdNc;nw2mcgUf}Z5LN5n4ZMVBrS*B>g zr{IleVOEG-B)%kGQNvItYJeBC?hFyIb4$l2EVbQvrd&^&_JtqSt8Es^bL}iRCA|>= zV(C?YWep0yYhALh+S1VPN{&0$&K0NXMTejqd`5s3T12LWJu%zkJ2ojJ(dWuV4@BwM zRDoJ_1mw8IGK}PncAcY_Ud3l%w8(Cty={xcG)^=9UpP=!D+tCJRSVhiJ(M0(Nl(-t zUR|K2+z=EIXSW@%XeQ>IgNBct5`+njto4yRE@ZQ|uhy?w;SW2<;5CZnI? zN}dYhV=yzl6+n&rk-T|j#VyZ{1Ro2ORR?vp7uM>vMJjp3iV;HsAE6de#3;-sHR9C5s zK*alpHOKV{0(=i5vNHkv^>5+^e)FCmt-QTimly@$@=qMv1OTo$N^@DycbI9@&;SD% z00jOwn-U8y4B$b%e-;uvpZ_1LeivWZxr+WPe*@h;QUCG_oip(AG2E-)fv0Hy=sgUt z{}J5ip?4~QS`lk<3m%=(%roDuZU7p(L0S#?P#)ExizyKHoAkdNW&X9>;k7NwX%?T^ z{EQ!R|FYAE+BEBAJ-l?8|2*`#2S5DHxc$6;0xv?eel%CGOo2<{u-{;ZU*wrWCkMOt zm6l;PhIY6SwQ-tr_=)xg+#i14!y{#oslUX?oo@5HSOe?5ee~1{b$Ph^mL1ksqTdE< zKP0)ZUdcMnk60t#w9&b_)0)o$IsGE@FfQ@IqN5Q5-UP4H|I`7mI&XFQss_*rdr@q< zWb_VVzGnJ=v404mM`%eP-fsUf8B5Uaw*^w2D-}#o_Z5dseb#!@37gF&J9y4Ul)B|4LRX zYp2jD@;%>TUVRL&#ijJ`gjYklXD;mKW`2{8mE1kJICi7cqL0XRiiK8n_wQ$3F`kh$ z*m=b|W&6HIzkUET`;J-yPb*EuKJbHm^HvTV>o_ujmsdxnD=aN6kg3rT^?3X7>li`6 z$QY)*T%=Com^_$KOHepa?7}%Iwow;g7aB^b*vn+)0%wI)o4w;xOPi_o6chK*1sk^G zT-$N35S(jU_Co1M9oh76hf>RpSitTP9tQad$Flv^7}jA-m(e~`CU}|MJ6%l-AZ1>Y zRCDFrgsj@-WfXm*X12;->=ZYb=ilsaU``I==s{*D%&+OiF@u<9H4EaxF)$1=1T!9( zRSxTQJ@9zR;aAYg379mrgEKZW_8hq}{c~^|Y!kph9~>RCo6+>Iy~b3e^=gOle8P#D zA=~oBIIF^X2b_u$T5%vWKqzCft`_0SDw`h%Xk{MUYQcpp9>Homb{+k&t`i6Ei)1A% z-m!a2c(5>J8ld|$&LhP=@jFX=h!v%1Ouq6=czC_cz+zIQYT_j(ino}xSi3VLN~`if z|71;R%FXE*Kk1sar{LLPgu`FqP}mvnRho2ubZ`DBT&!d?5sS5o8;(8>vooU&Q~}5@ z1ZSN~w`1*ywM9r~Lp>TLhnwAIXs#9GR%CnVHKfMixyyzz#VXej$%H4a=7}}|KgVW! zwSP5k`CatwzRzIfbJSdDD0ZP6H!U*f?Pj{s#k8!xKsxWVgqE23)WtXWlDwrBZvzi9 zGgr}nK3cKBEbm|#B&@>0YKMDlbPGt!v)pu|R;HR|+*OuwU#68F$yg?vO?Ykev^{Iq zDGmC*PwVU#yD!u-sm{+Sp;5zFQ%+20#iO?bU?rSAG3lVI>Biwz9{-F>VuRz*s7|Ro zd5H=1NO;9Jf*#V?OcOk^_WJl&hM8|9u&Kt;w9g=*e#c4l*a{hpJ=?erwCrjF2?PME zp3JT0#Fq9Xb{^~Gixjsr3Fa+jK!DoJ`d%Qy9Elhz zzc1CoDW+Q+>S&V*sm!Y*>Y-UfZ5DdKdV!$93)g%X1%%io8<-Tp*etT=E?COs@2O`>nr)eJparw9r&DS(@w%OQ=qq&Ll6!6tu8BJzv{v2e_tXX4vHaraJi!Nt9Ti&Uc0CNJz*MsDxfh6nfn{ zM8Sm-GZKrpA z+u{gzD0{yW^5V(*svrqMj{cK$d*&$Kz|6GfR5jPL`c{3lz6`xtD+#I5lIR5uL7MF< z%H(-x%x^S$z)Id2NmW6b732cUWpeT;VH6^$-m4RK4%@$0X-N9wgQ5`mxk`qm3?H$d zUSEXsEml83sAW75BLa~S8Bx#-qybINM4CtoX(JujXCmC8_x*a!ehS2~V>UnN%$Z^; zESH$ZE7%+qLbJxE3l(MtEt;FfKQhcQ*F5v#V9#f5EwE68MHa)gMC7hr*+%O4E%l;< zTT%PNHPp>5^xZwo(`V0ps~lGD_d6kH8l2lcSo!wdL*UBtQFcylUVcI0>LP0#bqwAw z2#DV(DMk9W2mZZ2P@&@cRn;}fKCbl%cbz-+4UKD>)~e($+}ehM`p1sWE;N;X>UJIg zta;9d?)3Y?rh!2Q;HSTJ+x8thFWa?y&)$9ehwSpxkGA{jYp%WSl#sIm@#Ul7YA|2A zyy@V}1fQjO`YVE8qwRS3UIh?OJW24>NMnQoPiqEXx>|At(;<;1s);0I*TBe&Cna~p*x{AF!xPX4LL$Bjhex;`Q zvo9_|8%uiV<*9Ehr-KG-{=PoY>-$8=z%Qduo#)1x;bq1ifq$UI_WbPRmU|v{w6k%h za)4aIu1FdQ^AQJ=>vx(gHd9VAi`=(FC=%m@L@JXjlq$7GtJ51$o5-g`qa2#-fhl*} z0YK%@Y-DLLN~en) zG)r{c-#n8F#Y%XZmZHt>Uf5Mj&CD$zzCg&plmu6*Q!~2*p>P|e)oPH@)FU1YS^uOI z#Fm*v;=3w(ImtDbm%po(J&bZmeFbqvl^GRW9o1ROs;jyQmxRh|ruaH5Vp(H}kU*lF z_9C^61Zm4TQi$Y8rom&Q0%ckx!tSClSR9@}B#|jph$7JRhFfe7H>B^=W9`UTQu+== zp;FTn-&3bI7)@r2m7WZ=JbTQUYd%m`QMG9Px}%|~rLBXY7+wrq-7M$2 zUOMUL1wA~c3pIW9q@LD))ltu?v!4H(uNOZjHR)>ZfnF2~LZ_Z80|lJLuT*Y}XkZ}0 zz#%}r!pVx)wj!ySO9)3jk_1D0wK>FA^;O@%4wJF~OA5BFQwVg1_F}@&1Eq-Zqk7>M zZsB%E!|S39^zU%AOXwEQT~|b&Ev2c)M=HlMXINH9cYZX)by;E>0ezjXD*lZ*!^&gV zO|_N~N1$EYRE@Q+x?88JywD=U`*#$$072f=p3~j+KGNi%Y@#F`R}DF9wIE&DcMf#m~1$iqvur=>Q&kG(wFYE6pC*3 zh^98@V8eC_GnfKY@iXUr_Es38v+s?!-HHS8( zyI{8}*BdMI5Z_ygww>QpsG38+QEoadP<@e$ZL*lIoYqF!r=Z&MC>NIW!UfvcbGDsG z=WnuGi}m!Nd~Mu+p#3lm9iUVjqiJrJF}IUD!dsZNr{trbjbAA`FkAEIhl%dZ^t!tP zI_RRY%c)oZLhEQU7Ec5^nh-PwLlEH~9xtd^06L`C7~jP%o^g}!D3M4_4rxu~i(ExR z^z75t{`>oR)rg&+DB9@vhOW;4A{#+M>y=1LY+~6GpZHBLF@T5ASn^)I7et6mUBONe zAt#xZk~;asl^QuFg_fE$de2^KJU}F`>R8is)1f0B(=Cm(&K5dl*yPi;+-gVigbFps?IhF- zw=0D10$a)bWOn=t{Cx;6iW4@F#(y91#aH+Sa++xHc{hdgns}R(x6)m74ZOw#k*iM}~TW9M!OTL7wH+_K$d@iLK+pv!cb4jBc3l0jG5L=!TCV&R&rDv=n;Ujscu9)k*XGi6*Ts%B|DxtFto3=t-jJ$yC0h5szF z@W(7TF=smcetqmH{x6j>(_2Dvo}TX95=u9%z3zmz&Y>Zu%VCJQISriXxuMx0JLG{G z5Xtn31|%X7A<01!Ul0+(P0$9}0Z>3h4FG^f0VzO$VSb$~L@$12B~BGx?kJWXS22`c z%qZHLhB@Fs2a!PtMo4bZh8Bwl|0pSmRezvuZ_ z%r5H}d2l{$X?KynfB@*;93-l_@Qm4Y*b|cQH+0Hc*w}hi75eL;cO^LTtRJ_-EcDW= zqj0m<2{-lV-X9OGq!GF>JL#UxoWyI=?Ibw+MLbTH+x$dd6yJq;urD%KpY4O(c5Ai7 zZHN|YBiDtjyuLa--eo`txB>DOSl+NxFG&bhd%Y z^|nvsY-#o%i_QW#GG|?{=33j=>Oo~nnHC$BQd+W59FlHym&smFsVu!8jbxrQRVsZy zY|Unc!9Oa#a!rj58!i_aR^9wMYCp2RC0H!9+Sy9Age}zyl!?yZ{Z>di&>gZgksQUq zwr6oovgL8oGXw|qOD^au`;3$f8iUMQJ`k7JGE(0ESnKx8->e&&x6<`TMU5KidRcu= zE`|IE-bEq_W>x+qXOK~UzHUQNF0JGVmjl!TwLvt%7F|MZU^&&_K?rRLq78QZ{!BGQ5*Zs zx3f-}OLgcHYhn4le#<-w2*_Z~*U@~Y?+2ooSvhZdXTTXA{{b_O{3_tE-P90akN0r+ zAxYyl!W1#4k1?93CjcT$<$#*xOzxgr`^fLsOM93d%mr75rI68*WL5@Cf9W{mH$VVK ze_;!ao#Fd_6Ztet4utkPzXy0Ifum=S9Zyd~eufL6#_4#;W=HsW&W{ht!VhUEp1>wC YRQs>jO~2&x?fMsTR{AA-V2JQK8qKqt1^@s6 literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/GeistMono-Medium.woff2 b/frontend/public/fonts/GeistMono-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..db3b40a54a666a32cad89d999bf6b9872871d749 GIT binary patch literal 51500 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..a879eea --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,74 @@ +import { useState, useCallback } from 'react'; +import { Outlet } from '@tanstack/react-router'; +import { AudioEngine } from './AudioEngine'; +import { NavRail } from './NavRail'; +import { TopBar } from './TopBar'; +import { PlaybackBar } from './PlaybackBar'; +import { NowPlayingPanel } from './NowPlayingPanel'; +import { LyricsOverlay } from './LyricsOverlay'; +import { Toaster } from './Toaster'; +import { CommandPalette } from './CommandPalette'; +import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard'; +import { Inspector, type InspectorMode } from './Inspector'; + +export default function AppShell() { + const [queueOpen, setQueueOpen] = useState(false); + const [lyricsOpen, setLyricsOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); + const [inspector, setInspector] = useState<{ mode: InspectorMode; id: string } | null>(null); + + const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []); + const closeInspector = useCallback(() => setInspector(null), []); + + // Ctrl+K — command palette (uses `code` so it works on any keyboard layout) + useKeyboard({ + code: 'KeyK', + ctrl: true, + handler: () => setPaletteOpen((p) => !p), + }); + + // Alt+← / Alt+→ — back / forward (uses `code` for layout independence) + useKeyboard({ + code: 'ArrowLeft', + alt: true, + handler: () => window.history.back(), + }); + useKeyboard({ + code: 'ArrowRight', + alt: true, + handler: () => window.history.forward(), + }); + + // Esc — closes inspector, palette, etc. + useKeyboard({ + code: 'Escape', + handler: () => { + if (inspector) closeInspector(); + }, + }); + + return ( +

+ + +
+ +
+ +
+ {inspector && } + {queueOpen && setQueueOpen(false)} />} + {lyricsOpen && setLyricsOpen(false)} />} +
+ setQueueOpen((o) => !o)} + onToggleLyrics={() => setLyricsOpen((o) => !o)} + /> + + + setPaletteOpen(false)} /> +
+ ); +} diff --git a/frontend/src/components/ArtistLinks.tsx b/frontend/src/components/ArtistLinks.tsx new file mode 100644 index 0000000..b6bd9e3 --- /dev/null +++ b/frontend/src/components/ArtistLinks.tsx @@ -0,0 +1,67 @@ +import { Link } from '@tanstack/react-router'; +import type { TrackArtist } from '../types'; + +interface ArtistLinksProps { + /** Ordered artists (first = main, rest = featured). */ + artists?: TrackArtist[] | null; + /** Shown when there are no structured artists (plain text, not a link). */ + fallback?: string; + className?: string; + /** Stop row/card click handlers from firing when an artist link is clicked. */ + stopPropagation?: boolean; +} + +/** + * Deduplicates artists by ID, preferring `main` over `featured` when the + * same artist has both roles (can happen because track_artists has a composite + * PK of track_id + artist_id + role). + */ +function deduplicateArtists(artists: TrackArtist[]): TrackArtist[] { + const map = new Map(); + for (const a of artists) { + const existing = map.get(a.id); + if (!existing || (existing.role === 'featured' && a.role === 'main')) { + map.set(a.id, a); + } + } + // Preserve original order, skipping duplicates. + const seen = new Set(); + return artists.filter((a) => { + if (seen.has(a.id)) return false; + seen.add(a.id); + return true; + }); +} + +/** + * Renders a track/album's artists as clickable links — main artist(s) then + * "feat." guests. Single source of truth used by TrackRow, the playback bar, + * the now-playing panel and album pages so artist navigation looks and behaves + * the same everywhere. + * + * Artists are deduplicated by id — if the same artist appears as both main + * and featured, only the main entry is shown. + */ +export function ArtistLinks({ artists, fallback, className = '', stopPropagation }: ArtistLinksProps) { + if (!artists || artists.length === 0) { + return {fallback || 'Unknown artist'}; + } + const unique = deduplicateArtists(artists); + return ( + + {unique.map((a, i) => ( + + {i > 0 && {a.role === 'featured' && unique[i - 1].role !== 'featured' ? ' feat. ' : ', '}} + e.stopPropagation() : undefined} + className={`hover:text-text hover:underline ${a.role === 'featured' ? 'opacity-75' : ''}`} + > + {a.name} + + + ))} + + ); +} diff --git a/frontend/src/components/Artwork.tsx b/frontend/src/components/Artwork.tsx new file mode 100644 index 0000000..721be0e --- /dev/null +++ b/frontend/src/components/Artwork.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { Music } from 'lucide-react'; +import { hueFromString } from '../lib/color'; + +interface ArtworkProps { + seed: string; + src?: string | null; + className?: string; + rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; + /** Skip native lazy-loading — set true for above-the-fold artwork (e.g. PlaybackBar). */ + eager?: boolean; +} + +const API_BASE = import.meta.env.VITE_API_URL || '/api'; + +/** + * Rewrite external image URLs through the backend proxy so the browser gets + * cache headers (1 year, immutable) and avoids per-domain connection limits + * to Discogs / Cover Art Archive etc. + */ +function proxySrc(src: string): string { + if (src.startsWith('http://') || src.startsWith('https://')) { + return `${API_BASE}/images/proxy?url=${encodeURIComponent(src)}`; + } + return src; +} + +export function Artwork({ seed, src, className = '', rounded = 'md', eager = false }: ArtworkProps) { + const hue = hueFromString(seed); + // Symmetric top sheen over a diagonal base — the highlight is centered + // horizontally so it reads as even behind the centered note glyph. + const gradient = + `radial-gradient(110% 90% at 50% 0%, hsl(${hue},55%,30%) 0%, transparent 60%), ` + + `linear-gradient(160deg, hsl(${hue},48%,23%), hsl(${(hue + 55) % 360},40%,11%))`; + const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded]; + + // If an image source is supplied we render it lazily over the gradient + // fallback, so a slow or broken cover never produces a blank rectangle: + // the gradient (with the music glyph) is painted underneath and only + // swapped out once the fires its onLoad. A 404 falls back too. + const [loaded, setLoaded] = useState(false); + const [errored, setErrored] = useState(false); + + if (src && !errored) { + const proxied = proxySrc(src); + return ( +
+ {!loaded && } + {seed} setLoaded(true)} + onError={() => setErrored(true)} + className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`} + /> +
+ ); + } + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/AudioEngine.tsx b/frontend/src/components/AudioEngine.tsx new file mode 100644 index 0000000..43c1c6b --- /dev/null +++ b/frontend/src/components/AudioEngine.tsx @@ -0,0 +1,291 @@ +import { useEffect, useRef } from 'react'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { trackService } from '../services/trackService'; +import { vibeService } from '../services/vibeService'; +import type { Track } from '../types'; + +// Threshold (seconds) above which a store position change is treated as a user +// scrub and applied to the audio element. Keeps the timeupdate -> setPosition -> +// effect loop from fighting itself. +const SEEK_THRESHOLD = 1; + +// If a track reaches this fraction of its duration, treat it as "effectively +// completed" even if the user clicks Next before the very end. +const COMPLETION_THRESHOLD = 0.95; + +// Relative seek increment (seconds) for MediaSession seekforward/seekbackward. +const SEEK_INCREMENT = 10; + +/** Build the artwork URLs for MediaSession metadata (OS media controls). */ +function buildArtwork(track: Track): MediaImage[] { + const sizes = [96, 128, 192, 256, 384, 512]; + const artwork = track.artwork_id; + if (!artwork) return []; + // artwork_id is either a full URL (external) or a relative path served by us. + const url = artwork.startsWith('http') + ? artwork + : `${window.location.origin}${artwork}`; + return sizes.map((s) => ({ src: url, sizes: `${s}x${s}`, type: 'image/jpeg' })); +} + +// Headless audio engine: one shared