Compare commits

...
62 Commits
Author SHA1 Message Date
mrleo1nidandClaude Sonnet 5.5 75b0f7bec3 Hide full-screen player controls after mouse click
ci / ts / ts (packages + web) (push) Successful in 5m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:34:26 +03:00
mrleo1nidandClaude Sonnet 5.5 3edb1af037 Fix indentation in full-screen player
ci / ts / ts (packages + web) (push) Successful in 4m36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:51:31 +03:00
mrleo1nidandClaude Sonnet 5.5 0458cbc46c Reveal full-screen player controls on hover and balance their layout
ci / ts / ts (packages + web) (push) Canceled after 14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:51:12 +03:00
mrleo1nidandClaude Sonnet 5.5 f7ffa5d742 Fix track page hero layout and add full-screen player
ci / ts / ts (packages + web) (push) Successful in 5m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 00:17:55 +03:00
mrleo1nidandClaude Sonnet 5.5 5d0decbb2a Use the primary artist for upload destination folders
ci / db / db (push) Successful in 1m47s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 23:14:48 +03:00
mrleo1nidandClaude Sonnet 5.5 3edbf489a3 Place cover and artist images in library folders on upload import
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 36s
ci / db / db (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 01:04:24 +03:00
mrleo1nid 82f3eed9a0 Improve search relevance and artist transliteration
ci / api / api (push) Successful in 56s
ci / db / db (push) Successful in 1m13s
2026-10-30 12:09:24 +03:00
mrleo1nid db4548b115 Omit missing years from upload destination paths
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 2m33s
ci / api / api (push) Successful in 1m10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m45s
ci / ts / ts (packages + web) (push) Successful in 6m29s
2026-10-30 11:40:42 +03:00
mrleo1nid 3309a79a98 Wait for all search indexes in API user journey
release / android (APK в релиз) (push) Successful in 4m55s
ci / db / db (push) Successful in 53s
release / release (версия и заметки) (push) Successful in 6s
release / desktop (Linux и Windows в релиз) (push) Successful in 19m16s
release / prune (старые файлы и прогоны) (push) Successful in 10s
ci / api / api (push) Successful in 31s
2026-10-30 11:24:44 +03:00
mrleo1nid bbd629dcf8 Isolate CI database journey and stabilize E2E dependency cache
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Failing after 29s
ci / api / api (push) Successful in 52s
ci / ts / ts (packages + web) (push) Successful in 4m22s
2026-10-30 11:05:13 +03:00
mrleo1nid 82bf015283 Import missing artist portraits from uploaded album folders
ci / module boundaries / boundaries (push) Successful in 6s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m1s
ci / ts / ts (packages + web) (push) Failing after 5m13s
ci / api / api (push) Successful in 41s
ci / db / db (push) Failing after 1m33s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m19s
2026-10-30 10:46:07 +03:00
mrleo1nid fd17c06397 Fix release matching for featured artists and credit annotations
ci / module boundaries / boundaries (push) Successful in 1m11s
ci / api / api (push) Successful in 1m54s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 6m50s
ci / ts / ts (packages + web) (push) Failing after 6m19s
ci / db / db (push) Failing after 11s
2026-10-30 10:23:12 +03:00
mrleo1nid f4681da876 Stabilize E2E server and retain failure diagnostics
ci / ts / ts (packages + web) (push) Successful in 6m9s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m48s
release / prune (старые файлы и прогоны) (push) Successful in 6s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m35s
2026-10-29 23:17:48 +03:00
mrleo1nid 5248074964 fix(web): stabilize navigation labels and upload browser tests
ci / ts / ts (packages + web) (push) Failing after 10m23s
2026-10-29 22:54:22 +03:00
mrleo1nid 6a1382dad5 fix(player): toggle current track without restarting playback
ci / ts / ts (packages + web) (push) Failing after 7m39s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Successful in 6m47s
release / desktop (Linux и Windows в релиз) (push) Successful in 15m18s
release / prune (старые файлы и прогоны) (push) Successful in 10s
2026-10-29 22:44:54 +03:00
mrleo1nid 5876ae29cc feat(uploads): show artist and album in batch headers
ci / module boundaries / boundaries (push) Successful in 19s
ci / ts / ts (packages + web) (push) Failing after 6m0s
2026-10-29 22:28:42 +03:00
mrleo1nid 39bf7d4b70 feat(uploads): match release groups with MusicBrainz and AcoustID
ci / api / api (push) Successful in 58s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Failing after 25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Failing after 6m3s
ci / module boundaries / boundaries (push) Successful in 10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
2026-10-29 20:44:59 +03:00
mrleo1nid 1e3a149aef feat(uploads): show batch format counts and quality ranges
ci / ts / ts (packages + web) (push) Failing after 5m56s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m39s
ci / module boundaries / boundaries (push) Successful in 5s
2026-10-29 17:41:28 +03:00
mrleo1nid e78cab6ce6 fix(uploads): serialize status filters and resize oversized covers
ci / module boundaries / boundaries (push) Successful in 9s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m37s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / ts / ts (packages + web) (push) Failing after 27m2s
ci / api / api (push) Successful in 58s
ci / db / db (push) Successful in 6m19s
2026-10-29 12:14:49 +03:00
mrleo1nid ca2a50b515 fix(uploads): load audio previews only on play
ci / module boundaries / boundaries (push) Successful in 7s
ci / ts / ts (packages + web) (push) Failing after 6m33s
2026-10-29 11:45:09 +03:00
mrleo1nid af06cf1e75 fix(uploads): pause previous audio preview when another starts
ci / ts / ts (packages + web) (push) Canceled after 3m50s
ci / module boundaries / boundaries (push) Successful in 8s
2026-10-29 11:41:11 +03:00
mrleo1nid 7d62531c02 fix(uploads): serve private previews through API and show batch cover status
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Canceled after 4m13s
ci / module boundaries / boundaries (push) Successful in 8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / api / api (push) Successful in 56s
ci / db / db (push) Successful in 1m7s
2026-10-29 11:34:55 +03:00
mrleo1nid 37e33c656b fix(uploads): allow signed audio previews and compact destination details
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 47s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m42s
ci / ts / ts (packages + web) (push) Failing after 5m24s
ci / api / api (push) Successful in 42s
2026-10-29 11:21:48 +03:00
mrleo1nid 30c3c85f89 fix(uploads): refresh destination previews and improve queue layout
ci / api / api (push) Successful in 50s
ci / module boundaries / boundaries (push) Successful in 8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
ci / ts / ts (packages + web) (push) Failing after 4m28s
ci / db / db (push) Successful in 56s
2026-10-29 11:10:54 +03:00
mrleo1nid eb75437708 fix(uploads): separate withdrawn proposals from default filters
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m44s
ci / ts / ts (packages + web) (push) Failing after 1m56s
2026-10-29 10:44:10 +03:00
mrleo1nid 64dd8ed05e feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Failing after 4m4s
ci / api / api (push) Successful in 55s
ci / db / db (push) Failing after 1m14s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
2026-10-29 10:28:29 +03:00
mrleo1nid 5e2d1e902b fix(uploads): retry failed files and report full storage
ci / db / db (push) Successful in 1m51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m40s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 33s
ci / ts / ts (packages + web) (push) Failing after 5m16s
2026-10-29 00:39:03 +03:00
mrleo1nid b39e4dc28f fix(uploads): hash files without WebAssembly under strict CSP
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 3m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m42s
ci / ts / ts (packages + web) (push) Failing after 4m37s
ci / module boundaries / boundaries (push) Successful in 5s
ci / desktop / rust (desktop proxy) (push) Successful in 6m12s
2026-10-29 00:05:49 +03:00
mrleo1nid f59d1e8d03 fix(search): skip deletion of missing temporary indexes
ci / api / api (push) Successful in 42s
ci / db / db (push) Successful in 52s
2026-10-28 23:56:46 +03:00
mrleo1nid 66d3897f66 feat(admin): group navigation into media, uploads and system
ci / ts / ts (packages + web) (push) Failing after 4m33s
release / android (APK в релиз) (push) Successful in 4m45s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m14s
release / prune (старые файлы и прогоны) (push) Successful in 12s
release / release (версия и заметки) (push) Successful in 8s
2026-10-28 23:47:17 +03:00
mrleo1nid 17bb0e1846 fix(uploads): allow WebAssembly hashing under CSP
ci / ts / ts (packages + web) (push) Failing after 4m29s
ci / desktop / rust (desktop proxy) (push) Successful in 5m54s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 3m23s
2026-10-28 23:34:42 +03:00
mrleo1nidandClaude Sonnet 5.5 17c85493b7 fix(library): always split comma-separated artists even if glued name exists
ci / api / api (push) Successful in 34s
ci / db / db (push) Successful in 42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:26:49 +03:00
mrleo1nidandClaude Sonnet 5.5 75f9c57eca fix(library): split compound album artist and re-split artists on rescan
ci / api / api (push) Successful in 37s
ci / db / db (push) Successful in 55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:29:50 +03:00
mrleo1nidandClaude Sonnet 5.5 8a007be036 feat(web): show spinner on play button while track is loading
ci / ts / ts (packages + web) (push) Successful in 3m15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:23:19 +03:00
mrleo1nidandClaude Sonnet 5.5 f77148df2c fix(web): show notification count as menu badge and mark read on open
ci / ts / ts (packages + web) (push) Successful in 2m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:56:05 +03:00
mrleo1nidandClaude Sonnet 5.5 139dd226fa feat(genius): fetch artist avatars by name and match names with transliteration suffix
ci / api / api (push) Successful in 27s
ci / db / db (push) Successful in 53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:50:55 +03:00
mrleo1nidandClaude Sonnet 5.5 795bb7de46 fix(library): split comma-separated main artists
ci / module boundaries / boundaries (push) Successful in 8s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / db / db (push) Canceled after 11m9s
ci / api / api (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m43s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:39:32 +03:00
mrleo1nid 11b890c2e7 docs: record architecture and correctness review 2026-10-24 02:16:05 +03:00
mrleo1nid 59983ed1b3 fix(social): complete shared listening request lifecycle 2026-10-24 02:16:00 +03:00
mrleo1nid cb22f15912 fix(web): polish social UI and prevent doubled shared audio
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 19m22s
ci / ts / ts (packages + web) (push) Successful in 3m9s
release / release (версия и заметки) (push) Successful in 10s
release / android (APK в релиз) (push) Successful in 4m7s
release / prune (старые файлы и прогоны) (push) Successful in 11s
2026-10-23 19:54:10 +03:00
mrleo1nid 6450027b88 feat(web): add desktop mini chat and move social controls to profile
ci / ts / ts (packages + web) (push) Successful in 4m55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m37s
2026-10-23 18:37:32 +03:00
mrleo1nid f087ec9f88 fix(ci): serialize integration suites sharing a database
ci / db / db (push) Successful in 1m6s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m59s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m17s
release / prune (старые файлы и прогоны) (push) Successful in 6s
2026-10-23 12:55:53 +03:00
mrleo1nid cd84ceecb2 feat(social): show friend presence and require online listening
ci / api / api (push) Successful in 1m11s
ci / db / db (push) Failing after 23s
ci / ts / ts (packages + web) (push) Successful in 3m15s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m29s
2026-10-23 12:52:00 +03:00
mrleo1nid 582c0dc3d4 fix(ci): align onboarding assertion with generated enum
ci / db / db (push) Successful in 57s
ci / api / api (push) Successful in 58s
2026-10-23 12:03:23 +03:00
mrleo1nid 1a693848f2 Redesign social conversations and shared music
ci / api / api (push) Successful in 19m12s
ci / ts / ts (packages + web) (push) Successful in 2m44s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 59s
ci / db / db (push) Failing after 18s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
ci / module boundaries / boundaries (push) Successful in 18m23s
2026-10-23 11:27:47 +03:00
mrleo1nid 11a16cd023 feat(social): add listening activity, shared rooms and modern chat
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / ts / ts (packages + web) (push) Canceled after 1s
ci / recommender / recommender (push) Canceled after 6m47s
2026-10-23 10:45:40 +03:00
mrleo1nid 0a05bfe2cd feat(social): add friends, private chats, inbox and music sharing
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 2m29s
ci / ts / ts (packages + web) (push) Successful in 2m55s
2026-10-23 03:19:29 +03:00
mrleo1nid f1528de948 feat(admin): transfer user accounts and show local cover coverage
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
2026-10-23 02:17:06 +03:00
mrleo1nid 0a4cd4bff1 fix(library): parallelize cover processing and retry missing artwork
ci / db / db (push) Successful in 1m1s
ci / api / api (push) Successful in 50s
2026-10-23 02:02:36 +03:00
mrleo1nid eed7038109 fix(web): hide browser URL preview for player artists
ci / ts / ts (packages + web) (push) Successful in 4m37s
2026-10-23 00:49:14 +03:00
mrleo1nid 7fa7ee2c7d fix(web): follow server job log output automatically
ci / ts / ts (packages + web) (push) Successful in 2m14s
2026-10-23 00:35:39 +03:00
mrleo1nid 24707a0bfc fix(admin): mask all stored configuration secrets and credential URLs
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m15s
ci / ts / ts (packages + web) (push) Successful in 2m19s
2026-10-23 00:27:20 +03:00
mrleo1nid a6c9fd8a00 fix(ui): reflect update availability and config changes; expand mobile player metadata
ci / api / api (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m0s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m39s
ci / ts / ts (packages + web) (push) Successful in 2m40s
ci / db / db (push) Successful in 58s
2026-10-23 00:20:59 +03:00
mrleo1nid bc55528afd fix(deploy): provision management agent during server updates
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m21s
2026-10-22 23:43:11 +03:00
mrleo1nid a29a8b18ad feat(admin): manage server updates and local configuration
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / db / db (push) Successful in 1m15s
2026-10-22 23:01:36 +03:00
mrleo1nid e33abc948c docs(agents): limit local checks to affected areas 2026-10-22 22:46:44 +03:00
mrleo1nid ab49e22357 feat(desktop): add offline cache and verified app updates
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 3m8s
ci / desktop / rust (desktop proxy) (push) Successful in 4m21s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m0s
release / desktop (Linux и Windows в релиз) (push) Successful in 11m49s
release / prune (старые файлы и прогоны) (push) Successful in 5s
ci / module boundaries / boundaries (push) Successful in 7s
Clean release notes in Android and desktop update dialogs. Persist downloaded audio and offline playback events per server and user; add desktop update checks, checksum verification and installer launch.
2026-10-22 22:37:35 +03:00
mrleo1nid 75ae2092ad feat(library): exclude configured folders from scanning and analysis
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / recommender / recommender (push) Failing after 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / ts / ts (packages + web) (push) Successful in 7m20s
ci / db / db (push) Successful in 1m8s
2026-10-22 15:51:15 +03:00
mrleo1nid 0dc0384c78 fix(library): expose scan activity and failed analysis file details
ci / api / api (push) Successful in 55s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / db / db (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
release / android (APK в релиз) (push) Successful in 3m50s
ci / ts / ts (packages + web) (push) Successful in 3m20s
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 44m45s
release / prune (старые файлы и прогоны) (push) Successful in 7s
2026-10-22 15:25:48 +03:00
mrleo1nid 17b6dd2041 fix(web): clarify Genius processing progress and queue actions
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / ts / ts (packages + web) (push) Successful in 5m23s
2026-10-22 15:05:11 +03:00
mrleo1nid 0a59059738 feat(playlists): share playlists with revocable read-only links
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 59s
ci / api / api (push) Successful in 55s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
2026-10-22 14:57:14 +03:00
mrleo1nid bfcf6e48b2 fix: harmonize wave colors from album covers
ci / ts / ts (packages + web) (push) Canceled after 5m28s
ci / module boundaries / boundaries (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
2026-10-22 14:52:57 +03:00
316 changed files with 26377 additions and 835 deletions

No files matched your search

+7 -1
View File
@@ -48,6 +48,7 @@ jobs:
TEST_REDIS_URL: redis://redis:6379/0 TEST_REDIS_URL: redis://redis:6379/0
TEST_MEILI_URL: http://meilisearch:7700 TEST_MEILI_URL: http://meilisearch:7700
TEST_MEILI_KEY: ci-master-key-0123456789 TEST_MEILI_KEY: ci-master-key-0123456789
MUSICBRAINZ_ENABLED: "false"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
@@ -71,7 +72,12 @@ jobs:
done done
- name: Go — миграции и запросы store - name: Go — миграции и запросы store
working-directory: services/api working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/ # These suites share one database; dbtest also rolls all migrations down
# and cleans orphan catalog rows. Do not run the packages concurrently.
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/ ./internal/uploads/
- name: Go — релевантность поиска на Meilisearch
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/search/
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch) - name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
working-directory: services/api working-directory: services/api
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/ run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
+9
View File
@@ -63,3 +63,12 @@ jobs:
pnpm --filter @pipu/web exec playwright install --with-deps chromium || pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
pnpm --filter @pipu/web exec playwright install chromium pnpm --filter @pipu/web exec playwright install chromium
- run: pnpm --filter @pipu/web test:e2e - run: pnpm --filter @pipu/web test:e2e
- name: E2E failure diagnostics
if: failure()
continue-on-error: true
uses: actions/upload-artifact@v3
with:
name: web-e2e-failure
path: apps/web/test-results/
if-no-files-found: ignore
retention-days: 3
+2
View File
@@ -47,6 +47,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Обновление сервера, агент и конфигурация
run: python3 -m unittest discover -s deploy -p 'test_*.py' -v
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и # Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги # --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`. # не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
+2
View File
@@ -20,6 +20,8 @@ __pycache__/
.ruff_cache/ .ruff_cache/
# env / secrets # env / secrets
deploy/config.local
deploy/.management.lock
.env .env
*.pem *.pem
*.keystore *.keystore
+2 -2
View File
@@ -5,6 +5,6 @@
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную. - Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации. - После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя. - Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
- Промежуточно запускай проверки затронутой области; перед сдачей — необходимые связанные проверки. Команды запускаются из корня, если не сказано иначе. - Локально достаточно запускать проверки затронутой области и непосредственно связанных сценариев. Полный набор тестов и проверок повторно выполняется в CI/CD; не запускай все проверки проекта перед каждой сдачей без необходимости. Расширяй локальную проверку при изменении общих контрактов, межмодульных зависимостей или при обнаружении ошибок. Команды запускаются из корня, если не сказано иначе.
- Общие проверки: `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`. - Справочник полных проверок (выбирай локально только относящиеся к изменению): `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы. - Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
+1136
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["net", "sync", "time"] } tokio = { version = "1", features = ["net", "sync", "time"] }
axum = "0.8" axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
+4 -1
View File
@@ -1,6 +1,7 @@
mod media; mod media;
mod proxy; mod proxy;
mod store; mod store;
mod update;
use media::Media; use media::Media;
use proxy::Proxy; use proxy::Proxy;
@@ -110,6 +111,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app))) .plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
.plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_window_state::Builder::default().build())
.manage(update::Updater::default())
.setup(|app| { .setup(|app| {
let saved = store::load_server(app.handle())?; let saved = store::load_server(app.handle())?;
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?; let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
@@ -150,7 +152,8 @@ pub fn run() {
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set,
update::update_check, update::update_install
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("desktop app failed to start"); .expect("desktop app failed to start");
+6 -2
View File
@@ -86,7 +86,7 @@ fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
} }
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS")); headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with")); headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after")); headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after, x-pipu-transport-error"));
} }
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response { async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
@@ -110,7 +110,11 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
} else { } else {
match transfer(&proxy, request, tail).await { match transfer(&proxy, request, tail).await {
Ok(response) => response, Ok(response) => response,
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(), Err(_) => {
let mut response = (StatusCode::BAD_GATEWAY, "Server unavailable").into_response();
response.headers_mut().insert("x-pipu-transport-error", HeaderValue::from_static("1"));
response
},
} }
}; };
cors(response.headers_mut(), origin.as_deref()); cors(response.headers_mut(), origin.as_deref());
+152
View File
@@ -0,0 +1,152 @@
//! Server-proxied releases: never accept installer paths or commands from the UI.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{io::Write, path::Path, time::Duration};
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::sync::Mutex;
#[derive(Clone, Deserialize, Serialize)]
pub struct Build {
client: String,
version: String,
filename: String,
size: u64,
sha256: Option<String>,
download_url: String,
}
#[derive(Deserialize)]
struct Catalog { version: String, notes_md: String, clients: Vec<Build> }
#[derive(Clone, Serialize)]
pub struct Release { build: Build, notes: String }
#[derive(Default)]
pub struct Updater { release: Mutex<Option<(String, Release)>> }
fn client() -> &'static str {
if cfg!(target_os = "windows") { "windows" }
else if cfg!(target_os = "macos") { "macos" } else { "linux" }
}
fn version_code(raw: &str) -> Option<u64> {
let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v);
let parts: Vec<u64> = base.split('.').map(str::parse).collect::<Result<_, _>>().ok()?;
let rc: u64 = rc.parse().ok()?;
if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; }
Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc)
}
fn http() -> Result<reqwest::Client, String> {
reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10)).read_timeout(Duration::from_secs(60))
.build().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result<Option<Release>, String> {
let mut saved = updater.release.lock().await;
*saved = None;
let server = crate::store::load_server(&app)?.ok_or("No server selected")?;
let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?
.json().await.map_err(|e| e.to_string())?;
let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); };
let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?;
if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); }
let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() };
let release = Release { build, notes };
validate_build(&release.build)?;
*saved = Some((server, release.clone()));
Ok(Some(release))
}
fn validate_build(build: &Build) -> Result<(), String> {
let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or("");
let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" };
let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()));
if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); }
// Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release.
if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) {
return Err("Invalid installer URL".into());
}
Ok(())
}
#[tauri::command]
pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> {
let saved = updater.release.lock().await;
let (server, release) = saved.as_ref().ok_or("Check for an update first")?;
if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); }
let build = &release.build;
validate_build(build)?;
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(&build.filename);
let part = dir.join(format!("{}.part", uuid::Uuid::new_v4()));
let result = async {
let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?;
let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?;
let mut hash = Sha256::new();
let mut bytes = 0u64;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
bytes += chunk.len() as u64;
if bytes > build.size { return Err("Installer exceeds expected size".to_string()); }
hash.update(&chunk);
file.write_all(&chunk).map_err(|e| e.to_string())?;
let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string());
}
if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); }
file.sync_all().map_err(|e| e.to_string())?;
drop(file);
if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; }
std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
launch(&path)?;
Ok(())
}.await;
let _ = std::fs::remove_file(&part);
result
}
fn launch(path: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") {
let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd
} else { std::process::Command::new(path) };
command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{ std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; }
#[cfg(target_os = "macos")]
{ std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; }
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_order() {
assert!(version_code("1.7.2") > version_code("1.7.2-rc.3"));
assert!(version_code("1.8.0-rc.1") > version_code("1.7.99"));
assert_eq!(version_code("1.7.1"), Some(1070199));
assert_eq!(version_code("1.7.100"), None);
assert_eq!(version_code("1.7.1;cmd"), None);
}
#[test]
fn installer_is_restricted_to_verified_server_asset() {
let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" };
let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42,
sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) };
assert!(validate_build(&build).is_ok());
build.download_url = "https://other.example/installer.exe".into();
assert!(validate_build(&build).is_err());
build.download_url = format!("/api/v1/app/download/{}/1.8.0", client());
build.filename = "../installer.exe".into();
assert!(validate_build(&build).is_err());
build.filename = format!("pipu.{extension}");
build.sha256 = None;
assert!(validate_build(&build).is_err());
}
}
+16 -4
View File
@@ -8,32 +8,38 @@ import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks'; import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme'; import { useColors } from '../../theme';
import { Cover, Icon, type IconName } from '../../ui/kit'; import { Cover, Icon, type IconName } from '../../ui/kit';
import { NativeChatProvider, useSocialUnread } from '../../features/social/Social';
import { ListeningBanner } from '../../features/social/Listening';
import { UpdateDialog } from '../../ui/UpdateDialog'; import { UpdateDialog } from '../../ui/UpdateDialog';
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' }; const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
export default function TabsLayout() { export default function TabsLayout() {
const colors = useColors(); const colors = useColors();
const { t } = useTranslation(); const { t } = useTranslation();
const unread = useSocialUnread();
const labels: Record<string, string> = { const labels: Record<string, string> = {
index: t('nav.home'), index: t('nav.home'),
search: t('mobile.search'), search: t('mobile.search'),
collection: t('nav.collection'), collection: t('nav.collection'),
profile: t('mobile.profile'), profile: t('mobile.profile'),
friends: t('social.people'),
}; };
const net = useNetInfo(); const net = useNetInfo();
return ( return (
<> <NativeChatProvider>
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />} {net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
<Tabs <Tabs
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }} screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
tabBar={({ state, navigation }) => ( tabBar={({ state, navigation }) => (
<View> <View>
<ListeningBanner />
<MiniPlayer /> <MiniPlayer />
<TabBar <TabBar
routes={state.routes.map((r) => r.name)} routes={state.routes.map((r) => r.name)}
active={state.index} active={state.index}
labels={labels} labels={labels}
badges={{ friends: unread }}
onPress={(name) => navigation.navigate(name)} onPress={(name) => navigation.navigate(name)}
/> />
</View> </View>
@@ -42,10 +48,11 @@ export default function TabsLayout() {
<Tabs.Screen name="index" /> <Tabs.Screen name="index" />
<Tabs.Screen name="search" /> <Tabs.Screen name="search" />
<Tabs.Screen name="collection" /> <Tabs.Screen name="collection" />
<Tabs.Screen name="friends" />
<Tabs.Screen name="profile" /> <Tabs.Screen name="profile" />
</Tabs> </Tabs>
<UpdateDialog /> <UpdateDialog />
</> </NativeChatProvider>
); );
} }
@@ -53,11 +60,13 @@ function TabBar({
routes, routes,
active, active,
labels, labels,
badges,
onPress, onPress,
}: { }: {
routes: string[]; routes: string[];
active: number; active: number;
labels: Record<string, string>; labels: Record<string, string>;
badges: Record<string, number>;
onPress: (name: string) => void; onPress: (name: string) => void;
}) { }) {
const colors = useColors(); const colors = useColors();
@@ -81,10 +90,13 @@ function TabBar({
onPress={() => onPress(name)} onPress={() => onPress(name)}
accessibilityRole="tab" accessibilityRole="tab"
accessibilityState={{ selected: on }} accessibilityState={{ selected: on }}
accessibilityLabel={labels[name]} accessibilityLabel={`${labels[name]}${badges[name] ? ` · ${badges[name]}` : ''}`}
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }} style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
> >
<View>
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} /> <Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
{!!badges[name] && <Text style={{ position: 'absolute', top: -5, left: 14, minWidth: 16, paddingHorizontal: 4, borderRadius: 9, backgroundColor: colors['accent-fill'], color: '#fff', fontSize: 10, textAlign: 'center' }}>{badges[name]! > 99 ? '99+' : badges[name]}</Text>}
</View>
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}> <Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
{labels[name]} {labels[name]}
</Text> </Text>
+2
View File
@@ -0,0 +1,2 @@
import { SocialScreen } from '../../features/social/Social';
export default function FriendsTab() { return <SocialScreen root />; }
+3
View File
@@ -110,6 +110,9 @@ function Screens() {
<Stack.Screen name="album/[id]" /> <Stack.Screen name="album/[id]" />
<Stack.Screen name="artist/[id]" /> <Stack.Screen name="artist/[id]" />
<Stack.Screen name="playlists" /> <Stack.Screen name="playlists" />
<Stack.Screen name="social" />
<Stack.Screen name="social-track" />
<Stack.Screen name="social-playlist" />
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} /> <Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack.Protected> </Stack.Protected>
</Stack> </Stack>
+11 -16
View File
@@ -1,7 +1,9 @@
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native'; import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { MusicShareSheet } from '../features/social/Social';
import { ListeningBanner } from '../features/social/Listening';
import { trackShareUrl } from '@pipu/core'; import { trackShareUrl } from '@pipu/core';
import { useSessionCtx, useUser } from '../lib/session'; import { useSessionCtx, useUser } from '../lib/session';
@@ -21,6 +23,7 @@ export default function PlayerScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const st = usePlayerState(); const st = usePlayerState();
const locked = st.listening?.locked === true;
const prefs = useUser().ui_prefs; const prefs = useUser().ui_prefs;
const { server } = useSessionCtx(); const { server } = useSessionCtx();
const [sharing, setSharing] = useState(false); const [sharing, setSharing] = useState(false);
@@ -39,20 +42,12 @@ export default function PlayerScreen() {
const duration = st.durationMs || track.duration_ms; const duration = st.durationMs || track.duration_ms;
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0; const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined); const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => { const share = () => setSharing(true);
if (!server || sharing) return;
setSharing(true);
try {
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
} catch {
Alert.alert(t('player.share'), t('player.share_error'));
} finally {
setSharing(false);
}
};
return ( return (
<Screen> <Screen>
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
<ListeningBanner />
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}> <View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}> <Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
@@ -102,7 +97,7 @@ export default function PlayerScreen() {
<View style={{ gap: 6 }}> <View style={{ gap: 6 }}>
<Pressable <Pressable
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)} onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)} disabled={locked} onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
accessibilityRole="adjustable" accessibilityRole="adjustable"
accessibilityLabel={t('player.progress')} accessibilityLabel={t('player.progress')}
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }} accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
@@ -124,18 +119,18 @@ export default function PlayerScreen() {
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}> <Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} /> <Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
</Pressable> </Pressable>
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}> <Pressable disabled={locked} onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
<Icon name="skip-previous" size={40} color={colors.text} /> <Icon name="skip-previous" size={40} color={colors.text} />
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => player.toggle()} disabled={locked} onPress={() => player.toggle()}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')} accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }} style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
> >
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} /> <Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
</Pressable> </Pressable>
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}> <Pressable disabled={locked} onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={40} color={colors.text} /> <Icon name="skip-next" size={40} color={colors.text} />
</Pressable> </Pressable>
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}> <Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
+19 -4
View File
@@ -1,11 +1,12 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { MusicShareSheet } from '../features/social/Social';
import { playlistShareUrl, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native'; import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
import { useSession, useUser } from '../lib/session'; import { useSession, useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller'; import { player } from '../player/controller';
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit'; import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
@@ -77,7 +78,9 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
} }
function Detail({ id }: { id: string }) { function Detail({ id }: { id: string }) {
const [shareToken, setShareToken] = useState<string | null>(null);
const session = useSession(); const session = useSession();
const { server } = useSessionCtx();
const user = useUser(); const user = useUser();
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -89,6 +92,15 @@ function Detail({ id }: { id: string }) {
const key = ['m-playlists', user.id, id]; const key = ['m-playlists', user.id, id];
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) }); const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
const p = query.data; const p = query.data;
const sharing = useMutation({ mutationFn: async () => {
if (!server || !p) throw new Error('Server unavailable');
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
setShareToken(token);
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })), const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); }, onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } }); onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
@@ -102,14 +114,17 @@ function Detail({ id }: { id: string }) {
const move = (index: number, delta: number) => { const move = (index: number, delta: number) => {
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids); const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
}; };
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching} return <Screen>{shareToken && server && p && <MusicShareSheet attachment={{ kind: 'playlist', id: shareToken, title: p.name }} url={playlistShareUrl(server, shareToken)} close={() => setShareToken(null)} />}<FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
ListHeaderComponent={<View style={{ padding:16,gap:12 }}> ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} /> <Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox> <ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
{query.isPending && <Muted>{t('playlists.loading')}</Muted>} {query.isPending && <Muted>{t('playlists.loading')}</Muted>}
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted> {p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} /> <Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} /> <Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
<Button title={t('playlists.share')} variant="secondary" busy={sharing.isPending} disabled={revoke.isPending} onPress={() => sharing.mutate()} />
<Muted>{t('playlists.share_hint')}</Muted>
{p.share_id && <Button title={t('playlists.revoke_share')} variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onPress={() => revoke.mutate()} />}
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} /> <Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} /> <Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[ <Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
+2
View File
@@ -0,0 +1,2 @@
import { SharedMusic } from '../features/social/SharedMusic';
export default function SharedPlaylistScreen() { return <SharedMusic kind="playlist" />; }
+2
View File
@@ -0,0 +1,2 @@
import { SharedMusic } from '../features/social/SharedMusic';
export default function SharedTrackScreen() { return <SharedMusic kind="track" />; }
+1
View File
@@ -0,0 +1 @@
export { SocialScreen as default } from '../features/social/Social';
@@ -0,0 +1,61 @@
import { CHAT_REACTIONS, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityInfo, Animated, KeyboardAvoidingView, Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { eventId } from '../../player/eventId';
import { useColors } from '../../theme';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
import { readDraft, saveDraft } from './drafts';
function Bubble({ children, mine, animate }: { children: ReactNode; mine: boolean; animate: boolean }) {
const colors = useColors(); const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => { if (!animate) return; opacity.setValue(0); const animation = Animated.timing(opacity,{toValue:1,duration:180,useNativeDriver:true});animation.start();return()=>animation.stop(); },[animate,opacity]);
return <Animated.View style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '90%', padding: 14, borderRadius: 16, backgroundColor: mine ? colors['accent-soft'] : colors.surface2, gap: 8, opacity, transform:[{translateY:opacity.interpolate({inputRange:[0,1],outputRange:[6,0]})}] }}>{children}</Animated.View>;
}
export function ChatConversation({ person, api, queryKey, userId, server, active }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; server: string; active: boolean }) {
const colors=useColors();const {t,i18n}=useTranslation();const router=useRouter();const cache=useQueryClient();const errorText=useErrorText();
const [text,setText]=useState('');const [ready,setReady]=useState(false);const touched=useRef(false);const [search,setSearch]=useState('');const [q,setQ]=useState('');
const [reply,setReply]=useState<DirectMessage|null>(null);const [editing,setEditing]=useState<DirectMessage|null>(null);const [menu,setMenu]=useState<DirectMessage|null>(null);const [emojis,setEmojis]=useState(false);const [reduced,setReduced]=useState(true);
const beforeEdit=useRef('');const pending=useRef<{id:string;text:string;reply_to?:string}|null>(null);
useEffect(()=>{let live=true;void readDraft(server,userId,person.id).then(value=>{if(live){if(!touched.current)setText(value);setReady(true);}});return()=>{live=false;};},[server,userId,person.id]);
useEffect(()=>{if(!ready||editing)return;const timer=setTimeout(()=>{void saveDraft(server,userId,person.id,text).catch(()=>undefined);},300);return()=>clearTimeout(timer);},[text,ready,editing,server,userId,person.id]);
useEffect(()=>{void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);const sub=AccessibilityInfo.addEventListener('reduceMotionChanged',setReduced);return()=>sub.remove();},[]);
useEffect(()=>{const timer=setTimeout(()=>setQ(search.trim()),250);return()=>clearTimeout(timer);},[search]);
useEffect(()=>{const timer=setTimeout(()=>{void api.typing(person.id,active&&touched.current&&!!text.trim()&&!editing).catch(()=>undefined);},400);return()=>clearTimeout(timer);},[text,active,editing,api,person.id]);
useEffect(()=>()=>{void api.typing(person.id,false).catch(()=>undefined);},[api,person.id]);
const meta=useQuery({queryKey:[...queryKey,'typing',person.id],queryFn:()=>api.chatMeta(person.id),enabled:active,refetchInterval:active?2000:false});
const messages=useInfiniteQuery({queryKey:[...queryKey,'chat',person.id,q],initialPageParam:undefined as number|undefined,queryFn:({pageParam})=>api.messages(person.id,pageParam,q),getNextPageParam:last=>last.length===50?last[0]?.seq:undefined,enabled:active,refetchInterval:active?3000:false});
const list=[...(messages.data?.pages??[])].reverse().flat();const last=list.at(-1)?.seq;const unread=list.some(m=>m.sender_id!==userId&&!m.read);
useEffect(()=>{if(last&&unread&&active&&!q)void api.action({action:'read_chat',target_id:person.id,up_to_seq:last}).then(()=>cache.invalidateQueries({queryKey})).catch(()=>undefined);},[last,unread,active,q,api,person.id,messages.dataUpdatedAt]);
const refresh=()=>cache.invalidateQueries({queryKey});
const update=useMutation({mutationFn:(input:{id:string;body:ChatMessageUpdate})=>api.updateMessage(person.id,input.id,input.body),onSuccess:refresh});
const send=useMutation({mutationFn:async()=>{if(editing){await api.updateMessage(person.id,editing.id,{action:'edit',text});return;}if(!pending.current||pending.current.text!==text||pending.current.reply_to!==reply?.id)pending.current={id:eventId(Date.now()),text,...(reply?{reply_to:reply.id}:{})};await api.send(person.id,{...pending.current,attachment_kind:'none'});},onSuccess:async()=>{setText(editing?beforeEdit.current:'');setEditing(null);setReply(null);pending.current=null;await refresh();}});
const scroll=useRef<ScrollView>(null);const [bottom,setBottom]=useState(true);useEffect(()=>setBottom(true),[last]);
const cancelEdit=()=>{setText(beforeEdit.current);setEditing(null);};
const mutate=(m:DirectMessage,body:ChatMessageUpdate)=>{if(!update.isPending)update.mutate({id:m.id,body});setMenu(null);};
return <KeyboardAvoidingView behavior="padding" style={{flex:1}}>
<View style={{padding:12,gap:8}}><H1>{person.display_name}</H1><Muted>{t(person.online ? 'social.online' : 'social.offline')}</Muted><Field label={t('social.chat_search')} value={search} onChangeText={setSearch} maxLength={100}/><ErrorBox>{messages.error||send.error||update.error?errorText(messages.error??send.error??update.error):null}</ErrorBox></View>
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{padding:14,gap:12}} onContentSizeChange={()=>{if(bottom&&!q)scroll.current?.scrollToEnd({animated:!reduced});}}>
{messages.hasNextPage&&<Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={()=>{setBottom(false);void messages.fetchNextPage();}}/>}
{!list.length&&<Muted>{t(messages.isPending?'social.loading':q?'social.chat_no_results':'social.first_message')}</Muted>}
{list.map((m,i)=><View key={m.id} style={{gap:8}}>
{(i===0||new Date(list[i-1]!.created_at).toDateString()!==new Date(m.created_at).toDateString())&&<Text style={{color:colors.text3,fontSize:11,textAlign:'center'}}>{new Date(m.created_at).toLocaleDateString(i18n.language,{day:'numeric',month:'long'})}</Text>}
<Bubble mine={m.sender_id===userId} animate={!reduced}>
<Pressable disabled={m.deleted} onLongPress={()=>setMenu(m)} accessibilityLabel={t('social.message_actions')}>
{m.reply_to&&<View style={{borderLeftWidth:2,borderLeftColor:colors.accent,paddingLeft:8,marginBottom:8}}><Muted>{m.reply_preview||t('social.message_deleted')}</Muted></View>}
<Text selectable style={{color:m.deleted?colors.text3:colors.text,fontSize:15,fontStyle:m.deleted?'italic':'normal'}}>{m.deleted?t('social.message_deleted'):m.text}</Text>
</Pressable>
{!m.deleted&&m.attachment_id&&m.attachment_kind!=='none'&&<Button title={t(m.attachment_kind==='track'?'social.listen_track':'social.listen_playlist')} variant="secondary" onPress={()=>router.push({pathname:m.attachment_kind==='track'?'/social-track':'/social-playlist',params:{id:m.attachment_id}})}/>}
{!m.deleted&&!!m.reactions?.length&&<View style={{flexDirection:'row',gap:6,flexWrap:'wrap'}}>{m.reactions.map(r=><Pressable key={r.emoji} accessibilityRole="button" accessibilityState={{selected:r.mine}} onPress={()=>mutate(m,{action:'react',emoji:r.emoji as typeof CHAT_REACTIONS[number]})} style={{padding:6,borderRadius:12,backgroundColor:r.mine?colors['accent-soft']:colors.surface3}}><Text style={{color:colors.text}}>{r.emoji} {r.count}</Text></Pressable>)}</View>}
<View style={{flexDirection:'row',gap:8,alignItems:'center'}}><Text style={{color:colors.text3,fontSize:10,flex:1}}>{new Date(m.created_at).toLocaleTimeString(i18n.language,{hour:'2-digit',minute:'2-digit'})}{m.edited_at&&!m.deleted?` · ${t('social.edited')}`:''}{m.sender_id===userId?` · ${t(m.read?'social.read':'social.sent')}`:''}</Text>{!m.deleted&&<Pressable accessibilityRole="button" accessibilityLabel={t('social.message_actions')} hitSlop={10} onPress={()=>setMenu(m)}><Text style={{color:colors.text3}}>•••</Text></Pressable>}</View>
</Bubble>
</View>)}
</ScrollView>
<Text accessibilityLiveRegion="polite" style={{minHeight:18,paddingHorizontal:14,color:colors.text3,fontSize:11}}>{active&&meta.data?.typing?`${person.display_name} ${t('social.typing')}`:''}</Text>
{(reply||editing)&&<View style={{padding:10,backgroundColor:colors['accent-soft'],gap:4}}><Muted>{t(editing?'social.editing':'social.replying')}</Muted><Text numberOfLines={2} style={{color:colors.text2}}>{(editing??reply)?.text}</Text><Button title={t(editing?'social.cancel_edit':'social.cancel_reply')} variant="ghost" onPress={()=>editing?cancelEdit():setReply(null)}/></View>}
<View style={{padding:12,gap:8,borderTopWidth:1,borderTopColor:colors.line}}><Field label={t('social.message')} value={text} onChangeText={value=>{touched.current=true;setText(value);}} maxLength={4000} multiline editable={!send.isPending}/><View style={{flexDirection:'row',gap:8}}><Button title="☺" variant="ghost" onPress={()=>setEmojis(v=>!v)}/><View style={{flex:1}}><Button title={t(editing?'playlists.save':'social.send')} busy={send.isPending} disabled={!text.trim()} onPress={()=>send.mutate()}/></View></View>{emojis&&<View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>{setText(v=>(v+emoji).slice(0,4000));setEmojis(false);}}/>)}</View>}</View>
<Modal transparent visible={!!menu} animationType={reduced?'none':'fade'} onRequestClose={()=>setMenu(null)}><View style={{flex:1,justifyContent:'flex-end',backgroundColor:colors.overlay}}><Screen style={{flex:0,maxHeight:'80%',borderTopLeftRadius:24,borderTopRightRadius:24}}><ScrollView contentContainerStyle={{padding:20,gap:10}}><Button title={t('form.close')} variant="ghost" onPress={()=>setMenu(null)}/>{menu&&<><Button title={t('social.reply')} variant="secondary" onPress={()=>{if(editing)cancelEdit();setReply(menu);setMenu(null);}}/><View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>mutate(menu,{action:'react',emoji})}/>)}</View>{menu.sender_id===userId&&<><Button title={t('social.edit_message')} variant="secondary" onPress={()=>{beforeEdit.current=text;setEditing(menu);setText(menu.text);setReply(null);setMenu(null);}}/><Button title={t('social.delete_message')} variant="danger" onPress={()=>mutate(menu,{action:'delete'})}/></>}</>}</ScrollView></Screen></View></Modal>
</KeyboardAvoidingView>;
}
@@ -0,0 +1,60 @@
import { socialApi, type ListeningPreferences, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, ScrollView, Switch, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { eventId } from '../../player/eventId';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from '../../ui/kit';
function useListening() {
const session = useSession(); const { server } = useSessionCtx(); const user = useUser();
const api = useMemo(() => socialApi(session), [session]); const key = ['m-listening', server, user.id] as const;
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
return { api, key, query, user };
}
export function ListeningPanel() {
const { api, key, query, user } = useListening(); const { t } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText(); const colors = useColors();
const [expanded, setExpanded] = useState(false); const prefs = query.data?.preferences;
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const action = useMutation({ mutationFn: (a: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: eventId(Date.now()), action: a.action, target_id: a.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <View style={{ gap: 12 }}><Button title={t('social.together')} variant="secondary" onPress={() => setExpanded(v => !v)} />
{expanded && <View style={{ padding: 16, borderRadius: 16, gap: 14, backgroundColor: colors.surface }}><Muted>{t('social.stream_prefs_hint')}</Muted>
{(['requests', 'auto_accept'] as const).map(field => <View key={field} style={{ gap: 8 }}><Muted>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</Muted><View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} disabled={!prefs || save.isPending} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onPress={() => prefs && save.mutate({ ...prefs, [field]: v })} />)}</View></View>)}
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}><Text style={{ color: colors.text, flex: 1 }}>{t('social.stream_autoplay')}</Text><Switch accessibilityLabel={t('social.stream_autoplay')} value={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onValueChange={autoplay => prefs && save.mutate({ ...prefs, autoplay })} /></View>
</View>}
{query.data?.requests.map(r => <View key={r.id} style={{ padding: 16, borderRadius: 16, gap: 10, backgroundColor: colors.surface }}><Text style={{ color: colors.text }}>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</Text>{r.recipient_id === user.id ? <><Button title={t('social.accept')} disabled={action.isPending} onPress={() => action.mutate({ action: 'accept', target: r.id })} /><Button title={t('social.decline')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'decline', target: r.id })} /></> : <Button title={t('form.cancel')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'cancel', target: r.id })} />}</View>)}
<ErrorBox>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</ErrorBox>
</View>;
}
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
const { t } = useTranslation(); const [open, setOpen] = useState(false);
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
return <><Button title={t('social.together')} disabled={!person.online} variant="ghost" onPress={() => setOpen(true)} />{!person.online && <Muted>{t('social.together_offline')}</Muted>}{open && person.online && <TogetherSheet person={person} close={() => setOpen(false)} />}</>;
}
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
<Muted>{t('social.stream_hint')}</Muted><ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
{action.isSuccess ? <Muted>{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</Muted> : <Button title={t('social.send')} busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onPress={() => action.mutate()} />}
</ScrollView></Screen></Modal>;
}
export function ListeningBanner() {
const state = usePlayerState(); const session = useSession(); const user = useUser(); const colors = useColors(); const { t } = useTranslation(); const errorText = useErrorText(); const room = state.listening?.room;
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: eventId(Date.now()), action: room.owner_id === user.id ? 'end' : 'leave', target_id: room.id }) });
if (!room) return null;
return <View style={{ padding: 12, gap: 6, backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.ok }}><Text style={{ color: colors.text, fontWeight: '600' }}>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</Text>
<Muted>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</Muted>
<Muted>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</Muted>
{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button title={t('social.stream_start_audio')} variant="secondary" onPress={() => player.beginListeningAudio()} />}
<Button title={t(room.owner_id === user.id ? 'social.stream_end' : 'social.stream_leave')} variant="ghost" busy={action.isPending} onPress={() => action.mutate()} />
<ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
</View>;
}
@@ -0,0 +1,38 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { Button, ErrorBox, H1, Muted, Screen, TrackRow, useErrorText } from '../../ui/kit';
export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
const { id, owner } = useLocalSearchParams<{ id: string; owner?: string }>();
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const errorText = useErrorText();
const [playError, setPlayError] = useState<unknown>();
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id, owner], refetchInterval: owner ? 15_000 : false, queryFn: async () => {
if (owner) return unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId: owner } } }));
if (kind === 'track') {
const track = unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } }));
return { id: track.id, name: track.title, tracks: [track] };
}
return unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }));
} });
const visible = query.isError ? undefined : query.data;
return <Screen><FlatList data={visible?.tracks ?? []} keyExtractor={x => x.id}
ListHeaderComponent={<View style={{ padding: 20, gap: 16 }}>
<Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />
<H1>{visible?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
{query.isPending && <Muted>{t('social.loading')}</Muted>}
<ErrorBox>{query.error || playError ? errorText(query.error ?? playError) : null}</ErrorBox>
</View>}
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(visible?.tracks ?? [], index, { type: kind === 'track' ? 'queue' : 'collection', id: visible?.id, label: visible?.name }).catch(setPlayError)} />}
/></Screen>;
}
+149
View File
@@ -0,0 +1,149 @@
import { attachmentPath, socialApi, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { eventId } from '../../player/eventId';
import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useFocusEffect, useRouter } from 'expo-router';
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AppState, Clipboard, KeyboardAvoidingView, Modal, Pressable, ScrollView, Share, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { useColors } from '../../theme';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
function useSocial() {
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
const api = useMemo(() => socialApi(session), [session]);
const [active, setActive] = useState(AppState.currentState === 'active');
useEffect(() => { const sub = AppState.addEventListener('change', s => setActive(s === 'active')); return () => sub.remove(); }, []);
const key = ['m-social', server, user.id] as const;
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, enabled: active, refetchInterval: active ? 15_000 : false });
return { api, key, inbox, user, active };
}
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
const ChatDockContext = createContext<(id: string) => boolean>(() => false);
export function NativeChatProvider({ children }: { children: ReactNode }) {
const { inbox } = useSocial(); const colors = useColors(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [peer, setPeer] = useState<string | null>(null);
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
const selected = friends.find(p => p.id === peer); const unread = friends.reduce((sum,p) => sum+p.unread,0);
return <ChatDockContext.Provider value={id => { setPeer(id); setOpen(true); return true; }}>{children}
<View style={{ position: 'absolute', bottom: 158, right: 16 }}><Button title={`${t('social.messages')}${unread ? ` · ${unread}` : ''}`} variant="secondary" onPress={() => setOpen(true)} /></View>
<Modal transparent visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: colors.overlay }}><View style={{ height: '82%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', backgroundColor: colors.bg }}>
<Screen><View style={{ padding: 12, flexDirection: 'row', justifyContent: 'space-between' }}><Button title={t('social.people')} variant="ghost" onPress={() => setPeer(null)} /><Button title={t('social.minimize')} variant="ghost" onPress={() => setOpen(false)} /></View>
{selected ? <Chat key={selected.id} person={selected} active={open} /> : <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}><H1>{t('social.messages')}</H1>{friends.map(p => <Button key={p.id} title={`${p.display_name}${p.unread ? ` · ${p.unread}` : ''}`} variant="secondary" onPress={() => setPeer(p.id)} />)}{!friends.length && <Muted>{t('social.no_friends_share')}</Muted>}</ScrollView>}
</Screen>
</View></View>
</Modal>
</ChatDockContext.Provider>;
}
export function FriendRecommendations() {
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <View style={{ gap: 8 }}><Button title={t('social.friends_recommendations')} variant={(inbox.data?.friends_recommendations ?? true) ? 'primary' : 'secondary'} disabled={!inbox.data || save.isPending} onPress={() => save.mutate(!(inbox.data?.friends_recommendations ?? true))} /><Muted>{t('social.friends_recommendations_hint')}</Muted><ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox></View>;
}
export function MusicShareSheet({ attachment, url, close }: { attachment: MusicAttachment; url: string; close: () => void }) {
const { t } = useTranslation();
const { inbox, api, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [sent, setSent] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const ids = useRef(new Map<string, string>());
const send = useMutation({ mutationFn: async (peer: string) => {
if (peer === 'copy') { Clipboard.setString(url); setCopied(true); return; }
if (peer === 'external') { await Share.share({ title: attachment.title, message: url }); return; }
let id = ids.current.get(peer); if (!id) { id = eventId(Date.now()); ids.current.set(peer, id); }
await api.send(peer, { id, text: attachment.title, attachment_kind: attachment.kind, attachment_id: attachment.id });
setSent(v => [...v, peer]); await cache.invalidateQueries({ queryKey: key });
} });
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 24, gap: 16 }}>
<Button title={t('form.cancel')} variant="ghost" onPress={close} />
<H1>{t('player.share')}</H1><Muted>{attachment.title}</Muted>
<Button title={t(copied ? 'social.copied' : 'social.copy')} disabled={send.isPending} onPress={() => send.mutate('copy')} />
<Button title={t('social.external')} variant="secondary" disabled={send.isPending} onPress={() => send.mutate('external')} />
<H1>{t('social.send_friend')}</H1>
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <Muted>{t('social.no_friends_share')}</Muted>}
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} title={`${p.display_name} · ${t(sent.includes(p.id) ? 'social.sent' : 'social.send')}`} variant="secondary" disabled={send.isPending || sent.includes(p.id)} onPress={() => send.mutate(p.id)} />)}
<ErrorBox>{send.error || inbox.error ? errorText(send.error ?? inbox.error) : null}</ErrorBox>
</ScrollView></Screen></Modal>;
}
export function SocialScreen({ root = false }: { root?: boolean }) {
const dock = useContext(ChatDockContext);
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [peer, setPeer] = useState('');
const [notifications, setNotifications] = useState(false);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
const act = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
if (selected) return <Screen><Button title={t('social.back')} variant="ghost" onPress={() => setPeer('')} /><Chat key={selected.id} person={selected} /></Screen>;
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
return <Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
{!root && <Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />}
<H1>{t('social.title')}</H1><Muted>{t('social.subtitle')}</Muted>
<View style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
<Muted>{t('social.activity_visibility')}</Muted>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} disabled={!inbox.data || visibility.isPending} onPress={() => visibility.mutate(v)} />)}</View>
<Muted>{t('social.activity_privacy_hint')}</Muted>
<ErrorBox>{visibility.error ? errorText(visibility.error) : null}</ErrorBox>
</View>
<ListeningPanel />
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
<Button title={t('social.people')} variant={notifications ? 'ghost' : 'primary'} onPress={() => setNotifications(false)} />
<Button title={`${t('social.notifications')} ${inbox.data?.unread || ''}`} variant={notifications ? 'primary' : 'ghost'} onPress={() => setNotifications(true)} />
</View>
<ErrorBox>{inbox.error || results.error || act.error ? errorText(inbox.error ?? results.error ?? act.error) : null}</ErrorBox>
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
{notifications ? <>
{inbox.data?.notifications.length === 0 && <Muted>{t('social.no_notifications')}</Muted>}
{inbox.data?.notifications.map(n => <View key={n.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10, borderWidth: 1, borderColor: n.read ? colors.line : colors.accent }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{n.actor_name}</Text><Muted>{t(`social.kind_${n.kind}`)}</Muted><Muted>{new Date(n.created_at).toLocaleString()}</Muted>
<Button title={t('social.open')} variant="secondary" onPress={() => { setNotifications(false); setSearch(''); setQ(''); setPeer(n.actor_id); }} />
{!n.read && <Button title={t('social.mark_read')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'read_notification', target_id: n.id })} />}
</View>)}
</> : <>
<Field label={t('social.search')} value={search} onChangeText={setSearch} maxLength={80} />
{q.length >= 2 && results.isFetching && <Muted>{t('social.loading')}</Muted>}
{!people.length && inbox.data && !results.isFetching && <Muted>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</Muted>}
{people.map(p => <View key={p.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
<Text style={{ color: colors.text, fontWeight: '600', fontSize: 17 }}>{p.display_name}</Text><Muted>@{p.username} · {t(`social.rel_${p.relationship}`)}</Muted>
{p.relationship === 'friend' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t(p.online ? 'social.online' : 'social.offline')}</Text>}
{p.activity && <View style={{ borderLeftWidth: 2, borderLeftColor: colors.ok, paddingLeft: 12, gap: 6 }}>
<Text style={{ color: colors.ok, fontSize: 12 }}>{t('social.listening')}</Text>
<Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-track', params: { id: p.activity!.track_id } })}><Text style={{ color: colors.accent2 }}>{p.activity.track_title}</Text></Pressable>
{!!p.activity.artist && <Muted>{p.activity.artist}</Muted>}
{p.activity.context === 'wave' && <Muted>{t('wave.title')}</Muted>}
{p.activity.context === 'playlist' && p.activity.playlist_id && <Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-playlist', params: { owner: p.id, id: p.activity!.playlist_id } })}><Text style={{ color: colors.accent2 }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Text></Pressable>}
</View>}
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => { if (!dock(p.id)) setPeer(p.id); }} />}
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
{p.relationship === 'none' && <Button title={t('social.add')} disabled={act.isPending} onPress={() => act.mutate({ action: 'request', target_id: p.id })} />}
{p.relationship === 'incoming' && <Button title={t('social.accept')} disabled={act.isPending} onPress={() => act.mutate({ action: 'accept', target_id: p.id })} />}
{['friend', 'incoming', 'outgoing'].includes(p.relationship) && <Button title={t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'remove', target_id: p.id })} />}
<Button title={t(p.relationship === 'blocked' ? 'social.unblock' : 'social.block')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: p.relationship === 'blocked' ? 'unblock' : 'block', target_id: p.id })} />
</View>)}
</>}
</ScrollView></Screen>;
}
function Chat({ person, active: visible = true }: { person: SocialPerson; active?: boolean }) {
const {api,key,user,active}=useSocial(); const {server}=useSessionCtx();
const [focused,setFocused]=useState(false);
useFocusEffect(useCallback(()=>{setFocused(true);return()=>setFocused(false);},[]));
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} server={server??''} active={visible&&active&&focused}/>;
}
+14
View File
@@ -0,0 +1,14 @@
import * as FileSystem from 'expo-file-system';
import { sha256 } from 'js-sha256';
import { chatDraftKey } from '@pipu/core';
const directory = `${FileSystem.documentDirectory}chat-drafts/`;
const path = (server: string, user: string, peer: string) => `${directory}${sha256(chatDraftKey(server,user,peer))}.txt`;
export async function readDraft(server: string, user: string, peer: string) {
try { return await FileSystem.readAsStringAsync(path(server,user,peer)); } catch { return ''; }
}
export async function saveDraft(server: string, user: string, peer: string, text: string) {
if (!text) { await FileSystem.deleteAsync(path(server,user,peer), { idempotent: true }); return; }
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
await FileSystem.writeAsStringAsync(path(server,user,peer),text);
}
+86 -5
View File
@@ -1,6 +1,10 @@
import NetInfo from '@react-native-community/netinfo'; import NetInfo from '@react-native-community/netinfo';
import { import {
EventBuffer, EventBuffer,
createActivityPublisher,
type ActivityPublisher,
ListeningCoordinator,
type ListeningRoom,
WaveCoordinator, WaveCoordinator,
EARLY_SKIP_MS, EARLY_SKIP_MS,
type WaveInfo, type WaveInfo,
@@ -32,6 +36,7 @@ export { offlineWave };
export type { WaveInfo } from '@pipu/core'; export type { WaveInfo } from '@pipu/core';
export interface PlayerState { export interface PlayerState {
listening?: import('@pipu/core').ListeningClientState;
ready: boolean; ready: boolean;
track: Track | null; track: Track | null;
queue: Track[]; queue: Track[];
@@ -72,6 +77,11 @@ async function quality(): Promise<Quality> {
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис. * реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
*/ */
class Controller { class Controller {
private activity: ActivityPublisher | null = null;
private listening: ListeningCoordinator | null = null;
private interrupted = false;
private remoteRoom: string | null = null;
private queueVersion = 0;
private session: AuthSession | null = null; private session: AuthSession | null = null;
private server = ''; private server = '';
private events: EventBuffer | null = null; private events: EventBuffer | null = null;
@@ -104,12 +114,27 @@ class Controller {
private update(patch: Partial<PlayerState>) { private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch }; this.state = { ...this.state, ...patch };
this.activity?.update(this.state);
this.listening?.update();
for (const fn of this.listeners) fn(); for (const fn of this.listeners) fn();
} }
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */ /** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
attach(session: AuthSession | null, server: string, userId: string | null) { attach(session: AuthSession | null, server: string, userId: string | null) {
if (this.session === session) return; if (this.session === session) return;
this.activity?.dispose();
this.listening?.dispose();
this.listening = null;
this.activity = session && userId ? createActivityPublisher(session, eventId(Date.now())) : null;
if (session && this.activity) {
this.listening = new ListeningCoordinator(session, this.activity.deviceId, {
read: this.getState,
pause: () => { this.queueVersion++; this.leaveWave(); void TrackPlayer.pause().catch(() => undefined); },
apply: (room, position, current) => this.syncListening(room, position, current),
changed: listening => this.update({ listening }),
});
this.listening.start();
}
void this.events?.flush(); void this.events?.flush();
this.session = session; this.session = session;
this.server = server; this.server = server;
@@ -240,35 +265,49 @@ class Controller {
} }
async playList(tracks: Track[], index: number, context: PlayContext) { async playList(tracks: Track[], index: number, context: PlayContext) {
if (this.listening?.locked) return;
const version = ++this.queueVersion;
const current = () => version === this.queueVersion && !this.listening?.locked;
const list = tracks.filter(playable); const list = tracks.filter(playable);
const target = tracks[index]; const target = tracks[index];
if (!target || !list.length) return; if (!target || !list.length) return;
await this.setup(); await this.setup();
if (!current()) return;
const native = await this.toRntp(list);
if (!current()) return;
this.leaveWave(); this.leaveWave();
this.finishAttempt(this.lastPositionMs / 1000); this.finishAttempt(this.lastPositionMs / 1000);
const start = Math.max(list.indexOf(target), 0); const start = Math.max(list.indexOf(target), 0);
this.update({ queue: list, context, index: start, track: list[start] ?? null }); this.update({ queue: list, context, index: start, track: list[start] ?? null });
await TrackPlayer.reset(); await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(list)); if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
await TrackPlayer.skip(start); await TrackPlayer.skip(start);
if (!current()) return;
await TrackPlayer.play(); await TrackPlayer.play();
} }
toggle() { toggle() {
if (this.listening?.locked) return;
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play()); void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
} }
next() { next() {
if (this.listening?.locked) return;
void TrackPlayer.skipToNext().catch(() => undefined); void TrackPlayer.skipToNext().catch(() => undefined);
} }
async prev() { async prev() {
if (this.listening?.locked) return;
const { position } = await TrackPlayer.getProgress(); const { position } = await TrackPlayer.getProgress();
if (this.listening?.locked) return;
if (position > 3) await TrackPlayer.seekTo(0); if (position > 3) await TrackPlayer.seekTo(0);
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0)); else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
} }
seek(ms: number) { seek(ms: number) {
if (this.listening?.locked) return;
const t = this.state.track; const t = this.state.track;
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms }); if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
this.attempt?.seek(ms); this.attempt?.seek(ms);
@@ -306,6 +345,7 @@ class Controller {
// --- «Моя волна» --- // --- «Моя волна» ---
async startWave(activity: string | null, mood: string | null) { async startWave(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
if (!this.session) return; if (!this.session) return;
await this.setup(); await this.setup();
this.leaveWave(); this.leaveWave();
@@ -335,6 +375,7 @@ class Controller {
} }
async setWaveContext(activity: string | null, mood: string | null) { async setWaveContext(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
const wave = this.state.wave; const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood); if (!wave || !this.session) return this.startWave(activity, mood);
const version = this.wavePolicy.changeContext(wave.sessionId); const version = this.wavePolicy.changeContext(wave.sessionId);
@@ -422,18 +463,28 @@ class Controller {
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */ /** Восстановить последний трек и позицию (на паузе, без автозапуска). */
async restore() { async restore() {
if (!this.session || this.state.track) return; if (!this.session || this.state.track || this.listening?.locked) return;
const session = this.session;
const initial = this.state;
const current = () => this.session === session && this.state.queue === initial.queue && !this.state.track && !this.listening?.locked;
await this.setup(); await this.setup();
if (!current()) return;
try { try {
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state')); const st = unwrap(await session.client.GET('/api/v1/me/playback-state'));
if (!current()) return;
if (!st.track_id || !st.queue.length) return; if (!st.track_id || !st.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } })); const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
if (!current()) return;
const tracks = res.items.filter(playable); const tracks = res.items.filter(playable);
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0); const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
if (!tracks.length) return; if (!tracks.length) return;
const native = await this.toRntp(tracks);
if (!current()) return;
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null }); this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
await TrackPlayer.reset(); await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(tracks)); if (this.state.queue !== tracks || this.listening?.locked) return;
await TrackPlayer.add(native);
if (this.state.queue !== tracks || this.listening?.locked) return;
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0); await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
} catch { } catch {
// офлайн или нет сохранённого состояния // офлайн или нет сохранённого состояния
@@ -441,6 +492,7 @@ class Controller {
} }
async reset() { async reset() {
this.queueVersion++;
this.wavePolicy.activate(null); this.wavePolicy.activate(null);
this.attempt = null; this.attempt = null;
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null }); this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
@@ -451,6 +503,35 @@ class Controller {
this.save(); this.save();
void this.events?.flush(); void this.events?.flush();
} }
play() { if (!this.listening?.locked) void TrackPlayer.play(); }
pause() { if (!this.listening?.locked) void TrackPlayer.pause(); }
setInterrupted(paused: boolean) { this.interrupted = paused; }
beginListeningAudio() { this.listening?.beginAudio(); }
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
if (!room.track_id) { await TrackPlayer.pause(); return; }
await this.setup();
if (!current()) return;
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
const track = unwrap(await this.session!.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
const native = await this.toRntp([track]);
if (!current()) return;
this.remoteRoom = room.id;
this.leaveWave(); this.finishAttempt(this.lastPositionMs / 1000);
await TrackPlayer.pause();
if (!current()) return;
this.update({ queue: [track], track, index: 0, durationMs: track.duration_ms, positionMs: 0, context: { type: 'queue' }, wave: null });
await TrackPlayer.reset();
if (!current()) return;
await TrackPlayer.add(native);
}
if (!current()) return;
const target = Math.min(position, Math.max(0, this.state.durationMs - 100));
if (Math.abs(this.state.positionMs - target) > 1500) await TrackPlayer.seekTo(target / 1000);
if (!current()) { await TrackPlayer.pause(); return; }
if (room.playing && !this.interrupted) { if (!this.state.playing) await TrackPlayer.play(); }
else if (this.state.playing) await TrackPlayer.pause();
}
} }
export const player = new Controller(); export const player = new Controller();
+4 -3
View File
@@ -7,14 +7,15 @@ import { player } from './controller';
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер. * команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
*/ */
export async function PlaybackService(): Promise<void> { export async function PlaybackService(): Promise<void> {
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play()); TrackPlayer.addEventListener(Event.RemotePlay, () => player.play());
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause()); TrackPlayer.addEventListener(Event.RemotePause, () => player.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop()); TrackPlayer.addEventListener(Event.RemoteStop, () => player.pause());
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next()); TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev()); TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000)); TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса). // Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => { TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
player.setInterrupted(paused || permanent);
if (permanent) void TrackPlayer.pause(); if (permanent) void TrackPlayer.pause();
else if (paused) void TrackPlayer.pause(); else if (paused) void TrackPlayer.pause();
}); });
+2
View File
@@ -3,6 +3,7 @@ import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FriendRecommendations } from '../features/social/Social';
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'; import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session'; import { useSession, useSessionCtx, useUser } from '../lib/session';
@@ -38,6 +39,7 @@ export function RecommendationSettings() {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const errorText = useErrorText(); const errorText = useErrorText();
return <View> return <View>
<FriendRecommendations />
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => { {editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] }); setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />} }} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
+5 -14
View File
@@ -1,4 +1,5 @@
import { useEffect, useSyncExternalStore } from 'react'; import { useEffect, useSyncExternalStore } from 'react';
import { releaseNotesText } from '@pipu/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native'; import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
@@ -10,7 +11,6 @@ import { Button } from './kit';
export function useUpdateState() { export function useUpdateState() {
return useSyncExternalStore(updater.subscribe, () => updater.state); return useSyncExternalStore(updater.subscribe, () => updater.state);
} }
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */ /** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
export function UpdateDialog() { export function UpdateDialog() {
const colors = useColors(); const colors = useColors();
@@ -32,6 +32,7 @@ export function UpdateDialog() {
const { release, required } = st; const { release, required } = st;
const busy = st.status === 'downloading' || st.status === 'verifying'; const busy = st.status === 'downloading' || st.status === 'verifying';
const notes = releaseNotesText(release.notes_md);
const action = () => void updater.downloadAndInstall(session, server ?? ''); const action = () => void updater.downloadAndInstall(session, server ?? '');
return ( return (
@@ -57,9 +58,9 @@ export function UpdateDialog() {
{t('mobile.update_title', { version: release.version })} {t('mobile.update_title', { version: release.version })}
</Text> </Text>
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>} {required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
{release.notes_md.trim() !== '' && ( {notes !== '' && (
<ScrollView style={{ maxHeight: 240 }}> <ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text> <Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
</ScrollView> </ScrollView>
)} )}
<Text style={{ color: colors.text3, fontSize: 12 }}> <Text style={{ color: colors.text3, fontSize: 12 }}>
@@ -115,13 +116,3 @@ export function UpdateDialog() {
</Modal> </Modal>
); );
} }
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
function plainNotes(md: string): string {
return md
.replace(/^#{1,6}\s*/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/^\s*[-*]\s+/gm, '• ')
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
.trim();
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core'; import { initialWaveState, step, wavePalette, waveScale, type WaveState } from '@pipu/core';
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia'; import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { AppState, Pressable, View } from 'react-native'; import { AppState, Pressable, View } from 'react-native';
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated'; import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
@@ -36,7 +36,7 @@ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, siz
}, []), }, []),
); );
const [foreground, setForeground] = useState(AppState.currentState === 'active'); const [foreground, setForeground] = useState(AppState.currentState === 'active');
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string]; const palette = useMemo(() => wavePalette(colors), [colors]);
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced }); const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
const state = useSharedValue<WaveState>(initialWaveState(size)); const state = useSharedValue<WaveState>(initialWaveState(size));
+15
View File
@@ -70,3 +70,18 @@ it('does not let a late context response replace a manually selected playlist',
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']); expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
expect(player.getState().context?.type).toBe('album'); expect(player.getState().context?.type).toBe('album');
}); });
it('does not restore a stale saved queue after manual playback starts', async () => {
const { player } = await import('../src/player/controller');
const saved = deferred<unknown>();
const get = vi.fn().mockReturnValue(saved.promise);
const post = vi.fn();
player.attach({ client: { GET: get, POST: post } } as unknown as AuthSession, 'http://localhost', null);
const restoring = player.restore();
await vi.waitFor(() => expect(get).toHaveBeenCalled());
await player.playList([track('chosen')], 0, { type: 'queue' });
saved.resolve({ data: { track_id: 'old', queue: ['old'], position_ms: 500 }, response: new Response() });
await restoring;
expect(player.getState().track?.id).toBe('chosen');
expect(post).not.toHaveBeenCalledWith('/api/v1/tracks/batch', expect.anything());
});
+73
View File
@@ -0,0 +1,73 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
test('admin groups related pages and remembers collapsed sections', async ({ page }, info) => {
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
await nav.getByRole('button', { name: 'Админка', exact: true }).click();
await expect(nav.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(nav.getByRole('button', { name: title, exact: true })).toHaveAttribute('aria-expanded', 'false');
}
await nav.getByRole('button', { name: 'Система', exact: true }).click();
const system = nav.getByRole('group', { name: 'Система', exact: true });
await expect(system.getByRole('link')).toHaveCount(4);
for (const title of ['Обновления', 'Конфигурация', 'Настройки', 'Журнал']) {
await expect(system.getByRole('link', { name: title, exact: true })).toBeVisible();
}
await nav.screenshot({ path: info.outputPath('admin-groups.png') });
// The fake API resets authentication on a full document load; sign in again.
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'true');
await nav.getByRole('button', { name: 'Система', exact: true }).click();
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'false');
await login(page, 'mobile-admin', '/admin/audit');
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toBeVisible();
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toHaveAttribute('data-status', 'active');
await nav.getByRole('button', { name: 'Медиатека', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link')).toHaveCount(2);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link')).toHaveCount(3);
});
test('moderator sees only permitted groups and pages', async ({ page }) => {
await login(page, 'moderator', '/admin/library');
const nav = page.getByRole('navigation', { name: 'Навигация' });
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link', { name: 'Библиотека' })).toBeVisible();
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveCount(0);
await expect(nav.getByRole('link', { name: 'Хранилища' })).toHaveCount(0);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
});
test('mobile menu exposes grouped pages and closes after navigation', async ({ page }, info) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Навигация' });
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(dialog.getByRole('heading', { name: title, exact: true })).toBeVisible();
}
await dialog.screenshot({ path: info.outputPath('admin-groups-mobile.png') });
await dialog.getByRole('group', { name: 'Система', exact: true }).getByRole('link', { name: 'Обновления', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/admin\/updates$/);
});
test('tablet icon sidebar keeps all admin destinations accessible', async ({ page }) => {
await page.setViewportSize({ width: 900, height: 900 });
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
for (const title of ['Библиотека', 'Хранилища', 'Предложить треки', 'Очередь', 'Журнал']) {
await expect(nav.getByRole('link', { name: title, exact: true })).toBeVisible();
}
});
+62
View File
@@ -0,0 +1,62 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { expect, test } from '@playwright/test';
test('an artist directory uploads each album as a separate batch with a shared portrait', async ({ page }, info) => {
const root = info.outputPath('творожный бизнес');
const albums = ['(2026) творог грехов - творожный бизнес', '(2025) молчанка - творожный бизнес'];
for (const album of albums) {
await mkdir(join(root, album), { recursive: true });
await writeFile(join(root, album, 'song.flac'), Buffer.alloc(128));
}
const portrait = Buffer.from('portrait');
await writeFile(join(root, 'artist.jpg'), portrait);
await writeFile(join(root, albums[0]!, 'cover.jpg'), Buffer.from('album cover'));
type Batch = { artist_image_base64?: string; cover_base64?: string; files: { client_id: string; rel_path: string }[] };
const batches: Batch[] = [];
await page.exposeFunction('recordArtistBatch', (batch: Batch) => { batches.push(batch); });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const req = new Request(input, init);
const path = new URL(req.url).pathname;
const json = (data: unknown) => new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
if (path === '/api/v1/uploads/precheck') {
const body = await req.json();
return json({ files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) });
}
if (path === '/api/v1/uploads' && req.method === 'POST') {
const body = await req.json();
await (window as unknown as { recordArtistBatch: (body: unknown) => Promise<void> }).recordArtistBatch(body);
return json({ batch_id: crypto.randomUUID(), files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, upload_file_id: f.client_id, tus_url: `/api/v1/uploads/files/${f.client_id}` })) });
}
return original(input, init);
};
history.pushState({}, '', '/uploads/new');
window.dispatchEvent(new PopStateEvent('popstate'));
});
await page.route('**/api/v1/uploads/files/*', async (route) => {
await route.fulfill({ status: route.request().method() === 'HEAD' ? 200 : 204,
headers: { 'Upload-Length': '128', 'Upload-Offset': route.request().method() === 'HEAD' ? '0' : '128', 'Tus-Resumable': '1.0.0' } });
});
await page.locator('input[webkitdirectory]').setInputFiles(root);
const send = page.getByRole('button', { name: /^Отправить/ });
await expect(send).toBeEnabled();
await expect(page.getByText(/Пропущен/)).toHaveCount(0);
await send.click();
await expect(page.getByText(/Готово: 2 файла/)).toBeVisible();
expect(batches).toHaveLength(2);
expect(batches.map((batch) => batch.files[0]!.rel_path).sort()).toEqual(albums.map((album) => `творожный бизнес/${album}/song.flac`).sort());
for (const batch of batches) {
expect(batch.files).toHaveLength(1);
expect(batch.artist_image_base64).toBe(portrait.toString('base64'));
}
expect(batches.find((batch) => batch.files[0]!.rel_path.includes(albums[0]!))!.cover_base64).toBe(Buffer.from('album cover').toString('base64'));
expect(batches.find((batch) => batch.files[0]!.rel_path.includes(albums[1]!))!.cover_base64).toBeUndefined();
});
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test';
test('desktop files survive restart, isolate owners and clear only recent cache', async ({ page }) => {
await page.goto('/login');
const saved = await page.evaluate(async () => {
const source = '/src/features/desktop/offline.ts';
const { DesktopOffline } = await import(source);
const track = { id: 'cached-track', title: 'Offline song', available: true, stream_url: 'data:audio/wav;base64,UklGRg==', duration_ms: 1000, artists: [], album: { id: 'album', title: 'Album', cover_url: null }, genres: [], reaction: null };
const session = { client: { POST: async () => ({ data: { items: [track] }, response: new Response('{}') }) } };
const offline = new DesktopOffline('test-server/alice', session);
await offline.ready;
await offline.save(track, true);
await offline.save({ ...track, id: 'recent-track' });
const tracks = offline.tracks();
const result = { count: tracks.length, blob: tracks[0].stream_url.startsWith('blob:'), pinned: offline.pinned(track.id) };
offline.dispose();
return result;
});
expect(saved).toEqual({ count: 2, blob: true, pinned: true });
await page.reload();
const restored = await page.evaluate(async () => {
const source = '/src/features/desktop/offline.ts';
const { DesktopOffline } = await import(source);
const session = { client: { POST: async () => { throw new TypeError('offline'); } } };
const alice = new DesktopOffline('test-server/alice', session);
const bob = new DesktopOffline('test-server/bob', session);
await Promise.all([alice.ready, bob.ready]);
const before = alice.tracks().length;
const body = await (await fetch(alice.tracks()[0].stream_url)).blob();
await alice.clearRecent();
const result = { before, bytes: body.size, after: alice.tracks().map((t: { id: string }) => t.id), other: bob.tracks().length };
alice.dispose(); bob.dispose();
return result;
});
expect(restored).toEqual({ before: 2, bytes: 4, after: ['cached-track'], other: 0 });
});
test('desktop update dialog hides build reports and launches installer', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('pipu.locale', 'ru');
(window as Window & { __TAURI__?: unknown }).__TAURI__ = {
core: { invoke: async (command: string) => {
if (command === 'bootstrap') return { proxyBase: location.origin, server: 'https://music.example' };
if (command === 'update_check') return { build: { version: '1.8.0', size: 1048576 }, notes: '<!-- android-status -->\n> APK SHA-256 abc\n<!-- /android-status -->\n## Изменения\n- **Офлайн-музыка**' };
if (command === 'update_install') document.documentElement.dataset.installer = 'opened';
return null;
} },
event: { listen: async () => () => undefined },
};
});
await page.goto('/login');
await page.getByLabel('Логин').fill('alice');
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
await page.getByRole('button', { name: 'Войти' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText('Доступна версия 1.8.0');
await expect(dialog).toContainText('• Офлайн-музыка');
await expect(dialog).not.toContainText('android-status');
await expect(dialog).not.toContainText('SHA-256');
await dialog.getByRole('button', { name: 'Скачать и установить' }).click();
await expect(dialog).toContainText('Установщик открыт');
await expect(page.locator('html')).toHaveAttribute('data-installer', 'opened');
});
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';
for (const scenario of ['pending', 'automatic', 'restricted', 'incoming']) {
test(`listening request lifecycle: ${scenario}`, async ({ page }, info) => {
await page.addInitScript(value => {
localStorage.setItem('pipu.locale', 'ru');
localStorage.setItem('pipu.e2e.listening', value);
}, scenario);
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
const banner = page.getByRole('region', { name: 'Слушать вместе', exact: true });
await expect(banner).toHaveCount(0);
if (scenario === 'incoming') {
await expect(page.getByRole('button', { name: 'Принять', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('pending-invite.png'), fullPage: true });
await page.getByRole('button', { name: 'Принять', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Слушать вместе', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Слушать вместе', exact: true });
await expect(dialog.getByRole('radio', { name: 'Подключиться к другу', exact: true })).toBeChecked();
await dialog.getByRole('button', { name: 'Отправить', exact: true }).click();
if (scenario === 'restricted') {
await expect(dialog.getByRole('alert')).toContainText('Совместное прослушивание недоступно');
await expect(dialog.getByRole('alert')).not.toContainText('Чат доступен только друзьям');
await page.screenshot({ path: info.outputPath('request-restricted.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
await expect(dialog).toHaveCount(0);
if (scenario === 'pending') {
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toBeVisible();
await expect(banner).toHaveCount(0);
await page.getByRole('button', { name: 'Отмена', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
}
await expect(banner).toContainText('Слушаете вместе с moderator');
await expect(page.getByRole('contentinfo', { name: 'Плеер', exact: true }).getByRole('button', { name: 'Следующий', exact: true })).toBeDisabled();
await banner.getByRole('button', { name: 'Выйти из прослушивания', exact: true }).click();
await expect(banner).toHaveCount(0);
});
}
+72
View File
@@ -0,0 +1,72 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const { width, height, light } of [{ width: 1280, height: 900, light: false }, { width: 1920, height: 900, light: false }, { width: 1024, height: 600, light: false }, { width: 1280, height: 720, light: true }]) {
test(`mini chat keeps the page and draft at ${width}x${height} ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
await page.setViewportSize({ width, height });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (width === 1920) {
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'Остров', exact: true }).click();
await expect(page.getByRole('button', { name: 'Остров', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('link', { name: 'Главная', exact: true }).first().click();
}
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
const dock = page.getByRole('complementary', { name: 'Друзья и чат', exact: true });
const toggle = dock.getByRole('button', { name: 'Друзья и чат', exact: true });
await toggle.click();
const panel = dock.getByRole('region', { name: 'Друзья и общение', exact: true });
await panel.getByRole('textbox', { name: 'Найти диалог', exact: true }).fill('moderator');
await expect(panel.getByRole('textbox', { name: 'Найти диалог', exact: true })).toHaveCSS('outline-style', 'none');
await panel.getByRole('button', { name: /moderator/ }).click();
const composer = panel.getByRole('textbox', { name: 'Сообщение', exact: true });
await composer.fill('Черновик из мини-чата');
await expect(page).toHaveURL(/\/$/);
await panel.getByRole('button', { name: 'Свернуть чат', exact: true }).click();
await expect(panel).toBeHidden();
await expect(toggle).toBeFocused();
await page.getByRole('link', { name: 'История', exact: true }).first().click();
await toggle.click();
await expect(composer).toHaveValue('Черновик из мини-чата');
await panel.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(panel.getByText('Черновик из мини-чата', { exact: true })).toBeVisible();
await expect(composer).toBeEmpty();
await expect(page).toHaveURL(/\/collection\/history$/);
const panelBox = (await panel.boundingBox())!;
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер', exact: true }).boundingBox())!;
expect(panelBox.width).toBeLessThanOrEqual(600);
expect(panelBox.x).toBeGreaterThanOrEqual(width - 640);
expect(panelBox.y).toBeGreaterThanOrEqual(60);
expect(panelBox.y + panelBox.height).toBeLessThan(playerBox.y);
await panel.evaluate(async element => {
await Promise.all(element.getAnimations({ subtree: true })
.filter(animation => animation.effect?.getTiming().iterations !== Infinity)
.map(animation => animation.finished.catch(() => undefined)));
});
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async (selector) => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => ({ target: node.target, summary: node.failureSummary })) }));
}, '[id="mini-social-panel"]');
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('mini-chat.png'), fullPage: true });
await composer.fill('Продолжить в полном чате');
await composer.press('Escape');
await expect(panel).toBeHidden();
await toggle.click();
await expect(composer).toHaveValue('Продолжить в полном чате');
await page.setViewportSize({ width: 800, height: 900 });
await expect(dock).toHaveCount(0);
await page.setViewportSize({ width, height });
await expect(composer).toHaveValue('Продолжить в полном чате');
await panel.getByRole('button', { name: 'Открыть раздел', exact: true }).click();
await expect(page).toHaveURL(/\/friends\?/);
await expect(dock).toHaveCount(0);
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveValue('Продолжить в полном чате');
});
}
+36 -1
View File
@@ -69,6 +69,8 @@ for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430
await page.getByRole('button', { name: 'Ещё', exact: true }).click(); await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click(); await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible();
await expect(page.locator('audio')).toBeHidden();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.locator('audio')).toBeVisible(); await expect(page.locator('audio')).toBeVisible();
await fits(page); await fits(page);
await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check(); await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check();
@@ -92,6 +94,31 @@ for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430
}); });
} }
for (const width of [390, 1280]) {
test(`player artist opens without a browser URL preview at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page, 'alice');
await page.setViewportSize({ width, height: 844 });
const box = page.getByRole('combobox', { name: 'Поиск', exact: true });
await box.fill('кино');
await box.press('Enter');
await page.getByRole('link', { name: /Группа крови/ }).first().click();
await page.getByRole('listitem').first().click();
const artist = page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('link', { name: 'Кино', exact: true });
await artist.hover();
await expect(artist).not.toHaveAttribute('href');
expect(await artist.evaluate((el) => Boolean(el.closest('[title], a[href]')))).toBe(false);
if (width === 390) {
await artist.focus();
await page.keyboard.press('Enter');
} else {
await artist.click();
}
await expect(page).toHaveURL(/\/artist\//);
await expect(page.getByRole('heading', { name: 'Кино', exact: true })).toBeVisible();
});
}
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => { test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 740 }); await page.setViewportSize({ width: 320, height: 740 });
await login(page, 'alice'); await login(page, 'alice');
@@ -102,7 +129,15 @@ test('mobile player keeps the island above navigation and exposes the queue', as
await page.getByRole('listitem').first().click(); await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' }); const player = page.getByRole('contentinfo', { name: 'Плеер' });
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible(); await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40); expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(220);
const artist = player.getByRole('link', { name: 'Кино', exact: true });
await expect(artist).toBeVisible();
const artistArea = await artist.locator('..').boundingBox();
expect(artistArea!.width).toBeGreaterThan(220);
const trackInfo = await player.getByText('Группа крови', { exact: true }).boundingBox();
const play = await player.getByRole('button', { name: /^(Пауза|Слушать)$/ }).boundingBox();
expect(trackInfo!.y + trackInfo!.height).toBeLessThanOrEqual(play!.y);
await page.screenshot({ path: info.outputPath('player-readable.png') });
await fits(page); await fits(page);
await page.getByRole('button', { name: 'Меню профиля' }).click(); await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click(); await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
+45
View File
@@ -0,0 +1,45 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const width of [390, 1280]) {
test(`notification history scrolls inside the dialog at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => {
localStorage.setItem('pipu.locale', 'ru');
localStorage.setItem('pipu.e2e.notifications', '1');
});
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await page.getByRole('menuitem', { name: 'Уведомления 4', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Уведомления', exact: true });
const list = dialog.getByRole('region', { name: 'Уведомления', exact: true });
await expect(list.getByRole('article')).toHaveCount(28);
const box = (await dialog.boundingBox())!;
expect(box.height).toBeLessThanOrEqual(680);
expect(box.width).toBeLessThanOrEqual(Math.min(600, width - (width < 768 ? 24 : 32)));
const close = dialog.getByRole('button', { name: 'Закрыть', exact: true });
const closeBefore = (await close.boundingBox())!;
await list.evaluate(element => { element.scrollTop = element.scrollHeight; });
await expect(close).toBeVisible();
expect((await close.boundingBox())!.y).toBe(closeBefore.y);
await list.evaluate(element => { element.scrollTop = 0; });
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async (selector) => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
}, '[role="dialog"]');
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('notifications.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Непрочитанные 4', exact: true }).click();
await expect(list.getByRole('article')).toHaveCount(4);
await list.getByRole('button', { name: 'Прочитано', exact: true }).first().click();
await expect(list.getByRole('article')).toHaveCount(3);
await dialog.getByRole('button', { name: 'Все', exact: true }).click();
await expect(list.getByRole('article')).toHaveCount(28);
await close.click();
await expect(dialog).toBeHidden();
});
}
+19
View File
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
for (const width of [320, 1280]) { for (const width of [320, 1280]) {
test(`playlists at ${width}px`, async ({ page }, info) => { test(`playlists at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice'); await page.getByLabel('Логин', { exact: true }).fill('alice');
@@ -30,10 +31,28 @@ for (const width of [320, 1280]) {
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click(); await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
await page.getByRole('button', { name: 'Управлять треками' }).click(); await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible(); await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
const shared = new URL(sharedUrl);
expect(shared.origin).toBe(new URL(page.url()).origin);
expect(shared.pathname).toMatch(/^\/shared\/playlist\/[a-f0-9-]+$/);
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => { await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!; const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth); return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1); })).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('playlist.png') }); await page.screenshot({ path: info.outputPath('playlist.png') });
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
}); });
} }
+221
View File
@@ -0,0 +1,221 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин').fill(username);
await page.getByRole('textbox', { name: 'Пароль' }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти' }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
test('job log follows new output and pauses while reading older lines', async ({ page }) => {
await login(page);
await page.evaluate(() => {
let lines = 100;
window.addEventListener('test-job-lines', (event) => { lines = (event as CustomEvent<number>).detail; });
const original = window.fetch;
window.fetch = async (input, init) => {
const response = await original(input, init);
const request = new Request(input, init);
if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/updates') {
const data = await response.json();
data.job = {
id: 'scroll-job', kind: 'update', state: 'running', target: 'main',
started_at: '2026-10-22T09:00:00Z', finished_at: '',
log: Array.from({ length: lines }, (_, i) => `Build line ${i + 1}`).join('\n'),
};
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
}
return response;
};
history.pushState({}, '', '/admin/updates');
window.dispatchEvent(new PopStateEvent('popstate'));
});
const log = page.getByLabel('Журнал выполнения');
const bottomGap = () => log.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop);
await expect(log).toContainText('Build line 100');
expect(await log.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true);
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 120 })));
await expect(log).toContainText('Build line 120');
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
await log.evaluate((el) => { el.scrollTop = 100; });
await expect.poll(() => log.evaluate((el) => el.scrollTop)).toBe(100);
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 140 })));
await expect(log).toContainText('Build line 140');
expect(await log.evaluate((el) => el.scrollTop)).toBe(100);
await log.evaluate((el) => { el.scrollTop = el.scrollHeight; });
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 160 })));
await expect(log).toContainText('Build line 160');
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
});
test('admin selects repository and version, confirms and sees persistent job log', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Последний релиз', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: 'Источники обновлений', exact: true }).click();
await page.getByRole('button', { name: 'Добавить источник' }).click();
await page.getByLabel('Название', { exact: true }).last().fill('Тестовая репа');
await page.getByLabel('URL Git-репозитория').last().fill('https://git.example.org/pipu/testing.git');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
await page.getByLabel('Источник', { exact: true }).selectOption({ label: 'Тестовая репа' });
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
await page.getByRole('button', { name: 'Установить обновление', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText('Тестовая репа');
await expect(dialog).toContainText('v1.10.0');
await dialog.getByRole('button', { name: 'Установить обновление' }).click();
await expect(page.getByRole('status').filter({ hasText: /^Выполняется$/ })).toBeVisible();
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true });
});
test('config keeps secret unchanged, saves local override and applies separately', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/config');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue('');
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug');
await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible();
await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6');
await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).not.toBeVisible();
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Отменить правки', exact: true })).not.toBeVisible();
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён');
await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible();
await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; });
await page.screenshot({ path: 'test-results/server-config.png', fullPage: true });
await page.getByRole('button', { name: 'Применить и перезапустить', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Применить и перезапустить' }).click();
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
});
test('update delegate can update but cannot open configuration', async ({ page }) => {
await login(page, 'update-manager', '/admin/updates');
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
await page.evaluate(() => { history.pushState({}, '', '/admin/config'); window.dispatchEvent(new PopStateEvent('popstate')); });
await expect(page).toHaveURL('/');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).not.toBeVisible();
});
test('configuration masks every stored secret even if an older API returns plaintext', async ({ page }) => {
await login(page);
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const response = await original(input, init);
const request = new Request(input, init);
if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/config') {
const data = await response.json();
const common = { group: 'api', type: 'string', secret: true, readonly: false };
data.entries = [
{ ...common, key: 'ENV_SECRET', env: 'env-canary', local: 'local-canary', effective: 'env-canary', default_value: 'default-canary', source: 'env' },
{ ...common, key: 'DEFAULT_SECRET', env: null, local: null, effective: 'default-canary', default_value: 'default-canary', source: 'default' },
{ ...common, key: 'READONLY_SECRET', readonly: true, env: 'readonly-canary', local: null, effective: 'readonly-canary', default_value: '', source: 'env' },
];
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
}
return response;
};
history.pushState({}, '', '/admin/config');
window.dispatchEvent(new PopStateEvent('popstate'));
});
const env = page.getByLabel('ENV_SECRET · config.local', { exact: true });
const defaults = page.getByLabel('DEFAULT_SECRET · config.local', { exact: true });
await expect(env).toHaveValue('');
await expect(env).toHaveAttribute('type', 'password');
await expect(defaults).toHaveValue('');
await expect(defaults).toHaveAttribute('placeholder', '');
await expect(page.getByText('READONLY_SECRET', { exact: true })).toBeVisible();
for (const canary of ['env-canary', 'local-canary', 'default-canary', 'readonly-canary']) {
expect(await page.getByRole('main').innerHTML()).not.toContain(canary);
}
await page.getByRole('button', { name: 'Показать пароль', exact: true }).first().click();
await expect(env).toHaveValue('');
});
test('a replacement secret is hidden and cleared from the form after saving', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/config');
const token = page.getByLabel('GITEA_TOKEN · config.local', { exact: true });
await token.fill('replacement-canary');
await expect(token).toHaveAttribute('type', 'password');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
await expect(token).toHaveValue('');
await expect(token).toHaveAttribute('placeholder', 'Секрет сохранён');
expect(await page.getByRole('main').innerHTML()).not.toContain('replacement-canary');
});
test('ordinary user is redirected away from server management', async ({ page }) => {
await login(page, 'alice', '/admin/updates');
await expect(page).toHaveURL('/');
});
test('installed main and release show no install action; another version can be installed', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'main', exact: true }).click();
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
await expect(page.getByText('Выберите версию для проверки', { exact: true })).toBeVisible();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.9.0');
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
await expect(page.getByText('Доступно обновление', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
});
test('explicit check bypasses cache and hides install action until the check finishes', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const request = new Request(input, init);
if (request.url.includes('/api/v1/admin/server/updates') && request.url.includes('fresh=true')) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const response = await original(input, init);
const data = await response.json();
return new Response(JSON.stringify({ ...data, update_state: 'current', target_commit: data.current_commit }), { headers: { 'Content-Type': 'application/json' } });
}
return original(input, init);
};
});
await page.getByRole('button', { name: 'Проверить обновления', exact: true }).click();
await expect(page.getByText('Проверяем выбранную версию…', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
});
test('saving panel disappears when all edited values return to their original state', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/config');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
const save = page.getByRole('button', { name: 'Сохранить', exact: true });
const apply = page.getByRole('button', { name: 'Применить и перезапустить', exact: true });
await expect(save).not.toBeVisible();
await expect(apply).not.toBeVisible();
const field = page.getByLabel('TRAIN_HOUR · config.local', { exact: true });
await field.fill('6');
await expect(save).toBeVisible();
await field.clear();
await expect(save).not.toBeVisible();
await expect(apply).not.toBeVisible();
await field.fill('7');
await page.getByRole('button', { name: 'Отменить правки', exact: true }).click();
await expect(save).not.toBeVisible();
await expect(field).toHaveValue('');
});
+43
View File
@@ -0,0 +1,43 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const { width, light } of [{ width: 390, light: false }, { width: 1280, light: false }, { width: 1280, light: true }]) {
test(`social profile settings at ${width}px ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await expect(page.getByRole('menuitem', { name: 'Уведомления', exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: 'Моя музыка скрыта', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Приватность и общение', exact: true });
const visibility = dialog.getByRole('group', { name: 'Кто видит мою музыку', exact: true });
const friends = visibility.getByRole('button', { name: 'Друзьям', exact: true });
await friends.click();
await expect(friends).toHaveAttribute('aria-pressed', 'true');
await expect(friends).toBeEnabled();
await expect(friends).toBeFocused();
await dialog.getByText('Слушать вместе', { exact: true }).click();
await expect(dialog.getByRole('group', { name: 'Принимать запросы и приглашения от', exact: true })).toBeVisible();
expect((await dialog.boundingBox())!.width).toBeLessThanOrEqual(width - 24);
await expect.poll(() => dialog.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async () => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[role="dialog"]')!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
});
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('privacy.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
const heading = page.getByRole('heading', { name: 'Друзья и общение', exact: true });
await expect(heading).toBeVisible();
expect((await heading.locator('..').boundingBox())!.height).toBeLessThanOrEqual(40);
await expect(page.getByRole('button', { name: 'Моя музыка скрыта', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Уведомления', exact: true })).toHaveCount(0);
});
}
+80
View File
@@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test';
for (const width of [320, 800, 1280]) {
test(`friends, messages and sharing at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: width === 1280 ? 'no-preference' : 'reduce' });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
if (width === 800) await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { sessionStorage.setItem('shared-url', data.url ?? ''); } }));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
const openFriends = async () => {
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
};
await openFriends();
if (width === 800) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
await page.getByRole('button', { name: /moderator Начать разговор/ }).click();
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByText('Не в сети', { exact: true })).toBeVisible();
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByRole('button', { name: 'Слушать вместе', exact: true })).toBeDisabled();
await expect(page.getByRole('region', { name: 'Написать', exact: true })).toHaveCSS('animation-name', width === 1280 ? /conversationIn/ : 'none');
if (width === 800) expect((await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!.width).toBeGreaterThan(180);
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill('Привет! Нашёл музыку для нашей поездки 🎵');
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(page.getByText('Привет! Нашёл музыку для нашей поездки 🎵', { exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeEmpty();
await page.getByRole('button', { name: 'Действия с сообщением', exact: true }).click();
await expect(page.getByRole('menuitem', { name: 'Удалить для всех', exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: '❤️', exact: true }).click();
await expect(page.getByRole('button', { name: '❤️ 1', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('conversation.png'), fullPage: true });
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('listitem').first().click();
await page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('button', { name: 'Поделиться треком', exact: true }).click();
await expect(page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true })).toBeVisible();
if (width === 800) {
await page.getByRole('dialog').getByRole('button', { name: 'Отправить в приложение', exact: true }).click();
await expect.poll(() => page.evaluate(() => sessionStorage.getItem('shared-url'))).toContain('/track/');
}
await page.getByRole('dialog').getByRole('button', { name: 'moderator · Отправить', exact: true }).click();
await expect(page.getByRole('button', { name: 'moderator · Отправлено', exact: true })).toBeDisabled();
await page.screenshot({ path: info.outputPath('share-music.png') });
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
await openFriends();
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Музыка из переписки', exact: true }).click();
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveCount(0);
await page.screenshot({ path: info.outputPath('shared-music.png'), fullPage: true });
await page.getByRole('button', { name: 'Переписка', exact: true }).click();
const longMessage = '%20%D0%BC%D1%83%D0%B7%D1%8B%D0%BA%D0%B0'.repeat(45);
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill(longMessage);
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(page.getByRole('button', { name: 'Показать полностью', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Показать полностью', exact: true }).click();
await expect(page.getByRole('button', { name: 'Свернуть текст', exact: true }).locator('..')).toContainText(longMessage);
await page.getByRole('button', { name: 'Свернуть текст', exact: true }).click();
if (width === 320) {
await page.getByRole('button', { name: 'Назад к сообщениям', exact: true }).click();
await expect(page.getByRole('searchbox', { name: 'Найти диалог', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeHidden();
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
await page.setViewportSize({ width, height: 560 });
}
await expect.poll(async () => {
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
return composerBox.y + composerBox.height - playerBox.y;
}).toBeLessThanOrEqual(0);
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
await page.getByRole('link', { name: 'Открыть трек', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Группа крови', exact: true })).toBeVisible();
});
}
+462
View File
@@ -0,0 +1,462 @@
import { expect, test, type Page } from '@playwright/test';
function previewWav(seconds = 2) {
const dataSize = 8000 * 2 * seconds;
const wav = Buffer.alloc(44 + dataSize);
wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
wav.write('data', 36); wav.writeUInt32LE(dataSize, 40);
return wav;
}
async function setup(page: Page, scenario: 'queue' | 'template' | 'mine' | 'release' | 'mixed', audioAvailable = true) {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
await page.evaluate(async ({ scenario, audioAvailable }) => {
const modulePath = '/src/test/mobileFixtures.ts';
const { mobileFixtures } = await import(modulePath);
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
const file = { ...structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]), batch_has_cover: true };
const files = [
{ ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac', target_storage_path: 'nas:/music/Artist/Album/01 - Song.flac', stream_url: `/api/v1/admin/uploads/files/${file.id}/stream?exp=9999999999&sig=test` },
{ ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' },
{ ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' },
{ ...file, id: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' },
{ ...file, id: 'rejected', original_name: 'rejected.flac', metadata: null, status: 'rejected' },
];
files[0]!.bitrate = 778000;
const missing = { ...file, id: 'missing', original_name: 'missing.flac', bitrate: 924000, target_source_id: null, target_source_name: null, target_rel_path: null, status_reason: 'uploads.no_target' };
const releaseId = '11111111-1111-4111-8111-111111111111';
const deluxeId = '22222222-2222-4222-8222-222222222222';
const tracks = [1, 2].map((n) => ({ recording_id: `recording-${n}`, title: `Song ${n}`, artist: 'Catalogue Artist', disc: 1, position: n, duration_ms: 180000 }));
const standard = { release: { id: releaseId, group_id: releaseId, title: 'Album', artist: 'Catalogue Artist', type: 'Album', date: '2024-01-01', country: 'RU', comment: 'Digital', has_cover: true, tracks },
matches: [{ file_id: 'visible', track_index: 0, confidence: 'high' }, { file_id: 'missing', track_index: 1, confidence: 'high' }], missing: [], existing: [], unmatched: [], matched: 2, total: 2, complete: true, confidence: 'high' };
const bonus = { ...tracks[0]!, title: 'Bonus', position: 3 };
const deluxe = { ...standard, release: { ...standard.release, id: deluxeId, comment: 'Deluxe', tracks: [...tracks, bonus] }, missing: [bonus], existing: [bonus], total: 3, complete: false };
let releaseReport = { state: 'ready', input_hash: 'original', selected_release: null as string | null, candidates: [standard, deluxe], audio_available: audioAvailable, audio_results: [] as { file_id: string; state: string }[] };
const groupA = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', artist: 'Artist A', album: 'Album A', file_ids: ['visible'], has_cover: true, state: 'ready' };
const groupB = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', artist: 'Artist B', album: 'Album B', file_ids: ['missing'], has_cover: false, state: 'ready' };
const groups = [groupA, groupB];
const groupReports = Object.fromEntries(groups.map((g, i) => [g.id, { ...releaseReport, group_id: g.id, groups, input_hash: g.id,
candidates: [{ ...standard, release: { ...standard.release, id: i ? deluxeId : releaseId, artist: g.artist, title: g.album, tracks: [tracks[i]] }, matches: [{ file_id: g.file_ids[0], track_index: 0, confidence: 'high' }], matched: 1, total: 1 }],
}]));
let rejectReleaseOnce = true;
let failOnce = true;
const deleted: string[] = [];
const original = window.fetch;
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
window.fetch = async (input, init) => {
const req = new Request(input, init);
const url = new URL(req.url);
if (scenario === 'mixed' && url.pathname.includes('/admin/uploads/batches/')) {
if (url.pathname.endsWith('/releases')) {
if (req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.releaseSearch = JSON.stringify(body);
return json({ ...groupReports[body.group_id], state: 'queued' }, 202);
}
return json(groupReports[url.searchParams.get('group_id') ?? ''] ?? { ...releaseReport, state: 'needs_input', candidates: [], groups });
}
if (url.pathname.endsWith('/cover')) {
document.documentElement.dataset.releaseCoverGroup = url.searchParams.get('group_id') ?? '';
return json({ image_base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', mime_type: 'image/png', source_url: 'https://coverartarchive.org/release/' + releaseId + '/front-1200', exact_release: true });
}
if (url.pathname.endsWith('/release') && req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.releaseApplied = JSON.stringify(body);
const target = groups.find((g) => g.id === body.group_id)!;
if (body.apply_cover) target.has_cover = true;
if (body.apply_metadata) {
const f = body.group_id === groupA.id ? files[0]! : missing;
Object.assign(f, { metadata: { artist: target.artist, albumartist: target.artist, album: target.album, title: 'Confirmed ' + target.album, genre: '' }, updated_at: new Date().toISOString() });
}
groupReports[body.group_id]!.selected_release = body.release_id;
return json(groupReports[body.group_id]);
}
}
if (scenario === 'mixed' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
if (url.pathname.endsWith('/releases') && url.pathname.includes('/admin/uploads/batches/')) {
if (scenario !== 'release') return json({ state: 'disabled', input_hash: '', selected_release: null, candidates: [] });
if (req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.releaseSearch = JSON.stringify(body);
if (body.audio) releaseReport = { ...releaseReport, audio_results: [{ file_id: 'visible', state: 'matched' }, { file_id: 'missing', state: 'ambiguous' }], candidates: [
{ ...standard, complete: false, confidence: 'medium', matches: [{ ...standard.matches[0]!, method: 'acoustid' }] } as typeof standard, deluxe,
] };
return json({ ...releaseReport, state: 'queued' }, 202);
}
return json(releaseReport);
}
if (scenario === 'release' && url.pathname.endsWith('/cover')) {
document.documentElement.dataset.releaseCoverRequests = String(Number(document.documentElement.dataset.releaseCoverRequests ?? 0) + 1);
return json({ image_base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', mime_type: 'image/png', source_url: 'https://coverartarchive.org/release-group/11111111-1111-4111-8111-111111111111/front-1200', exact_release: false });
}
if (scenario === 'release' && url.pathname.endsWith('/release') && req.method === 'POST') {
const body = await req.json();
if (body.apply_metadata && rejectReleaseOnce) { rejectReleaseOnce = false; return json({ code: 'uploads.release_stale', status: 409 }, 409); }
document.documentElement.dataset.releaseApplied = JSON.stringify(body);
if (body.apply_metadata) {
for (const [index, f] of [files[0]!, missing].entries()) Object.assign(f, { metadata: { artist: 'Catalogue Artist', albumartist: 'Catalogue Artist', title: `Song ${index + 1}`, album: 'Album', year: 2024, track: index + 1, disc: 1, genre: '' }, updated_at: new Date().toISOString() });
}
releaseReport = { ...releaseReport, input_hash: 'applied', selected_release: body.release_id };
return json(releaseReport);
}
if (scenario === 'release' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
if (scenario === 'queue' && url.pathname.startsWith('/api/v1/admin/uploads/batches/') && req.method === 'PATCH') {
const body = await req.json();
const bytes = Uint8Array.from(atob(body.cover_base64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
document.documentElement.dataset.coverDimensions = `${image.width}x${image.height}`;
document.documentElement.dataset.coverBytes = String(bytes.length);
image.close();
return json({ items: [files[0], missing] });
}
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads/files/missing' && req.method === 'PATCH') {
const body = await req.json();
Object.assign(missing, { metadata: body.metadata, target_source_id: source.id, target_source_name: source.name, target_rel_path: 'Artist/Album/02 - Fixed.flac', status_reason: null });
return json(missing);
}
if (scenario === 'template') {
if (url.pathname === '/api/v1/admin/uploads') {
const path = document.documentElement.dataset.savedTemplate ? 'Artist/Album/01. Song.flac' : 'Artist/Album/Song.flac';
return json({ items: [{ ...files[0], target_rel_path: path, target_storage_path: `nas:/music/${path}` }], next_cursor: null });
}
if (url.pathname.endsWith('/write-template/suggest')) return json({ template: '{albumartist}/{album}/{track:02}. {title}.{ext}', matched: 180, total: 200, examples: ['Кино/Группа крови/01. Группа крови.flac'] });
if (url.pathname.endsWith('/write-template/check')) return json({ ok: false, total: 50, round_trip: 0, same_path: 45, mismatches: [] });
if (url.pathname === `/api/v1/admin/sources/${source.id}` && req.method === 'PATCH') {
const body = await req.json();
document.documentElement.dataset.savedTemplate = body.write_template;
Object.assign(source, body);
return json(source);
}
if (url.pathname === `/api/v1/admin/sources/${source.id}`) return json(source);
}
if (scenario === 'mine' && url.pathname === '/api/v1/uploads/mine') {
if (url.searchParams.getAll('status').length > 1) return json({ code: 'common.bad_request', status: 400 }, 400);
if (url.searchParams.has('batch')) {
document.documentElement.dataset.batchPages = String(Number(document.documentElement.dataset.batchPages ?? 0) + 1);
return json({ items: [url.searchParams.has('cursor') ? files[2] : files[0]], next_cursor: url.searchParams.has('cursor') ? null : 'next' });
}
const statuses = url.searchParams.get('status')?.split(',') ?? [];
const visible = [files[0], files[1], files[3], files[4]].filter((f) => !statuses.length || statuses.includes(f.status));
return json({ items: visible, next_cursor: 'more' });
}
if (scenario === 'mine' && req.method === 'DELETE' && url.pathname.startsWith('/api/v1/uploads/files/')) {
const id = url.pathname.split('/').pop()!;
deleted.push(id);
document.documentElement.dataset.deleted = JSON.stringify(deleted);
if (id === 'hidden' && failOnce) { failOnce = false; return json({ code: 'uploads.wrong_status', status: 409 }, 409); }
files.find((f) => f.id === id)!.status = 'withdrawn';
return new Response(null, { status: 204 });
}
return original(input, init);
};
const target = scenario === 'queue' || scenario === 'release' || scenario === 'mixed' ? '/admin/uploads' : scenario === 'mine' ? '/uploads/mine' : `/admin/sources/${source.id}/patterns`;
history.pushState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}, { scenario, audioAvailable });
}
test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => {
await setup(page, 'queue');
await expect(page.getByText('Есть обложка', { exact: true })).toBeVisible();
await expect(page.getByText('2 × FLAC', { exact: true })).toBeVisible();
await expect(page.getByText('778–924 kbps · 44.1 kHz · 16 bit', { exact: true })).toBeVisible();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01 - Song.flac');
await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
await expect(page.getByRole('button', { name: 'Развернуть пачку', exact: true })).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).toBeChecked();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).press('Enter');
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
// Continue keyboard selection after keyboard expansion, avoiding another scroll-dependent pointer action.
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).press('Space');
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).not.toBeChecked();
await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist');
await dialog.getByLabel('Название', { exact: true }).fill('Fixed');
await dialog.getByRole('combobox').selectOption({ label: 'Музыкальное хранилище' });
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('queue-placement.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
});
test('automatically suggests a dominant template and saves a custom edit only on request', async ({ page }, info) => {
await setup(page, 'template');
const field = page.getByRole('textbox', { name: 'Шаблон пути', exact: true });
await expect(field).toHaveValue('{albumartist}/{album}/{track:02}. {title}.{ext}');
await expect(page.getByText(/180 из 200/)).toBeVisible();
await expect(page.getByText('Кино/Группа крови/01. Группа крови.flac', { exact: true })).toBeVisible();
await expect(page.locator('html')).not.toHaveAttribute('data-saved-template');
await field.fill('{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
await section.getByRole('checkbox').check();
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
await page.screenshot({ path: info.outputPath('auto-template.png'), fullPage: true });
});
test('selects a whole own batch across pages and reports partial withdrawal failures', async ({ page }, info) => {
await setup(page, 'mine');
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
await expect(page.locator('html')).toHaveAttribute('data-batch-pages', '2');
await expect(page.getByText(/Выбрано 2 файла/)).toBeVisible();
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
await expect(page.getByText(/Не удалось отозвать: 1/)).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden"]');
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => Math.max(document.documentElement.scrollWidth - innerWidth, document.querySelector('main')!.scrollWidth - document.querySelector('main')!.clientWidth))).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('bulk-withdraw-mobile.png'), fullPage: true });
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden","hidden"]');
await expect(page.getByRole('region', { name: 'Массовый отзыв предложений' })).toHaveCount(0);
});
test('keeps withdrawn proposals in their own filter', async ({ page }) => {
await setup(page, 'mine');
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Отозванные', exact: true }).click();
await expect(page.getByText('withdrawn.flac', { exact: true }).first()).toBeVisible();
await expect(page.getByText('visible.flac', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Не приняты', exact: true }).click();
await expect(page.getByText('rejected.flac', { exact: true }).first()).toBeVisible();
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Все', exact: true }).click();
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
});
test('queue shows the current full destination after saving a changed source template', async ({ page }) => {
await setup(page, 'template');
const sourcePath = new URL(page.url()).pathname;
const navigate = async (path: string) => page.evaluate((path) => {
history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}, path);
await navigate('/admin/uploads');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/Song.flac');
await navigate(sourcePath);
await page.getByRole('textbox', { name: 'Шаблон пути', exact: true }).fill('{albumartist}/{album}/{track:02}. {title}.{ext}');
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
await section.getByRole('checkbox').check();
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/{album}/{track:02}. {title}.{ext}');
await navigate('/admin/uploads');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01. Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01. Song.flac');
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveCount(0);
});
test('queue preview only requests audio after play', async ({ page }) => {
const wav = previewWav();
let requested = 0;
let otherRequested = 0;
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => {
requested++;
return route.fulfill({ contentType: 'audio/wav', body: wav });
});
await page.route('**/api/v1/stream/mobile-preview', (route) => {
otherRequested++;
return route.fulfill({ contentType: 'audio/wav', body: wav });
});
await setup(page, 'queue');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
const audio = page.locator('audio').first();
await expect(audio).toHaveAttribute('preload', 'none');
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
expect(requested).toBe(0);
expect(otherRequested).toBe(0);
await audio.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
expect(requested).toBeGreaterThan(0);
expect(otherRequested).toBe(0);
expect(await audio.evaluate((el: HTMLAudioElement) => el.error)).toBeNull();
});
test('queue preview explains HTTP failures and recovers on retry', async ({ page }) => {
let available = false;
// The E2E bridge mocks window.fetch; media diagnostics must reach the same
// intercepted HTTP route as the native audio element.
await page.addInitScript(() => Object.assign(window, { previewNativeFetch: window.fetch.bind(window) }));
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => available
? route.fulfill({ contentType: 'audio/wav', body: previewWav() })
: route.fulfill({ status: 403, contentType: 'text/plain', body: 'Forbidden' }));
await setup(page, 'queue');
await page.evaluate(() => {
const original = window.fetch;
const native = (window as unknown as { previewNativeFetch: typeof fetch }).previewNativeFetch;
window.fetch = (input, init) => new URL(new Request(input, init).url).pathname.endsWith('/stream')
? native(input, init) : original(input, init);
});
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
const preview = page.locator('audio').first().locator('..');
await preview.locator('audio').evaluate((el: HTMLAudioElement) => el.play().catch(() => undefined));
await expect(preview.getByRole('alert')).toHaveText('Не удалось загрузить аудио: HTTP 403.');
available = true;
await preview.getByRole('button', { name: 'Повторить', exact: true }).click();
await expect.poll(() => preview.locator('audio').evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
await expect(preview.getByRole('alert')).toHaveCount(0);
});
test('starting another queue preview pauses the previous track', async ({ page }) => {
const wav = previewWav(10);
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
await page.route('**/api/v1/stream/mobile-preview', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
await setup(page, 'queue');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
const first = page.locator('audio').nth(0);
const second = page.locator('audio').nth(1);
await first.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
await second.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
expect(await second.evaluate((el: HTMLAudioElement) => el.paused)).toBe(false);
expect(await first.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
await first.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => second.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
expect(await first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(false);
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
});
test('oversized album cover is reduced before saving the batch', async ({ page }) => {
await setup(page, 'queue');
const png = await page.evaluate(() => {
const canvas = document.createElement('canvas');
canvas.width = 3000; canvas.height = 1800;
canvas.getContext('2d')!.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png').split(',')[1]!;
});
// A valid image with trailing padding reproduces an oversized input without a large fixture in Git.
const largeCover = Buffer.concat([Buffer.from(png, 'base64'), Buffer.alloc(12 * 1024 * 1024)]);
await page.getByRole('button', { name: 'На всю пачку', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.locator('input[type=file]').setInputFiles({ name: 'Cover.png', mimeType: 'image/png', buffer: largeCover });
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-cover-dimensions', '2048x1229');
expect(Number(await page.locator('html').getAttribute('data-cover-bytes'))).toBeLessThan(8 * 1024 * 1024);
});
test('mixed batch checks each release and applies only within the chosen group', async ({ page }, info) => {
await setup(page, 'mixed');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist A — Album A', { exact: true })).toBeVisible();
await expect(page.getByText('Artist B — Album B', { exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: 'Выбрать загруженные строки релиза Album A', exact: true }).check();
await expect(page.getByRole('checkbox', { name: 'Выбрать visible.flac', exact: true })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Выбрать missing.flac', exact: true })).not.toBeChecked();
await page.getByRole('button', { name: 'Релизов в пачке: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await expect(dialog.getByRole('button', { name: 'Распознать по звуку', exact: true })).toBeDisabled();
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
await expect(dialog.getByText('Artist B — Album B', { exact: true })).toBeVisible();
await expect(dialog.getByText('Без обложки', { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-cover-group', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
await page.screenshot({ path: info.outputPath('mixed-release.png') });
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
await dialog.getByRole('checkbox', { name: 'Добавить обложку выбранного релиза', exact: true }).check();
await dialog.getByRole('button', { name: 'Применить выбранное', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-applied', /"group_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"/);
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa');
await expect(dialog.getByText('Artist A — Album A', { exact: true })).toBeVisible();
await expect(dialog.getByText('Сопоставлено 1 из 1', { exact: false })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await expect(dialog.getByRole('checkbox', { name: /Применить названия/ })).not.toBeChecked();
});
test('audio recognition starts only on request and keeps suggestions separate', async ({ page }) => {
await setup(page, 'release');
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(page.locator('html')).not.toHaveAttribute('data-release-search');
await expect(dialog.getByText(/только аудиоотпечатки и длительности/)).toBeVisible();
await dialog.getByRole('button', { name: 'Распознать по звуку', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-search', '{"audio":true}');
await expect(dialog.getByText('По звуку распознано: 1 из 2 треков', { exact: true })).toBeVisible();
await expect(dialog.getByText(/Неоднозначное или недостаточно уверенное совпадение/)).toBeVisible();
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
await expect(dialog.getByText('Совпадение по звуку · AcoustID', { exact: true })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
await expect(page.locator('html')).not.toHaveAttribute('data-release-cover-requests');
});
test('audio identification without application key shows setup and stays disabled', async ({ page }) => {
await setup(page, 'release', false);
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(dialog.getByRole('button', { name: 'Распознать по звуку', exact: true })).toBeDisabled();
await expect(dialog.getByText(/задайте ACOUSTID_API_KEY/)).toBeVisible();
await expect(page.locator('html')).not.toHaveAttribute('data-release-search');
});
test('release suggestions require explicit application and handle stale metadata', async ({ page }) => {
await setup(page, 'release');
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
await expect(dialog.getByText('Уверенность сопоставления: высокая', { exact: true })).toBeVisible();
const apply = dialog.getByRole('button', { name: 'Применить выбранное', exact: true });
await expect(apply).toBeDisabled();
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
await expect(page.locator('html')).not.toHaveAttribute('data-release-cover-requests');
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
await apply.click();
await expect(dialog.getByText(/Состав или теги пачки изменились/)).toBeVisible();
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
await dialog.getByRole('textbox', { name: 'Исполнитель', exact: true }).fill('Catalogue Artist');
await dialog.getByRole('textbox', { name: 'Альбом', exact: true }).fill('Album');
await dialog.getByRole('button', { name: 'Найти / повторить поиск', exact: true }).click();
await expect(dialog.getByRole('status')).toHaveText('Найдено изданий: 2');
await expect(dialog.getByRole('combobox', { name: 'Издание' })).toBeVisible();
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
await expect(apply).toBeEnabled();
await apply.click();
await expect(page.locator('html')).toHaveAttribute('data-release-applied', '{"release_id":"11111111-1111-4111-8111-111111111111","apply_metadata":true,"apply_cover":false}');
await expect(dialog.getByText('Выбранное издание применено', { exact: true })).toBeVisible();
});
test('deluxe completeness and release-group artwork are shown before replacing an existing cover', async ({ page }, info) => {
await setup(page, 'release');
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('22222222-2222-4222-8222-222222222222');
await expect(dialog.getByText('Альбом · сопоставлено 2/3 · Неполный состав', { exact: true })).toBeVisible();
await expect(dialog.getByText('Файл не сопоставлен', { exact: true })).toBeVisible();
await expect(dialog.getByText(/найдены совпадения в библиотеке/)).toBeVisible();
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible();
await expect(dialog.getByRole('img', { name: 'Обложка пачки' })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await page.screenshot({ path: info.outputPath('release-comparison.png') });
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await dialog.getByRole('checkbox', { name: 'Заменить текущую обложку пачки этой обложкой', exact: true }).check();
await dialog.getByRole('button', { name: 'Применить выбранное', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-applied', '{"release_id":"22222222-2222-4222-8222-222222222222","apply_metadata":false,"apply_cover":true}');
});
+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { expect, test } from '@playwright/test';
const nginxHeaders = readFileSync(new URL('../../../deploy/nginx/security-headers.conf', import.meta.url), 'utf8');
const desktopConfig = JSON.parse(readFileSync(new URL('../../desktop/src-tauri/tauri.conf.json', import.meta.url), 'utf8'));
const policies: Record<string, string> = {
web: nginxHeaders.match(/add_header Content-Security-Policy "([^"]+)" always;/)![1],
desktop: desktopConfig.app.security.csp,
};
for (const [platform, policy] of Object.entries(policies)) {
test(`upload hash worker runs under ${platform} CSP`, async ({ page }) => {
// Apply the deployed policy to both the document and worker responses.
await page.route('**/*', async (route) => {
if (new URL(route.request().url()).pathname === '/csp-upload-test') {
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Upload CSP</title>', headers: { 'Content-Security-Policy': policy } });
return;
}
if (new URL(route.request().url()).pathname === '/csp-eval-test.js') {
await route.fulfill({ contentType: 'application/javascript', body: `
try { new Function('return 1')(); document.documentElement.dataset.evalBlocked = 'false'; }
catch { document.documentElement.dataset.evalBlocked = 'true'; }
WebAssembly.compile(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])).then(
() => { document.documentElement.dataset.wasmBlocked = 'false'; },
() => { document.documentElement.dataset.wasmBlocked = 'true'; },
);
` });
return;
}
const response = await route.fetch();
await route.fulfill({ response, headers: { ...response.headers(), 'Content-Security-Policy': policy } });
});
await page.goto('/csp-upload-test');
const result = await page.evaluate(async () => {
const worker = new Worker('/src/features/uploads/hash.worker.ts', { type: 'module' });
try {
const results = [];
// Reuse one worker for empty, known-vector and multi-chunk binary files.
const binary = Uint8Array.from({ length: (9 << 20) + 17 }, (_, i) => i % 251);
for (const [id, bytes] of [['empty', new Uint8Array()], ['abc', new TextEncoder().encode('abc')], ['binary', binary]] as const) {
results.push(await new Promise((resolve, reject) => {
const progress: number[] = [];
worker.onerror = (event) => reject(new Error(event.message));
worker.onmessage = ({ data }) => {
if (data.type === 'progress') { progress.push(data.done); return; }
resolve({ ...data, progress });
};
worker.postMessage({ id, file: new File([bytes], `${id}.flac`) });
}));
}
return results;
} finally {
worker.terminate();
}
});
const binary = Uint8Array.from({ length: (9 << 20) + 17 }, (_, i) => i % 251);
expect(result).toEqual([
{ id: 'empty', type: 'done', sha256: createHash('sha256').update('').digest('hex'), progress: [] },
{ id: 'abc', type: 'done', sha256: createHash('sha256').update('abc').digest('hex'), progress: [] },
{ id: 'binary', type: 'done', sha256: createHash('sha256').update(binary).digest('hex'), progress: expect.arrayContaining([expect.any(Number)]) },
]);
// A page script observes CSP; Playwright's evaluate bypasses the eval restriction.
await page.addScriptTag({ url: '/csp-eval-test.js' });
await expect(page.locator('html')).toHaveAttribute('data-eval-blocked', 'true');
await expect(page.locator('html')).toHaveAttribute('data-wasm-blocked', 'true');
});
}
+84
View File
@@ -0,0 +1,84 @@
import { expect, test } from '@playwright/test';
test('locks the comment and retries one failed upload while another is running', async ({ page }, info) => {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const req = new Request(input, init);
const path = new URL(req.url).pathname;
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
if (path === '/api/v1/uploads/quota') return json({ limit_bytes: 1e9, used_bytes: 0, per_user_limit_bytes: null, user_used_bytes: 0, max_file_bytes: 1e9 });
if (path === '/api/v1/uploads/precheck') {
const body = await req.json();
return json({ files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) });
}
if (path === '/api/v1/uploads' && req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.uploadComment = body.comment;
document.documentElement.dataset.uploadReservations = String(Number(document.documentElement.dataset.uploadReservations ?? 0) + 1);
return json({ batch_id: 'test-batch', files: body.files.map((f: { client_id: string; name: string }) => ({ client_id: f.client_id, upload_file_id: f.name, tus_url: `/api/v1/uploads/files/${f.name}` })) }, 201);
}
return original(input, init);
};
history.pushState({}, '', '/uploads/new');
window.dispatchEvent(new PopStateEvent('popstate'));
});
let retry = false;
let failures = 0;
let resumedOffset: string | undefined;
let releaseSecond!: () => void;
const secondPending = new Promise<void>((resolve) => { releaseSecond = resolve; });
await page.route('**/api/v1/uploads/files/*', async (route) => {
const req = route.request();
const first = req.url().endsWith('/first.flac');
if (req.method() === 'HEAD') {
await route.fulfill({ status: 200, headers: { 'Upload-Length': '1024', 'Upload-Offset': first && failures > 0 ? '256' : '0', 'Tus-Resumable': '1.0.0' } });
return;
}
if (first && !retry) {
failures++;
await route.fulfill({ status: 507, contentType: 'application/problem+json', body: JSON.stringify({ status: 507, code: 'uploads.storage_full', detail: 'upload storage is full' }) });
return;
}
if (first) resumedOffset = req.headers()['upload-offset'];
else await secondPending;
await route.fulfill({ status: 204, headers: { 'Upload-Offset': '1024', 'Tus-Resumable': '1.0.0' } });
});
const inputs = page.locator('input[type=file]');
await inputs.first().setInputFiles(['first.flac', 'second.flac'].map((name) => ({ name, mimeType: 'audio/flac', buffer: Buffer.alloc(1024) })));
const comment = page.getByRole('textbox', { name: 'Комментарий для администратора' });
await comment.fill('Lossless album');
await expect(comment).toBeEnabled();
const send = page.getByRole('button', { name: /^Отправить/ });
await expect(send).toBeEnabled();
await send.click();
await expect(comment).toBeDisabled();
await expect(page.getByRole('button', { name: 'Очистить список' })).toBeDisabled();
await expect(page.getByText(/На сервере закончилось место для загрузок/)).toBeVisible();
const retryButton = page.getByRole('button', { name: 'Повторить загрузку first.flac', exact: true });
await expect(retryButton).toBeEnabled();
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('upload-retry.png'), fullPage: true });
expect(failures).toBe(1);
retry = true;
await retryButton.click();
await expect.poll(() => resumedOffset).toBe('256');
await expect(retryButton).toHaveCount(0);
await expect(comment).toBeDisabled();
await expect(page.locator('html')).toHaveAttribute('data-upload-reservations', '1');
await expect(page.locator('html')).toHaveAttribute('data-upload-comment', 'Lossless album');
releaseSecond();
await expect(page.getByText(/Готово: 2 файла/)).toBeVisible();
await page.getByRole('button', { name: 'Загрузить ещё', exact: true }).click();
await inputs.first().setInputFiles({ name: 'third.flac', mimeType: 'audio/flac', buffer: Buffer.alloc(1024) });
await expect(comment).toBeEnabled();
});
+70
View File
@@ -0,0 +1,70 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/admin/users') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
for (const width of [320, 1280]) {
test(`user transfer and cover counts at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await login(page);
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const req = new Request(input, init);
const path = new URL(req.url).pathname;
if (path === '/api/v1/admin/users/export') return new Response(JSON.stringify({
version: 1, users: [{ username: 'alice', role: 'user', password_hash: '$argon2id$test' }],
}), { headers: { 'Content-Type': 'application/json' } });
if (path === '/api/v1/admin/users/import') {
const archive = await req.json();
if (archive.users[0].password_hash !== '$argon2id$test') throw new Error('lost hash');
return new Response(JSON.stringify({ created: 1, skipped: ['alice'] }), { headers: { 'Content-Type': 'application/json' } });
}
return original(input, init);
};
});
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Экспорт пользователей', exact: true }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('pipu-users.json');
await page.getByRole('button', { name: 'Импорт пользователей', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Импорт пользователей' });
const file = dialog.getByLabel('Файл пользователей (.json)');
await file.setInputFiles({ name: 'bad.json', mimeType: 'application/json', buffer: Buffer.from('{}') });
await expect(dialog.getByRole('alert')).toContainText('Некорректный файл');
await expect(dialog.getByRole('button', { name: 'Импортировать', exact: true })).toBeDisabled();
await file.setInputFiles({ name: 'users.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({
version: 1, users: [{ username: 'new-user', role: 'moderator', password_hash: '$argon2id$test' }],
})) });
await expect(dialog).toContainText('Учётных записей в файле: 1');
await page.screenshot({ path: info.outputPath('user-import.png') });
await dialog.getByRole('button', { name: 'Импортировать', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('Создано: 1. Пропущено существующих: 1.');
await expect(dialog.getByRole('status')).toContainText('alice');
await page.keyboard.press('Escape');
if (width < 768) {
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
} else await page.evaluate(() => {
history.pushState({}, '', '/admin/sources');
window.dispatchEvent(new PopStateEvent('popstate'));
});
await expect(page.getByRole('article')).toContainText('Локальные обложки: 87 из 120 файлов');
await expect(page.getByRole('article')).toContainText('Альбомов ожидают обработки: 3');
await page.screenshot({ path: info.outputPath('source-covers.png') });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
});
}
test('moderator cannot see user transfer controls', async ({ page }) => {
await login(page, 'moderator');
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Экспорт пользователей', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Импорт пользователей', exact: true })).toHaveCount(0);
});
+1 -1
View File
@@ -23,7 +23,7 @@
"@tanstack/react-query": "^5.80.7", "@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20", "@tanstack/react-router": "^1.120.20",
"@tanstack/react-virtual": "^3.14.21", "@tanstack/react-virtual": "^3.14.21",
"hash-wasm": "^4.12.0", "js-sha256": "^1.0.0",
"music-metadata": "^12.2.3", "music-metadata": "^12.2.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
+6 -1
View File
@@ -3,7 +3,12 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
timeout: 30_000, timeout: 30_000,
use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] }, use: {
baseURL: 'http://127.0.0.1:4173',
...devices['Desktop Chrome'],
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: { webServer: {
command: 'pnpm exec vite --port 4173 --strictPort', command: 'pnpm exec vite --port 4173 --strictPort',
url: 'http://127.0.0.1:4173', url: 'http://127.0.0.1:4173',
+344 -4
View File
@@ -1,4 +1,4 @@
import { AuthSession } from '@pipu/core'; import { AuthSession, refreshListening } from '@pipu/core';
import { createI18n } from '@pipu/i18n'; import { createI18n } from '@pipu/i18n';
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import { createMemoryHistory } from '@tanstack/react-router'; import { createMemoryHistory } from '@tanstack/react-router';
@@ -10,10 +10,260 @@ import { afterEach, describe, expect, it } from 'vitest';
import { App } from './App'; import { App } from './App';
import { createAppRouter, safeRedirect } from './app/router'; import { createAppRouter, safeRedirect } from './app/router';
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi'; import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup); afterEach(cleanup);
describe('social', () => {
it.each([false, true])('closes a join dialog and follows only after admission (automatic=%s)', async (automatic) => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, session } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 1000 });
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: automatic ? 'friends' : 'nobody', autoplay: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
if (!automatic) {
expect(api.listeningMembers.has(alice.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
expect(api.listeningRequests.size).toBe(1);
const request = [...api.listeningRequests.values()][0]!;
const room = [...api.listeningRooms.values()][0]!;
api.listeningMembers.set(alice.id, room.id);
api.listeningRequests.delete(request.id);
refreshListening(session);
}
await screen.findByText('Слушаете вместе с bob');
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument());
});
it('closes a pending invitation without announcing a stream and allows cancellation', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, router } = await renderApp([alice, bob], `/track/${albumTracks[0]!.id}`, { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: 'nobody', autoplay: true });
await router.navigate({ to: '/friends' });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
expect(api.listeningMembers.has(bob.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: 'Отмена' }));
await waitFor(() => expect(api.listeningRequests.size).toBe(0));
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
});
it('explains listening restrictions instead of reporting a chat error and can close after rejection', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Совместное прослушивание недоступно');
expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Чат доступен только друзьям');
expect(api.listeningRequests.size).toBe(0);
expect(api.listeningRooms.size).toBe(0);
await user.click(within(dialog).getByRole('button', { name: 'Закрыть' }));
expect(dialog).not.toBeInTheDocument();
});
it('enables listening together only while a friend is online, independently of music privacy', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
const conversation = await screen.findByRole('region', { name: 'Написать' });
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
api.presence.set(bob.id, Date.now());
await queryClient.invalidateQueries({ queryKey: ['social'] });
await within(conversation).findByText('В сети');
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeEnabled();
expect(api.activity.has(bob.id)).toBe(false);
await user.click(within(conversation).getByRole('button', { name: 'Слушать вместе' }));
await screen.findByRole('dialog', { name: 'Слушать вместе' });
api.presence.set(bob.id, Date.now() - 46_000);
await queryClient.invalidateQueries({ queryKey: ['social'] });
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Слушать вместе' })).not.toBeInTheDocument());
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
});
it('finds music beyond the last text page without acknowledging unread messages', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
for (let seq = 1; seq <= 61; seq++) api.messages.push({ id: crypto.randomUUID(), seq, sender_id: bob.id, recipient: alice.id, text: seq === 1 ? albumTracks[0]!.title : `Сообщение ${seq}`, attachment_kind: seq === 1 ? 'track' : 'none', ...(seq === 1 ? { attachment_id: albumTracks[0]!.id } : {}), created_at: new Date().toISOString(), read: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Музыка из переписки' }));
await screen.findByRole('link', { name: 'Открыть трек' });
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
const firstCall = api.calls.length;
api.messages.push({ id: crypto.randomUUID(), seq: 62, sender_id: bob.id, recipient: alice.id, text: 'Новая музыка', attachment_kind: 'track', attachment_id: albumTracks[0]!.id, created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByText('Новая музыка');
expect(api.messages.at(-1)?.read).toBe(false);
expect(api.calls.slice(firstCall).some(c => c.path.endsWith('/actions') && (c.body as { action?: string })?.action === 'read_chat')).toBe(false);
});
it('keeps the current conversation when another friend sends newer messages and confirms blocking', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const carol = makeUser('carol', 'user');
const { api, user, queryClient } = await renderApp([alice, bob, carol], '/friends', { signedInAs: alice });
for (const friend of [bob, carol]) api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByRole('heading', { name: 'bob' });
await user.type(screen.getByRole('textbox', { name: 'Сообщение' }), 'Черновик для bob');
api.messages.push({ id: crypto.randomUUID(), seq: 1, sender_id: carol.id, recipient: alice.id, text: 'Новое сообщение от carol', attachment_kind: 'none', created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByText('Новое сообщение от carol');
expect(screen.getByRole('heading', { name: 'bob' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Сообщение' })).toHaveValue('Черновик для bob');
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
const confirm = await screen.findByRole('dialog', { name: 'Заблокировать bob?' });
expect(api.friendships.size).toBe(2);
await user.click(within(confirm).getByRole('button', { name: 'Отмена' }));
expect(api.friendships.size).toBe(2);
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
await user.click(within(await screen.findByRole('dialog', { name: 'Заблокировать bob?' })).getByRole('button', { name: 'Заблокировать' }));
await waitFor(() => expect(api.friendships.size).toBe(1));
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
});
it('follows a room with locked playback controls, independent volume and local exit', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, session, user } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
const id = crypto.randomUUID();
api.listeningRooms.set(id, { id, owner_id: bob.id, owner_name: 'bob', device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, position_ms: 5000, playing: false, updated_at: new Date().toISOString(), participants: [] });
api.listeningMembers.set(alice.id, id); api.listeningMembers.set(bob.id, id);
refreshListening(session);
await screen.findByText('Слушаете вместе с bob');
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(player).getByRole('button', { name: 'Слушать' })).toBeDisabled());
expect(within(player).getByRole('slider', { name: 'Громкость' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
await waitFor(() => expect(screen.queryByText('Слушаете вместе с bob')).not.toBeInTheDocument());
expect(api.listeningMembers.has(alice.id)).toBe(false);
});
it('edits, replies, reacts, searches, deletes and preserves a draft between sections', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: /bob Начать разговор/ }));
const composer = () => screen.getByRole('textbox', { name: 'Сообщение' });
await user.type(composer(), 'Первый вариант');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
await screen.findByText('Первый вариант');
const actions = async (name: string) => {
await user.click((await screen.findAllByRole('button', { name: 'Действия с сообщением' }))[0]!);
await user.click(await screen.findByRole('menuitem', { name }));
};
await actions('Редактировать');
await user.clear(composer()); await user.type(composer(), 'Исправленный текст');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByText('Исправленный текст', { exact: true });
await actions('❤️');
await screen.findByRole('button', { name: '❤️ 1' });
await actions('Ответить');
await user.type(composer(), 'Ответ на музыку');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(api.messages).toHaveLength(2));
expect(api.messages[1]?.reply_to).toBe(api.messages[0]?.id);
await user.click(screen.getByRole('button', { name: 'Поиск в переписке' }));
await user.type(screen.getByRole('searchbox', { name: 'Поиск в переписке' }), 'Исправленный');
await waitFor(() => expect(screen.queryByText('Ответ на музыку', { exact: true })).not.toBeInTheDocument());
await actions('Удалить для всех');
await waitFor(() => expect(api.messages[0]?.deleted).toBe(true));
await user.clear(screen.getByRole('searchbox', { name: 'Поиск в переписке' }));
await user.type(composer(), 'Мой черновик');
await user.click(screen.getByRole('button', { name: 'Друзья' }));
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Написать' }));
expect(composer()).toHaveValue('Мой черновик');
});
it('saves privacy and independent request admission preferences', async () => {
const alice = makeUser('alice', 'user');
const { api, user } = await renderApp([alice], '/', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(await screen.findByRole('menuitem', { name: 'Моя музыка скрыта' }));
const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' });
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
await waitFor(() => expect(api.activityVisibility.get(alice.id)).toBe('friends'));
await user.click(screen.getByText('Слушать вместе', { exact: true }));
const requests = await screen.findByRole('group', { name: 'Принимать запросы и приглашения от' });
const auto = screen.getByRole('group', { name: 'Автоматически принимать от' });
await user.click(within(requests).getByRole('button', { name: 'Друзьям' }));
await waitFor(() => expect(api.listeningPreferences.get(alice.id)?.requests).toBe('friends'));
await waitFor(() => expect(within(auto).getByRole('button', { name: 'Всем' })).toBeEnabled());
await user.click(within(auto).getByRole('button', { name: 'Всем' }));
await waitFor(() => expect(api.listeningPreferences.get(alice.id)).toMatchObject({ requests: 'friends', auto_accept: 'everyone' }));
});
it('shows persistent notifications and acknowledges only the chosen event', async () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user');
const { api, user, queryClient, router } = await renderApp([alice, bob], '/', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(await screen.findByRole('menuitem', { name: 'Уведомления 1' }));
await screen.findByText('Хочет добавить вас в друзья');
await user.click(screen.getByRole('button', { name: 'Прочитано' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
expect(api.notifications[0]?.read).toBe(true);
await user.click(screen.getByRole('button', { name: 'Открыть: bob' }));
await screen.findByRole('button', { name: 'Принять' });
expect(router.state.location.pathname).toBe('/friends');
expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id });
});
it('searches, requests, cancels, accepts and exchanges messages and music', async () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user', { display_name: 'Борис' });
const { api, router, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
await user.click((await screen.findAllByRole('button', { name: 'Найти друзей' }))[0]!);
await user.type(await screen.findByLabelText('Найти по имени или логину'), 'bob');
await user.click(await screen.findByRole('button', { name: 'В друзья' }));
await screen.findByRole('button', { name: 'Отменить заявку' });
await user.click(screen.getByRole('button', { name: 'Отменить заявку' }));
await screen.findByRole('button', { name: 'В друзья' });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Принять' }));
await user.click(await screen.findByRole('button', { name: 'Написать' }));
await user.type(await screen.findByLabelText('Сообщение', { exact: true }), 'Привет, послушай это');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
expect(await screen.findByText('Привет, послушай это')).toBeInTheDocument();
expect(api.messages).toHaveLength(1);
await router.navigate({ to: '/track/$id', params: { id: albumTracks[0]!.id } });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(player).getByRole('button', { name: 'Поделиться треком' }));
await user.click(await screen.findByRole('button', { name: 'Борис · Отправить' }));
await screen.findByRole('button', { name: 'Борис · Отправлено' });
expect(api.messages.at(-1)).toMatchObject({ attachment_kind: 'track', attachment_id: albumTracks[0]!.id, recipient: bob.id });
expect(screen.getByRole('button', { name: 'Борис · Отправлено' })).toBeDisabled();
});
});
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) { async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
const api = fakeApi(users); const api = fakeApi(users);
const i18n = await createI18n('ru'); const i18n = await createI18n('ru');
@@ -27,7 +277,7 @@ async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: Fak
<App session={session} router={router} queryClient={queryClient} /> <App session={session} router={router} queryClient={queryClient} />
</I18nextProvider>, </I18nextProvider>,
); );
return { api, router, session, i18n, user: userEvent.setup() }; return { api, router, session, i18n, queryClient, user: userEvent.setup() };
} }
describe('sign-in', () => { describe('sign-in', () => {
@@ -281,6 +531,41 @@ describe('search box', () => {
}); });
describe('player', () => { describe('player', () => {
it.each(['album', 'artist'] as const)('toggles the current %s row without restarting or replacing the listening attempt', async (kind) => {
const alice = makeUser('alice', 'user');
const id = kind === 'artist' ? '00000000-0000-4000-8000-0000000000aa' : '00000000-0000-4000-8000-0000000000bb';
const { user, api } = await renderApp([alice], `/${kind}/${id}`, { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument());
await user.keyboard('{ArrowRight}');
const progress = within(bar).getByRole('slider', { name: 'Позиция' });
expect(progress).toHaveValue('10000');
await user.click(row);
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
expect(progress).toHaveValue('10000');
await user.click(row);
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
expect(progress).toHaveValue('10000');
await user.keyboard('{Enter}');
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
await user.keyboard('{Enter}');
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
expect(progress).toHaveValue('10000');
window.dispatchEvent(new Event('pagehide'));
await waitFor(() => {
const events = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events);
expect(events.filter((event) => event.type === 'play_start')).toHaveLength(1);
expect(events.find((event) => event.type === 'play_start')).toMatchObject({ context_type: kind });
expect(events.some((event) => event.type === 'skip')).toBe(false);
});
const other = screen.getAllByText('Группа крови').map((node) => node.closest('[role="listitem"]')).find(Boolean)!;
await user.click(other);
await waitFor(() => expect(within(bar).getByText('Группа крови')).toBeInTheDocument());
expect(progress).toHaveValue('0');
});
it('opens a shared track after login and copies its page URL from the player', async () => { it('opens a shared track after login and copies its page URL from the player', async () => {
const alice = makeUser('alice', 'user'); const alice = makeUser('alice', 'user');
const path = '/track/00000000-0000-4000-8000-000000000101'; const path = '/track/00000000-0000-4000-8000-000000000101';
@@ -294,7 +579,8 @@ describe('player', () => {
await user.click(screen.getByRole('button', { name: 'Слушать' })); await user.click(screen.getByRole('button', { name: 'Слушать' }));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' })); await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована'); await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
await screen.findByRole('button', { name: 'Ссылка скопирована' });
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`); expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
}); });
@@ -506,3 +792,57 @@ describe('recommendation preferences', () => {
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null }); expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
}); });
}); });
describe('playlist sharing', () => {
it('copies a stable read-only link and revokes it without exposing editing controls', async () => {
const alice = makeUser('alice', 'user');
const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice });
const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } });
const playlist = created.data!;
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await screen.findByRole('heading', { name: 'Общий микс' });
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
await screen.findByRole('button', { name: 'Ссылка скопирована' });
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Закрыть' }));
const shareId = api.playlists.get(playlist.id)!.share_id!;
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled());
expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2);
expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId);
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
await screen.findByRole('heading', { name: 'Общий микс' });
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Слушать всё' }));
await screen.findByRole('contentinfo', { name: 'Плеер' });
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' }));
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull());
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy());
expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId);
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
await screen.findByRole('alert');
expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument();
});
it('returns to the shared playlist after login without starting playback', async () => {
const bob = makeUser('bob', 'user');
const shareId = '00000000-0000-4000-9001-000000000001';
const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`);
api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId });
await screen.findByRole('heading', { name: 'Вход' });
await user.type(screen.getByLabelText('Логин'), bob.username);
await user.type(screen.getByLabelText('Пароль'), bob.password);
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'От друга' });
expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`);
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
});
});
+25 -1
View File
@@ -9,6 +9,8 @@ import {
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import { lazy } from 'react'; import { lazy } from 'react';
const ServerUpdatesPage = lazy(() => import('../features/server-management/UpdatesPage').then((m) => ({ default: m.UpdatesPage })));
const ServerConfigPage = lazy(() => import('../features/server-management/ConfigPage').then((m) => ({ default: m.ConfigPage })));
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage }))); const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage }))); const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage }))); const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
@@ -24,7 +26,7 @@ import { ConnectPage } from '../pages/ConnectPage';
import { TrackPage } from '../pages/TrackPage'; import { TrackPage } from '../pages/TrackPage';
import { ArtistPage } from '../pages/ArtistPage'; import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage'; import { CollectionPage } from '../pages/CollectionPage';
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage'; import { ActivityPlaylistPage, PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
import { HomePage } from '../pages/HomePage'; import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage'; import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage'; import { LoginPage } from '../pages/LoginPage';
@@ -33,6 +35,8 @@ import { SettingsPage } from '../pages/SettingsPage';
import { SetupPage } from '../pages/SetupPage'; import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell'; import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError'; import { RouteError } from '../widgets/RouteError';
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
import { SocialPage } from '../features/social/Social';
export interface RouterContext { export interface RouterContext {
session: AuthSession; session: AuthSession;
@@ -144,8 +148,14 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
}; };
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage }); const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string; tab?: 'messages' | 'people' | 'requests' } => ({ ...(typeof s.peer === 'string' ? { peer: s.peer } : {}), ...(s.tab === 'messages' || s.tab === 'people' || s.tab === 'requests' ? { tab: s.tab } : {}) }), component: () => { const search = socialRoute.useSearch(); return <SocialPage peer={search.peer} initialTab={search.tab} />; } });
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> }); const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage }); const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
const downloadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/downloads', component: DesktopDownloadsPage,
beforeLoad: ({ context }) => { if (!context.desktop) throw redirect({ to: '/settings' }); },
});
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> }); const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage }); const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
@@ -269,6 +279,14 @@ const adminSettingsRoute = createRoute({
component: AdminSettingsPage, component: AdminSettingsPage,
}); });
const serverUpdatesRoute = createRoute({
getParentRoute: () => appRoute, path: '/admin/updates', component: ServerUpdatesPage,
beforeLoad: ({ context }) => {
if (context.user.role !== 'admin' && !context.user.manages_updates) throw redirect({ to: '/' });
},
});
const serverConfigRoute = createRoute({ getParentRoute: () => appRoute, path: '/admin/config', beforeLoad: adminOnly, component: ServerConfigPage });
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
downloadAppRoute, downloadAppRoute,
androidAppRoute, androidAppRoute,
@@ -278,8 +296,12 @@ const routeTree = rootRoute.addChildren([
setupPasswordRoute, setupPasswordRoute,
appRoute.addChildren([ appRoute.addChildren([
homeRoute, homeRoute,
activityPlaylistRoute,
socialRoute,
trackRoute, trackRoute,
playlistsRoute, playlistsRoute,
downloadsRoute,
sharedPlaylistRoute,
playlistRoute, playlistRoute,
settingsRoute, settingsRoute,
searchRoute, searchRoute,
@@ -297,6 +319,8 @@ const routeTree = rootRoute.addChildren([
myUploadsRoute, myUploadsRoute,
uploadQueueRoute, uploadQueueRoute,
adminSettingsRoute, adminSettingsRoute,
serverUpdatesRoute,
serverConfigRoute,
]), ]),
]); ]);
+18 -1
View File
@@ -1,12 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthSession, type AuthUser } from '@pipu/core';
import { createDesktop, isDesktop } from './desktop'; import { createDesktop, desktopFetch, isDesktop } from './desktop';
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
describe('desktop bridge', () => { describe('desktop bridge', () => {
it('restores the last desktop user when the proxy cannot reach the server', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
const user = { id: 'alice', username: 'alice' } as AuthUser;
const clear = vi.fn();
const session = new AuthSession({ clientType: 'desktop', fetch: desktopFetch, baseUrl: 'http://local', refreshStore: {
get: async () => 'refresh', set: vi.fn(), clear, getUser: async () => user,
} });
expect(await session.restore()).toEqual({ status: 'signed_in', offline: true, user });
expect(clear).not.toHaveBeenCalled();
});
it('reports a failed proxy hop as a network failure, preserving real HTTP errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
await expect(desktopFetch('http://local/api')).rejects.toThrow('Server unavailable');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('denied', { status: 401 })));
expect((await desktopFetch('http://local/api')).status).toBe(401);
});
it('is inactive in the browser', () => { it('is inactive in the browser', () => {
expect(isDesktop()).toBe(false); expect(isDesktop()).toBe(false);
}); });
+9 -1
View File
@@ -14,11 +14,19 @@ export function isDesktop(): boolean {
return api() !== undefined; return api() !== undefined;
} }
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> { /** A failed loopback hop must behave like a network failure to offline auth restore. */
export const desktopFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (response.headers.get('x-pipu-transport-error') === '1') throw new TypeError('Server unavailable');
return response;
};
export function desktopInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const tauri = api(); const tauri = api();
if (!tauri) throw new Error('Tauri API unavailable'); if (!tauri) throw new Error('Tauri API unavailable');
return tauri.core.invoke(cmd, args) as Promise<T>; return tauri.core.invoke(cmd, args) as Promise<T>;
} }
const invoke = desktopInvoke;
export type DesktopHandle = { export type DesktopHandle = {
proxyBase: string; proxyBase: string;
+18 -8
View File
@@ -1,16 +1,17 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { Link } from '@tanstack/react-router'; import { Link, useNavigate } from '@tanstack/react-router';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react'; import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists'; import { AddToPlaylist } from './playlists';
import { SaveOfflineButton } from '../features/desktop/DesktopFeatures';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context'; import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player'; import type { PlayContext } from '../player/player';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons'; import { albumIcon, likeFillIcon, likeIcon, pauseIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui'; import { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css'; import s from './catalog.module.css';
@@ -70,15 +71,19 @@ export function Cover({
} }
/** Исполнители через запятую, каждый — ссылка. */ /** Исполнители через запятую, каждый — ссылка. */
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) { export function ArtistLinks({ artists, hideUrlPreview = false }: { artists: ArtistRef[]; hideUrlPreview?: boolean }) {
const navigate = useNavigate();
return ( return (
<> <>
{artists.map((a, i) => ( {artists.map((a, i) => (
<Fragment key={a.id}> <Fragment key={a.id}>
{i > 0 && ', '} {i > 0 && ', '}
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}> {hideUrlPreview ? <button type="button" role="link" className={s.link}
onClick={() => void navigate({ to: '/artist/$id', params: { id: a.id } })}>
{a.name} {a.name}
</Link> </button> : <Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
{a.name}
</Link>}
</Fragment> </Fragment>
))} ))}
</> </>
@@ -138,7 +143,7 @@ export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
/** /**
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома). * Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры. * Клик или Enter запускает список с выбранного трека; у текущего трека переключает паузу без перезапуска.
*/ */
export function TrackList({ export function TrackList({
tracks, tracks,
@@ -178,7 +183,11 @@ export function TrackList({
const row = (tr: Track, i: number) => { const row = (tr: Track, i: number) => {
const isCurrent = current?.id === tr.id; const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context); const play = () => {
if (!tr.available) return;
if (isCurrent) player.toggle();
else player.playList(tracks, i, context);
};
const liked = tr.reaction === 'like'; const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i); const meta = rowMeta?.(tr, i);
return ( return (
@@ -201,7 +210,7 @@ export function TrackList({
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />} {numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && ( {tr.available && (
<span className={s.playHint} aria-hidden> <span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} /> <Icon src={isCurrent && playing ? pauseIcon : playIcon} size={20} />
</span> </span>
)} )}
</span> </span>
@@ -218,6 +227,7 @@ export function TrackList({
</Link> </Link>
)} )}
<span className={s.end} data-playlist-actions={actions ? true : undefined}> <span className={s.end} data-playlist-actions={actions ? true : undefined}>
{!actions && <SaveOfflineButton track={tr} compact />}
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />} {actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
{disliked ? ( {disliked ? (
<button <button
+7 -4
View File
@@ -3,7 +3,7 @@ import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { memo, useCallback, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession, useUser } from '../app/session';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui'; import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { playlistAddIcon } from '../shared/ui/icons'; import { playlistAddIcon } from '../shared/ui/icons';
@@ -18,10 +18,13 @@ export function usePlaylists(enabled = true) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) }); queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
} }
export function usePlaylist(id: string) { export function usePlaylist(id: string, shared = false) {
const session = useSession(); const session = useSession();
return useQuery({ queryKey: ['playlists', id], const user = useUser();
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) }); return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
queryFn: async () => shared
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
: unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
} }
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } { export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
@@ -0,0 +1,147 @@
import { releaseNotesText, type Track } from '@pipu/core';
import { useEffect, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession } from '../../app/session';
import { desktopInvoke, useDesktop } from '../../desktop';
import { TrackList } from '../../entities/catalog';
import { usePlayer, usePlayerState } from '../../player/context';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import { formatBytes } from '../../shared/lib/format';
import { checkIcon, downloadIcon } from '../../shared/ui/icons';
import s from '../../pages/page.module.css';
export function useOffline() {
const offline = usePlayer().offline;
useSyncExternalStore(offline?.subscribe ?? (() => () => undefined), offline?.snapshot ?? (() => 0));
return offline;
}
export function SaveOfflineButton({ track, compact = false }: { track: Track; compact?: boolean }) {
const offline = useOffline();
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
if (!offline) return null;
return <span>
<Button busy={busy} disabled={!track.available} icon={offline.pinned(track.id) ? checkIcon : downloadIcon}
aria-label={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}
title={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')} onClick={() => {
setBusy(true); setError(false);
void (offline.pinned(track.id) ? offline.remove(track.id) : offline.save(track, true))
.catch(() => setError(true)).finally(() => setBusy(false));
}}>{compact ? null : t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}</Button>
{error && <span role="alert">{t('desktop.download_failed')}</span>}
</span>;
}
export function DesktopOfflineSection() {
const offline = useOffline();
const { track } = usePlayerState();
const { t, i18n } = useTranslation();
const [error, setError] = useState(false);
useEffect(() => { void offline?.ready.catch(() => setError(true)); }, [offline]);
if (!offline) return null;
const tracks = offline.tracks();
return <section className={s.section}>
<h2>{t('desktop.offline')}</h2>
<p className={s.note}>{t('desktop.cache_hint')}</p>
<div className={s.toolbar}>
<span>{formatBytes(offline.bytes, i18n.language)}</span>
<Button onClick={() => void offline.clearRecent().catch(() => setError(true))}>{t('desktop.clear_recent')}</Button>
{track && <SaveOfflineButton track={track} />}
</div>
<FormError>{error ? t('desktop.storage_failed') : null}</FormError>
{tracks.length > 0 ? <TrackList tracks={tracks} context={{ type: 'collection', id: 'offline' }} actions={(track) => <SaveOfflineButton track={track} compact />} /> : <p>{t('desktop.offline_empty')}</p>}
</section>;
}
export function DesktopDownloadsPage() {
const { t } = useTranslation();
return <div className={s.page}><h1>{t('desktop.offline')}</h1><DesktopOfflineSection /></div>;
}
export function DesktopOfflineBanner() {
const state = useAuthState();
const desktop = useDesktop();
const { t } = useTranslation();
return desktop && state.status === 'signed_in' && state.offline ? <p role="status">{t('mobile.offline')}</p> : null;
}
type Release = { build: { version: string; size: number }; notes: string };
type Update = { status: 'idle' | 'checking' | 'latest' | 'error' | 'available' | 'installing' | 'ready'; release?: Release; dismissed?: boolean; progress?: number };
let update: Update = { status: 'idle' };
let generation = 0;
const listeners = new Set<() => void>();
function setUpdate(value: Update) { update = value; for (const fn of listeners) fn(); }
const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; };
function useUpdate() { return useSyncExternalStore(subscribe, () => update); }
async function checkUpdate(server: string, manual = false) {
if (update.status === 'checking' || update.status === 'installing') return;
const key = `pipu.update.checked.${server}`;
if (!manual && Date.now() - Number(localStorage.getItem(key) ?? 0) < 6 * 60 * 60 * 1000) return;
setUpdate({ status: 'checking' });
const requestGeneration = generation;
try {
const release = await desktopInvoke<Release | null>('update_check');
if (requestGeneration !== generation) return;
localStorage.setItem(key, String(Date.now()));
setUpdate(release ? { status: 'available', release } : { status: manual ? 'latest' : 'idle' });
} catch { if (requestGeneration === generation) setUpdate({ status: manual ? 'error' : 'idle' }); }
}
export function DesktopUpdateButton() {
const desktop = useDesktop();
const state = useUpdate();
const { t } = useTranslation();
if (!desktop?.server) return null;
return <section className={s.section}>
<h2>{t('desktop.updates')}</h2>
<Button busy={state.status === 'checking'} disabled={state.status === 'installing'} onClick={() => void checkUpdate(desktop.server!, true)}>{t('mobile.update_check')}</Button>
{state.status === 'latest' && <p role="status">{t('mobile.update_latest')}</p>}
{state.status === 'error' && !state.release && <FormError>{t('desktop.check_failed')}</FormError>}
</section>;
}
export function DesktopUpdateDialog() {
const desktop = useDesktop();
const session = useSession();
const state = useUpdate();
const { t } = useTranslation();
useEffect(() => {
if (!desktop?.server) return;
const check = () => void checkUpdate(desktop.server!);
check();
window.addEventListener('focus', check);
return () => { window.removeEventListener('focus', check); generation++; setUpdate({ status: 'idle' }); };
}, [desktop, session]);
useEffect(() => {
if (!desktop) return;
let stop: (() => void) | undefined;
let disposed = false;
// The native updater emits progress through the same Tauri event surface as media controls.
const api = (window as Window & { __TAURI__?: { event: { listen: (name: string, fn: (e: { payload: string }) => void) => Promise<() => void> } } }).__TAURI__;
void api?.event.listen('update-progress', (e) => {
const progress = Number(e.payload);
if (update.status === 'installing' && Number.isFinite(progress)) setUpdate({ ...update, progress: Math.min(1, Math.max(0, progress)) });
}).then((fn) => { if (disposed) fn(); else stop = fn; });
return () => { disposed = true; stop?.(); };
}, [desktop]);
if (!desktop || !state.release || state.dismissed) return null;
const release = state.release;
const busy = state.status === 'installing';
const close = () => { if (!busy) setUpdate({ ...state, dismissed: true }); };
const notes = releaseNotesText(release.notes);
return <Dialog title={t('mobile.update_title', { version: release.build.version })} onClose={close} footer={<>
<Button disabled={busy} onClick={close}>{t('mobile.update_later')}</Button>
<Button busy={busy} onClick={() => {
setUpdate({ status: 'installing', release, progress: 0 });
void desktopInvoke('update_install').then(() => setUpdate({ status: 'ready', release })).catch(() => setUpdate({ status: 'error', release }));
}}>{t('mobile.update_install')}</Button>
</>}>
{notes && <p style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '35vh', overflowY: 'auto' }}>{notes}</p>}
<p>{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}</p>
{busy && <p role="status">{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}</p>}
{state.status === 'ready' && <p role="status">{t('desktop.installer_open')}</p>}
{state.status === 'error' && <FormError>{t('mobile.update_failed')}</FormError>}
</Dialog>;
}
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AuthSession, PlayEvent } from '@pipu/core';
import { desktopEventSink, evictRecent, offlineOwner } from './offline';
import { createDesktop } from '../../desktop';
import { albumTracks } from '../../test/fakeApi';
afterEach(() => {
localStorage.clear();
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
});
describe('desktop cache policy', () => {
it('evicts oldest recent files while keeping manual downloads outside the limit', () => {
const entry = (id: string, used: number, pinned = false) => ({ id, used, pinned, track: albumTracks[0]!, audio: { size: 60 } as Blob });
expect(evictRecent([entry('new', 3), entry('manual', 0, true), entry('old', 1), entry('middle', 2)], 100)).toEqual(['old', 'middle']);
});
});
it('persists offline events across sink restarts and deduplicates retries', async () => {
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke: async () => ({ proxyBase: 'http://local', server: 'https://music.example' }) } };
await createDesktop();
const post = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue({ data: {}, response: new Response('{}') });
const session = { state: { status: 'signed_in', user: { id: 'alice' } }, client: { POST: post } } as unknown as AuthSession;
const owner = offlineOwner(session)!;
const event: PlayEvent = { id: 'one', type: 'play_start', occurred_at: new Date().toISOString(), track_id: 'track' };
await expect(desktopEventSink(session, owner)([event])).rejects.toThrow('offline');
await desktopEventSink(session, owner)([event]);
expect(post.mock.calls[1]?.[1].body.events).toEqual([event]);
expect(JSON.parse(localStorage.getItem(`pipu.events.${owner}`)!)).toEqual([]);
});
+181
View File
@@ -0,0 +1,181 @@
import { unwrap, type AuthSession, type PlayEvent, type Track } from '@pipu/core';
import { desktopHandle } from '../../desktop';
export const RECENT_LIMIT = 500 * 1024 * 1024;
type Entry = { id: string; track: Track; audio: Blob; cover?: Blob; pinned: boolean; used: number };
const size = (e: Entry) => e.audio.size + (e.cover?.size ?? 0);
export function evictRecent(entries: Entry[], limit = RECENT_LIMIT): string[] {
let total = entries.filter((e) => !e.pinned).reduce((n, e) => n + size(e), 0);
const removed: string[] = [];
for (const e of entries.filter((e) => !e.pinned).sort((a, b) => a.used - b.used)) {
if (total <= limit) break;
removed.push(e.id);
total -= size(e);
}
return removed;
}
export function offlineOwner(session: AuthSession): string | null {
const desktop = desktopHandle();
return desktop?.server && session.state.status === 'signed_in'
? JSON.stringify([desktop.server, session.state.user.id]) : null;
}
/** WebView IndexedDB is persistent across desktop restarts; signed URLs are never stored. */
export class DesktopOffline {
private db: Promise<IDBDatabase> | null = null;
private loaded: Promise<void> | null = null;
private entries: Entry[] = [];
private urls = new Map<string, Track>();
private objects = new Map<string, string[]>();
private inUse = new Set<string>();
private listeners = new Set<() => void>();
private revision = 0;
private writes: Promise<unknown> = Promise.resolve();
private disposed = false;
get ready(): Promise<void> {
this.loaded ??= this.reload();
return this.loaded;
}
activate() { this.disposed = false; }
constructor(readonly owner: string, private session: AuthSession) {}
private database(): Promise<IDBDatabase> {
return this.db ??= new Promise((resolve, reject) => {
const request = indexedDB.open(`pipu.offline.${this.owner}`, 1);
request.onupgradeneeded = () => request.result.createObjectStore('tracks', { keyPath: 'id' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
snapshot = () => this.revision;
private async reload() {
const db = await this.database();
const entries = await new Promise<Entry[]>((resolve, reject) => {
const req = db.transaction('tracks').objectStore('tracks').getAll();
req.onsuccess = () => resolve(req.result as Entry[]);
req.onerror = () => reject(req.error);
});
if (this.disposed) return;
this.entries = entries;
for (const e of entries) {
if (this.urls.has(e.id)) continue;
const audio = URL.createObjectURL(e.audio);
const cover = e.cover ? URL.createObjectURL(e.cover) : null;
this.objects.set(e.id, [...(this.objects.get(e.id) ?? []), audio, ...(cover ? [cover] : [])]);
this.urls.set(e.id, { ...e.track, stream_url: audio, available: true, album: { ...e.track.album, cover_url: cover } });
}
for (const id of this.urls.keys()) if (!entries.some((e) => e.id === id)) {
this.urls.delete(id);
if (!this.inUse.has(id)) this.release(id);
}
this.revision++;
for (const fn of this.listeners) fn();
}
tracks() { return this.entries.map((e) => this.local(e.track)); }
private release(id: string) {
for (const url of this.objects.get(id) ?? []) URL.revokeObjectURL(url);
this.objects.delete(id);
}
/** Keep only files currently playing/crossfading alive after cache deletion. */
keep(ids: string[]) {
this.inUse = new Set(ids);
for (const id of this.objects.keys()) if (!this.inUse.has(id) && !this.urls.has(id)) this.release(id);
}
local(track: Track): Track { return this.urls.get(track.id) ?? track; }
has(id: string) { return this.entries.some((e) => e.id === id); }
pinned(id: string) { return this.entries.some((e) => e.id === id && e.pinned); }
get bytes() { return this.entries.reduce((n, e) => n + size(e), 0); }
private mutate(fn: (store: IDBObjectStore) => void): Promise<void> {
const work = this.writes.catch(() => undefined).then(async () => {
const db = await this.database();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('tracks', 'readwrite');
fn(tx.objectStore('tracks'));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
await this.reload();
});
this.writes = work;
return work;
}
private downloads = new Map<string, Promise<void>>();
private downloadTail: Promise<unknown> = Promise.resolve();
async save(track: Track, pinned = false): Promise<void> {
await this.ready;
const pending = this.downloads.get(track.id);
if (pending) { await pending; return this.save(track, pinned); }
const existing = this.entries.find((e) => e.id === track.id);
if (existing) {
await this.mutate((store) => {
const latest = this.entries.find((e) => e.id === track.id);
if (latest) store.put({ ...latest, pinned: latest.pinned || pinned, used: Date.now() });
});
return;
}
const job = this.downloadTail.catch(() => undefined).then(() => this.download(track, pinned));
this.downloadTail = job;
this.downloads.set(track.id, job);
try { await job; } finally { this.downloads.delete(track.id); }
}
private async download(track: Track, pinned: boolean) {
if (this.disposed) return;
const fresh = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })).items[0];
if (!fresh?.available || !fresh.stream_url) throw new Error('Track unavailable');
const response = await fetch(fresh.stream_url);
if (!response.ok) throw new Error('Download failed');
const audio = await response.blob();
if (!audio.size) throw new Error('Empty download');
let cover: Blob | undefined;
if (fresh.album.cover_url) {
try {
const response = await fetch(`${fresh.album.cover_url}&size=300`);
if (response.ok) cover = await response.blob();
} catch { /* Artwork is optional. */ }
}
if (this.disposed) return;
const entry: Entry = { id: track.id, track: { ...fresh, stream_url: null, album: { ...fresh.album, cover_url: null } }, audio, cover, pinned, used: Date.now() };
await this.mutate((store) => {
const removed = evictRecent([...this.entries.filter((e) => e.id !== entry.id), entry]);
for (const id of removed) store.delete(id);
if (!removed.includes(entry.id)) store.put(entry);
});
}
async remove(id: string) { await this.mutate((store) => store.delete(id)); }
async clearRecent() { await this.mutate((store) => { for (const e of this.entries) if (!e.pinned) store.delete(e.id); }); }
dispose() {
this.disposed = true;
queueMicrotask(() => {
if (!this.disposed) return;
for (const id of this.objects.keys()) this.release(id);
void this.downloadTail.catch(() => undefined).then(() => this.writes)
.finally(() => this.db?.then((db) => db.close())).catch(() => undefined);
});
}
}
/** Persist before sending; retries keep event IDs for server-side deduplication. */
export function desktopEventSink(session: AuthSession, owner: string) {
const key = `pipu.events.${owner}`;
let sending = Promise.resolve();
return async (events: PlayEvent[]) => {
const work = sending.catch(() => undefined).then(async () => {
const old = JSON.parse(localStorage.getItem(key) ?? '[]') as PlayEvent[];
const merged = [...new Map([...old, ...events].map((e) => [e.id, e])).values()];
localStorage.setItem(key, JSON.stringify(merged));
if (offlineOwner(session) !== owner) return;
while (merged.length) {
if (offlineOwner(session) !== owner) return;
const batch = merged.slice(0, 100);
unwrap(await session.client.POST('/api/v1/events', { body: { events: batch } }));
merged.splice(0, batch.length);
localStorage.setItem(key, JSON.stringify(merged));
}
});
sending = work;
await work;
};
}
@@ -0,0 +1,95 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError, PageSkeleton } from '../../shared/ui/ui';
import { JobPanel } from './JobPanel';
import s from './management.module.css';
export function ConfigPage() {
const { t } = useTranslation('admin');
const session = useSession();
const errorText = useErrorText();
const qc = useQueryClient();
const [draft, setDraft] = useState<Record<string, string | null>>({});
const [baseRevision, setBaseRevision] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [group, setGroup] = useState('all');
const [confirm, setConfirm] = useState(false);
const [saved, setSaved] = useState(false);
const dirty = Object.keys(draft).length > 0;
const query = useQuery({ queryKey: ['server-config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/config')), retry: false });
const status = useQuery({ queryKey: ['server-updates', 'config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates')), refetchInterval: 3000, retry: false });
const running = status.data?.job?.state === 'running';
useEffect(() => {
if (!dirty) return;
const prevent = (e: BeforeUnloadEvent) => { e.preventDefault(); };
window.addEventListener('beforeunload', prevent);
return () => window.removeEventListener('beforeunload', prevent);
}, [dirty]);
useEffect(() => {
if (status.data?.job?.state === 'succeeded') void qc.invalidateQueries({ queryKey: ['server-config'] });
}, [status.data?.job?.id, status.data?.job?.state, qc]);
const save = useMutation({
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/server/config', { body: { revision: baseRevision ?? query.data!.revision, values: draft } })),
onSuccess: (data) => { qc.setQueryData(['server-config'], data); setDraft({}); setBaseRevision(null); setSaved(true); },
});
const apply = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/config/apply')),
onSuccess: (job) => {
setConfirm(false);
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', 'config'], (old) => old ? { ...old, job } : old);
void qc.invalidateQueries({ queryKey: ['server-updates'] });
},
});
const change = (key: string, value: string | null) => {
const entry = query.data!.entries.find((v) => v.key === key)!;
const unchanged = value === null ? entry.local === null
: entry.secret ? value === '' : value === (entry.local ?? '');
const next = { ...draft };
if (unchanged) delete next[key]; else next[key] = value;
setBaseRevision(Object.keys(next).length ? baseRevision ?? query.data!.revision : null);
setDraft(next); setSaved(false); save.reset();
};
if (query.isPending) return <PageSkeleton />;
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
return <div className={s.page}>
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.config')}</h1><p>{t('management.config_intro')}</p></div></header>
{query.error && <FormError>{errorText(query.error)}</FormError>}
{query.data && <>
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.priority')}</span><strong className={s.priority}>ENV → config.local → {t('management.defaults')}</strong></div><p>{t('management.priority_hint')}</p></section>
<div className={s.toolbar}><Field type="search" label={t('management.search')} value={search} onChange={setSearch} /><label className={s.selectLabel}>{t('management.group')}<select value={group} onChange={(e) => setGroup(e.target.value)}><option value="all">{t('management.all')}</option>{[...new Set(query.data.entries.map((v) => v.group))].map((v) => <option key={v} value={v}>{t(`management.groups.${v}`, { defaultValue: v })}</option>)}</select></label></div>
<p className={s.note}>{t('management.config_files_hint')}</p>
<div className={s.configList}>{entries.map((entry) => {
const changed = Object.hasOwn(draft, entry.key);
const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? '';
const display = (stored: string | null) => entry.secret && stored ? '••••••••' : stored;
return <section className={s.configRow} key={entry.key} data-changed={changed || undefined}>
<div className={s.configMeta}><code>{entry.key}</code><span className={s.tag}>{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}</span><span className={s.note}>{t(`management.value_source.${entry.source}`)}</span></div>
<div className={s.values}><span>{t('management.effective')}: <code>{display(entry.effective) || '—'}</code></span><span>ENV: <code>{display(entry.env) ?? '—'}</code></span></div>
{entry.readonly ? <p className={s.note}>{t('management.readonly')}</p> : <div className={s.editor}>
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} autoComplete={entry.secret ? 'new-password' : 'off'} value={value} disabled={running || save.isPending} placeholder={entry.secret ? entry.local ? t('management.secret_saved') : '' : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
<Button disabled={running || save.isPending || (!changed && entry.local === null)} onClick={() => change(entry.key, null)}>{t('management.reset')}</Button>
</div>}
</section>;
})}</div>
{!entries.length && <p className={s.note}>{t('management.no_results')}</p>}
{saved && !dirty && <p className={s.note} role="status">{t('management.saved')}</p>}
{dirty && <section className={s.saveBar} aria-label={t('management.unsaved_panel')}>
<div role="status">{t('management.unsaved', { count: Object.keys(draft).length })}</div>
<div className={s.actions}><Button disabled={save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button></div>
</section>}
{!dirty && query.data.restart_required && !running && <section className={s.saveBar} aria-label={t('management.pending_restart')}>
<div role="status">{t('management.pending_restart')}</div>
<Button busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button>
</section>}
{save.error && <FormError>{errorText(save.error)}</FormError>}
{apply.error && <FormError>{errorText(apply.error)}</FormError>}
<JobPanel job={status.data?.job} reconnecting={status.isError} />
</>}
{confirm && <Dialog title={t('management.apply')} onClose={() => !apply.isPending && setConfirm(false)}><p>{t('management.apply_warning')}</p>{apply.error && <FormError>{errorText(apply.error)}</FormError>}<div className={s.actions}><Button disabled={apply.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={apply.isPending} onClick={() => apply.mutate()}>{t('management.apply')}</Button></div></Dialog>}
</div>;
}
@@ -0,0 +1,33 @@
import type { components } from '@pipu/api-client';
import { useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import s from './management.module.css';
export type Job = components['schemas']['ServerJob'];
export function JobPanel({ job, reconnecting }: { job: Job | null | undefined; reconnecting?: boolean }) {
const { t } = useTranslation('admin');
const logRef = useRef<HTMLPreElement>(null);
const followLog = useRef(true);
useLayoutEffect(() => {
followLog.current = true;
}, [job?.id]);
useLayoutEffect(() => {
const log = logRef.current;
if (log && followLog.current) log.scrollTop = log.scrollHeight;
}, [job?.id, job?.log]);
if (!job) return null;
return <section className={s.card} aria-label={t('management.job')}>
<div className={s.cardHead}>
<div><span className={s.eyebrow}>{t('management.job')}</span><h2>{job.target}</h2></div>
<span className={s.status} data-state={job.state} role="status">{t(`management.state.${job.state}`)}</span>
</div>
<p className={s.note}>{t('management.started')}: {new Date(job.started_at).toLocaleString()}
{job.finished_at && <> · {t('management.finished')}: {new Date(job.finished_at).toLocaleString()}</>}</p>
{job.state === 'running' && <p className={s.note}>{t(reconnecting ? 'management.reconnecting' : 'management.running_hint')}</p>}
<pre ref={logRef} className={s.log} tabIndex={0} aria-label={t('management.log')} onScroll={(event) => {
const log = event.currentTarget;
followLog.current = log.scrollHeight - log.clientHeight - log.scrollTop <= 4;
}}>{job.log || t('management.waiting')}</pre>
</section>;
}
@@ -0,0 +1,109 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError, PageSkeleton, Segmented } from '../../shared/ui/ui';
import { JobPanel } from './JobPanel';
import s from './management.module.css';
type Source = components['schemas']['ServerSource'];
type Channel = components['schemas']['ServerUpdateRequest']['channel'];
export function UpdatesPage() {
const { t } = useTranslation('admin');
const session = useSession();
const qc = useQueryClient();
const errorText = useErrorText();
const [sourceId, setSourceId] = useState('');
const [channel, setChannel] = useState<Channel>('latest');
const [version, setVersion] = useState('');
const [confirm, setConfirm] = useState(false);
const [editing, setEditing] = useState<Source[] | null>(null);
const fresh = useRef(false);
const queryKey = ['server-updates', sourceId, channel, version];
const query = useQuery({
queryKey,
queryFn: async () => {
const force = fresh.current;
fresh.current = false;
return unwrap(await session.client.GET('/api/v1/admin/server/updates', {
params: { query: { source_id: sourceId || undefined, channel, version: channel === 'version' ? version : undefined, fresh: force } },
}));
},
placeholderData: keepPreviousData,
refetchInterval: (q) => q.state.data?.job?.state === 'running' ? 3000 : 60000,
retry: false,
});
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
const running = query.data?.job?.state === 'running';
const checking = query.isFetching;
const availability = query.isError ? 'unavailable' : query.data?.update_state || 'unknown';
const canInstall = availability === 'available' && !checking && !query.isPlaceholderData && !query.isError && !running;
const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] });
const save = useMutation({
mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })),
onSuccess: () => { setEditing(null); setSourceId(''); void refresh(); },
});
const start = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', {
body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined, expected_commit: query.data!.target_commit },
})),
onSuccess: (job) => {
setConfirm(false);
qc.setQueryData<components['schemas']['ServerUpdates']>(queryKey, (old) => old ? { ...old, job } : old);
void refresh();
},
onError: () => { void refresh(); },
});
if (query.isPending) return <PageSkeleton />;
return <div className={s.page}>
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div>
<Button onClick={() => { fresh.current = true; void query.refetch(); }} busy={query.isFetching}>{t('management.check_updates')}</Button>
</header>
{query.error && <FormError>{errorText(query.error)}</FormError>}
{query.data && <>
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.current')}</span><strong>{query.data.current_version || '—'}</strong></div><p>{t('management.manual')}</p></section>
<section className={s.card}>
<div className={s.cardHead}><h2>{t('management.update_target')}</h2><Button disabled={running} onClick={() => { save.reset(); setEditing(query.data.sources.map((v) => ({ ...v }))); }}>{t('management.sources')}</Button></div>
<label className={s.selectLabel}>{t('management.source')}
<select aria-label={t('management.source')} value={source?.id ?? ''} disabled={running || start.isPending} onChange={(e) => { setSourceId(e.target.value); setVersion(''); start.reset(); }}>
{query.data.sources.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</label>
<p className={s.url}>{source?.url}</p>
<Segmented label={t('management.channel')} value={channel} options={(['latest', 'version', 'main'] as const).map((value) => ({ value, label: t(`management.channel_${value}`) }))} onChange={(v) => { setChannel(v); start.reset(); }} />
<p className={s.note}>{t(`management.hint_${channel}`)}</p>
{channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>}
{query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>}
{start.error && <FormError>{errorText(start.error)}</FormError>}
<div className={s.updateAction}>
<div role="status" aria-live="polite">
<strong>{t(`management.availability.${checking ? 'checking' : running ? 'running' : availability}`)}</strong>
{query.data.target_version && <p className={s.note}>{query.data.target_version} · {query.data.target_commit.slice(0, 12)}</p>}
</div>
{canInstall && <Button variant="primary" disabled={!source || !!query.data.source_error} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button>}
</div>
</section>
<JobPanel job={query.data.job} reconnecting={query.isError} />
</>}
{confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}>
<p>{t('management.confirm_update_text', { source: source?.name, target: query.data?.target_version || version })}</p>
<p className={s.note}>{t('management.update_warning')}</p>
{start.error && <FormError>{errorText(start.error)}</FormError>}
<div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" disabled={!canInstall} busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div>
</Dialog>}
{editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}>
<p className={s.note}>{t('management.sources_hint')}</p>
<div className={s.sourceList}>{editing.map((item, index) => <div className={s.sourceEditor} key={item.id}>
<Field label={t('management.source_name')} value={item.name} maxLength={100} onChange={(name) => setEditing(editing.map((v, i) => i === index ? { ...v, name } : v))} />
<Field label={t('management.repository')} value={item.url} maxLength={2048} mono onChange={(url) => setEditing(editing.map((v, i) => i === index ? { ...v, url } : v))} />
<Button disabled={editing.length === 1 || save.isPending} onClick={() => setEditing(editing.filter((v) => v.id !== item.id))}>{t('management.remove_source')}</Button>
</div>)}</div>
{save.error && <FormError>{errorText(save.error)}</FormError>}
<div className={s.actions}><Button disabled={editing.length >= 20 || save.isPending} onClick={() => setEditing([...editing, { id: crypto.randomUUID(), name: '', url: '' }])}>{t('management.add_source')}</Button><Button variant="primary" disabled={editing.some((v) => !v.name.trim() || !v.url.trim())} busy={save.isPending} onClick={() => save.mutate(editing)}>{t('management.save')}</Button></div>
</Dialog>}
</div>;
}
@@ -0,0 +1,36 @@
.page { width: 100%; min-width: 0; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
.eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--text2); font-weight: 600; }
.hero { display: flex; gap: 24px; align-items: center; justify-content: space-between; padding: 28px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: linear-gradient(120deg, var(--accent-soft), var(--surface-a)); }
.hero strong { display: block; font-family: var(--font-display); font-size: 30px; margin-top: 8px; overflow-wrap: anywhere; }
.hero strong.priority { font-size: 19px; }
.hero p { color: var(--text2); max-width: 340px; line-height: 1.6; font-size: 13px; }
.card { padding: 24px; background: var(--surface-a); border: 1px solid var(--line); border-radius: var(--radius-lg); display: flex; flex-direction: column; gap: 18px; }
.card h2 { margin: 0; font-size: 18px; }
.selectLabel { display: flex; flex-direction: column; gap: 8px; font-size: 13px; font-weight: 500; }
.selectLabel select { height: 42px; padding: 0 12px; font: inherit; color: var(--text); border: 1px solid var(--line); border-radius: 10px; background: var(--surface2); max-width: 100%; }
.selectLabel select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.url { color: var(--text2); font: 12px monospace; overflow-wrap: anywhere; margin: -8px 0 0; }
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.status, .tag { padding: 5px 10px; border: 1px solid var(--line); border-radius: 99px; color: var(--text2); font-size: 12px; }
.status[data-state='running'] { background: var(--accent-soft); color: var(--accent); }
.status[data-state='succeeded'] { color: var(--ok); }
.status[data-state='failed'] { color: var(--danger); }
.log { margin: 0; padding: 18px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); max-height: 380px; min-height: 100px; overflow: auto; font-size: 12px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
.sourceList { display: flex; flex-direction: column; gap: 16px; max-height: 50vh; overflow: auto; margin-bottom: 18px; }
.sourceEditor { display: flex; flex-direction: column; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; }
.toolbar { display: grid; grid-template-columns: 1fr 220px; gap: 16px; align-items: end; }
.configList { display: flex; flex-direction: column; gap: 12px; }
.configRow { padding: 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-a); display: flex; flex-direction: column; gap: 14px; }
.configRow[data-changed] { border-color: var(--accent); }
.configMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.configMeta > code { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
.values { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--text2); font-size: 12px; }
.values code { overflow-wrap: anywhere; }
.editor { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
.saveBar { position: sticky; bottom: 12px; z-index: 2; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); box-shadow: 0 8px 32px #0003; font-size: 13px; }
@media (max-width: 650px) { .hero { flex-direction: column; align-items: flex-start; gap: 10px; padding: 20px; } .hero p { max-width: none; } .toolbar { grid-template-columns: 1fr; } .editor { grid-template-columns: 1fr; } .card, .configRow { padding: 16px; } .actions { justify-content: flex-start; } }
.updateAction { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 16px; padding-top: 16px; border-top: 1px solid var(--line); font-size: 14px; }
@@ -0,0 +1,132 @@
import { CHAT_REACTIONS, chatDraftKey, uuidv7, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useDesktop } from '../../desktop';
import { useErrorText } from '../../shared/lib/errors';
import { arrowBackIcon, arrowDownIcon, arrowUpIcon, chatIcon, closeIcon, moreIcon, musicIcon, searchIcon, smileIcon } from '../../shared/ui/icons';
import { Button, EmptyState, Field, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
import { ListenTogetherButton } from './Listening';
import { ChatMusic, MessageText } from './ChatMusic';
import { Presence } from './Presence';
import s from './social.module.css';
export function ChatConversation({ person, api, queryKey, userId, active = true, onBack, actions }: {
person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; active?: boolean; onBack?: () => void; actions?: ReactNode;
}) {
const { t, i18n } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const server = useDesktop()?.server ?? window.location.origin;
const draftKey = chatDraftKey(server, userId, person.id);
const [text, setText] = useState(() => { try { return localStorage.getItem(draftKey) ?? ''; } catch { return ''; } });
const [reply, setReply] = useState<DirectMessage | null>(null);
const [editing, setEditing] = useState<DirectMessage | null>(null);
const [view, setView] = useState<'conversation' | 'music'>('conversation');
const [searchOpen, setSearchOpen] = useState(false);
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [emojis, setEmojis] = useState(false);
const [atBottom, setAtBottom] = useState(true);
const beforeEdit = useRef('');
const pending = useRef<{ id: string; text: string; reply_to?: string } | null>(null);
const typedAt = useRef(0);
const composer = useRef<HTMLTextAreaElement>(null);
const scroll = useRef<HTMLDivElement>(null);
const previousLast = useRef<number | undefined>(undefined);
const [visible, setVisible] = useState(document.visibilityState === 'visible');
const music = view === 'music';
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 250); return () => clearTimeout(timer); }, [search]);
useEffect(() => {
if (editing) return;
try { if (text) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* Storage may be unavailable in private mode. */ }
}, [text, draftKey, editing]);
useEffect(() => {
const timer = setTimeout(() => { void api.typing(person.id, active && visible && !music && !!text.trim() && !editing && Date.now() - typedAt.current < 6000).catch(() => undefined); }, 400);
return () => clearTimeout(timer);
}, [text, active, visible, editing, music, person.id, api]);
useEffect(() => () => { void api.typing(person.id, false).catch(() => undefined); }, [api, person.id]);
useEffect(() => { if (composer.current) { composer.current.style.height = 'auto'; composer.current.style.height = `${Math.min(composer.current.scrollHeight, 144)}px`; } }, [text, music, active]);
const meta = useQuery({ queryKey: [...queryKey, 'typing', person.id], queryFn: () => api.chatMeta(person.id), enabled: active && visible && !music, refetchInterval: active && visible && !music ? 2000 : false });
const messages = useInfiniteQuery({ queryKey: [...queryKey, 'chat', person.id, q, music], initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) => api.messages(person.id, pageParam, q, music), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined,
enabled: active && visible, refetchInterval: active && visible ? 3000 : false });
const list = [...(messages.data?.pages ?? [])].reverse().flat();
const last = list.at(-1)?.seq;
const firstUnread = list.find(m => m.sender_id !== userId && !m.read)?.id;
useEffect(() => {
const el = scroll.current;
if (el && active && !q && !music && (atBottom || previousLast.current === undefined)) el.scrollTop = el.scrollHeight;
previousLast.current = last;
}, [last, active, q, music]);
useEffect(() => {
const el = scroll.current;
const latestVisible = el && el.scrollHeight - el.scrollTop - el.clientHeight < 64;
if (last && firstUnread && latestVisible && visible && active && !q && !music && atBottom) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey })).catch(() => undefined);
}, [last, firstUnread, visible, active, q, music, atBottom, api, person.id, cache, messages.dataUpdatedAt]);
const refresh = () => cache.invalidateQueries({ queryKey });
const update = useMutation({ mutationFn: (input: { id: string; body: ChatMessageUpdate }) => api.updateMessage(person.id, input.id, input.body), onSuccess: refresh });
const send = useMutation({ mutationFn: async () => {
if (editing) { await api.updateMessage(person.id, editing.id, { action: 'edit', text }); return; }
if (!pending.current || pending.current.text !== text || pending.current.reply_to !== reply?.id) pending.current = { id: uuidv7(), text, ...(reply ? { reply_to: reply.id } : {}) };
await api.send(person.id, { ...pending.current, attachment_kind: 'none' });
}, onSuccess: async () => { setText(editing ? beforeEdit.current : ''); setEditing(null); setReply(null); pending.current = null; setAtBottom(true); await refresh(); if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; composer.current?.focus(); } });
const cancelEdit = () => { setText(beforeEdit.current); setEditing(null); };
const mutate = (id: string, body: ChatMessageUpdate) => { if (!update.isPending) update.mutate({ id, body }); };
const changeView = (next: typeof view) => { setView(next); setSearch(''); setQ(''); setSearchOpen(false); previousLast.current = undefined; setAtBottom(true); };
const older = async () => {
const el = scroll.current;
const height = el?.scrollHeight ?? 0;
const top = el?.scrollTop ?? 0;
await messages.fetchNextPage();
requestAnimationFrame(() => { if (el) el.scrollTop = top + el.scrollHeight - height; });
};
return <section className={s.chat} aria-label={t('social.chat')}>
<header className={s.chatHeader}>
{onBack && <Button className={`${s.iconButton} ${s.backButton}`} variant="ghost" icon={arrowBackIcon} aria-label={t('social.back_to_messages')} onClick={onBack} />}
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
<div className={s.chatIdentity}><h2>{person.display_name}</h2><Presence online={person.online} /></div>
<div className={s.chatTools}><ListenTogetherButton person={person} /><Button variant="ghost" className={s.iconButton} icon={searchIcon} aria-label={t('social.chat_search')} aria-expanded={searchOpen} onClick={() => { setSearchOpen(v => !v); setSearch(''); setQ(''); }} />{actions}</div>
</header>
{person.activity && <div className={s.nowListening}><Icon src={musicIcon} size={18} /><div><span>{t('social.listening')}</span><Link to="/track/$id" params={{ id: person.activity.track_id }}>{person.activity.track_title}{person.activity.artist ? ` · ${person.activity.artist}` : ''}</Link>{person.activity.context === 'playlist' && person.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: person.id }}>{t('social.from_playlist', { name: person.activity.playlist_name ?? '' })}</Link>}</div></div>}
<nav className={s.chatTabs} aria-label={t('social.conversation_content')}><button type="button" aria-pressed={!music} onClick={() => changeView('conversation')}><Icon src={chatIcon} size={16} />{t('social.conversation')}</button><button type="button" aria-pressed={music} onClick={() => changeView('music')}><Icon src={musicIcon} size={16} />{t('social.shared_music')}</button></nav>
{searchOpen && <div className={s.chatSearch}><Field label={t('social.chat_search')} value={search} onChange={setSearch} type="search" maxLength={100} autoFocus /><Button variant="ghost" icon={closeIcon} className={s.iconButton} aria-label={t('social.close_search')} onClick={() => { setSearchOpen(false); setSearch(''); setQ(''); }} /></div>}
<FormError>{messages.error || send.error || update.error ? errorText(messages.error ?? send.error ?? update.error) : null}</FormError>
<div ref={scroll} className={`${s.messages} ${music ? s.musicMessages : ''}`} onScroll={e => { const el = e.currentTarget; setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 64); }}>
{messages.hasNextPage && <Button className={s.older} busy={messages.isFetchingNextPage} onClick={() => void older()}>{t('social.older')}</Button>}
{messages.isPending && <div className={s.chatSkeleton} role="status" aria-label={t('social.loading')}><Skeleton style={{ height: 70, width: '70%' }} /><Skeleton style={{ height: 46, width: '55%', alignSelf: 'end' }} /><Skeleton style={{ height: 80, width: '65%' }} /></div>}
{!list.length && messages.isSuccess && <EmptyState icon={music ? musicIcon : chatIcon}>{t(q ? 'social.chat_no_results' : music ? 'social.no_shared_music' : 'social.first_message')}</EmptyState>}
{list.map((m, i) => <Fragment key={m.id}>
{(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && <time className={s.date}>{new Date(m.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'long', year: 'numeric' })}</time>}
{m.id === firstUnread && !q && !music && <div className={s.newMessages}>{t('social.new_messages')}</div>}
<article id={`message-${m.id}`} className={`${s.bubble} ${m.sender_id === userId ? s.mine : ''} ${m.deleted ? s.deleted : ''} ${music ? s.mediaBubble : ''}`}>
{m.reply_to && !music && <blockquote className={s.quote}>{m.reply_preview || t('social.message_deleted')}</blockquote>}
{(m.deleted || m.text && m.attachment_kind === 'none') && <div className={s.messageText}>{m.deleted ? t('social.message_deleted') : <MessageText text={m.text} />}</div>}
{!m.deleted && m.attachment_id && m.attachment_kind !== 'none' && <ChatMusic message={m} scope={queryKey} />}
{!m.deleted && !!m.reactions?.length && <div className={s.reactions}>{m.reactions.map(r => <button key={r.emoji} type="button" aria-pressed={r.mine} disabled={update.isPending} onClick={() => mutate(m.id, { action: 'react', emoji: r.emoji as typeof CHAT_REACTIONS[number] })}>{r.emoji} {r.count}</button>)}</div>}
<div className={s.messageFoot}><small className={s.muted}>{new Date(m.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })}{m.edited_at && !m.deleted ? ` · ${t('social.edited')}` : ''}{m.sender_id === userId ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</small>
{!m.deleted && <Menu portal label={t('social.message_actions')} trigger={<Icon src={moreIcon} size={18} />} items={[
{ label: t('social.reply'), onSelect: () => { changeView('conversation'); if (editing) cancelEdit(); setReply(m); requestAnimationFrame(() => composer.current?.focus()); } },
...CHAT_REACTIONS.map(emoji => ({ label: emoji, onSelect: () => mutate(m.id, { action: 'react', emoji }) })),
...(m.sender_id === userId ? [{ label: t('social.edit_message'), onSelect: () => { changeView('conversation'); beforeEdit.current = text; setEditing(m); setReply(null); setText(m.text); requestAnimationFrame(() => composer.current?.focus()); } }, { label: t('social.delete_message'), danger: true, onSelect: () => mutate(m.id, { action: 'delete' }) }] : []),
]} />}
</div>
</article>
</Fragment>)}
{send.isPending && !!text.trim() && !editing && !music && !list.some(m => m.id === pending.current?.id) && <article className={`${s.bubble} ${s.mine} ${s.sending}`} role="status">{text} · …</article>}
</div>
{!atBottom && !music && <Button className={s.jumpToBottom} icon={arrowDownIcon} onClick={() => { if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; setAtBottom(true); }}>{t('social.latest_messages')}</Button>}
{!music && <>
<div className={s.typing} role="status" aria-live="polite">{active && meta.data?.typing ? <><span className={s.typingDots} aria-hidden><i /><i /><i /></span>{person.display_name} {t('social.typing')}</> : null}</div>
{(reply || editing) && <div className={s.replyBar}><div><small>{t(editing ? 'social.editing' : 'social.replying')}</small><p>{(editing ?? reply)?.text}</p></div><Button variant="ghost" icon={closeIcon} className={s.iconButton} onClick={() => editing ? cancelEdit() : setReply(null)} aria-label={t(editing ? 'social.cancel_edit' : 'social.cancel_reply')} /></div>}
{emojis && <div className={s.emojiPicker}>{CHAT_REACTIONS.map(emoji => <button key={emoji} type="button" aria-label={emoji} onClick={() => { setText(v => (v + emoji).slice(0, 4000)); setEmojis(false); composer.current?.focus(); }}>{emoji}</button>)}</div>}
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
<Button variant="ghost" icon={smileIcon} className={s.iconButton} aria-label={t('social.emoji')} aria-expanded={emojis} onClick={() => setEmojis(v => !v)} />
<textarea ref={composer} aria-label={t('social.message')} placeholder={t('social.message_placeholder')} maxLength={4000} rows={1} value={text} disabled={send.isPending} onChange={e => { typedAt.current = Date.now(); setText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); } }} />
<Button type="submit" variant="primary" className={s.iconButton} icon={arrowUpIcon} aria-label={t(editing ? 'playlists.save' : 'social.send')} busy={send.isPending} disabled={!text.trim()} />
</form>
</>}
</section>;
}
@@ -0,0 +1,33 @@
import { createI18n } from '@pipu/i18n';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest';
import { MessageText } from './ChatMusic';
afterEach(cleanup);
describe('message text', () => {
it('compacts a long URL while preserving its full destination when text is collapsed', async () => {
const i18n = await createI18n('ru');
const url = `https://example.com/music/${'long-path-'.repeat(90)}?share=abc`;
render(<I18nextProvider i18n={i18n}><MessageText text={`Послушай ${url}`} /></I18nextProvider>);
expect(screen.getByRole('link')).toHaveAttribute('href', url);
expect(screen.getByRole('link').textContent!.length).toBeLessThan(65);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Показать полностью' }));
expect(screen.getByRole('link')).toHaveAttribute('href', url);
expect(screen.getByRole('button', { name: 'Свернуть текст' })).toHaveAttribute('aria-expanded', 'true');
});
it('keeps encoded text and markup literal, with expansion for long messages', async () => {
const i18n = await createI18n('ru');
const text = `<script>alert(1)</script> ${'%D0%BC%20'.repeat(100)}`;
const { container } = render(<I18nextProvider i18n={i18n}><MessageText text={text} /></I18nextProvider>);
expect(container.querySelector('script')).toBeNull();
expect(container.textContent).toContain('%D0%BC%20');
expect(screen.queryByRole('link')).not.toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: 'Показать полностью' }));
expect(container.textContent).toContain(text);
});
});
@@ -0,0 +1,63 @@
import { unwrap, type DirectMessage } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { useSession } from '../../app/session';
import { Cover } from '../../entities/catalog';
import { usePlayer, usePlayerState } from '../../player/context';
import { formatDuration } from '../../shared/lib/format';
import { musicIcon, pauseIcon, playIcon, queueIcon } from '../../shared/ui/icons';
import { Button, Icon } from '../../shared/ui/ui';
import s from './social.module.css';
/** Catalog reads stay in the client adapter; message text is never decoded into metadata. */
export function ChatMusic({ message, scope }: { message: DirectMessage; scope: readonly unknown[] }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const state = usePlayerState();
const id = message.attachment_id!;
const track = useQuery({ queryKey: [...scope, 'attachment', 'track', id], staleTime: 60_000, retry: false,
enabled: message.attachment_kind === 'track', queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })) });
const playlist = useQuery({ queryKey: [...scope, 'attachment', 'playlist', id], retry: false,
enabled: message.attachment_kind === 'playlist', queryFn: async () => unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } })) });
const isTrack = message.attachment_kind === 'track';
const query = isTrack ? track : playlist;
const tr = !track.isError ? track.data : undefined;
const pl = !playlist.isError ? playlist.data : undefined;
const metadataTitle = isTrack ? tr?.title : pl?.name;
const title = metadataTitle || message.text || t(isTrack ? 'social.shared_track' : 'social.shared_playlist');
const album = isTrack ? tr?.album : pl?.tracks[0]?.album;
const current = !!tr && tr.id === state.track?.id;
const available = !query.isError && (isTrack ? tr?.available : pl?.tracks.some(x => x.available));
const subtitle = query.isError ? t('social.attachment_unavailable') : isTrack ? tr ? `${tr.artists.map(a => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}` : t('social.shared_track') : t('social.shared_playlist');
return <><div className={`${s.musicCard} ${query.isError ? s.musicUnavailable : ''}`}>
{album ? <Cover album={album} size={48} /> : <span className={s.musicCover}><Icon src={isTrack ? musicIcon : queueIcon} size={24} /></span>}
<div className={s.musicInfo}>
<Link to={isTrack ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id, shareId: id }} aria-label={t(isTrack ? 'social.listen_track' : 'social.listen_playlist')} title={title}>{title}</Link>
<span>{subtitle}</span>
</div>
<Button variant="ghost" className={s.iconButton} icon={current && state.playing ? pauseIcon : playIcon} aria-label={t(current && state.playing ? 'player.pause' : 'catalog.play')} disabled={!available || state.listening?.locked} onClick={() => {
if (isTrack && tr) { if (current) player.toggle(); else player.playList([tr], 0, { type: 'queue' }); }
else if (pl) player.playList(pl.tracks.filter(x => x.available), 0, { type: 'queue' });
}} />
</div>{message.text && message.text.trim() !== metadataTitle?.trim() && <span className={s.attachmentCaption}><MessageText text={message.text} /></span>}</>;
}
/** Compact only actual HTTP(S) URLs. Arbitrary encoded text remains literal. */
export function MessageText({ text }: { text: string }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const long = text.length > 600;
const shown = long && !expanded ? `${text.slice(0, 400)}…` : text;
const originalParts = text.split(/(https?:\/\/[^\s<>]+)/g);
return <>{shown.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) => {
if (!/^https?:\/\//.test(part)) return part;
try {
const original = originalParts[i] ?? part;
const url = new URL(original);
return <a key={i} href={url.href} target="_blank" rel="noopener noreferrer" title={original}>{url.hostname}{url.pathname === '/' ? '' : `${url.pathname.slice(0, 34)}${url.pathname.length > 34 || url.search ? '…' : ''}`}</a>;
} catch { return part; }
})}{long && <button type="button" className={s.expandText} aria-expanded={expanded} onClick={() => setExpanded(v => !v)}>{t(expanded ? 'social.show_less' : 'social.show_more')}</button>}</>;
}
@@ -0,0 +1,63 @@
import { socialApi, uuidv7, type ListeningPreferences, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
import { usePlayer, usePlayerState } from '../../player/context';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import { waveHeadphonesIcon } from '../../shared/ui/icons';
import s from './social.module.css';
function useListening() {
const session = useSession(); const user = useUser(); const server = useDesktop()?.server ?? '';
const api = useMemo(() => socialApi(session), [session]);
const key = ['listening', server, user.id] as const;
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
return { api, key, query, user };
}
export function ListeningPanel({ showPreferences = true, showRequests = true }: { showPreferences?: boolean; showRequests?: boolean }) {
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
const { api, key, query, user } = useListening();
const prefs = query.data?.preferences;
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const action = useMutation({ mutationFn: (input: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: uuidv7(), action: input.action, target_id: input.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <div className={s.stack}>
{showPreferences && <details className={s.privacy}><summary>{t('social.together')}</summary><p className={s.muted}>{t('social.stream_prefs_hint')}</p>
{(['requests', 'auto_accept'] as const).map(field => <fieldset key={field} className={s.privacy} disabled={!prefs || save.isPending}><legend>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(prefs?.[field] ?? 'nobody') === v} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => prefs && save.mutate({ ...prefs, [field]: v })}>{t(`social.visibility_${v}`)}</Button>)}</div></fieldset>)}
<label><input type="checkbox" checked={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onChange={e => prefs && save.mutate({ ...prefs, autoplay: e.target.checked })} /> {t('social.stream_autoplay')}</label>
</details>}
{showRequests && query.data?.requests.map(r => <article key={r.id} className={s.person}><strong>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</strong><div className={s.actions}>{r.recipient_id === user.id ? <><Button disabled={action.isPending} onClick={() => action.mutate({ action: 'accept', target: r.id })}>{t('social.accept')}</Button><Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'decline', target: r.id })}>{t('social.decline')}</Button></> : <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'cancel', target: r.id })}>{t('form.cancel')}</Button>}</div></article>)}
<FormError>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</FormError>
</div>;
}
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
const { t } = useTranslation(); const [open, setOpen] = useState(false);
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
return <><Button disabled={!person.online} title={!person.online ? t('social.together_offline') : undefined} variant="ghost" icon={waveHeadphonesIcon} aria-label={t('social.together')} onClick={() => setOpen(true)}><span className={s.togetherLabel}>{t('social.together')}</span></Button>{open && person.online && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
}
function ListenTogetherDialog({ person, close }: { person: SocialPerson; close: () => void }) {
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
const { api, key } = useListening(); const state = usePlayerState();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
const [id] = useState(uuidv7);
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
</fieldset><p className={s.muted}>{t('social.stream_hint')}</p>
<FormError>{action.error ? errorText(action.error) : null}</FormError>
{action.isSuccess ? <p role="status">{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</p> : <Button variant="primary" busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onClick={() => action.mutate()}>{t('social.send')}</Button>}
</Dialog>;
}
export function ListeningBanner() {
const { t } = useTranslation(); const state = usePlayerState(); const player = usePlayer();
const session = useSession(); const errorText = useErrorText(); const room = state.listening?.room;
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: uuidv7(), action: room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'end' : 'leave', target_id: room.id }) });
if (!room) return null;
return <section className={s.stream} aria-label={t('social.together')}><div><strong>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</strong><p className={s.muted}>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</p><details><summary>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</summary>{room.participants.map(p => <div key={p.id}>{p.name}</div>)}</details></div>
<div className={s.actions}>{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button onClick={() => player.beginListeningAudio()}>{t('social.stream_start_audio')}</Button>}<Button busy={action.isPending} variant="ghost" onClick={() => action.mutate()}>{t(room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'social.stream_end' : 'social.stream_leave')}</Button></div>
<FormError>{action.error ? errorText(action.error) : null}</FormError>
</section>;
}
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react';
import { useRouterState, useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Button, EmptyState, FormError, Icon } from '../../shared/ui/ui';
import { chatIcon, closeIcon, groupIcon, searchIcon } from '../../shared/ui/icons';
import { useErrorText } from '../../shared/lib/errors';
import { ChatConversation } from './ChatConversation';
import { Presence } from './Presence';
import { useSocialDialogOpen } from './Social';
import { useSocial } from './queries';
import s from './social.module.css';
/** Lives in the shell so browsing and minimizing never change the selected conversation. */
export function MiniSocial() {
const { t } = useTranslation();
const { api, inbox, key, user } = useSocial();
const errorText = useErrorText();
const navigate = useNavigate();
const pathname = useRouterState({ select: state => state.location.pathname });
const dialogOpen = useSocialDialogOpen();
const [desktop, setDesktop] = useState(() => window.matchMedia?.('(min-width: 1024px)').matches ?? false);
const [open, setOpen] = useState(false);
const [peer, setPeer] = useState('');
const [search, setSearch] = useState('');
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLElement>(null);
const shown = desktop && pathname !== '/friends';
useEffect(() => {
const media = window.matchMedia?.('(min-width: 1024px)');
if (!media) return;
const change = () => setDesktop(media.matches);
media.addEventListener('change', change);
return () => media.removeEventListener('change', change);
}, []);
useEffect(() => {
if (open && shown) panel.current?.querySelector<HTMLInputElement>('input')?.focus();
}, [open, shown]);
const friends = (inbox.data?.people ?? []).filter(person => person.relationship === 'friend');
const selected = friends.find(person => person.id === peer);
const unread = friends.reduce((sum, person) => sum + person.unread, 0);
const filtered = [...friends].sort((a, b) => Number(b.online) - Number(a.online) || (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name))
.filter(person => `${person.display_name} ${person.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
const minimize = () => { setOpen(false); trigger.current?.focus(); };
if (!shown) return null;
return <aside className={`${s.miniDock} ${user.ui_prefs.web_player === 'island' ? s.miniDockIsland : ''}`} aria-label={t('social.mini_chat')}>
<section hidden={!open} ref={panel} id="mini-social-panel" className={s.miniPanel} aria-label={t('social.title')}
onKeyDown={event => { if (event.key === 'Escape' && !event.defaultPrevented && !dialogOpen) { event.stopPropagation(); minimize(); } }}>
<header className={s.miniHeader}>
<strong>{t('social.title')}</strong>
<Button variant="ghost" onClick={() => { setOpen(false); void navigate({ to: '/friends', search: selected ? { peer: selected.id, tab: 'messages' } : {} }); }}>{t('social.open_full')}</Button>
<Button variant="ghost" icon={closeIcon} aria-label={t('social.minimize_chat')} onClick={minimize} />
</header>
<FormError>{inbox.error ? errorText(inbox.error) : null}</FormError>
<div className={s.miniWorkspace}>
<div className={s.miniFriends}>
<label className={s.listSearch}><Icon src={searchIcon} size={16} /><input aria-label={t('social.find_conversation')} placeholder={t('social.find_conversation')} value={search} onChange={event => setSearch(event.target.value)} /></label>
{inbox.isPending && <p className={s.muted}>{t('social.loading')}</p>}
{inbox.isSuccess && !filtered.length && <p className={s.muted}>{t(search ? 'social.no_results' : 'social.no_friends_share')}</p>}
{filtered.map(person => <button key={person.id} type="button" className={`${s.dialogRow} ${peer === person.id ? s.selected : ''}`} aria-pressed={peer === person.id} onClick={() => setPeer(person.id)}>
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
<span className={s.dialogBody}><strong>{person.display_name}</strong><Presence online={person.online} /></span>
{person.unread > 0 && <span className={s.badge}>{person.unread}</span>}
</button>)}
</div>
{selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={open && !dialogOpen} />
: <div className={s.welcome}><EmptyState icon={groupIcon}>{t('social.choose_chat')}</EmptyState></div>}
</div>
</section>
<button ref={trigger} type="button" className={s.miniTrigger} aria-label={t('social.mini_chat')} title={t('social.mini_chat')} aria-expanded={open} aria-controls="mini-social-panel" onClick={() => setOpen(value => !value)}>
<Icon src={open ? closeIcon : chatIcon} size={22} />
{unread > 0 && <span className={s.badge}>{unread > 99 ? '99+' : unread}</span>}
</button>
</aside>;
}
@@ -0,0 +1,44 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../../shared/lib/errors';
import { checkIcon, chevronRightIcon, inboxIcon } from '../../shared/ui/icons';
import { Dialog, EmptyState, FormError, Icon, Skeleton } from '../../shared/ui/ui';
import { useSocial } from './queries';
import s from './notifications.module.css';
export function NotificationsDialog({ onClose, onOpen }: { onClose: () => void; onOpen: (id: string) => void }) {
const { t, i18n } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [unreadOnly, setUnreadOnly] = useState(false);
const read = useMutation({ mutationFn: (id: string) => api.action({ action: 'read_notification', target_id: id }), onSuccess: () => cache.invalidateQueries({ queryKey: key }) });
const events = inbox.data?.notifications ?? [];
const unread = events.filter(event => !event.read).length;
const shown = unreadOnly ? events.filter(event => !event.read) : events;
return <Dialog title={t('social.notifications')} onClose={onClose} className={s.dialog}>
<nav className={s.filters} aria-label={t('social.notifications')}>
<button type="button" aria-pressed={!unreadOnly} onClick={() => setUnreadOnly(false)}>{t('social.notifications_all')}</button>
<button type="button" aria-pressed={unreadOnly} onClick={() => setUnreadOnly(true)}>{t('social.notifications_unread')}{unread > 0 && <span className={s.count}>{unread}</span>}</button>
</nav>
<FormError>{read.error || inbox.error ? errorText(read.error ?? inbox.error) : null}</FormError>
<div className={s.list} tabIndex={0} role="region" aria-label={t('social.notifications')}>
{inbox.isPending && <div className={s.loading} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(value => <Skeleton key={value} style={{ height: 68 }} />)}</div>}
{inbox.isSuccess && !shown.length && <div className={s.empty}><EmptyState icon={unreadOnly ? checkIcon : inboxIcon}>{t(unreadOnly ? 'social.notifications_caught_up' : 'social.no_notifications')}</EmptyState></div>}
{shown.map(event => {
const date = new Date(event.created_at);
return <article key={event.id} className={`${s.row} ${!event.read ? s.unread : ''}`}>
<button type="button" className={s.open} aria-label={t('social.open_notification', { name: event.actor_name })} onClick={() => { if (!event.read) read.mutate(event.id); onOpen(event.actor_id); }}>
<span className={s.avatar} aria-hidden>{event.actor_name.slice(0, 1).toUpperCase()}</span>
<span className={s.content}><strong>{event.actor_name}</strong><span>{t(`social.kind_${event.kind}`)}</span>
<time dateTime={event.created_at} title={date.toLocaleString(i18n.language)}>{date.toLocaleString(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</time>
</span>
<Icon src={chevronRightIcon} size={18} className={s.arrow} />
</button>
{!event.read && <button type="button" className={s.read} disabled={read.isPending} aria-label={t('social.mark_read')} title={t('social.mark_read')} onClick={() => read.mutate(event.id)}><span className={s.dot} /><Icon src={checkIcon} size={18} /></button>}
</article>;
})}
</div>
</Dialog>;
}
@@ -0,0 +1,7 @@
import { useTranslation } from 'react-i18next';
import s from './social.module.css';
export function Presence({ online }: { online: boolean }) {
const { t } = useTranslation();
return <span className={`${s.presence} ${online ? s.presenceOnline : ''}`}><span className={s.presenceDot} aria-hidden />{t(online ? 'social.online' : 'social.offline')}</span>;
}
+210
View File
@@ -0,0 +1,210 @@
import { uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons';
import s from './social.module.css';
import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation';
import { Presence } from './Presence';
import { useSocial } from './queries';
import { NotificationsDialog } from './NotificationsDialog';
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
export function FriendRecommendations() {
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <div><label><input type="checkbox" checked={inbox.data?.friends_recommendations ?? true} disabled={!inbox.data || save.isPending} onChange={e => save.mutate(e.target.checked)} /> {t('social.friends_recommendations')}</label><p className={s.muted}>{t('social.friends_recommendations_hint')}</p><FormError>{save.error ? errorText(save.error) : null}</FormError></div>;
}
type ShareValue = { attachment: MusicAttachment; url: string };
type AccountDialog = 'privacy' | 'notifications' | null;
const AccountDialogContext = createContext<{ dialog: AccountDialog; setDialog: (dialog: AccountDialog) => void }>({ dialog: null, setDialog: () => {} });
export const useSocialDialogOpen = () => useContext(AccountDialogContext).dialog !== null;
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
export const useMusicShare = () => useContext(ShareContext);
export function SocialProvider({ children }: { children: ReactNode }) {
const [share, setShare] = useState<ShareValue | null>(null);
const [dialog, setDialog] = useState<AccountDialog>(null);
return <AccountDialogContext.Provider value={{ dialog, setDialog }}><ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider></AccountDialogContext.Provider>;
}
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
const { t } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [done, setDone] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const ids = useRef(new Map<string, string>());
const action = useMutation({ mutationFn: async (peer: string) => {
if (peer === 'copy') { await navigator.clipboard.writeText(value.url); setCopied(true); return; }
if (peer === 'external') {
try { await navigator.share({ title: value.attachment.title, url: value.url }); }
catch (error) { if (!(error instanceof DOMException && error.name === 'AbortError')) throw error; }
return;
}
let id = ids.current.get(peer); if (!id) { id = uuidv7(); ids.current.set(peer, id); }
await api.send(peer, { id, text: value.attachment.title, attachment_kind: value.attachment.kind, attachment_id: value.attachment.id });
setDone(old => [...old, peer]);
await cache.invalidateQueries({ queryKey: key });
} });
return <Dialog title={t('player.share')} onClose={close}>
<div className={s.stack}><strong>{value.attachment.title}</strong>
<Button busy={action.isPending && action.variables === 'copy'} onClick={() => action.mutate('copy')}>{t(copied ? 'social.copied' : 'social.copy')}</Button>
{!!navigator.share && <Button busy={action.isPending && action.variables === 'external'} onClick={() => action.mutate('external')}>{t('social.external')}</Button>}
<h3>{t('social.send_friend')}</h3>
{inbox.isPending && <p>{t('social.loading')}</p>}
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <p>{t('social.no_friends_share')}</p>}
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} disabled={done.includes(p.id) || action.isPending} onClick={() => action.mutate(p.id)}>{p.display_name} · {t(done.includes(p.id) ? 'social.sent' : 'social.send')}</Button>)}
<FormError>{action.error || inbox.error ? errorText(action.error ?? inbox.error) : null}</FormError>
</div>
</Dialog>;
}
export function useSocialProfileMenu() {
const { t } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const navigate = useNavigate();
const errorText = useErrorText();
const { dialog, setDialog } = useContext(AccountDialogContext);
const closeDialog = useCallback(() => setDialog(null), [setDialog]);
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const people = inbox.data?.people ?? [];
const currentVisibility = inbox.data?.activity_visibility ?? 'nobody';
const open = (id: string) => {
const relationship = people.find(p => p.id === id)?.relationship;
const tab = relationship === 'friend' ? 'messages' : relationship === 'incoming' || relationship === 'outgoing' ? 'requests' : 'people';
setDialog(null);
void navigate({ to: '/friends', search: { peer: id, tab } });
};
return {
items: [
{ label: t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings'), icon: currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon, onSelect: () => setDialog('privacy') },
{ label: t('social.notifications'), badge: inbox.data?.unread, icon: inboxIcon, onSelect: () => setDialog('notifications') },
],
dialogs: <>
{dialog === 'privacy' && <Dialog title={t('social.privacy_settings')} onClose={closeDialog} className={s.preferencesDialog}>
<fieldset className={s.privacy} disabled={!inbox.data} aria-busy={visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-disabled={visibility.isPending} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => { if (!visibility.isPending) visibility.mutate(v); }}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset>
<FormError>{visibility.error || action.error || inbox.error ? errorText(visibility.error ?? action.error ?? inbox.error) : null}</FormError>
<ListeningPanel showRequests={false} />
{people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => action.mutate({ action: op, target_id: p.id })} />)}</section>}
</Dialog>}
{dialog === 'notifications' && <NotificationsDialog onClose={closeDialog} onOpen={open} />}
</>,
};
}
type SocialTab = 'messages' | 'people' | 'requests';
type ConfirmAction = { person: SocialPerson; action: 'remove' | 'block' };
export function SocialPage({ peer = '', initialTab }: { peer?: string; initialTab?: SocialTab }) {
const { dialog } = useContext(AccountDialogContext);
const { t, i18n } = useTranslation();
const { api, inbox, key, user } = useSocial();
const cache = useQueryClient();
const navigate = useNavigate();
const errorText = useErrorText();
const [tab, setTab] = useState<SocialTab>('messages');
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLElement>(null);
useEffect(() => {
const media = window.matchMedia?.('(max-width: 767px)');
if (!media) return;
const change = () => setNarrow(media.matches);
media.addEventListener('change', change);
return () => media.removeEventListener('change', change);
}, []);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: tab === 'people' && q.length >= 2 });
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { setConfirm(null); await cache.invalidateQueries({ queryKey: key }); } });
const people = inbox.data?.people ?? [];
const friends = people.filter(p => p.relationship === 'friend');
const conversations = [...friends].sort((a, b) => (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name));
useEffect(() => { if (initialTab) setTab(initialTab); }, [peer, initialTab]);
const initialPeer = conversations[0]?.id;
useEffect(() => {
// Pin the initial desktop selection: a newer message in another dialog must
// never switch the conversation the user is reading or composing in.
if (!narrow && !peer && initialPeer) void navigate({ to: '/friends', search: { peer: initialPeer }, replace: true });
}, [narrow, peer, initialPeer, navigate]);
const selected = friends.find(p => p.id === peer) ?? (!peer ? conversations[0] : undefined);
const requests = people.filter(p => p.relationship === 'incoming' || p.relationship === 'outgoing');
const incoming = requests.filter(p => p.relationship === 'incoming').length;
const messageUnread = friends.reduce((sum, p) => sum + p.unread, 0);
const filtered = (tab === 'messages' ? conversations : requests).filter(p => `${p.display_name} ${p.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
const shownPeople = tab === 'people' ? q.length >= 2 ? results.data ?? [] : friends : filtered;
const open = (id: string) => {
const person = people.find(p => p.id === id);
setSearch(''); setQ('');
setTab(person?.relationship === 'friend' ? 'messages' : person?.relationship === 'incoming' || person?.relationship === 'outgoing' ? 'requests' : 'people');
void navigate({ to: '/friends', search: { peer: id } });
};
const findPeople = () => { setTab('people'); setSearch(''); setQ(''); requestAnimationFrame(() => searchRef.current?.focus()); };
const back = () => { void navigate({ to: '/friends', search: {} }).then(() => listRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')?.focus()); };
const act = (person: SocialPerson, op: SocialAction['action']) => {
if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op });
else action.mutate({ action: op, target_id: person.id });
};
const listLabel = t(tab === 'messages' ? 'social.find_conversation' : tab === 'requests' ? 'social.find_request' : 'social.search');
return <div className={`${s.page} ${tab === 'messages' && peer && selected ? s.conversationOpen : ''}`}>
<header className={s.pageHeader}>
<h1>{t('social.title')}</h1>
</header>
<ListeningPanel showPreferences={false} />
<section className={s.hub}>
<nav className={s.tabs} aria-label={t('social.title')}>
{(['messages', 'people', 'requests'] as const).map(value => <button key={value} type="button" aria-pressed={tab === value} onClick={() => { setTab(value); setSearch(''); setQ(''); }}>{t(`social.${value}`)}{(value === 'requests' ? incoming : value === 'messages' ? messageUnread : 0) > 0 && <span className={s.badge}>{value === 'requests' ? incoming : messageUnread}</span>}</button>)}
<Button className={s.addFriend} variant="ghost" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button>
</nav>
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
<div className={tab === 'messages' ? s.workspace : s.directory}>
<aside ref={listRef} className={s.peopleList} aria-label={t(tab === 'messages' ? 'social.messages' : 'social.people')}>
<div className={s.listSearch}><Icon src={searchIcon} size={18} /><input ref={searchRef} type="search" aria-label={listLabel} placeholder={listLabel} value={search} maxLength={80} onChange={e => setSearch(e.target.value)} /></div>
{inbox.isPending && <div className={s.stack} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(i => <Skeleton key={i} style={{ height: 64 }} />)}</div>}
{tab === 'people' && q.length >= 2 && results.isFetching && <p className={s.muted} role="status">{t('social.loading')}</p>}
{!shownPeople.length && inbox.data && !results.isFetching && <EmptyState icon={tab === 'requests' ? inboxIcon : groupIcon} action={!search && tab !== 'people' ? <Button onClick={findPeople}>{t('social.find_people')}</Button> : undefined}>{t(search ? 'social.no_results' : tab === 'requests' ? 'social.no_requests' : 'social.empty')}</EmptyState>}
{shownPeople.map(p => tab === 'messages' ? <button key={p.id} type="button" className={`${s.dialogRow} ${selected?.id === p.id ? s.selected : ''}`} aria-pressed={selected?.id === p.id} onClick={() => open(p.id)}>
<span className={`${s.avatar} ${p.online ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span>
<span className={s.dialogBody}><span className={s.dialogTitle}><strong>{p.display_name}</strong>{p.last_message && <time>{new Date(p.last_message.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'short' })}</time>}</span><span className={s.preview}>{p.last_message ? `${p.last_message.sender_id === user.id ? `${t('social.you')}: ` : ''}${p.last_message.deleted ? t('social.message_deleted') : p.last_message.attachment_kind !== 'none' ? t(p.last_message.attachment_kind === 'track' ? 'social.shared_track' : 'social.shared_playlist') : p.last_message.text}` : t('social.start_conversation')}</span><Presence online={p.online} />{p.activity && <span className={s.listeningPreview}><Icon src={musicIcon} size={13} />{p.activity.track_title}</span>}</span>
{p.unread > 0 && <span className={s.badge}>{p.unread}</span>}
</button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)}
</aside>
{tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !dialog && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)}
</div>
</section>
{confirm && <Dialog title={t(confirm.action === 'block' ? 'social.confirm_block' : 'social.confirm_remove', { name: confirm.person.display_name })} onClose={() => { if (!action.isPending) setConfirm(null); }} footer={<><Button disabled={action.isPending} onClick={() => setConfirm(null)}>{t('form.cancel')}</Button><Button variant="danger" busy={action.isPending} onClick={() => action.mutate({ action: confirm.action, target_id: confirm.person.id })}>{t(`social.${confirm.action}`)}</Button></>}><p className={s.muted}>{t(confirm.action === 'block' ? 'social.block_hint' : 'social.remove_hint')}</p><FormError>{action.error ? errorText(action.error) : null}</FormError></Dialog>}
</div>;
}
function FriendMenu({ person, act }: { person: SocialPerson; act: (a: SocialAction['action']) => void }) {
const { t } = useTranslation();
return <Menu label={t('social.person_actions', { name: person.display_name })} trigger={<Icon src={moreIcon} />} items={[
...(person.relationship === 'friend' ? [{ label: t('social.remove'), danger: true, onSelect: () => act('remove') }] : []),
{ label: t(person.relationship === 'blocked' ? 'social.unblock' : 'social.block'), danger: person.relationship !== 'blocked', onSelect: () => act(person.relationship === 'blocked' ? 'unblock' : 'block') },
]} />;
}
function Person({ person: p, busy, open, act, selected }: { person: SocialPerson; busy: boolean; selected?: boolean; open: () => void; act: (a: SocialAction['action']) => void }) {
const { t } = useTranslation();
return <article className={`${s.person} ${selected ? s.selected : ''}`}>
<div className={s.identity}><span className={`${s.avatar} ${p.online ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><div><strong>{p.display_name}</strong><div className={s.muted}>@{p.username} · {t(`social.rel_${p.relationship}`)}</div>{p.relationship === 'friend' && <Presence online={p.online} />}</div></div>
{p.activity && <div className={s.activity}><span className={s.listening}><Icon src={musicIcon} size={16} />{t('social.listening')}</span><Link to="/track/$id" params={{ id: p.activity.track_id }}>{p.activity.track_title}</Link>{p.activity.artist && <span className={s.muted}>{p.activity.artist}</span>}{p.activity.context === 'playlist' && p.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: p.id }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Link>}</div>}
<div className={s.actions}>
{p.relationship === 'friend' && <><Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button><ListenTogetherButton person={p} /></>}
{p.relationship === 'none' && <Button disabled={busy} onClick={() => act('request')}>{t('social.add')}</Button>}
{p.relationship === 'incoming' && <Button disabled={busy} variant="primary" onClick={() => act('accept')}>{t('social.accept')}</Button>}
{['incoming', 'outgoing'].includes(p.relationship) && <Button disabled={busy} variant="ghost" onClick={() => act('remove')}>{t(p.relationship === 'incoming' ? 'social.decline' : 'social.cancel')}</Button>}
<FriendMenu person={p} act={act} />
</div>
</article>;
}
@@ -0,0 +1,40 @@
.dialog { width: 600px; max-height: min(680px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
.dialog > :first-child { flex-shrink: 0; padding: 20px 24px 16px; }
.dialog > :nth-child(2) { display: flex; flex-direction: column; gap: 0; min-height: 0; padding: 0; overflow: hidden; }
.filters { display: flex; gap: 6px; flex-shrink: 0; padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
.filters button { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 12px; border-radius: 9px; color: var(--text2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
.filters button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
.filters button:hover { background: var(--hover); color: var(--text); }
.count { font-size: 11px; font-variant-numeric: tabular-nums; color: var(--accent2); }
.list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line2) transparent; padding: 8px 12px 12px; }
.row { position: relative; display: flex; align-items: center; border-radius: 12px; }
.row + .row { margin-top: 4px; }
.row.unread { background: var(--accent-soft); }
.row:hover { background: var(--hover); }
.open { display: flex; align-items: center; gap: 14px; flex: 1; min-width: 0; padding: 14px 12px; border-radius: 12px; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
.avatar { display: grid; place-items: center; flex-shrink: 0; width: 40px; height: 40px; border: 1px solid var(--line2); border-radius: 50%; background: var(--surface2); color: var(--accent2); font-size: 14px; font-weight: 600; }
.content { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 4px; overflow-wrap: anywhere; }
.content strong { font-size: 13px; font-weight: 600; }
.content > span { font-size: 13px; color: var(--text2); line-height: 1.45; }
.content time { color: var(--text3); font-size: 11px; line-height: 1.4; }
.arrow { flex-shrink: 0; color: var(--text3); }
.read { position: relative; display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; margin-right: 10px; border-radius: 9px; color: var(--accent2); cursor: pointer; }
.read > svg, .read > span:last-child { opacity: 0; }
.dot { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); }
.read:hover, .read:focus-visible { background: var(--accent-soft); }
.read:hover .dot, .read:focus-visible .dot { opacity: 0; }
.read:hover > :last-child, .read:focus-visible > :last-child { opacity: 1; }
.read:disabled { opacity: .5; cursor: wait; }
.open:focus-visible, .read:focus-visible, .filters button:focus-visible, .list:focus-visible { outline: 2px solid var(--accent2); outline-offset: -2px; }
.loading { display: grid; gap: 8px; padding: 12px; }
.empty { padding: 36px 20px; }
@media(max-width: 767px) {
.dialog { max-height: min(680px, calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))); }
.dialog > :first-child { padding: 16px; }
.filters { padding: 0 16px 12px; }
.list { padding: 6px; }
.open { gap: 10px; padding: 12px 10px; }
.avatar { width: 34px; height: 34px; }
.read { width: 36px; min-height: 44px; margin-right: 4px; }
}
@media(hover: none) { .read > :last-child { opacity: 1; } .read .dot { display: none; } }
+20
View File
@@ -0,0 +1,20 @@
import { socialApi } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
const scopes = new WeakMap<object, number>();
let nextScope = 0;
export function useSocial() {
const session = useSession();
const user = useUser();
const server = useDesktop()?.server ?? '';
const api = useMemo(() => socialApi(session), [session]);
if (!scopes.has(session)) scopes.set(session, ++nextScope);
const key = ['social', server, scopes.get(session), user.id] as const;
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, staleTime: 5000, refetchInterval: 15_000, refetchOnWindowFocus: true });
return { api, key, inbox, user };
}
@@ -0,0 +1,206 @@
.page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
.pageHeader { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-shrink: 0; }
.page h1 { font-size: 24px; letter-spacing: -.035em; margin: 0; }
.muted { color: var(--text2); font-size: 13px; line-height: 1.6; }
.hub { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--surface); border: 1px solid var(--line2); border-radius: 20px; overflow: hidden; box-shadow: 0 12px 48px var(--hover); }
.tabs { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 10px 16px; flex-shrink: 0; }
.tabs > button:not(.addFriend) { position: relative; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 10px; color: var(--text2); font-weight: 600; transition: color .18s, background .18s; }
.tabs > button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
.tabs > button[aria-pressed='true']::after { content: ''; position: absolute; bottom: -11px; height: 2px; left: 16px; right: 16px; background: var(--accent2); border-radius: 2px; animation: tabIn .2s ease-out; }
.tabs > button:hover { color: var(--text); background: var(--hover); }
.addFriend { margin-left: auto; }
.badge { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: var(--accent-fill); color: var(--on-accent, #fff); font-size: 11px; font-weight: 600; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(230px, 29%) minmax(0, 1fr); }
.peopleList { padding: 12px 10px; min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.workspace > .peopleList { border-right: 1px solid var(--line); background: var(--bg2); }
.listSearch { display: flex; align-items: center; gap: 10px; background: var(--surface2); border: 1px solid var(--line); border-radius: 10px; padding: 0 12px; min-height: 44px; margin: 2px 4px 14px; color: var(--text3); }
.listSearch:focus-within { border-color: var(--accent); }
.listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; }
.listSearch input::placeholder { color: var(--text3); }
.listSearch input:focus-visible { outline: none; }
.dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; }
.dialogRow:hover { background: var(--hover); }
.dialogRow:active { transform: scale(.985); }
.dialogRow.selected { background: var(--accent-soft); }
.dialogBody { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
.dialogTitle { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }
.dialogTitle strong { font-size: 14px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.dialogTitle time { color: var(--text3); font-size: 10px; white-space: nowrap; flex-shrink: 0; }
.preview { color: var(--text2); font-size: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.listeningPreview { display: flex; align-items: center; gap: 4px; color: var(--ok); font-size: 11px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.avatar { position: relative; width: 42px; height: 42px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); font-size: 16px; font-weight: 600; }
.avatarListening::after { content: ''; position: absolute; right: 0; bottom: 0; width: 10px; height: 10px; background: var(--ok); border-radius: 50%; border: 2px solid var(--surface); }
.presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; }
.presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; }
.presenceOnline .presenceDot { background: var(--ok); }
.directory { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; }
.directory .peopleList { padding: 0; overflow: visible; }
.directory .listSearch { max-width: 440px; margin-left: 0; }
.person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; }
.person > .actions { margin-left: auto; }
.person.selected { background: var(--accent-soft); }
.identity { display: flex; gap: 12px; align-items: center; min-width: 0; }
.identity > div { min-width: 0; }
.identity strong { display: block; overflow-wrap: anywhere; }
.activity { display: grid; gap: 4px; max-width: 300px; font-size: 13px; }
.activity a, .nowListening a { color: var(--text); text-decoration: none; }
.activity a:hover, .nowListening a:hover { color: var(--accent2); text-decoration: underline; }
.listening { display: flex; align-items: center; gap: 5px; color: var(--ok); font-size: 11px; }
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.stack { display: grid; gap: 12px; }
.stack:empty { display: none; }
.welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 12px; padding: 32px; }
.welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; }
.welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; }
.welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; }
.privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; }
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
.privacy summary { cursor: pointer; font-weight: 600; }
.privacy p { margin-bottom: 0; }
.privacy .privacy { margin: 12px 0; }
.privacy input { accent-color: var(--accent); }
.preferencesDialog { width: 560px; max-height: min(720px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
.preferencesDialog > :first-child { padding: 20px 24px 16px; flex-shrink: 0; border-bottom: 1px solid var(--line); }
.preferencesDialog > :nth-child(2) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.preferencesDialog fieldset.privacy { border: 0; padding: 0; margin: 0; }
.preferencesDialog .privacy legend { padding: 0; margin-bottom: 12px; color: var(--text); font-weight: 600; }
.preferencesDialog .privacy .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); }
.preferencesDialog .privacy .actions button { min-width: 0; padding: 0 8px; border-radius: 8px; min-height: 36px; }
.preferencesDialog details.privacy { padding: 16px; margin: 8px 0 0; border: 1px solid var(--line); background: var(--surface2); }
.preferencesDialog details.privacy > summary { line-height: 1.5; }
.preferencesDialog details.privacy fieldset { margin: 20px 0; }
.preferencesDialog details.privacy > label { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.5; }
.preferencesDialog details.privacy > label input { margin-top: 3px; }
.choice { display: flex; gap: 12px; padding: 14px; align-items: center; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; background: var(--surface2); font-size: 13px; line-height: 1.5; }
.choice + .choice { margin-top: 8px; }
.choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.choice:has(input:disabled) { opacity: .5; cursor: not-allowed; }
.choice input { flex-shrink: 0; accent-color: var(--accent); }
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
.chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); }
.chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
.chatIdentity { flex: 1; min-width: 0; }
.chatIdentity h2 { font-size: 16px; line-height: 1.4; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chatIdentity > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.chatIdentity > .presence { display: inline-flex; }
.chatTools { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.iconButton { width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 12px; flex-shrink: 0; }
.backButton { display: none; }
.nowListening { display: flex; gap: 10px; align-items: center; padding: 10px 22px; background: var(--hover); color: var(--ok); flex-shrink: 0; }
.nowListening > div { min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 12px; }
.nowListening span { font-size: 11px; }
.nowListening a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.chatTabs { display: flex; gap: 16px; padding: 0 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
.chatTabs button { position: relative; display: flex; align-items: center; gap: 6px; padding: 12px 0; font-size: 12px; color: var(--text3); transition: color .16s; }
.chatTabs button[aria-pressed='true'] { color: var(--text); }
.chatTabs button[aria-pressed='true']::after { content: ''; height: 2px; position: absolute; left: 0; right: 0; bottom: -1px; border-radius: 2px; background: var(--accent2); animation: tabIn .18s ease-out; }
.chatSearch { display: flex; gap: 12px; align-items: end; padding: 12px 20px; border-bottom: 1px solid var(--line); animation: panelIn .18s ease-out; flex-shrink: 0; }
.chatSearch > div { flex: 1; }
.messages { display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; scrollbar-width: thin; padding: 22px 28px; }
.chatSkeleton { display: flex; flex-direction: column; gap: 20px; }
.older { align-self: center; flex-shrink: 0; }
.date { align-self: center; color: var(--text3); font-size: 11px; margin: 6px 0 10px; flex-shrink: 0; }
.newMessages { text-align: center; color: var(--accent2); font-size: 11px; border-bottom: 1px solid var(--line2); padding: 6px; }
.bubble { display: flex; flex-direction: column; gap: 7px; padding: 10px 14px 5px; border-radius: 16px 16px 16px 4px; background: var(--surface2); max-width: min(82%, 560px); min-width: 100px; flex-shrink: 0; align-self: start; overflow-wrap: anywhere; animation: messageIn .22s cubic-bezier(.2,.8,.2,1); }
.mine { align-self: end; background: var(--accent-soft); border-radius: 16px 16px 4px 16px; }
.messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; }
.messageText a, .attachmentCaption a { color: var(--accent2); }
.messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; }
.messageFoot small { font-size: 11px; color: var(--text2); }
.messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); }
.messageFoot button:hover { color: var(--text); background: var(--hover); }
.quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; }
.deleted { font-style: italic; color: var(--text3); }
.sending { opacity: .6; }
.reactions { display: flex; flex-wrap: wrap; gap: 5px; }
.reactions button { border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--text); padding: 4px 8px; font-size: 12px; cursor: pointer; transition: transform .15s, background .15s; }
.reactions button:hover { transform: translateY(-2px); }
.reactions button[aria-pressed='true'] { background: var(--accent-soft); border-color: var(--accent2); }
.typing { display: flex; align-items: center; gap: 8px; min-height: 24px; padding: 0 24px 4px; color: var(--text3); font-size: 11px; flex-shrink: 0; }
.typingDots { display: flex; gap: 3px; }
.typingDots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: typingPulse 1s ease-in-out infinite alternate; }
.typingDots i:nth-child(2) { animation-delay: .15s; }
.typingDots i:nth-child(3) { animation-delay: .3s; }
.replyBar { display: flex; gap: 10px; align-items: center; padding: 10px 22px; background: var(--hover); border-top: 1px solid var(--line); flex-shrink: 0; animation: panelIn .16s ease-out; }
.replyBar > div { flex: 1; min-width: 0; }
.replyBar small { color: var(--accent2); }
.replyBar p { margin: 3px 0; font-size: 12px; max-height: 35px; overflow: hidden; overflow-wrap: anywhere; }
.composer { display: flex; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--line); align-items: end; flex-shrink: 0; background: var(--surface); }
.composer textarea { resize: none; min-width: 0; flex: 1; border: 1px solid transparent; border-radius: 12px; background: var(--surface2); color: var(--text); padding: 10px 14px; min-height: 42px; max-height: 144px; font: inherit; font-size: 14px; line-height: 22px; scrollbar-width: thin; transition: border-color .18s, background .18s; }
.composer textarea:focus { border-color: var(--accent); }
.composer textarea::placeholder { color: var(--text3); }
.emojiPicker { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 20px; border-top: 1px solid var(--line); flex-shrink: 0; animation: panelIn .18s ease-out; }
.emojiPicker button { width: 40px; height: 40px; border-radius: 10px; font-size: 20px; transition: background .15s, transform .15s; }
.emojiPicker button:hover { background: var(--hover); transform: translateY(-2px); }
.jumpToBottom { position: absolute; bottom: 94px; align-self: center; border: 1px solid var(--line2); background: var(--elev); box-shadow: var(--shadow); z-index: 2; animation: panelIn .18s ease-out; }
.musicCard { display: flex; align-items: center; gap: 12px; min-width: 0; width: 320px; max-width: 100%; padding: 6px 0; }
.musicInfo { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; }
.musicInfo a { color: var(--text); font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
.musicInfo a:hover { color: var(--accent2); text-decoration: underline; }
.musicInfo > span { font-size: 11px; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.musicCover { width: 48px; height: 48px; display: grid; place-items: center; background: var(--surface3); border-radius: 8px; color: var(--text3); flex-shrink: 0; }
.musicUnavailable { opacity: .7; }
.musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; }
.musicMessages .musicCard { width: 100%; }
.miniDock { grid-area: content; align-self: end; justify-self: end; z-index: 25 !important; display: flex; flex-direction: column; align-items: end; gap: 12px; width: min(600px, calc(100% - 32px)); min-height: 0; margin: 16px; pointer-events: none; }
.miniPanel { pointer-events: auto; width: 100%; height: min(640px, calc(100dvh - 250px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); overflow: hidden; animation: panelIn .2s ease-out; }
.miniDockIsland { margin-bottom: 132px; }
.miniDockIsland .miniPanel { height: min(640px, calc(100dvh - 290px)); }
.miniPanel[hidden] { display: none; }
.miniHeader { display: flex; align-items: center; gap: 6px; padding: 8px 10px 8px 16px; border-bottom: 1px solid var(--line); }
.miniHeader > strong { flex: 1; font-size: 14px; }
.miniHeader > button { min-height: 36px; padding: 0 8px; font-size: 12px; }
.miniWorkspace { flex: 1; min-height: 0; display: grid; grid-template-columns: 170px minmax(0, 1fr); }
.miniFriends { overflow-y: auto; min-width: 0; padding: 8px; border-right: 1px solid var(--line); }
.miniFriends .listSearch { padding: 0 8px; gap: 5px; margin: 0 0 8px; min-height: 36px; }
.miniFriends .listSearch input { height: 36px; font-size: 11px; }
.miniFriends .dialogRow { gap: 8px; padding: 10px 6px; }
.miniFriends .avatar { width: 28px; height: 28px; font-size: 12px; }
.miniFriends .dialogBody strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
.miniFriends .badge { min-width: 17px; height: 17px; padding: 0 4px; font-size: 10px; }
.miniWorkspace .chat { min-width: 0; }
.miniWorkspace .chatHeader { gap: 8px; padding: 10px; }
.miniWorkspace .chatHeader .avatar { width: 30px; height: 30px; font-size: 12px; }
.miniWorkspace .chatIdentity h2 { font-size: 14px; }
.miniWorkspace .chatTools .togetherLabel { display: none; }
.miniWorkspace .chatTools > button, .miniWorkspace .chatTools > div > button { width: 34px; min-width: 34px; padding: 0; }
.miniWorkspace .chatTabs { gap: 14px; padding: 0 12px; }
.miniWorkspace .chatTabs button { font-size: 11px; }
.miniWorkspace .messages { padding: 12px; gap: 10px; }
.miniWorkspace .bubble { max-width: 94%; font-size: 13px; padding: 8px 10px 4px; }
.miniWorkspace .composer { padding: 8px; gap: 4px; }
.miniWorkspace .composer .iconButton { width: 34px; min-width: 34px; }
.miniWorkspace .composer textarea { padding: 8px; font-size: 13px; }
.miniWorkspace .welcome { padding: 20px; }
.miniTrigger { position: relative; pointer-events: auto; display: grid; place-items: center; width: 48px; height: 48px; flex-shrink: 0; border-radius: 50%; color: var(--text); border: 1px solid var(--line2); background: var(--surface); box-shadow: 0 6px 24px #0005; cursor: pointer; }
.miniTrigger:hover, .miniTrigger[aria-expanded='true'] { background: var(--accent-fill); color: var(--on-accent, #fff); }
.miniTrigger:focus-visible { outline: 2px solid var(--accent2); outline-offset: 3px; }
.miniTrigger > .badge { position: absolute; top: -5px; right: -5px; }
@media(max-width: 1023px) { .miniDock { display: none; } }
@media(prefers-reduced-motion: reduce) { .miniPanel { animation: none; } }
.attachmentCaption { font-size: 12px; color: var(--text2); white-space: pre-wrap; }
.expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; }
@keyframes conversationIn { from { opacity: .4; transform: translateX(8px); } to { opacity: 1; transform: none; } }
@keyframes panelIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
@keyframes messageIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
@keyframes tabIn { from { opacity: 0; transform: scaleX(.5); } to { opacity: 1; transform: none; } }
@keyframes typingPulse { from { opacity: .35; transform: translateY(0); } to { opacity: 1; transform: translateY(-3px); } }
@media(max-width: 1150px) { .pageHeader { align-items: start; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } }
@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } }
@media(max-width: 767px) {
.page { gap: 12px; } .pageHeader { padding: 0 2px; } .page h1 { font-size: 23px; }
.hub { border-radius: 14px; } .tabs { flex-wrap: wrap; } .tabs > button:not(.addFriend) { flex: 1; font-size: 12px; } .tabs .addFriend { width: 100%; height: 36px; font-size: 12px; }
.workspace { display: flex; flex-direction: column; } .workspace > .peopleList { flex: 1; border: 0; } .workspace > .chat, .workspace > .welcome { display: none; }
.conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; }
.conversationOpen .workspace > .chat { display: flex; flex: 1; } .backButton { display: inline-flex; } .chatHeader { padding: 10px 8px; gap: 6px; }
.chatHeader .avatar { width: 32px; height: 32px; font-size: 13px; } .chatIdentity h2 { font-size: 14px; } .chatIdentity > span { font-size: 11px; }
.chatTools > button, .chatTools > div > button { min-width: 36px; width: 36px; } .iconButton { min-width: 36px; width: 36px; } .chatTools > div:last-child > button { width: 32px; min-width: 32px; }
.chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; }
.nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; }
.composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; }
.directory { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; }
.musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; }
.chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; }
}
@media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } }
@media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } }
@@ -0,0 +1,69 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { refreshIcon } from '../../shared/ui/icons';
import { Button } from '../../shared/ui/ui';
import u from '../../pages/uploads/uploads.module.css';
// Preview elements may belong to different batches, but only one may play.
const previews = new Set<HTMLAudioElement>();
export function AudioPreview({ url, name, active }: { url: string; name: string; active: boolean }) {
const { t } = useTranslation('admin');
const queryClient = useQueryClient();
const audio = useRef<HTMLAudioElement>(null);
const [error, setError] = useState<number | null>(null);
const [httpStatus, setHttpStatus] = useState<number | null>(null);
useEffect(() => {
const element = audio.current;
if (!element) return;
previews.add(element);
return () => { previews.delete(element); element.pause(); };
}, []);
useEffect(() => {
if (!active) audio.current?.pause();
}, [active]);
useEffect(() => { setError(null); setHttpStatus(null); }, [url]);
useEffect(() => {
if (error === null) return;
const controller = new AbortController();
// A media error alone cannot distinguish an HTTP failure from an unsupported codec.
// Probe one byte using the same signed URL, then release the response immediately.
void fetch(url, { headers: { Range: 'bytes=0-0' }, cache: 'no-store', signal: controller.signal })
.then(async (response) => {
await response.body?.cancel();
if (!controller.signal.aborted) setHttpStatus(response.ok ? null : response.status);
})
.catch(() => { /* The native media error remains visible for network failures. */ });
return () => controller.abort();
}, [url, error]);
const retry = () => {
setError(null);
setHttpStatus(null);
void queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
audio.current?.load();
void audio.current?.play().catch(() => { /* Native media errors are shown below. */ });
};
return <div className={u.preview}>
<audio ref={audio} controls preload="none" src={url}
aria-label={t('uploads.listen', { name })}
onPlay={(event) => {
if (event.currentTarget.paused) return;
for (const other of previews) {
if (other !== event.currentTarget && !other.paused) other.pause();
}
}}
onLoadedMetadata={() => { setError(null); setHttpStatus(null); }}
onError={(event) => setError(event.currentTarget.error?.code ?? 0)} />
{error !== null && <div className={u.previewError}>
<span role="alert" className={u.bad}>{httpStatus !== null
? t('uploads.preview_http_error', { status: httpStatus })
: t(error === 3 || error === 4 ? 'uploads.preview_decode_error' : 'uploads.preview_error')}</span>
<Button variant="ghost" icon={refreshIcon} onClick={retry}>{t('uploads.retry')}</Button>
</div>}
</div>;
}
@@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { useUploadsApi } from './queries'; import { useUploadsApi } from './queries';
import { coverBase64 } from './files';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui'; import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css'; import s from '../../pages/page.module.css';
@@ -13,6 +14,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
const [album, setAlbum] = useState(''); const [album, setAlbum] = useState('');
const [year, setYear] = useState(''); const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined); const [cover, setCover] = useState<string | null | undefined>(undefined);
const prepare = useMutation({ mutationFn: coverBase64, onSuccess: setCover });
const save = useMutation({ const save = useMutation({
mutationFn: () => api.updateBatch(batchId, { mutationFn: () => api.updateBatch(batchId, {
...(artist.trim() ? { artist: artist.trim() } : {}), ...(artist.trim() ? { artist: artist.trim() } : {}),
@@ -23,17 +25,13 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
onSuccess: onSaved, onSuccess: onSaved,
}); });
const pickCover = (file: File | undefined) => { const pickCover = (file: File | undefined) => {
if (!file) { save.reset();
prepare.reset();
setCover(undefined); setCover(undefined);
if (!file) {
return; return;
} }
const reader = new FileReader(); prepare.mutate(file);
reader.onload = () => {
const data = String(reader.result ?? '');
const comma = data.indexOf(',');
setCover(comma >= 0 ? data.slice(comma + 1) : data);
};
reader.readAsDataURL(file);
}; };
return ( return (
<Dialog <Dialog
@@ -44,7 +42,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')} {t('common:form.cancel')}
</Button> </Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}> <Button variant="primary" busy={save.isPending || prepare.isPending} disabled={prepare.isError} onClick={() => save.mutate()}>
{t('uploads.save')} {t('uploads.save')}
</Button> </Button>
</> </>
@@ -57,12 +55,12 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} /> <Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
<label className={s.sessions}> <label className={s.sessions}>
<span>{t('uploads.batch_cover')}</span> <span>{t('uploads.batch_cover')}</span>
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} /> <input type="file" accept="image/jpeg,image/png,image/webp" disabled={prepare.isPending || save.isPending} onChange={(e) => pickCover(e.target.files?.[0])} />
</label> </label>
<Button variant="ghost" onClick={() => setCover(null)}> <Button variant="ghost" disabled={prepare.isPending || save.isPending} onClick={() => { prepare.reset(); setCover(null); }}>
{t('uploads.batch_cover_clear')} {t('uploads.batch_cover_clear')}
</Button> </Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError> <FormError>{(save.error ?? prepare.error) ? errorText(save.error ?? prepare.error) : null}</FormError>
</div> </div>
</Dialog> </Dialog>
); );
@@ -0,0 +1,172 @@
import type { components } from '@pipu/api-client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUploadsApi } from './queries';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import type { UploadFile } from './common';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
type Candidate = components['schemas']['ReleaseCandidate'];
export type ReleaseGroup = components['schemas']['UploadReleaseGroup'];
/** External suggestions remain separate from uploaded metadata until explicitly applied. */
export function BatchRelease({ files, onGroupsChange }: { files: UploadFile[]; onGroupsChange?: (groups: ReleaseGroup[]) => void }) {
const { t } = useTranslation('admin');
const api = useUploadsApi();
const qc = useQueryClient();
const errorText = useErrorText();
const first = files[0]!;
const id = first.batch_id;
const [groupId, setGroupId] = useState('');
const [groups, setGroups] = useState<ReleaseGroup[]>([]);
const key = ['upload-release', id, groupId];
const [open, setOpen] = useState(false);
const [artist, setArtist] = useState(first.metadata?.albumartist || first.metadata?.artist || '');
const [album, setAlbum] = useState(first.metadata?.album || '');
const [mbid, setMbid] = useState('');
const [selected, setSelected] = useState('');
const [preview, setPreview] = useState(false);
const [applyMetadata, setApplyMetadata] = useState(false);
const [applyCover, setApplyCover] = useState(false);
const version = files.map((f) => `${f.id}:${f.updated_at}`).sort().join('|');
const previousVersion = useRef(version);
useEffect(() => {
if (previousVersion.current !== version) {
previousVersion.current = version;
void qc.invalidateQueries({ queryKey: ['upload-release', id] });
}
}, [version, qc, id]);
const report = useQuery({
queryKey: key,
queryFn: () => api.releaseReport(id, groupId || undefined),
retry: false,
refetchInterval: (q) => q.state.data?.state === 'queued' || q.state.data?.state === 'searching' || (!groupId && q.state.data?.groups?.some((g) => g.state === 'queued' || g.state === 'searching')) ? 3000 : q.state.data?.state === 'error' ? 60000 : false,
});
useEffect(() => {
if (report.data?.groups) { setGroups(report.data.groups); onGroupsChange?.(report.data.groups); }
}, [report.data?.groups, onGroupsChange]);
const scopeId = report.data?.group_id || groupId;
const group = groups.find((g) => g.id === scopeId);
const needsGroup = groups.length > 1 && !scopeId;
const candidate: Candidate | undefined = report.data?.candidates.find((c) => c.release.id === selected)
?? report.data?.candidates.find((c) => c.release.id === report.data?.selected_release)
?? (report.data?.candidates.length === 1 ? report.data.candidates[0] : undefined);
const releaseId = candidate?.release.id ?? '';
useEffect(() => { setPreview(false); setApplyCover(false); setApplyMetadata(false); }, [releaseId, report.data?.input_hash]);
const cover = useQuery({
queryKey: ['upload-release-cover', id, scopeId, releaseId, report.data?.input_hash],
queryFn: () => api.releaseCover(id, releaseId, scopeId || undefined),
enabled: open && preview && !!releaseId,
retry: false,
staleTime: 3600000,
});
const search = useMutation({
mutationFn: (audio: boolean) => api.searchReleases(id, { ...(scopeId ? { group_id: scopeId } : {}), ...(audio ? { audio: true } : mbid.trim() ? { release_id: mbid.trim() } : { artist: artist.trim(), album: album.trim() }) }),
onSuccess: (data) => { setSelected(''); apply.reset(); qc.setQueryData(key, data); },
});
const apply = useMutation({
mutationFn: () => api.applyRelease(id, { ...(scopeId ? { group_id: scopeId } : {}), release_id: releaseId, apply_metadata: applyMetadata, apply_cover: applyCover }),
onSuccess: async (data) => {
qc.setQueryData(key, data);
setApplyCover(false); setApplyMetadata(false);
await qc.invalidateQueries({ queryKey: ['admin-uploads'] });
},
});
const kind = (value: string) => value === 'Album' ? t('release_lookup.album') : value === 'Single' ? t('release_lookup.single') : value === 'EP' ? 'EP' : value || t('release_lookup.release');
const stateText = needsGroup ? t('release_lookup.groups_count', { count: groups.length }) : report.data?.state === 'ready'
? candidate ? `${kind(candidate.release.type)} · ${t('release_lookup.matched_count', { matched: candidate.matched, total: candidate.total })} · ${t(candidate.complete ? 'release_lookup.complete' : candidate.confidence === 'high' && candidate.missing.length > 0 && !candidate.unmatched.length ? 'release_lookup.incomplete' : 'release_lookup.partial')}`
: t('release_lookup.variants', { count: report.data.candidates.length })
: t(`release_lookup.state_${report.data?.state ?? 'searching'}`);
if (report.data?.state === 'disabled') return null;
return <>
<Button variant="ghost" onClick={() => setOpen(true)} title={t('release_lookup.title')}>
{report.error ? t('release_lookup.open') : stateText}
</Button>
{open && <Dialog className={u.releaseDialog} title={t('release_lookup.title')} onClose={() => setOpen(false)} footer={
<Button variant="primary" busy={apply.isPending} disabled={needsGroup || search.isPending || !candidate || report.data?.state !== 'ready' || (!applyMetadata && !applyCover) || (applyCover && !cover.data)} onClick={() => apply.mutate()}>
{t('release_lookup.apply')}
</Button>
}>
<div className={s.sessions}>
<p className={s.note}>{t('release_lookup.intro')}</p>
{groups.length > 1 && <>
<p className={s.note}>{t('release_lookup.groups_hint')}</p>
<label className={s.sessions}>
{t('release_lookup.group')}
<select className={s.select} value={scopeId} disabled={apply.isPending || search.isPending} onChange={(e) => {
const next = groups.find((g) => g.id === e.target.value)!;
setGroupId(next.id); setArtist(next.artist); setAlbum(next.album); setMbid(''); setSelected(''); setPreview(false); setApplyMetadata(false); setApplyCover(false); search.reset(); apply.reset();
}}>
<option value="" disabled>{t('release_lookup.choose_group')}</option>
{groups.map((g) => <option key={g.id} value={g.id}>{g.artist} — {g.album} · {t('release_lookup.tracks_total', { total: g.file_ids.length })}</option>)}
</select>
</label>
{group && <p className={s.note}>{t(group.has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}</p>}
</>}
<div className={u.grid}>
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
</div>
<Field label={t('release_lookup.mbid')} value={mbid} onChange={setMbid} maxLength={36} />
<Button busy={search.isPending} disabled={needsGroup || apply.isPending || report.data?.state === 'queued' || report.data?.state === 'searching' || (!mbid.trim() && (!artist.trim() || !album.trim()))} onClick={() => search.mutate(false)}>{t('release_lookup.search')}</Button>
<Button busy={search.isPending && search.variables === true} disabled={needsGroup || !report.data?.audio_available || search.isPending || apply.isPending || report.data?.state === 'queued' || report.data?.state === 'searching'} onClick={() => search.mutate(true)}>{t('release_lookup.audio_search')}</Button>
<p className={s.note}>{t(report.data?.audio_available ? 'release_lookup.audio_privacy' : 'release_lookup.audio_setup')}</p>
<p role="status" className={s.note}>{stateText}</p>
{!!report.data?.audio_results?.length && <>
<p className={s.note}>{t('release_lookup.audio_summary', { matched: report.data.audio_results.filter((r) => r.state === 'matched').length, total: report.data.audio_results.length })}</p>
{report.data.audio_results.filter((r) => r.state !== 'matched').map((r) => <div key={r.file_id} className={u.sub}>{files.find((f) => f.id === r.file_id)?.original_name ?? r.file_id}: {t(r.state === 'ambiguous' ? 'release_lookup.audio_ambiguous' : r.state === 'not_found' ? 'release_lookup.audio_not_found' : 'release_lookup.audio_unavailable')}</div>)}
<p className={s.note}>{t('release_lookup.audio_editions')}</p>
</>}
<FormError>{(report.error ?? search.error ?? apply.error) ? errorText(report.error ?? search.error ?? apply.error) : null}</FormError>
{report.data?.state === 'ready' && report.data.candidates.length > 0 && <>
{report.data.candidates.length > 1 && <label className={s.sessions}>
{t('release_lookup.edition')}
<select className={s.select} value={releaseId} disabled={apply.isPending || search.isPending} onChange={(e) => { setSelected(e.target.value); apply.reset(); }}>
<option value="" disabled>{t('release_lookup.choose_edition')}</option>
{report.data.candidates.map((c) => <option key={c.release.id} value={c.release.id}>{c.release.artist} — {c.release.title} · {[c.release.date, c.release.country, c.release.comment, t('release_lookup.tracks_total', { total: c.total })].filter(Boolean).join(' · ')}</option>)}
</select>
</label>}
{candidate && <>
<strong>{candidate.release.artist} — {candidate.release.title}</strong>
<span className={s.note}>{[kind(candidate.release.type), candidate.release.date, candidate.release.country, candidate.release.comment].filter(Boolean).join(' · ')}</span>
<a href={`https://musicbrainz.org/release/${encodeURIComponent(releaseId)}`} target="_blank" rel="noreferrer">MusicBrainz</a>
<p className={s.note}>{t('release_lookup.confidence', { value: t(candidate.confidence === 'high' ? 'release_lookup.confidence_high' : candidate.confidence === 'medium' ? 'release_lookup.confidence_medium' : 'release_lookup.confidence_low') })}</p>
<p className={s.note}>{t('release_lookup.coverage', { matched: candidate.matched, total: candidate.total, missing: candidate.missing.length, extra: candidate.unmatched.length })}</p>
{candidate.existing.length > 0 && <p className={s.note}>{t('release_lookup.existing', { count: candidate.existing.length })}</p>}
<div className={s.tableWrap}>
<table className={s.table}>
<thead><tr><th>#</th><th>{t('release_lookup.uploaded')}</th><th>{t('release_lookup.catalogue')}</th></tr></thead>
<tbody>{candidate.release.tracks.map((track, index) => {
const match = candidate.matches.find((m) => m.track_index === index);
const file = files.find((f) => f.id === match?.file_id);
return <tr key={`${track.disc}:${track.position}:${index}`}>
<td>{track.disc}.{track.position}</td>
<td className={u.releaseCell}>{match ? file?.metadata ? `${file.metadata.artist} — ${file.metadata.title}` : file?.original_name ?? t('release_lookup.matched') : t('release_lookup.missing')}
{file?.metadata && <div className={u.sub}>{[file.metadata.albumartist, file.metadata.album, file.metadata.year, `${file.metadata.disc ?? 1}.${file.metadata.track ?? '?'}`].filter(Boolean).join(' · ')}</div>}
{match?.method === 'acoustid' && <div className={u.sub}>{t('release_lookup.audio_match')}</div>}
{match?.confidence === 'medium' && <div className={u.warn}>{t('release_lookup.tentative')}</div>}</td>
<td className={u.releaseCell}>{track.artist} — {track.title}</td>
</tr>;
})}</tbody>
</table>
</div>
<label><input type="checkbox" checked={applyMetadata} disabled={apply.isPending || search.isPending || !candidate.matches.length} onChange={(e) => setApplyMetadata(e.target.checked)} /> {t('release_lookup.apply_metadata')}</label>
<Button busy={cover.isFetching} onClick={() => setPreview(true)}>{t('release_lookup.preview_cover')}</Button>
{preview && cover.error && <FormError>{errorText(cover.error)}</FormError>}
{preview && cover.data && <>
<img className={u.releaseCover} width={180} height={180} src={`data:${cover.data.mime_type};base64,${cover.data.image_base64}`} alt={t('uploads.batch_cover')} />
<span className={s.note}>{t(cover.data.exact_release ? 'release_lookup.exact_cover' : 'release_lookup.group_cover')}</span>
<a href={cover.data.source_url} target="_blank" rel="noreferrer">Cover Art Archive</a>
<label><input type="checkbox" checked={applyCover} disabled={apply.isPending} onChange={(e) => setApplyCover(e.target.checked)} /> {t(group ? group.has_cover ? 'release_lookup.replace_group_cover' : 'release_lookup.apply_group_cover' : first.batch_has_cover ? 'release_lookup.replace_cover' : 'release_lookup.apply_cover')}</label>
</>}
{report.data.selected_release === releaseId && <p role="status" className={s.ok}>{t('release_lookup.applied')}</p>}
</>}
</>}
</div>
</Dialog>}
</>;
}
+69 -7
View File
@@ -1,6 +1,10 @@
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format'; import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { editIcon, refreshIcon } from '../../shared/ui/icons'; import { chevronRightIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
import { useId, useState } from 'react';
import { needsFormatAttention } from './files'; import { needsFormatAttention } from './files';
import { AudioPreview } from './AudioPreview';
import { batchSummary } from './batchSummary';
import { BatchRelease, type ReleaseGroup } from './BatchRelease';
import { UploadStatusBadge, useReasonText, type UploadFile } from './common'; import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, cx, Icon } from '../../shared/ui/ui'; import { Button, cx, Icon } from '../../shared/ui/ui';
@@ -24,31 +28,69 @@ export function BatchCard({
onRetry: (id: string) => void; onRetry: (id: string) => void;
}) { }) {
const { t, i18n } = useTranslation('admin'); const { t, i18n } = useTranslation('admin');
const [collapsed, setCollapsed] = useState(true);
const [releaseGroups, setReleaseGroups] = useState<ReleaseGroup[]>([]);
const contentId = useId();
const first = files[0]!; const first = files[0]!;
const ids = files.map((f) => f.id); const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id)); const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0); const size = files.reduce((n, f) => n + f.size_bytes, 0);
const summary = batchSummary(files);
const metadata = first.metadata;
const albumArtist = metadata?.albumartist || metadata?.artist || '';
const singleAlbum = releaseGroups.length === 1 ? releaseGroups[0]
: releaseGroups.length === 0 && metadata?.album && files.every((file) =>
file.metadata?.album.trim().toLowerCase() === metadata.album.trim().toLowerCase()
&& (file.metadata.albumartist || file.metadata.artist || '').trim().toLowerCase() === albumArtist.trim().toLowerCase())
? { artist: albumArtist, album: metadata.album } : undefined;
const albumTitle = singleAlbum?.album && singleAlbum.album.trim().toLowerCase() !== 'unknown album'
? [singleAlbum.artist.trim().toLowerCase() === 'unknown artist' ? '' : singleAlbum.artist, singleAlbum.album].filter(Boolean).join(' — ')
: undefined;
return ( return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}> <section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}> <div className={u.batchHead}>
<Button variant="ghost" className={u.batchToggle} aria-label={t(collapsed ? 'uploads.expand_batch' : 'uploads.collapse_batch')} title={t(collapsed ? 'uploads.expand_batch' : 'uploads.collapse_batch')} aria-expanded={!collapsed} aria-controls={contentId} onClick={() => setCollapsed((value) => !value)}>
<Icon src={chevronRightIcon} size={20} className={!collapsed ? u.batchExpanded : undefined} />
</Button>
{selectable && ( {selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} /> <input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
)} )}
<b>{first.author.username}</b> {albumTitle ? <div className={u.batchIdentity}>
<strong title={albumTitle}>{albumTitle}</strong>
<span>{first.author.username}</span>
</div> : <b>{first.author.username}</b>}
<span>{formatDateTime(first.created_at, i18n.language)}</span> <span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span> <span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>} <span>{summary.formats.map(([format, count]) => `${count} × ${format || t('uploads.format_unknown')}`).join(' · ')}</span>
{summary.quality && <span>{summary.quality}</span>}
<span className={cx(s.badge, first.batch_has_cover ? u.tone_info : u.tone_muted)}>
{t(first.batch_has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}
</span>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
{selectable && <BatchRelease files={files} onGroupsChange={setReleaseGroups} />}
{selectable && ( {selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}> <Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
{t('uploads.edit_batch')} {t('uploads.edit_batch')}
</Button> </Button>
)} )}
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
</div> </div>
{files.map((f) => ( <div id={contentId} hidden={collapsed}>
{(releaseGroups.length > 1 ? [
...releaseGroups.map((group) => ({ group, rows: files.filter((f) => group.file_ids.includes(f.id)) })),
{ group: undefined, rows: files.filter((f) => !releaseGroups.some((g) => g.file_ids.includes(f.id))) },
] : [{ group: undefined, rows: files }]).filter((part) => part.rows.length > 0).map((part) => <div key={part.group?.id ?? 'other'}>
{part.group && <div className={u.releaseHead}>
{selectable && <input type="checkbox" aria-label={t('release_lookup.select_group', { album: part.group.album })} checked={part.rows.every((f) => selected.has(f.id))} onChange={(e) => onToggle(part.rows.map((f) => f.id), e.target.checked)} />}
<strong>{part.group.artist} — {part.group.album}</strong>
<span>{t('release_lookup.tracks_total', { total: part.group.file_ids.length })}</span>
<span>{t(part.group.has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}</span>
</div>}
{part.rows.map((f) => (
<QueueRow <QueueRow
key={f.id} key={f.id}
file={f} file={f}
active={!collapsed}
selectable={selectable} selectable={selectable}
checked={selected.has(f.id)} checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)} onCheck={(on) => onToggle([f.id], on)}
@@ -56,12 +98,15 @@ export function BatchCard({
onRetry={() => onRetry(f.id)} onRetry={() => onRetry(f.id)}
/> />
))} ))}
</div>)}
</div>
</section> </section>
); );
} }
function QueueRow({ function QueueRow({
file: f, file: f,
active,
selectable, selectable,
checked, checked,
onCheck, onCheck,
@@ -69,6 +114,7 @@ function QueueRow({
onRetry, onRetry,
}: { }: {
file: UploadFile; file: UploadFile;
active: boolean;
selectable: boolean; selectable: boolean;
checked: boolean; checked: boolean;
onCheck: (on: boolean) => void; onCheck: (on: boolean) => void;
@@ -127,15 +173,14 @@ function QueueRow({
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span> m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)} )}
</div> </div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>} {reason && (!editable || f.target_rel_path) && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && ( {f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span> <span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)} )}
</div> </div>
<div className={u.rowActions}> <div className={u.rowActions}>
{f.stream_url && ( {f.stream_url && (
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} /> <AudioPreview url={f.stream_url} name={f.original_name} active={active} />
)} )}
{editable && ( {editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}> <Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
@@ -148,6 +193,23 @@ function QueueRow({
</Button> </Button>
)} )}
</div> </div>
{(editable || f.target_rel_path) && (
<div className={u.placement} role="group" aria-label={t('uploads.destination')}>
<span className={u.placementLabel}>{t(f.status === 'imported' ? 'uploads.destination_imported' : 'uploads.destination')}</span>
<strong>{f.target_source_name ?? t(f.target_source_id ? 'uploads.target_selected' : 'uploads.target_not_selected')}</strong>
{f.target_rel_path ? (
<code className={u.target} title={f.target_storage_path ?? undefined}>{f.target_rel_path}</code>
) : (
<>
<span className={u.warn}>{t('uploads.target_path_missing')}</span>
{reason && <span className={u.warn}>{reason}</span>}
<Button variant="secondary" onClick={onEdit}>
{t(f.target_source_id ? 'uploads.fix_destination' : 'uploads.choose_destination')}
</Button>
</>
)}
</div>
)}
</div> </div>
); );
} }
+20
View File
@@ -0,0 +1,20 @@
import { createApiClient } from '@pipu/api-client';
import { describe, expect, it } from 'vitest';
describe('upload status query serialization', () => {
it('sends comma-separated statuses for own and admin queues, including batch selection', async () => {
const urls: URL[] = [];
const client = createApiClient({ baseUrl: 'https://pipu.test', fetch: async (input) => {
urls.push(new URL((input as Request).url));
return new Response(JSON.stringify({ items: [], next_cursor: null }), { headers: { 'Content-Type': 'application/json' } });
} });
await client.GET('/api/v1/uploads/mine', { params: { query: { status: ['pending_review', 'imported', 'expired'], limit: 50 } } });
await client.GET('/api/v1/admin/uploads', { params: { query: { status: ['uploading', 'processing'], limit: 100 } } });
await client.GET('/api/v1/uploads/mine', { params: { query: { batch: '00000000-0000-4000-8000-000000000001', status: ['uploading', 'processing', 'pending_review'] } } });
expect(urls.map((url) => url.searchParams.getAll('status'))).toEqual([
['pending_review,imported,expired'], ['uploading,processing'], ['uploading,processing,pending_review'],
]);
expect(urls[0]!.searchParams.get('limit')).toBe('50');
expect(urls[2]!.searchParams.get('batch')).toBe('00000000-0000-4000-8000-000000000001');
});
});
+4
View File
@@ -8,6 +8,10 @@ type SettingsPatch = paths['/api/v1/admin/settings/uploads']['patch']['requestBo
/** The feature owns transport mapping; views only provide form values. */ /** The feature owns transport mapping; views only provide form values. */
export function uploadsApi(client: ApiClient) { export function uploadsApi(client: ApiClient) {
return { return {
releaseReport: async (id: string, group_id?: string) => unwrap(await client.GET('/api/v1/admin/uploads/batches/{id}/releases', { params: { path: { id }, query: group_id ? { group_id } : {} } })),
searchReleases: async (id: string, body: paths['/api/v1/admin/uploads/batches/{id}/releases']['post']['requestBody']['content']['application/json']) => unwrap(await client.POST('/api/v1/admin/uploads/batches/{id}/releases', { params: { path: { id } }, body })),
releaseCover: async (id: string, release_id: string, group_id?: string) => unwrap(await client.GET('/api/v1/admin/uploads/batches/{id}/releases/{release_id}/cover', { params: { path: { id, release_id }, query: group_id ? { group_id } : {} } })),
applyRelease: async (id: string, body: paths['/api/v1/admin/uploads/batches/{id}/release']['post']['requestBody']['content']['application/json']) => unwrap(await client.POST('/api/v1/admin/uploads/batches/{id}/release', { params: { path: { id } }, body })),
list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap( list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap(
await client.GET('/api/v1/admin/uploads', { await client.GET('/api/v1/admin/uploads', {
params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } }, params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } },
@@ -0,0 +1,21 @@
import { expect, it } from 'vitest';
import { batchSummary } from './batchSummary';
it('counts formats and combines measured quality ranges without repeating equal values', () => {
const summary = batchSummary([
{ format: 'FLAC', original_name: 'one.flac', bitrate: 778000, sample_rate: 44100, bit_depth: 16 },
{ format: 'flac', original_name: 'two.flac', bitrate: 924000, sample_rate: 44100, bit_depth: 16 },
{ format: null, original_name: 'three.mp3', bitrate: 320000, sample_rate: 48000, bit_depth: null },
]);
expect(summary.formats).toEqual([['FLAC', 2], ['MP3', 1]]);
expect(summary.quality).toBe('320–924 kbps · 44.1–48 kHz · 16 bit');
});
it('ignores missing and invalid measurements and retains fractional sample rates', () => {
expect(batchSummary([
{ format: null, original_name: 'unknown', bitrate: 0, sample_rate: null, bit_depth: null },
{ format: 'WAV', original_name: 'one.wav', bitrate: null, sample_rate: 11025, bit_depth: 24 },
{ format: 'wav', original_name: 'two.wav', bitrate: NaN, sample_rate: 96000, bit_depth: 32 },
])).toEqual({ formats: [['WAV', 2], ['', 1]], quality: '11.025–96 kHz · 24–32 bit' });
expect(batchSummary([]).quality).toBe('');
});
@@ -0,0 +1,30 @@
import type { UploadFile } from './common';
import { audioExt } from './files';
type SummaryFile = Pick<UploadFile, 'format' | 'original_name' | 'bitrate' | 'sample_rate' | 'bit_depth'>;
function range(values: (number | null)[], divisor: number, decimals: number, unit: string): string | null {
const known = values.filter((value): value is number => value !== null && Number.isFinite(value) && value > 0);
if (!known.length) return null;
const format = (value: number) => String(Number((value / divisor).toFixed(decimals)));
const min = format(Math.min(...known));
const max = format(Math.max(...known));
return `${min === max ? min : `${min}–${max}`} ${unit}`;
}
/** Only measured positive values contribute to quality ranges; absent metadata is not zero. */
export function batchSummary(files: SummaryFile[]) {
const counts = new Map<string, number>();
for (const file of files) {
const format = (file.format?.trim() || audioExt(file.original_name)).toUpperCase();
counts.set(format, (counts.get(format) ?? 0) + 1);
}
return {
formats: [...counts].sort(([a, ac], [b, bc]) => bc - ac || a.localeCompare(b)),
quality: [
range(files.map((file) => file.bitrate), 1000, 0, 'kbps'),
range(files.map((file) => file.sample_rate), 1000, 3, 'kHz'),
range(files.map((file) => file.bit_depth), 1, 0, 'bit'),
].filter(Boolean).join(' · '),
};
}
+14 -1
View File
@@ -1,6 +1,19 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { needsFormatAttention } from './files'; import { artistImageFor, isArtistImage, needsFormatAttention } from './files';
it('inherits the nearest artist portrait without crossing sibling folders', () => {
const root = new File(['portrait'], 'artist.jpg');
const local = new File(['local'], 'ARTIST.PNG');
const images = new Map([['творожный бизнес', root], ['творожный бизнес/Альбом', local]]);
expect(isArtistImage(root.name)).toBe(true);
expect(isArtistImage(local.name)).toBe(true);
expect(isArtistImage('cover.jpg')).toBe(false);
expect(artistImageFor('творожный бизнес/(2026) творог грехов', images)).toBe(root);
expect(artistImageFor('творожный бизнес/Альбом/CD1', images)).toBe(local);
expect(artistImageFor('другой исполнитель/Альбом', images)).toBeUndefined();
expect(artistImageFor('творожный бизнес 2/Альбом', images)).toBeUndefined();
});
describe('needsFormatAttention', () => { describe('needsFormatAttention', () => {
it('marks everything except lossless formats', () => { it('marks everything except lossless formats', () => {
+61
View File
@@ -1,4 +1,31 @@
// Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5). // Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5).
import { ApiError } from '@pipu/core';
const MAX_COVER_BYTES = 8 * 1024 * 1024;
/** Reduce oversized artwork before base64 expansion; small covers keep their original bytes. */
async function prepareCover(file: File): Promise<Blob> {
if (file.size <= MAX_COVER_BYTES) return file;
let image: ImageBitmap;
try { image = await createImageBitmap(file); }
catch { throw new ApiError(422, 'validation.cover_type'); }
try {
const scale = Math.min(1, 2048 / Math.max(image.width, image.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(image.width * scale));
canvas.height = Math.max(1, Math.round(image.height * scale));
const context = canvas.getContext('2d');
if (!context) throw new ApiError(422, 'validation.cover_type');
context.fillStyle = '#fff';
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
for (const quality of [0.9, 0.75]) {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
if (blob && blob.size <= MAX_COVER_BYTES) return blob;
}
throw new ApiError(422, 'validation.cover_too_large');
} finally { image.close(); }
}
export interface PickedFile { export interface PickedFile {
file: File; file: File;
@@ -30,6 +57,40 @@ export function isAudio(name: string): boolean {
return AUDIO_EXTENSIONS.includes(ext); return AUDIO_EXTENSIONS.includes(ext);
} }
export function isAlbumCover(name: string): boolean {
return /^(cover|folder|front)\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name);
}
export function isArtistImage(name: string): boolean {
return /^artist\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name);
}
/** The nearest ancestor wins; sibling artist folders must never share portraits. */
export function artistImageFor(folder: string, images: ReadonlyMap<string, File>): File | undefined {
for (;;) {
const image = images.get(folder);
if (image) return image;
if (!folder) return undefined;
folder = albumFolder(folder);
}
}
export function albumFolder(relPath: string): string {
const path = relPath.replaceAll('\\', '/');
const at = path.lastIndexOf('/');
return at < 0 ? '' : path.slice(0, at);
}
export async function coverBase64(file: File): Promise<string> {
const prepared = await prepareCover(file);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result).split(',')[1]!);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(prepared);
});
}
/** Файлы из <input type="file"> (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */ /** Файлы из <input type="file"> (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */
export function fromInput(list: FileList | null): PickedFile[] { export function fromInput(list: FileList | null): PickedFile[] {
return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name })); return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name }));
+4 -5
View File
@@ -1,7 +1,7 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
// SHA-256 файла потоком (docs/uploads.md#4): Web Crypto не умеет считать по частям, а файлы бывают по 2 ГБ — // SHA-256 файла потоком (docs/uploads.md#4): Web Crypto не умеет считать по частям, а файлы бывают по 2 ГБ —
// hash-wasm считает чанками в воркере, не держа файл в памяти и не блокируя интерфейс. // js-sha256 считает чанками в воркере без WebAssembly: работает и со строгим CSP у прокси.
import { createSHA256 } from 'hash-wasm'; import { sha256 } from 'js-sha256';
export interface HashRequest { export interface HashRequest {
id: string; id: string;
@@ -18,8 +18,7 @@ const ctx = self as unknown as DedicatedWorkerGlobalScope;
ctx.onmessage = async (e: MessageEvent<HashRequest>) => { ctx.onmessage = async (e: MessageEvent<HashRequest>) => {
const { id, file } = e.data; const { id, file } = e.data;
try { try {
const h = await createSHA256(); const h = sha256.create();
h.init();
const reader = file.stream().getReader(); const reader = file.stream().getReader();
let done = 0; let done = 0;
let lastReport = 0; let lastReport = 0;
@@ -33,7 +32,7 @@ ctx.onmessage = async (e: MessageEvent<HashRequest>) => {
ctx.postMessage({ id, type: 'progress', done } satisfies HashResponse); ctx.postMessage({ id, type: 'progress', done } satisfies HashResponse);
} }
} }
ctx.postMessage({ id, type: 'done', sha256: h.digest('hex') } satisfies HashResponse); ctx.postMessage({ id, type: 'done', sha256: h.hex() } satisfies HashResponse);
} catch (err) { } catch (err) {
ctx.postMessage({ id, type: 'error', message: err instanceof Error ? err.message : String(err) } satisfies HashResponse); ctx.postMessage({ id, type: 'error', message: err instanceof Error ? err.message : String(err) } satisfies HashResponse);
} }
@@ -0,0 +1,101 @@
import { AuthSession } from '@pipu/core';
import { waitFor } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { Uploader } from './uploader';
vi.mock('music-metadata', () => ({
parseBlob: vi.fn(async (file: File) => ({
format: {}, common: file.name.startsWith('01') ? { picture: [{ data: new Uint8Array([0xff, 0xd8, 0xff, 1]), format: 'image/jpeg', type: 'Cover (front)' }] } : {},
})),
}));
vi.mock('tus-js-client', () => ({
Upload: class {
constructor(_file: File, private options: { onSuccess: () => void }) {}
start() { queueMicrotask(this.options.onSuccess); }
},
}));
afterEach(() => { vi.unstubAllGlobals(); localStorage.clear(); });
it('keeps external and embedded album covers in separate batches without skipping images', async () => {
vi.stubGlobal('Worker', class extends EventTarget {
postMessage({ id }: { id: string }) {
queueMicrotask(() => this.dispatchEvent(new MessageEvent('message', { data: { id, type: 'done', sha256: id.padEnd(64, '0') } })));
}
});
const batches: { cover_base64?: string; files: { client_id: string; rel_path: string }[] }[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input) => {
const req = input as Request;
const body = await req.json();
const data = req.url.endsWith('/precheck')
? { files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) }
: (batches.push(body), { batch_id: String(batches.length), files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, upload_file_id: f.client_id, tus_url: `/files/${f.client_id}` })) });
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
} });
const uploader = new Uploader(session);
uploader.add([
{ file: new File(['external-image'], 'Cover.jpg'), relPath: 'A/Cover.jpg' },
{ file: new File(['audio'], '02.flac'), relPath: 'A/02.flac' },
{ file: new File(['audio'], '01.flac'), relPath: 'A/01.flac' },
{ file: new File(['audio'], '02.flac'), relPath: 'B/02.flac' },
{ file: new File(['audio'], '01.flac'), relPath: 'B/01.flac' },
]);
await waitFor(() => expect(uploader.ready).toHaveLength(4));
expect(uploader.getState().skipped).toBe(0);
expect(uploader.getState().items.find((f) => f.relPath === 'A/01.flac')?.coverName).toBe('Cover.jpg');
expect(uploader.getState().items.find((f) => f.relPath === 'B/01.flac')?.coverName).toBe('embedded-cover');
await uploader.start('Two albums');
expect(batches).toHaveLength(2);
expect(batches[0]!.cover_base64).toBe(btoa('external-image'));
expect(batches[1]!.cover_base64).toBe(btoa(String.fromCharCode(0xff, 0xd8, 0xff, 1)));
expect(batches[0]!.files.map((f) => f.rel_path)).toEqual(['A/01.flac', 'A/02.flac']);
expect(batches[1]!.files.map((f) => f.rel_path)).toEqual(['B/01.flac', 'B/02.flac']);
expect(uploader.getState().items.every((f) => f.stage === 'done')).toBe(true);
});
it('splits an artist discography into album batches and inherits only that artist portrait', async () => {
vi.stubGlobal('Worker', class extends EventTarget {
postMessage({ id }: { id: string }) {
queueMicrotask(() => this.dispatchEvent(new MessageEvent('message', { data: { id, type: 'done', sha256: id.padEnd(64, '0') } })));
}
});
const batches: { artist_image_base64?: string; cover_base64?: string; files: { client_id: string; rel_path: string }[] }[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input) => {
const req = input as Request;
const body = await req.json();
const data = req.url.endsWith('/precheck')
? { files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) }
: (batches.push(body), { batch_id: String(batches.length), files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, upload_file_id: f.client_id, tus_url: `/files/${f.client_id}` })) });
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
} });
const uploader = new Uploader(session);
const first = 'творожный бизнес/(2026) творог грехов - творожный бизнес';
const second = 'творожный бизнес/(2025) молчанка - творожный бизнес';
uploader.add([
{ file: new File(['portrait'], 'artist.jpg'), relPath: 'творожный бизнес/artist.jpg' },
{ file: new File(['cover'], 'cover.jpg'), relPath: `${first}/cover.jpg` },
{ file: new File(['a'], '02.flac'), relPath: `${first}/02.flac` },
{ file: new File(['b'], '03.flac'), relPath: `${first}/03.flac` },
{ file: new File(['c'], '02.flac'), relPath: `${second}/02.flac` },
{ file: new File(['d'], '02.flac'), relPath: 'Другой исполнитель/Альбом/02.flac' },
]);
await waitFor(() => expect(uploader.ready).toHaveLength(4));
expect(uploader.getState().skipped).toBe(0);
await uploader.start('Дискография');
expect(batches).toHaveLength(3);
const a = batches.find((batch) => batch.files[0]!.rel_path.startsWith(first))!;
const b = batches.find((batch) => batch.files[0]!.rel_path.startsWith(second))!;
const other = batches.find((batch) => batch.files[0]!.rel_path.startsWith('Другой'))!;
expect(a.files).toHaveLength(2);
expect(b.files).toHaveLength(1);
expect(a.artist_image_base64).toBe(btoa('portrait'));
expect(b.artist_image_base64).toBe(btoa('portrait'));
expect(a.cover_base64).toBe(btoa('cover'));
expect(b.cover_base64).toBeUndefined();
expect(other.artist_image_base64).toBeUndefined();
uploader.clear();
uploader.add([{ file: new File(['new'], '02.flac'), relPath: `${second}/02.flac` }]);
await waitFor(() => expect(uploader.ready).toHaveLength(1));
await uploader.start('New selection');
expect(batches[3]!.artist_image_base64).toBeUndefined();
});
+90 -19
View File
@@ -1,7 +1,7 @@
import { unwrap, type AuthSession } from '@pipu/core'; import { unwrap, type AuthSession } from '@pipu/core';
import * as tus from 'tus-js-client'; import * as tus from 'tus-js-client';
import { isAudio, type PickedFile } from './files'; import { albumFolder, artistImageFor, coverBase64, isAlbumCover, isArtistImage, isAudio, type PickedFile } from './files';
import type { HashRequest, HashResponse } from './hash.worker'; import type { HashRequest, HashResponse } from './hash.worker';
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей // Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
@@ -29,6 +29,7 @@ export interface UploadItem {
progress: number; // 0..1 текущего этапа (хэш или загрузка) progress: number; // 0..1 текущего этапа (хэш или загрузка)
sha256?: string; sha256?: string;
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number }; tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
coverName?: string;
trackId?: string | null; trackId?: string | null;
possibleDuplicate?: boolean; possibleDuplicate?: boolean;
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, … code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
@@ -68,7 +69,12 @@ export class Uploader {
private hashQueue: string[] = []; private hashQueue: string[] = [];
private hashing = false; private hashing = false;
private uploads = new Map<string, tus.Upload>(); private uploads = new Map<string, tus.Upload>();
private transferQueue: { id: string; url: string }[] | null = null;
private transferWorkers = 0;
private transferDone: (() => void) | null = null;
private refreshing: Promise<unknown> | null = null; private refreshing: Promise<unknown> | null = null;
private folderCovers = new Map<string, File>();
private artistImages = new Map<string, File>();
constructor(private readonly session: AuthSession) { constructor(private readonly session: AuthSession) {
this.restore(); this.restore();
@@ -97,17 +103,22 @@ export class Uploader {
/** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */ /** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */
add(files: PickedFile[]) { add(files: PickedFile[]) {
if (this.state.busy) return; if (this.state.busy) return;
for (const f of files) {
if (isAlbumCover(f.file.name)) this.folderCovers.set(albumFolder(f.relPath), f.file);
if (isArtistImage(f.file.name)) this.artistImages.set(albumFolder(f.relPath), f.file);
}
const fresh: UploadItem[] = []; const fresh: UploadItem[] = [];
let skipped = 0; let skipped = 0;
for (const f of files) { for (const f of files) {
if (!isAudio(f.file.name)) { if (!isAudio(f.file.name)) {
skipped++; if (!isAlbumCover(f.file.name) && !isArtistImage(f.file.name)) skipped++;
continue; continue;
} }
// Тот же путь и размер уже в списке — не добавляем второй раз. // Тот же путь и размер уже в списке — не добавляем второй раз.
if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue; if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue;
fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 }); fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 });
} }
fresh.sort((a, b) => a.relPath.localeCompare(b.relPath, undefined, { numeric: true }));
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null }); this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
this.hashQueue.push(...fresh.map((it) => it.id)); this.hashQueue.push(...fresh.map((it) => it.id));
void this.hashNext(); void this.hashNext();
@@ -124,6 +135,8 @@ export class Uploader {
clear() { clear() {
if (this.state.busy) return; if (this.state.busy) return;
this.hashQueue = []; this.hashQueue = [];
this.folderCovers.clear();
this.artistImages.clear();
this.set({ items: [], skipped: 0, batchId: null, error: null }); this.set({ items: [], skipped: 0, batchId: null, error: null });
this.persist(); this.persist();
} }
@@ -217,11 +230,23 @@ export class Uploader {
if (this.state.busy || !ready.length) return; if (this.state.busy || !ready.length) return;
this.set({ busy: true, error: null }); this.set({ busy: true, error: null });
try { try {
const groups = new Map<string, UploadItem[]>();
for (const it of ready) {
const folder = albumFolder(it.relPath);
groups.set(folder, [...(groups.get(folder) ?? []), it]);
}
const queue: { id: string; url: string }[] = [];
for (const [folder, group] of groups) {
try {
const cover = this.folderCovers.get(folder);
const artistImage = artistImageFor(folder, this.artistImages);
const res = unwrap( const res = unwrap(
await this.session.client.POST('/api/v1/uploads', { await this.session.client.POST('/api/v1/uploads', {
body: { body: {
comment: comment.trim() || undefined, comment: comment.trim() || undefined,
files: ready.map((it) => ({ ...(cover ? { cover_base64: await coverBase64(cover) } : {}),
...(artistImage ? { artist_image_base64: await coverBase64(artistImage) } : {}),
files: group.map((it) => ({
client_id: it.id, client_id: it.id,
name: it.file?.name ?? it.relPath, name: it.file?.name ?? it.relPath,
rel_path: it.relPath.includes('/') ? it.relPath : undefined, rel_path: it.relPath.includes('/') ? it.relPath : undefined,
@@ -232,7 +257,6 @@ export class Uploader {
}), }),
); );
this.set({ batchId: res.batch_id }); this.set({ batchId: res.batch_id });
const queue: { id: string; url: string }[] = [];
for (const f of res.files) { for (const f of res.files) {
if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id }); if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id });
else if (f.tus_url && f.upload_file_id) { else if (f.tus_url && f.upload_file_id) {
@@ -241,7 +265,11 @@ export class Uploader {
} }
} }
this.persist(); this.persist();
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); } catch (err) {
this.set({ error: err });
}
}
await this.transfer(queue);
} catch (err) { } catch (err) {
this.set({ error: err }); this.set({ error: err });
} finally { } finally {
@@ -249,24 +277,57 @@ export class Uploader {
} }
} }
get canRetryFailed(): boolean {
return !this.state.busy || this.transferQueue !== null;
}
/** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */ /** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */
async retryFailed() { async retryFailed(id?: string) {
const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl); const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl && it.file && (!id || it.id === id));
if (this.state.busy || !failed.length) return; if (!this.canRetryFailed || !failed.length) return;
const queue = failed.map((it) => ({ id: it.id, url: it.tusUrl! }));
for (const it of failed) this.patch(it.id, { stage: 'uploading', message: undefined, code: undefined });
// При продолжающейся пачке ставим повтор в её очередь, сохраняя предел параллельности.
if (this.transferQueue) {
this.transferQueue.push(...queue);
this.pumpTransfers();
return;
}
this.set({ busy: true, error: null }); this.set({ busy: true, error: null });
try { try {
const queue = failed.map((it) => { await this.transfer(queue);
this.patch(it.id, { stage: 'uploading', message: undefined });
return { id: it.id, url: it.tusUrl! };
});
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} finally { } finally {
this.set({ busy: false }); this.set({ busy: false });
} }
} }
private async drain(queue: { id: string; url: string }[]) { private transfer(queue: { id: string; url: string }[]): Promise<void> {
for (let next = queue.shift(); next; next = queue.shift()) await this.upload(next.id, next.url); return new Promise((resolve) => {
this.transferQueue = queue;
this.transferDone = resolve;
this.pumpTransfers();
});
}
private pumpTransfers() {
const queue = this.transferQueue;
if (!queue) return;
while (this.transferWorkers < PARALLEL && queue.length) {
const next = queue.shift()!;
this.transferWorkers++;
void this.upload(next.id, next.url).catch((err: unknown) => {
this.patch(next.id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
}).finally(() => {
this.transferWorkers--;
this.pumpTransfers();
});
}
if (this.transferWorkers === 0) {
this.transferQueue = null;
const done = this.transferDone;
this.transferDone = null;
done?.();
}
} }
private upload(id: string, url: string): Promise<void> { private upload(id: string, url: string): Promise<void> {
@@ -291,7 +352,7 @@ export class Uploader {
return true; return true;
} }
// Сеть, перегрузка, рассинхрон смещения — повторяем (tus-js-client спросит HEAD и продолжит). // Сеть, перегрузка, рассинхрон смещения — повторяем (tus-js-client спросит HEAD и продолжит).
return status === undefined || status === 0 || status >= 500 || status === 409 || status === 423; return status === undefined || status === 0 || (status >= 500 && status !== 507) || status === 409 || status === 423;
}, },
onProgress: (sent, total) => this.patch(id, { progress: sent / Math.max(total, 1) }), onProgress: (sent, total) => this.patch(id, { progress: sent / Math.max(total, 1) }),
onSuccess: () => { onSuccess: () => {
@@ -302,7 +363,8 @@ export class Uploader {
}, },
onError: (err) => { onError: (err) => {
this.uploads.delete(id); this.uploads.delete(id);
this.patch(id, { stage: 'error', message: err.message }); const status = (err as tus.DetailedError).originalResponse?.getStatus();
this.patch(id, { stage: 'error', message: err.message, code: status === 507 ? 'uploads.storage_full' : undefined });
resolve(); resolve();
}, },
}); });
@@ -315,7 +377,12 @@ export class Uploader {
if (!it.file) return; if (!it.file) return;
try { try {
const { parseBlob } = await import('music-metadata'); const { parseBlob } = await import('music-metadata');
const meta = await parseBlob(it.file); const folder = albumFolder(it.relPath);
const meta = await parseBlob(it.file, { skipCovers: this.folderCovers.has(folder) });
if (!this.folderCovers.has(folder)) {
const picture = meta.common.picture?.find((p) => p.type === 'Cover (front)') ?? meta.common.picture?.[0];
if (picture) this.folderCovers.set(folder, new File([new Uint8Array(picture.data)], 'embedded-cover', { type: picture.format }));
}
const duration = meta.format.duration; const duration = meta.format.duration;
this.patch(it.id, { this.patch(it.id, {
tags: { tags: {
@@ -328,6 +395,10 @@ export class Uploader {
} catch { } catch {
// Теги — подсказка для предпроверки; без них сервер прочитает их сам. // Теги — подсказка для предпроверки; без них сервер прочитает их сам.
} }
const folder = albumFolder(it.relPath);
const cover = this.folderCovers.get(folder);
const first = this.state.items.find((item) => albumFolder(item.relPath) === folder);
if (cover && first) this.patch(first.id, { coverName: cover.name });
} }
/** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */ /** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
@@ -398,7 +469,7 @@ export class Uploader {
if (!queue.length) return; if (!queue.length) return;
this.set({ busy: true }); this.set({ busy: true });
try { try {
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); await this.transfer(queue);
} finally { } finally {
this.set({ busy: false }); this.set({ busy: false });
this.persist(); this.persist();
@@ -0,0 +1,68 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import { downloadUserArchive, MAX_ARCHIVE_BYTES, parseUserArchive, type UserArchive } from './transfer';
export function UserTransfer() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [open, setOpen] = useState(false);
const [archive, setArchive] = useState<UserArchive | null>(null);
const [fileError, setFileError] = useState(false);
const [reading, setReading] = useState(false);
const exporting = useMutation({
mutationFn: async () => downloadUserArchive(unwrap(await session.client.GET('/api/v1/admin/users/export'))),
});
const importing = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/users/import', { body: archive! })),
onSuccess: () => { setArchive(null); void queryClient.invalidateQueries({ queryKey: ['admin-users'] }); },
});
const busy = importing.isPending || reading;
const close = () => { if (!busy) { setOpen(false); setArchive(null); } };
const read = async (file: File | undefined) => {
setArchive(null);
setFileError(false);
importing.reset();
if (!file) return;
setReading(true);
try {
if (file.size > MAX_ARCHIVE_BYTES) throw new Error('size');
setArchive(parseUserArchive(await file.text()));
} catch { setFileError(true); }
finally { setReading(false); }
};
return <>
<Button busy={exporting.isPending} onClick={() => exporting.mutate()}>{t('transfer.export')}</Button>
<Button onClick={() => { importing.reset(); setFileError(false); setOpen(true); }}>{t('transfer.import')}</Button>
<FormError>{exporting.error ? errorText(exporting.error) : null}</FormError>
{open && <Dialog title={t('transfer.import')} onClose={close} footer={<>
<Button variant="ghost" disabled={busy} onClick={close}>{t('common:form.close')}</Button>
<Button variant="primary" busy={importing.isPending} disabled={!archive || busy} onClick={() => importing.mutate()}>
{t('transfer.apply')}
</Button>
</>}>
<p className={s.note}>{t('transfer.description')}</p>
<p className={s.note}>{t('transfer.sensitive')}</p>
<label className={s.sessions}>
<span>{t('transfer.file')}</span>
<input type="file" accept=".json,application/json" disabled={busy} onChange={(e) => void read(e.target.files?.[0])} />
</label>
{archive && <p className={s.note}>{t('transfer.preview', { count: archive.users.length })}</p>}
<FormError>{fileError ? t('transfer.invalid') : importing.error ? errorText(importing.error) : null}</FormError>
{importing.data && <div role="status">
<p>{t('transfer.result', { count: importing.data.created, skipped: importing.data.skipped.length })}</p>
{importing.data.skipped.length > 0 && <p className={s.note}>{importing.data.skipped.join(', ')}</p>}
</div>}
</Dialog>}
</>;
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { parseUserArchive } from './transfer';
describe('user archive preview', () => {
const entry = { username: 'alice', role: 'user', password_hash: '$argon2id$hash' };
it('accepts a versioned export and a UTF-8 BOM', () => {
const archive = { version: 1, users: [entry] };
expect(parseUserArchive(`\uFEFF${JSON.stringify(archive)}`)).toEqual(archive);
});
it.each([
'{}', '{"version":2,"users":[]}', '{"version":1,"users":{}}',
JSON.stringify({ version: 1, users: [{ ...entry, role: 'owner' }] }),
JSON.stringify({ version: 1, users: [{ username: 'alice', role: 'user' }] }),
JSON.stringify({ version: 1, users: Array(10001).fill(entry) }),
])('rejects unsupported or malformed input', (text) => {
expect(() => parseUserArchive(text)).toThrow();
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { components } from '@pipu/api-client';
export type UserArchive = components['schemas']['UserArchive'];
export const MAX_ARCHIVE_BYTES = 16 * 1024 * 1024;
/** Only previews metadata; the server validates every account before writing. */
export function parseUserArchive(text: string): UserArchive {
const data: unknown = JSON.parse(text.replace(/^\uFEFF/, ''));
if (!data || typeof data !== 'object' || !('version' in data) || data.version !== 1 ||
!('users' in data) || !Array.isArray(data.users) || data.users.length > 10000) throw new Error('archive');
for (const entry of data.users) {
if (!entry || typeof entry !== 'object' || typeof entry.username !== 'string' ||
!['admin', 'moderator', 'user'].includes(entry.role) || typeof entry.password_hash !== 'string') {
throw new Error('archive');
}
}
return data as UserArchive;
}
export function downloadUserArchive(archive: UserArchive) {
const url = URL.createObjectURL(new Blob([JSON.stringify(archive, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = 'pipu-users.json';
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+2 -1
View File
@@ -9,7 +9,7 @@ import { I18nextProvider } from 'react-i18next';
import { App } from './App'; import { App } from './App';
import { createAppRouter } from './app/router'; import { createAppRouter } from './app/router';
import { applyTheme } from './app/session'; import { applyTheme } from './app/session';
import { createDesktop, DesktopProvider } from './desktop'; import { createDesktop, DesktopProvider, desktopFetch } from './desktop';
const LOCALE_KEY = 'pipu.locale'; const LOCALE_KEY = 'pipu.locale';
@@ -70,6 +70,7 @@ const session = new AuthSession({
deviceName: desktop ? 'Pipu / Desktop' : deviceName(), deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
baseUrl: desktop?.proxyBase, baseUrl: desktop?.proxyBase,
refreshStore: desktop?.refreshStore, refreshStore: desktop?.refreshStore,
fetch: desktop ? desktopFetch : undefined,
}); });
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу. // До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
await session.restore(); await session.restore();
+1 -5
View File
@@ -41,8 +41,6 @@ const ICONS: Record<string, string> = {
rainy: waveRainyIcon, rainy: waveRainyIcon,
}; };
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
type Tr = (key: string) => string; type Tr = (key: string) => string;
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */ /** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
@@ -97,8 +95,6 @@ function WaveHome() {
const waveOn = st.wave !== null; const waveOn = st.wave !== null;
const playing = waveOn && st.playing; const playing = waveOn && st.playing;
const track = waveOn ? st.track : null; const track = waveOn ? st.track : null;
const colors: [string, string, string] =
track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS;
const run = (fn: () => Promise<void> | void) => { const run = (fn: () => Promise<void> | void) => {
setError(null); setError(null);
@@ -129,7 +125,7 @@ function WaveHome() {
readAudio={player.readAudio} readAudio={player.readAudio}
bpm={track?.features?.bpm ?? undefined} bpm={track?.features?.bpm ?? undefined}
energy={track?.features?.energy ?? undefined} energy={track?.features?.energy ?? undefined}
colors={colors} colors={track?.album.cover_colors}
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'} theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
size={320} size={320}
label={t(playing ? 'wave.pause' : 'wave.start')} label={t(playing ? 'wave.pause' : 'wave.start')}
+45 -10
View File
@@ -1,5 +1,5 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router'; import { Link, useNavigate } from '@tanstack/react-router';
import { useState, type MouseEvent } from 'react'; import { useState, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -9,9 +9,12 @@ import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { playlistShareUrl } from '@pipu/core';
import { desktopHandle } from '../desktop';
import { useMusicShare } from '../features/social/Social';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui'; import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon } from '../shared/ui/icons'; import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons';
import c from './catalog.module.css'; import c from './catalog.module.css';
import s from './page.module.css'; import s from './page.module.css';
import p from './playlists.module.css'; import p from './playlists.module.css';
@@ -42,19 +45,33 @@ export function PlaylistsPage() {
</div>; </div>;
} }
export function PlaylistPage({ id }: { id: string }) { export function PlaylistPage({ id, shared = false }: { id: string; shared?: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const query = usePlaylist(id); const query = usePlaylist(id, shared);
const errorText = useErrorText(); const errorText = useErrorText();
return <div className={s.page}> return <div className={s.page}>
<Link to="/collection/playlists">← {t('playlists.title')}</Link> <Link to="/collection/playlists">← {t('playlists.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError> <FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <PageSkeleton />} {query.isPending && <PageSkeleton />}
{query.data && <PlaylistDetail key={id} playlist={query.data} />} {query.data && !query.isError && <PlaylistDetail key={id} playlist={query.data} readOnly={shared} shareId={shared ? id : undefined} />}
</div>; </div>;
} }
function PlaylistDetail({ playlist }: { playlist: Playlist }) { export function ActivityPlaylistPage({ userId }: { userId: string }) {
const session = useSession();
const errorText = useErrorText();
const { t } = useTranslation();
const query = useQuery({ queryKey: ['activity-playlist', session.state.status === 'signed_in' ? session.state.user.id : '', userId], queryFn: async () => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })), staleTime: 0, refetchInterval: 15_000 });
return <div className={s.page}>
<Link to="/friends">← {t('social.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <PageSkeleton />}
{query.data && !query.isError && <PlaylistDetail key={query.data.id} playlist={query.data} readOnly activity />}
</div>;
}
function PlaylistDetail({ playlist, readOnly = false, shareId, activity = false }: { playlist: Playlist; readOnly?: boolean; shareId?: string; activity?: boolean }) {
const openShare = useMusicShare();
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const cache = useQueryClient(); const cache = useQueryClient();
@@ -64,6 +81,18 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
const [edit, setEdit] = useState(false); const [edit, setEdit] = useState(false);
const [remove, setRemove] = useState(false); const [remove, setRemove] = useState(false);
const [manage, setManage] = useState(false); const [manage, setManage] = useState(false);
const sharing = useMutation({ mutationFn: async () => {
let token = shareId ?? playlist.share_id;
if (!readOnly) {
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
}
if (!token) throw new Error('Share link unavailable');
openShare({ attachment: { kind: 'playlist', id: token, title: playlist.name }, url: playlistShareUrl(desktopHandle()?.server ?? window.location.origin, token) });
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })),
onSuccess: () => { cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name }; const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids }, params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
@@ -84,7 +113,7 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
return <> return <>
<header className={c.colHero}> <header className={c.colHero}>
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} /> <Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1> <div className={c.heroText}><span className={c.kicker}>{t(readOnly || playlist.share_id ? 'playlists.shared' : 'playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
<p className={p.description}>{playlist.description}</p> <p className={p.description}>{playlist.description}</p>
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span> <span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
</div> </div>
@@ -92,10 +121,16 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
<div className={s.toolbar}> <div className={s.toolbar}>
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button> <Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button> <Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
{!activity && <Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>}
{!readOnly && <>
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button> <Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button> <Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button> <Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
{playlist.share_id && <Button variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onClick={() => revoke.mutate()}>{t('playlists.revoke_share')}</Button>}
</>}
</div> </div>
{!readOnly && <p>{t('playlists.share_hint')}</p>}
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
<FormError>{save.error ? errorText(save.error) : null}</FormError> <FormError>{save.error ? errorText(save.error) : null}</FormError>
{!playlist.tracks.length && ( {!playlist.tracks.length && (
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}> <EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
@@ -103,13 +138,13 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
</EmptyState> </EmptyState>
)} )}
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)} <TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
actions={manage ? (track, i) => <span className={p.actions}> actions={!readOnly && manage ? (track, i) => <span className={p.actions}>
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button> <button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button> <button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button> <button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
</span> : undefined} /> </span> : undefined} />
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />} {!readOnly && edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
{remove && ( {!readOnly && remove && (
<Dialog <Dialog
title={t('playlists.delete')} title={t('playlists.delete')}
onClose={() => { onClose={() => {
+5
View File
@@ -13,6 +13,8 @@ import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch'; import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css'; import s from './page.module.css';
import { Onboarding } from './Onboarding'; import { Onboarding } from './Onboarding';
import { FriendRecommendations } from '../features/social/Social';
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>; type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>; type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
@@ -27,6 +29,8 @@ export function SettingsPage() {
<ProfileSection /> <ProfileSection />
<RecommendationSettings /> <RecommendationSettings />
<AppearanceSection /> <AppearanceSection />
<DesktopOfflineSection />
<DesktopUpdateButton />
<PasswordSection /> <PasswordSection />
<SessionsSection /> <SessionsSection />
</div> </div>
@@ -368,6 +372,7 @@ function RecommendationSettings() {
const errorText = useErrorText(); const errorText = useErrorText();
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 }); const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
return <section className={s.section}> return <section className={s.section}>
<FriendRecommendations />
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} /> {editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>} : <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
<FormError>{options.error ? errorText(options.error) : null}</FormError> <FormError>{options.error ? errorText(options.error) : null}</FormError>
+1 -1
View File
@@ -46,7 +46,7 @@ export function TrackPage({ id }: { id: string }) {
<img src={coverSrc(tr.album.cover_url, 300)} alt="" /> <img src={coverSrc(tr.album.cover_url, 300)} alt="" />
</span> </span>
)} )}
<Cover album={tr.album} size={280} className={c.heroCover} /> <Cover album={tr.album} size="100%" className={c.heroCover} />
<div className={c.heroBody}> <div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span> <span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr.title}</h1> <h1 className={c.heroTitle}>{tr.title}</h1>
@@ -0,0 +1,59 @@
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { SessionProvider } from '../../app/session';
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
afterEach(cleanup);
async function fixture(mode: 'pages' | 'empty' | 'error' = 'pages') {
const cursors: (string | null)[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => {
const req = new Request(input, init);
const cursor = new URL(req.url).searchParams.get('cursor');
cursors.push(cursor);
if (mode === 'error') return new Response(JSON.stringify({ code: 'server.error' }), { status: 500 });
return new Response(JSON.stringify({
items: mode === 'empty' ? [] : cursor ? [{ track_id: 'second', title: 'Без файла', source_id: null, source_name: null, rel_path: null, error: '' }]
: [{ track_id: 'first', title: 'Короткий трек', source_id: 'source', source_name: 'Base', rel_path: 'Исполнитель/Альбом/track.mp3', error: 'audio is shorter than 1 s' }],
next_cursor: mode === 'pages' && !cursor ? 'next-page' : null,
}), { headers: { 'Content-Type': 'application/json' } });
} });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const i18n = await createI18n('ru');
const close = vi.fn();
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
<AnalysisErrorsDialog onClose={close} />
</QueryClientProvider></SessionProvider></I18nextProvider>);
return { close, cursors, user: userEvent.setup() };
}
it('shows storage, path and error, loads the next page and closes with Escape', async () => {
const { user, cursors, close } = await fixture();
expect(screen.getByRole('dialog', { name: 'Ошибки аудиоанализа' })).toBeVisible();
expect(await screen.findByText('Исполнитель/Альбом/track.mp3')).toBeVisible();
expect(screen.getByText('Хранилище: Base')).toBeVisible();
expect(screen.getByText('audio is shorter than 1 s')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Показать ещё' }));
expect(await screen.findByText('В библиотеке больше нет файла этого трека.')).toBeVisible();
expect(screen.getByText('Короткий трек')).toBeVisible();
expect(cursors).toEqual([null, 'next-page']);
expect(screen.queryByRole('button', { name: 'Показать ещё' })).not.toBeInTheDocument();
await user.keyboard('{Escape}');
expect(close).toHaveBeenCalledOnce();
});
it('shows an empty state after failed jobs have been retried', async () => {
await fixture('empty');
expect(await screen.findByText('Неудавшихся задач анализа нет.')).toBeVisible();
});
it('shows a request failure without claiming the list is empty', async () => {
await fixture('error');
expect(await screen.findByRole('alert')).toBeVisible();
expect(screen.queryByText('Неудавшихся задач анализа нет.')).not.toBeInTheDocument();
});
@@ -0,0 +1,40 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import s from './library.module.css';
export function AnalysisErrorsDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const errors = useInfiniteQuery({
queryKey: ['analysis-errors'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => unwrap(await session.client.GET('/api/v1/admin/library/analysis/errors', {
params: { query: { limit: 50, cursor: pageParam } },
})),
getNextPageParam: (page) => page.next_cursor ?? undefined,
});
const items = errors.data?.pages.flatMap((page) => page.items) ?? [];
return <Dialog title={t('library.analysis_errors_title')} onClose={onClose}
footer={<Button onClick={onClose}>{t('common:form.close')}</Button>}>
<p>{t('library.analysis_errors_hint')}</p>
{errors.isPending && <p role="status">{t('common:loading')}</p>}
<FormError>{errors.error ? errorText(errors.error) : null}</FormError>
{errors.isSuccess && items.length === 0 && <p>{t('library.analysis_errors_empty')}</p>}
{items.length > 0 && <ul className={s.analysisErrors}>
{items.map((item) => <li key={item.track_id}>
<strong>{item.title}</strong>
{item.source_name && <span className={s.scanSummary}>{t('library.analysis_error_source', { name: item.source_name })}</span>}
<code className={s.analysisPath}>{item.rel_path ?? t('library.analysis_error_no_file')}</code>
<span className={s.scanError}>{item.error || t('library.analysis_error_unknown')}</span>
</li>)}
</ul>}
{errors.hasNextPage && <Button busy={errors.isFetchingNextPage} onClick={() => void errors.fetchNextPage()}>{t('library.analysis_errors_more')}</Button>}
</Dialog>;
}
@@ -0,0 +1,67 @@
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it } from 'vitest';
import { SessionProvider } from '../../app/session';
import { GeniusPanel } from './GeniusPanel';
afterEach(cleanup);
const initial = {
configured: true, paused: false, running: true,
total: 100, pending: 80, matched: 17, lyrics: 15, annotations: 5, images: 17,
unmatched: 2, errors: 1, last_error: '', cooldown_until: '2000-01-01T00:00:00Z',
};
async function fixture(overrides: Partial<typeof initial> = {}) {
let data = { ...initial, ...overrides };
const actions: string[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (url, init) => {
const request = new Request(url, init);
if (request.method === 'POST') {
const { action } = await request.json() as { action: string };
actions.push(action);
data = { ...data, paused: action === 'pause' };
}
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
} });
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(['genius-stats'], data);
const i18n = await createI18n('ru');
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
<GeniusPanel />
</QueryClientProvider></SessionProvider></I18nextProvider>);
return { actions, user: userEvent.setup() };
}
it.each<[Partial<typeof initial>, string]>([
[{}, 'Получение данных из Genius…'],
[{ running: false }, 'Очередь ожидает обработки'],
[{ paused: true }, 'Обработка приостановлена'],
[{ cooldown_until: '2099-01-01T00:00:00Z' }, 'Ожидание после ограничения Genius'],
[{ running: false, pending: 0 }, 'Ожидание новых треков'],
])('shows queue state and progress for %j', async (overrides, status) => {
await fixture(overrides);
expect(screen.getByRole('status')).toHaveTextContent(status);
const progress = screen.getByRole('progressbar', { name: 'Прогресс обработки Genius' });
expect(progress).toHaveAttribute('max', '100');
expect(progress).toHaveAttribute('value', String(100 - (overrides.pending ?? 80)));
});
it('pauses and resumes the existing queue without requesting a retry', async () => {
const { user, actions } = await fixture();
await user.click(screen.getByRole('button', { name: 'Приостановить обработку' }));
await user.click(await screen.findByRole('button', { name: 'Продолжить обработку' }));
expect(actions).toEqual(['pause', 'start']);
expect(screen.getByRole('progressbar')).toHaveAttribute('value', '20');
});
it('explains the retry scope and sends the retry action', async () => {
const { user, actions } = await fixture();
expect(screen.getByText(/Повтор добавит в очередь только/)).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Добавить проблемные треки в очередь' }));
expect(actions).toEqual(['retry']);
});
+12 -3
View File
@@ -8,7 +8,7 @@ import s from '../page.module.css';
import g from '../genius.module.css'; import g from '../genius.module.css';
export function GeniusPanel() { export function GeniusPanel() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const session = useSession(); const session = useSession();
const queries = useQueryClient(); const queries = useQueryClient();
const errorText = useErrorText(); const errorText = useErrorText();
@@ -24,16 +24,23 @@ export function GeniusPanel() {
}); });
const data = stats.data; const data = stats.data;
const cooling = data && Date.parse(data.cooldown_until) > Date.now(); const cooling = data && Date.parse(data.cooldown_until) > Date.now();
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : t('genius.waiting'); const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : data.pending > 0 ? t('genius.queued') : t('genius.waiting');
const processed = data ? Math.max(0, data.total - data.pending) : 0;
const formatNumber = (value: number) => value.toLocaleString(i18n.language);
return <section className={s.section} aria-label="Genius"> return <section className={s.section} aria-label="Genius">
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div> <div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
<p className={s.subtitle}>{t('genius.description')}</p> <p className={s.subtitle}>{t('genius.description')}</p>
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError> <FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
{data && <> {data && <>
{!data.configured && <p>{t('genius.configure')}</p>} {!data.configured && <p>{t('genius.configure')}</p>}
{data.total > 0 && <div className={g.progress}>
<span>{t('genius.progress', { processed: formatNumber(processed), total: formatNumber(data.total) })}</span>
<progress aria-label={t('genius.progress_label')} value={processed} max={data.total} />
<p className={s.subtitle}>{t('genius.progress_hint')}</p>
</div>}
<dl className={g.stats}> <dl className={g.stats}>
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) => {(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{data[key].toLocaleString()}</dd></div>)} <div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{formatNumber(data[key])}</dd></div>)}
</dl> </dl>
{data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>} {data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>}
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>} {cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
@@ -41,6 +48,8 @@ export function GeniusPanel() {
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button> <Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button> <Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
</div> </div>
<p className={s.subtitle}>{t('genius.controls_hint')}</p>
<p className={s.subtitle}>{t('genius.retry_hint')}</p>
</>} </>}
</section>; </section>;
} }
+12 -2
View File
@@ -1,6 +1,6 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId } from 'react'; import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session'; import { useSession, useUser } from '../../app/session';
@@ -10,6 +10,7 @@ import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
import l from './library.module.css'; import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus'; import { ScanLine, StatusBadge } from './ScanStatus';
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */ /** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
export function LibraryPage() { export function LibraryPage() {
@@ -18,6 +19,7 @@ export function LibraryPage() {
const user = useUser(); const user = useUser();
const errorText = useErrorText(); const errorText = useErrorText();
const admin = user.role === 'admin'; const admin = user.role === 'admin';
const [showAnalysisErrors, setShowAnalysisErrors] = useState(false);
const status = useQuery({ const status = useQuery({
queryKey: ['library-status'], queryKey: ['library-status'],
@@ -27,6 +29,7 @@ export function LibraryPage() {
const errors = useQuery({ const errors = useQuery({
queryKey: ['library-errors'], queryKey: ['library-errors'],
enabled: admin, enabled: admin,
refetchInterval: status.data?.sources.some((source) => source.scan?.running) ? 2000 : 15000,
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })), queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
}); });
const reindex = useMutation({ const reindex = useMutation({
@@ -35,7 +38,10 @@ export function LibraryPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const retry = useMutation({ const retry = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')), mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }), onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['library-status'] });
void queryClient.invalidateQueries({ queryKey: ['analysis-errors'] });
},
}); });
const d = status.data; const d = status.data;
const tiles = d const tiles = d
@@ -89,13 +95,17 @@ export function LibraryPage() {
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>} {d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
</span> </span>
{admin && ( {admin && (
<div className={l.analysisActions}>
<Button onClick={() => setShowAnalysisErrors(true)}>{t('library.analysis_show_errors')}</Button>
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}> <Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')} {t('library.analysis_retry')}
</Button> </Button>
</div>
)} )}
</div> </div>
)} )}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>} {d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
{showAnalysisErrors && <AnalysisErrorsDialog onClose={() => setShowAnalysisErrors(false)} />}
</section> </section>
)} )}
{d && ( {d && (
+1 -1
View File
@@ -83,7 +83,7 @@ export function PatternsPage({ id }: { id: string }) {
}); });
return ( return (
<div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}> <div className={s.page}>
<div className={s.head}> <div className={s.head}>
<div> <div>
<h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1> <h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1>
@@ -0,0 +1,42 @@
import type { components } from '@pipu/api-client';
import { createI18n } from '@pipu/i18n';
import { cleanup, render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it } from 'vitest';
import { ScanLine } from './ScanStatus';
afterEach(cleanup);
const scan: components['schemas']['ScanProgress'] = {
running: true, full: false, started_at: '2026-10-22T12:00:00Z', finished_at: null,
found: 12, to_process: 0, processed: 0, added: 0, updated: 0, moved: 0, missing: 0, errors: 0, error: null,
phase: 'walking', walked_dirs: 4, current_dir: 'Artist/Album',
};
async function fixture(overrides: Partial<typeof scan> = {}) {
const i18n = await createI18n('ru');
render(<I18nextProvider i18n={i18n}><ScanLine scan={{ ...scan, ...overrides }} lastScanAt={null} /></I18nextProvider>);
}
it('shows changing folder and file counts during the walk without a misleading percentage', async () => {
await fixture();
expect(screen.getByText(/Проверено папок: 4 · найдено аудиофайлов: 12/)).toBeVisible();
expect(screen.getByText('Текущая папка: Artist/Album')).toBeVisible();
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
});
it('shows a percentage only once file processing has a known total', async () => {
await fixture({ phase: 'processing', to_process: 12, processed: 3 });
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25');
expect(screen.queryByText(/Текущая папка/)).not.toBeInTheDocument();
});
it.each([['preparing', 'Сопоставление файлов с библиотекой…'], ['finalizing', 'Завершение сканирования…']] as const)('labels the %s stage', async (phase, label) => {
await fixture({ phase });
expect(screen.getByText(label)).toBeVisible();
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
});
it('supports servers without the new progress fields', async () => {
await fixture({ phase: undefined, walked_dirs: undefined, current_dir: undefined, found: 0 });
expect(screen.getByText('Обход папок…')).toBeVisible();
});
+11 -3
View File
@@ -19,14 +19,22 @@ export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; last
const { t, i18n } = useTranslation('admin'); const { t, i18n } = useTranslation('admin');
const id = useId(); const id = useId();
if (scan?.running) { if (scan?.running) {
const phase = scan.phase ?? (scan.found === 0 ? 'walking' : scan.to_process > 0 ? 'processing' : 'preparing');
const determinate = phase === 'processing' && scan.to_process > 0;
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0; const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
const state = phase === 'walking'
? scan.walked_dirs === undefined ? t('sources.scan_walking') : t('sources.scan_walk_progress', { dirs: scan.walked_dirs.toLocaleString(i18n.language), found: scan.found.toLocaleString(i18n.language) })
: phase === 'preparing' ? t('sources.scan_preparing')
: phase === 'finalizing' ? t('sources.scan_finalizing')
: t('sources.scanning', { processed: scan.processed, total: scan.to_process });
return ( return (
<div className={s.scan}> <div className={s.scan}>
<div className={s.scanText} id={id}> <div className={s.scanText} id={id}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })} {state}
</div> </div>
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}> {phase === 'walking' && scan.current_dir && <div className={s.scanPath} title={scan.current_dir}>{t('sources.scan_current_dir', { path: scan.current_dir })}</div>}
<span style={{ width: `${pct}%` }} /> <div className={cx(s.bar, !determinate && s.indeterminate)} role="progressbar" aria-labelledby={id} aria-valuenow={determinate ? pct : undefined} aria-valuemin={0} aria-valuemax={100}>
<span style={determinate ? { width: `${pct}%` } : undefined} />
</div> </div>
</div> </div>
); );
@@ -0,0 +1,63 @@
import type { components } from '@pipu/api-client';
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { SessionProvider } from '../../app/session';
import { mobileFixtures } from '../../test/mobileFixtures';
import { SourceDialog } from './SourcesPage';
afterEach(cleanup);
type Source = components['schemas']['Source'];
const base = (mobileFixtures['/api/v1/admin/sources'] as { items: Source[] }).items[0]!;
async function fixture(source: Source | null = { ...base, excluded_dirs: ['Downloads_PumpEars'] }, reject = false) {
const bodies: Record<string, unknown>[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => {
const req = new Request(input, init);
if (req.method === 'PATCH' || req.method === 'POST') {
const body = await req.json() as Record<string, unknown>;
bodies.push(body);
return new Response(JSON.stringify(reject
? { code: 'validation.excluded_dirs_invalid', errors: [{ field: 'excluded_dirs', code: 'validation.excluded_dirs_invalid' }] }
: { ...base, ...body }), { status: reject ? 422 : 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ path: '', dirs: [] }), { headers: { 'Content-Type': 'application/json' } });
} });
const i18n = await createI18n('ru');
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const close = vi.fn();
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
<SourceDialog source={source} onClose={close} />
</QueryClientProvider></SessionProvider></I18nextProvider>);
return { bodies, close, user: userEvent.setup() };
}
it('loads and saves one excluded path per line', async () => {
const { bodies, close, user } = await fixture();
const input = screen.getByLabelText('Не сканировать папки');
expect(input).toHaveValue('Downloads_PumpEars');
await user.type(input, '\nArtist/Samples\n');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(bodies[0]?.excluded_dirs).toEqual(['Downloads_PumpEars', 'Artist/Samples']);
});
it('clears the list explicitly so excluded folders can be scanned again', async () => {
const { bodies, close, user } = await fixture();
await user.clear(screen.getByLabelText('Не сканировать папки'));
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(bodies[0]?.excluded_dirs).toEqual([]);
});
it('keeps invalid paths in the editor and displays the server validation', async () => {
const { user, close } = await fixture({ ...base, excluded_dirs: ['../outside'] }, true);
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(screen.getByLabelText('Не сканировать папки')).toHaveAttribute('aria-invalid', 'true'));
expect(screen.getByLabelText('Не сканировать папки')).toHaveValue('../outside');
expect(close).not.toHaveBeenCalled();
});
Loaded 100 of 316 files, more files were not shown because too many files have changed in this diff. Show more