Compare commits

..
58 Commits
Author SHA1 Message Date
mrleo1nidandCursor 72567acd28 feat(web): polish catalog motion, empty states, and mobile now-playing
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:53:37 +03:00
mrleo1nidandCursor 39aaaed969 feat(web): list previous app builds on the download page
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 2m1s
ci / db / db (push) Successful in 1m4s
GET /app/clients now returns up to 8 Gitea releases per OS so users can download older installers while they still exist after prune.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:40:08 +03:00
mrleo1nidandCursor 8eb6b36888 ci(release): keep files and Actions runs for the last 8 releases
ci / ts / ts (packages + web) (push) Successful in 3m30s
release / android (APK в релиз) (push) Successful in 8m57s
release / release (версия и заметки) (push) Successful in 8s
release / desktop (Linux и Windows в релиз) (push) Successful in 16m4s
release / prune (старые файлы и прогоны) (push) Successful in 8s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:32:18 +03:00
mrleo1nidandCursor 83b604437c perf(desktop): ship a .deb instead of an 80MB AppImage
ci / api / api (push) Successful in 50s
ci / db / db (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 49s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Canceled after 2m4s
ci / desktop / rust (desktop proxy) (push) Successful in 5m33s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Failing after 4m6s
release / desktop (Linux и Windows в релиз) (push) Successful in 17m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:26:50 +03:00
mrleo1nidandCursor 73ad61e7a5 feat(genius): load song metadata, lyrics, and artwork
ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:23:54 +03:00
mrleo1nid fb20258489 feat(player): add current track to playlists 2026-10-22 07:54:57 +03:00
mrleo1nid 78c006184b feat(branding): add unified Pipu Music app icons
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m30s
ci / ts / ts (packages + web) (push) Successful in 3m29s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m10s
ci / desktop / rust (desktop proxy) (push) Successful in 3m15s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
2026-10-22 07:48:00 +03:00
mrleo1nidandCursor d3116aba96 perf(desktop): stop packing GStreamer into the AppImage
ci / ts / ts (packages + web) (push) Successful in 2m54s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Successful in 7m28s
ci / desktop / rust (desktop proxy) (push) Successful in 7m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m56s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 01:30:01 +03:00
mrleo1nidandCursor b7518aa450 fix(web): keep the track title readable in the 320px player
ci / ts / ts (packages + web) (push) Successful in 3m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:50:34 +03:00
mrleo1nidandCursor eb8031b126 ci(release): cross-compile Windows NSIS on the Linux runner
ci / ts / ts (packages + web) (push) Failing after 1m41s
release / release (версия и заметки) (push) Successful in 7s
release / android (APK в релиз) (push) Successful in 6m0s
ci / desktop / rust (desktop proxy) (push) Successful in 10m12s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m8s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:43:16 +03:00
mrleo1nidandCursor e4b841a8d8 ci(release): attach Linux and Windows desktop installers
ci / ts / ts (packages + web) (push) Failing after 1m42s
release / android (APK в релиз) (push) Successful in 7m49s
release / release (версия и заметки) (push) Successful in 8s
ci / desktop / rust (desktop proxy) (push) Successful in 7m35s
release / desktop (Linux в релиз) (push) Canceled after 2m48s
release / desktop (Windows в релиз) (push) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:32:33 +03:00
mrleo1nidandCursor 597067689a feat: add a platform-aware apps download page
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m10s
ci / db / db (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / desktop / rust (desktop proxy) (push) Successful in 5m37s
ci / ts / ts (packages + web) (push) Successful in 4m10s
release / release (версия и заметки) (push) Successful in 11s
release / android (APK в релиз) (push) Successful in 4m22s
Serve desktop and Android builds from the latest Gitea release on /download, and fix the desktop compile and CI flakes that blocked v1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:10:03 +03:00
mrleo1nidandCursor efe78d7e07 feat(desktop): wrap the web client in a Tauri app
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Failing after 2m49s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / android (APK в релиз) (push) Failing after 2m0s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:45:03 +03:00
mrleo1nidandCursor fa79394869 feat: add personal playlists and shareable track links
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m8s
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:37:53 +03:00
mrleo1nid 3a097c15c8 perf(web): preload restored audio before playback
ci / ts / ts (packages + web) (push) Successful in 2m20s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m43s
2026-10-21 22:49:10 +03:00
mrleo1nid 20abe20686 fix(mobile): prevent crash when restoring a saved playback queue
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m32s
ci / ts / ts (packages + web) (push) Successful in 2m49s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Successful in 4m20s
2026-10-21 18:41:48 +03:00
mrleo1nid c34272d508 fix(auth): allow admins to change any username including their own
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / api / api (push) Successful in 49s
ci / db / db (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 2m59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
2026-10-21 17:33:19 +03:00
mrleo1nid b0cbd0f149 fix: repair Android startup and improve mobile web layouts
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 2m22s
release / android (APK в релиз) (push) Successful in 4m15s
release / release (версия и заметки) (push) Successful in 6s
2026-10-21 17:14:05 +03:00
mrleo1nid 4c2286e70d feat(web): react wave and ambient visuals to live audio
ci / ts / ts (packages + web) (push) Failing after 2m2s
2026-10-21 16:44:04 +03:00
mrleo1nid 9223516547 fix(web): soften wave glow and follow liquid shape
ci / ts / ts (packages + web) (push) Successful in 2m25s
2026-10-21 16:33:22 +03:00
mrleo1nidandClaude Opus 5.5 7b7ce78de8 fix(web): align the upload queue settings fields
ci / ts / ts (packages + web) (push) Successful in 1m53s
Labels that wrapped to two lines pushed their inputs down, the storage
select had its own label style, and the checkbox sat in the middle of its
cell. The section now lays label, input and hint rows out with subgrid, and
the select no longer stretches past its column.

Also add a web-fake launch config that runs the web app on the fake API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:44:20 +03:00
mrleo1nidandClaude Opus 5.5 4d2c6ce22f fix(web): treat AIFF and WavPack as lossless in the upload format hint
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m11s
The web check only accepted FLAC and WAV, so AIFF files were flagged
"needs attention" although the server already counts them as lossless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:35:43 +03:00
mrleo1nidandClaude Opus 5.5 dca7173a88 ci(ts): install Playwright system deps and cache the browser
ci / ts / ts (packages + web) (push) Successful in 3m30s
E2e failed to launch Chromium on the runner (missing libnspr4.so). Install
with --with-deps and cache ~/.cache/ms-playwright, since the primary download
host is unreachable from the runner and the fallback takes minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:21 +03:00
mrleo1nid 1e3b350dfc feat(upload): enhance audio upload handling and user feedback
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / api / api (push) Successful in 45s
ci / recommender / recommender (push) Successful in 1m50s
ci / ts / ts (packages + web) (push) Failing after 6m53s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m13s
- Introduced a new warning for users uploading non-FLAC or WAV formats, indicating that these formats should only be used as a last resort.
- Added functionality to highlight files needing format attention in the upload queue.
- Updated the UI to provide clearer guidance on acceptable audio formats during uploads.
- Improved documentation to reflect changes in upload requirements and user interface updates.
2026-10-21 10:07:43 +03:00
mrleo1nidandClaude Opus 5.5 74da457362 feat(auth): make upload review a separate grant instead of an admin privilege
Reviewing the upload queue is now a per-user flag (reviews_uploads), not part
of the admin role: several people can hold it, a moderator can have it, and an
admin may not. Only admins grant or revoke it; existing admins keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:07:05 +03:00
mrleo1nidandCursor 066ba24834 feat: finish the upload, playback, and test gaps from the docs
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:37:53 +03:00
mrleo1nidandCursor 2b76060ea3 fix(web): keep menu labels on one line and fold the admin section by default
ci / ts / ts (packages + web) (push) Successful in 1m27s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:06:33 +03:00
mrleo1nidandCursor dfc72b1d69 chore(deploy): leave database backups to external tools
ci / db / db (push) Successful in 1m40s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 3m51s
ci / ts / ts (packages + web) (push) Successful in 54s
The app no longer dumps Postgres, restores it, or checks copies in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:03:06 +03:00
mrleo1nidandClaude Opus 5.5 978f03b8f8 feat(admin): reset a user's recommendations from the users panel
ci / api / api (push) Successful in 40s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m10s
ci / db / db (push) Successful in 2m35s
ci / ts / ts (packages + web) (push) Successful in 1m23s
- POST /admin/users/{id}/reset-recommendations clears the taste profile
  (onboarding, CF vector, bandit), play stats and wave impressions; likes,
  dislikes and history stay, wave sessions stay so a playing wave keeps going
- moderators may reset plain users, admins anyone including themselves;
  the reset is written to the audit log
- users panel gets the menu action with a confirmation; docs updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:52:31 +03:00
mrleo1nidandClaude Opus 5.5 b27b915b63 feat(web): settings to keep the search open and hide home pickers; collapsible menu sections
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m44s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m43s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / api / api (push) Successful in 2m27s
ci / db / db (push) Successful in 2m23s
- appearance settings get "collapse search" (off keeps the field always open)
  and separate switches for the activity and mood pickers on the home page;
  stored in ui_prefs (search_collapse, home_activities, home_moods)
- Collection and Admin headers in the sidebar fold their items; the state is
  remembered in the browser, a folded Admin shows the review queue badge;
  icon-only and phone layouts always show every item

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:26:48 +03:00
mrleo1nidandClaude Opus 5.5 73e642c1ec fix(mobile): APK starts on Android 15+ 16 KB page devices and emulators
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m56s
release / android (APK в релиз) (push) Successful in 4m53s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 1m48s
release / release (версия и заметки) (push) Successful in 8s
On an Android 16 emulator the release APK crashed at launch with
"Cannot read property 'NativeModule' of undefined": libexpo-modules-core.so
was missing for the ABI the device picked. Two build defects:

- librnskia.so was linked with 4 KB segment alignment (Skia 2.0.0-next.4
  lacks ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES), so 16 KB page devices cannot
  load it; a pnpm patch adds the flag
- the APK carried incomplete x86, x86_64 and armeabi-v7a folders from the
  React Native libraries; an Expo config plugin keeps only arm64-v8a, the ABI
  the native modules are built for (the APK also halves in size)

scripts/check-apk.mjs now fails the release on another ABI, a missing core
library or a library not aligned to 16 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 03:22:13 +03:00
mrleo1nidandClaude Opus 5.5 66f5749fc1 fix(deploy): take the Gitea address for Android updates from git remote
ci / api / api (push) Successful in 22s
ci / db / db (push) Successful in 2m4s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m11s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m33s
The server kept the git.example.lan placeholder from .env.example, so the
/android page and in-app updates had no release. install.sh and update.sh now
fill GITEA_URL and GITEA_REPO from the clone's origin (https, ssh and scp
forms, credentials stripped) when they are empty or still the placeholder,
and the API treats the placeholder as not configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:23:28 +03:00
mrleo1nidandClaude Opus 5.5 823d1eb841 fix(web): suggestions stay open when you return to the search quickly
ci / ts / ts (packages + web) (push) Successful in 1m1s
release / android (APK в релиз) (push) Successful in 4m10s
release / release (версия и заметки) (push) Successful in 6s
Leaving the field scheduled a close 120 ms later; coming back and typing
within that window let the old timer hide the fresh suggestions. Focus now
cancels the pending close. This also made the search box test flaky.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:13:08 +03:00
mrleo1nidandClaude Opus 5.5 860072e542 feat(web): choose where the search sits, and controls no longer select text
ci / api / api (push) Successful in 36s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / ts / ts (packages + web) (push) Canceled after 12s
ci / db / db (push) Successful in 2m43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 59s
- settings get a search position (left, center by default, right); the top bar
  is three zones and the suggestions open towards the free space
- buttons, chips, tabs, menu items and the home screen controls are not
  text-selectable, so clicks and double clicks no longer highlight labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:11:06 +03:00
mrleo1nidandClaude Opus 5.5 d2f17d8d62 ci(db): fail early with a clear message when the runner disk is full
ci / db / db (push) Successful in 45s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 3m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:03:32 +03:00
mrleo1nidandClaude Opus 5.5 4763339335 ci(db): wait for the service containers before the tests
ci / db / db (push) Successful in 53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:59:28 +03:00
mrleo1nidandClaude Opus 5.5 fda6ed48ab feat: Android download page, analysis retry and a collapsible search
ci / api / api (push) Successful in 37s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / db / db (push) Failing after 14s
ci / recommender / recommender (push) Successful in 51s
ci / ts / ts (packages + web) (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
- /android is a public page with the latest APK, the server address to copy
  and three install steps; linked from the sign-in screen and the profile menu
- failed audio analysis can be retried from the library page, which also shows
  the most common error; the analyzer no longer burns attempts on files that
  are missing because the disk is not mounted
- the top bar search folds into a round button and slides open on click, "/"
  or Ctrl+K

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:55:51 +03:00
mrleo1nidandClaude Opus 5.5 2d49cb3f19 fix(web): header dropdowns sit above the page again
ci / ts / ts (packages + web) (push) Successful in 50s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m45s
Every shell child got the same z-index, so the page painted over the search
suggestions and the profile menu and swallowed their clicks: suggestions could
not be picked, and the language switch and settings item did nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:47:18 +03:00
mrleo1nidandClaude Opus 5.5 3da82e902a fix(library): survive a music disk that is not mounted
ci / api / api (push) Successful in 29s
ci / db / db (push) Successful in 47s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
release / release (версия и заметки) (push) Successful in 4s
release / android (APK в релиз) (push) Successful in 3m20s
On the test server the host music folder was not mounted, so every stream and
fingerprint failed with no such file, and a scan of the empty folder would have
marked the whole library missing and purged it after 30 days.

- a scan of an empty source folder with known files aborts with a clear error
- fingerprinting skips such a source with one warning instead of one per file
- LIBRARY_HOST mounts use rslave, so a disk mounted after start shows up
- update.sh refuses to deploy with an empty LIBRARY_HOST

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:34:52 +03:00
mrleo1nidandClaude Opus 5.5 421b1a3eb9 ci: allow manual runs of the db and mobile checks
ci / db / db (push) Successful in 54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 56s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:18:11 +03:00
mrleo1nidandClaude Opus 5.5 b8243c3072 docs(roadmap): M6 and M7 status
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:15:59 +03:00
mrleo1nidandClaude Opus 5.5 30f063456e fix(mobile): white text sits on the contrast-safe accent fill (M7)
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 7s
ci / ts / ts (packages + web) (push) Successful in 49s
Primary buttons, selected segments and context chips use accent-fill, as on
the web, so their labels meet WCAG AA contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:15:38 +03:00
mrleo1nidandClaude Opus 5.5 de4e59d9f8 fix(web): accessibility pass to WCAG 2.1 AA (M7)
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 31s
ci / ts / ts (packages + web) (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 44s
An axe-core audit of every screen in both themes found low contrast on white
text over the accent and on caption text, track rows using row roles without
cells, and unlabelled progress bars. Accent fills now use a darker token,
caption text meets 4.5:1, track lists are lists and progress bars are labelled.
The queue drawer slides in from the right as designed, takes focus, closes on
Escape and notes that the wave never ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:15:03 +03:00
mrleo1nidandClaude Opus 5.5 c0e50bf88e docs: administrator guide (M7)
Day-to-day operation after install: users and roles, storages and scans, the
upload queue, wave metrics and training, updates, backups and restore,
monitoring and common situations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:02:54 +03:00
mrleo1nidandClaude Opus 5.5 86c3e4e477 test(load): k6 smoke for 100 wave listeners and 20 rps suggest (M7)
Three minutes of wave sessions with stream starts, play events and next batches
alongside a steady suggest load; thresholds follow the documented p95 targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:01:10 +03:00
mrleo1nidandClaude Opus 5.5 aacf55679a feat(deploy): nightly database backups with verified restore (M7)
ci / db / db (push) Failing after 52s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m3s
A backup container dumps the database every night, keeps 14 daily and 8 weekly
copies and once a week restores a copy into a scratch database to check tables
and row counts. update.sh takes a copy before updating, restore.sh brings one
back and keeps the old database aside. CI runs the dump, verify and restore
cycle on the database the end-to-end journey filled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:59:27 +03:00
mrleo1nidandClaude Opus 5.5 d91ff89c64 feat(admin): wave metrics split by model version (M6)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m5s
ci / ts / ts (packages + web) (push) Successful in 1m3s
ci / api / api (push) Successful in 29s
ci / db / db (push) Failing after 12s
The library page compares listen-through, early-skip and like rates per
impression model version, so v1 and the trained ranker can be compared online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:57:11 +03:00
mrleo1nidandClaude Opus 5.5 fbbe5d7528 feat(search): personalize suggestions by listening history (M6)
ci / api / api (push) Successful in 18s
ci / db / db (push) Successful in 30s
Suggest fetches three times as many candidates and reranks them in the API:
what the user plays, finishes and likes moves up a few positions and what they
dislike moves down, with Meilisearch relevance still the base order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:55:07 +03:00
mrleo1nidandClaude Opus 5.5 e250837de9 fix(search): reindex on a fresh Meilisearch
ci / db / db (push) Successful in 34s
release / release (версия и заметки) (push) Successful in 5s
ci / api / api (push) Successful in 19s
release / android (APK в релиз) (push) Successful in 3m17s
Deleting a missing <uid>_next index is accepted with 202 and fails inside the
task with index_not_found. That error was not treated as not-found, so the first
reindex aborted and search stayed unavailable until indexes existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:44:59 +03:00
mrleo1nidandClaude Opus 5.5 58bd14ef38 feat(recommender): LightGBM lambdarank ranker with impression features (M6)
ci / db / db (push) Failing after 1m15s
ci / api / api (push) Successful in 17s
ci / recommender / recommender (push) Successful in 32s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m41s
The wave logs each served candidate's feature vector to rec_impression_features.
Nightly training joins them with impression outcomes, validates on the last week
and activates the model only when it beats the v1 order and the active model.
The service loads the active ranker and uses its order in place of the v1 score.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:41:26 +03:00
mrleo1nidandClaude Opus 5.5 fb07f962eb feat: taste onboarding for new listeners (M6)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Failing after 1m29s
ci / ts / ts (packages + web) (push) Successful in 49s
ci / api / api (push) Successful in 1m25s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 48s
ci / recommender / recommender (push) Successful in 21s
- GET /onboarding offers the library's top artists (with album covers)
  and genres and says whether to show it (not done and < 10 plays);
  PUT /me/onboarding stores the choice or a skip (migration 00009).
- The recommender seeds the taste from it while the user has fewer than
  30 scored tracks: chosen artists' tracks +1, chosen genres +0.5.
- Web: onboarding replaces the wave on the home page until done or
  skipped; artist grid with covers, genre chips, at least 3 artists.
- e2e journey and the recommender DB test cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:35:43 +03:00
mrleo1nidandClaude Opus 5.5 ff0aafdb1e feat(recommender): Thompson-sampling bandit over candidate sources (M6)
ci / recommender / recommender (push) Failing after 4s
ci / db / db (push) Failing after 2m21s
Nightly Beta(α, β) per user and source from wave impressions (reward:
complete or like, ×0.99 daily decay) stored in user_profiles_rec.bandit;
the wave adds 0.2·(θ − 0.5) per candidate source, sources without data
stay neutral. The user's exploration share now follows how well `fresh`
does against the other sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:31:24 +03:00
mrleo1nidandClaude Opus 5.5 f7722e28ad ci(release): rebalance APK build memory so it does not thrash and get OOM-killed
release / android (APK в релиз) (push) Successful in 3m19s
release / release (версия и заметки) (push) Successful in 8s
v0.7.3 crawled for 35 minutes at the 6 GB cap and was killed without a
log. The container now gets 7 GB, the Gradle heap is 2 GB, the Metro
node process is capped at 1.5 GB and the job times out after 60 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:12:54 +03:00
mrleo1nidandClaude Opus 5.5 ed0302d90f docs(uploads): import writes only the final track file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:11:56 +03:00
mrleo1nidandClaude Opus 5.5 85fceaa686 fix(storage-agent): write nothing but the music files to the mounted storage
ci / api / api (push) Successful in 50s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Failing after 10s
ci / db / db (push) Failing after 19s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 31m43s
- Imported tracks are created directly under their final name with
  O_EXCL (never overwriting; " (2)" on conflict), fsync'ed and checked
  against SHA-256 — no hidden .pipu-tmp file next to them any more.
- On any failure the agent removes its own partial file and the empty
  directories it created for it.
- The write check uses access(W_OK) and the read-only mount flag instead
  of creating and deleting .pipu-write-test in the storage root.
- Tests assert the storage tree holds only audio files after successful
  and failed writes and after the write check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:22:49 +03:00
mrleo1nidandClaude Opus 5.5 6ad49fe0e3 fix(wave): switch to the new context right away when activity or mood changes
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
ci / ts / ts (packages + web) (push) Successful in 1m47s
release / release (версия и заметки) (push) Successful in 12s
release / android (APK в релиз) (push) Failing after 34m58s
Changing the activity or mood used to keep the current track playing
and only replace the upcoming ones, so nothing seemed to happen. Now the
first track of the new batch starts immediately (web and Android). The
interrupted track is not reported to the wave as a skip and is recorded
in history only if it played for at least 30 s, so a context change does
not teach the recommender a false dislike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:18:26 +03:00
mrleo1nidandClaude Opus 5.5 7cf741d82f ci(release): recover from a corrupted Gradle cache instead of failing
release / release (версия и заметки) (push) Successful in 10s
release / android (APK в релиз) (push) Successful in 9m5s
A hard reboot of the runner VM mid-build left broken transform metadata
in the persistent Gradle cache ("Could not read workspace metadata").
The APK step now detects it, drops the transforms cache and retries the
build once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:04:33 +03:00
mrleo1nidandClaude Opus 5.5 4fb4e124a0 ci(release): cap the Android build so it cannot freeze the runner host
release / android (APK в релиз) (push) Failing after 2m4s
release / release (версия и заметки) (push) Successful in 9s
The APK job container now runs with --cpus=3 --memory=6g and no swap;
CMake / ninja for the React Native native code are limited to 2 jobs
(they used every core), and Gradle runs under nice / ionice. Notes for
hypervisor-hosted runners (Proxmox) are in docs/ci-cd.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:59:46 +03:00
290 changed files with 15872 additions and 6706 deletions

No files matched your search

+32 -3
View File
@@ -4,14 +4,43 @@
{
"name": "design",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1", "--directory", "docs"],
"runtimeArgs": [
"-m",
"http.server",
"8765",
"--bind",
"127.0.0.1",
"--directory",
"docs"
],
"port": 8765
},
{
"name": "web",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@pipu/web", "dev"],
"runtimeArgs": [
"--filter",
"@pipu/web",
"dev"
],
"port": 5173
},
{
"name": "web-fake",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"--filter",
"@pipu/web",
"exec",
"vite",
"--port",
"4173",
"--strictPort"
],
"env": {
"VITE_E2E": "1"
},
"port": 4173
}
]
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
# Go: сервис api и storage-agent (docs/ci-cd.md). Только при изменении их кода или контракта.
# Go: сервис api и storage-agent. Только при изменении их кода или контракта.
name: ci / api
on:
+1 -1
View File
@@ -1,4 +1,4 @@
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md).
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками.
name: ci / contract
on:
+18
View File
@@ -4,6 +4,7 @@
name: ci / db
on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push:
branches: [main]
paths:
@@ -51,6 +52,23 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
run: |
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
free=$(df -Pm / | awk 'NR==2 {print $4}')
echo "свободно на раннере: ${free} МБ"
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
for svc in postgres:5432 redis:6379 meilisearch:7700; do
host=${svc%:*}; port=${svc#*:}
for i in $(seq 1 60); do
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
echo "$svc готов"; break
fi
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
sleep 2
done
done
- name: Go — миграции и запросы store
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/
+33
View File
@@ -0,0 +1,33 @@
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
# (Linux .deb и Windows NSIS через cargo-xwin на ubuntu-latest).
name: ci / desktop
on:
push:
branches: [main]
paths:
- 'apps/desktop/**'
- '.gitea/workflows/ci-desktop.yml'
pull_request:
paths:
- 'apps/desktop/**'
- '.gitea/workflows/ci-desktop.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
name: rust (desktop proxy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
+2 -1
View File
@@ -1,7 +1,8 @@
# Expo: expo-doctor и Metro-бандл Android (docs/ci-cd.md). Полная сборка APK — только в release.yml.
# Expo: expo-doctor и Metro-бандл Android. Полная сборка APK — только в release.yml.
name: ci / mobile
on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push:
branches: [main]
paths:
+1 -1
View File
@@ -1,4 +1,4 @@
# Python: recommender и analyzer (docs/ci-cd.md).
# Python: recommender и analyzer.
name: ci / recommender
on:
+13 -1
View File
@@ -1,4 +1,4 @@
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза (docs/ci-cd.md).
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза.
name: ci / ts
on:
@@ -51,3 +51,15 @@ jobs:
- run: pnpm typecheck
- run: pnpm test
- run: pnpm --filter @pipu/web build
# Браузер качается долго (основной CDN Microsoft из раннера недоступен) — кэшируем по lock-файлу.
# Системные библиотеки (libnspr4 и т.п.) в образе раннера нет, их ставит --with-deps на каждом прогоне.
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
# git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
- run: |
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
pnpm --filter @pipu/web exec playwright install chromium
- run: pnpm --filter @pipu/web test:e2e
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
- uses: actions/checkout@v4
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune` (docs/ci-cd.md).
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
- name: Сборка образов
env: { DOCKER_BUILDKIT: "1" }
run: |
+37
View File
@@ -0,0 +1,37 @@
# Ночные проверки, которым нужен поднятый стенд или эмулятор. На каждый push не запускаются.
name: nightly / e2e
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * *"
jobs:
schemathesis:
name: schemathesis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install schemathesis
- name: Фаззинг API
env:
PIPU_API_URL: ${{ vars.PIPU_API_URL }}
PIPU_API_TOKEN: ${{ secrets.PIPU_API_TOKEN }}
run: |
if [ -z "$PIPU_API_URL" ]; then echo "PIPU_API_URL is not set"; exit 0; fi
sh tests/contract/schemathesis.sh
maestro:
name: maestro
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Сценарии Android
env:
PIPU_E2E_SERVER: ${{ vars.PIPU_E2E_SERVER }}
PIPU_E2E_USER: ${{ vars.PIPU_E2E_USER }}
PIPU_E2E_PASSWORD: ${{ secrets.PIPU_E2E_PASSWORD }}
run: |
if [ -z "$PIPU_E2E_SERVER" ]; then echo "emulator flow is configured in apps/mobile/.maestro"; exit 0; fi
curl -Ls "https://get.maestro.mobile.dev" | bash
~/.maestro/bin/maestro test apps/mobile/.maestro
+177 -7
View File
@@ -1,6 +1,8 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): Gitea Release с заметками → APK.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs).
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
# и переписывают статус: собран / не удалась (scripts/release.mjs).
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
#
# Секреты (Settings → Actions → Secrets):
@@ -48,16 +50,21 @@ jobs:
android:
name: android (APK в релиз)
needs: release
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
timeout-minutes: 60
runs-on: ubuntu-latest
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
# (container.valid_volumes: ['/opt/ci-cache/**']); иначе раннер их пропускает и всё качается заново.
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
defaults:
run:
shell: bash
container:
image: docker.gitea.com/runner-images:ubuntu-latest
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
options: --cpus=3 --memory=7g --memory-swap=7g
volumes:
- /opt/ci-cache/gradle:/root/.gradle
- /opt/ci-cache/android:/root/.android
@@ -100,6 +107,11 @@ jobs:
# CI=1 — Metro без file watcher и с 2 воркерами (metro.config.js); NODE_ENV нужен expo для .env-файлов
CI: "1"
NODE_ENV: production
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
CMAKE_BUILD_PARALLEL_LEVEL: "2"
NINJAFLAGS: "-j2"
# JS-бандл (Metro) собирается внутри Gradle отдельным node — не даём ему разрастись.
NODE_OPTIONS: "--max-old-space-size=1536"
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
@@ -109,17 +121,36 @@ jobs:
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
run: |
set -o pipefail
npx expo prebuild --platform android --clean --no-install
cd android
gradle=(./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2048m -XX:MaxMetaspaceSize=512m")
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
if grep -q '^newArchEnabled=true' gradle.properties; then
"${gradle[@]}" generateCodegenArtifactsFromSchema
fi
"${gradle[@]}" assembleRelease -x lintVitalRelease
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
./gradlew --stop || true
find ~/.gradle -name "*.lock" -delete || true
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
"${gradle[@]}" assembleRelease -x lintVitalRelease
else
exit 1
fi
fi
./gradlew --stop
# Только arm64-v8a, все нужные .so на месте и выровнены по 16 КБ — иначе APK падает при запуске
# на части устройств (Android 15+ с 16 КБ страницами, эмуляторы x86).
- name: Проверка APK (ABI, 16 КБ страницы)
run: node scripts/check-apk.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
- name: Диагностика сбоя (диск, память, OOM)
if: failure()
run: |
@@ -168,3 +199,142 @@ jobs:
- name: Статус в релизе — сборка отменена
if: cancelled()
run: node scripts/release.mjs android cancelled
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
desktop:
name: desktop (Linux и Windows в релиз)
needs: [release, android]
if: always() && needs.release.result == 'success'
timeout-minutes: 90
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PIPU_VERSION: ${{ needs.release.outputs.version }}
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
CARGO_BUILD_JOBS: "2"
XWIN_CACHE_DIR: /opt/ci-cache/xwin
steps:
- uses: actions/checkout@v4
- name: Статус в релизе — собирается
run: |
node scripts/release.mjs desktop linux building
node scripts/release.mjs desktop windows building
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libdbus-1-dev libsecret-1-dev patchelf \
nsis lld llvm clang
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-xwin
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
restore-keys: cargo-desktop-
- run: pnpm install --frozen-lockfile
- name: Сборка deb
id: linux
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:linux
bundle=src-tauri/target/release/bundle
shopt -s nullglob
for f in "$bundle"/deb/*.deb; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
done
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
rm -rf src-tauri/target/release
- name: Linux в релиз
id: linux-upload
run: |
set -euo pipefail
f="pipu-music-${PIPU_VERSION}-linux.deb"
if [ ! -f "$f" ]; then
echo "::error::нет $f после сборки"
exit 1
fi
node scripts/release.mjs desktop linux success "$f"
- name: Windows toolchain
if: ${{ !cancelled() }}
run: |
set -euo pipefail
rustup target add x86_64-pc-windows-msvc
if ! command -v cargo-xwin >/dev/null; then
cargo install --locked cargo-xwin
fi
if [ ! -d /opt/ci-cache ]; then
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
mkdir -p "$HOME/.cache/xwin"
else
mkdir -p /opt/ci-cache/xwin
fi
- name: Сборка NSIS
id: win
if: ${{ !cancelled() }}
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:windows:cross
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
shopt -s nullglob
copied=
for f in "$bundle"/*-setup.exe; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
copied=1
done
if [ -z "$copied" ]; then
echo "::error::нет NSIS setup.exe после сборки в $bundle"
ls -la "$bundle" || true
exit 1
fi
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Windows в релиз
id: win-upload
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Статус Linux — сборка не удалась
if: failure() && steps.linux-upload.outcome != 'success'
run: node scripts/release.mjs desktop linux failure
- name: Статус Windows — сборка не удалась
if: failure() && steps.win-upload.outcome != 'success'
run: node scripts/release.mjs desktop windows failure
- name: Статус в релизе — сборка отменена
if: cancelled()
run: |
node scripts/release.mjs desktop linux cancelled
node scripts/release.mjs desktop windows cancelled
prune:
name: prune (старые файлы и прогоны)
needs: [release, android, desktop]
if: always() && needs.release.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}" }
- name: Оставить 8 релизов и 8 прогонов
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: node scripts/release.mjs prune
+9
View File
@@ -27,6 +27,8 @@ __pycache__/
# generated test audio
tests/fixtures/music/out/
apps/web/test-results/
apps/web/playwright-report/
# os / editors
.DS_Store
@@ -35,3 +37,10 @@ Thumbs.db
.vscode/*
!.vscode/extensions.json
.cache/
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/tauri.release.json
apps/desktop/release/
# Intermediate platform icon renders (final assets live under apps/).
/artifacts/icon-build/
+4 -4
View File
@@ -1,11 +1,10 @@
# pipu-music
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
- Документация для разработки: [docs/README.md](docs/README.md)
- План: [docs/roadmap.md](docs/roadmap.md)
- Документация: [docs/README.md](docs/README.md)
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
## Структура
@@ -16,6 +15,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
| `apps/web` | Веб-клиент (React + Vite) |
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
| `apps/mobile` | Android-приложение (React Native + Expo) |
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
@@ -67,4 +67,4 @@ pnpm generate && (cd services/api && go generate ./...)
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него.
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md).
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты перечислены в шапке `.gitea/workflows/release.yml`.
+613 -9
View File
@@ -22,6 +22,51 @@ tags:
security:
- bearerAuth: []
paths:
/api/v1/admin/genius:
get:
operationId: getGeniusStats
tags: [admin]
responses:
"200":
description: Persistent enrichment statistics (admin only)
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusStats"
post:
operationId: controlGenius
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action]
properties:
action:
type: string
enum: [start, pause, retry]
responses:
"200":
description: Updated statistics
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusStats"
/api/v1/tracks/{id}/genius:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: getTrackGenius
tags: [catalog]
responses:
"200":
description: Cached lyrics and annotations; never makes an upstream request
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusContent"
/healthz:
get:
operationId: getHealthz
@@ -359,6 +404,22 @@ paths:
$ref: "#/components/schemas/TemporaryPassword"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/users/{id}/reset-recommendations:
parameters:
- $ref: "#/components/parameters/UserId"
post:
operationId: resetUserRecommendations
tags: [admin]
summary: Сбросить рекомендации
description: |
Рекомендации начинаются с нуля: стираются профиль вкуса (онбординг, CF-вектор, бандит),
статистика прослушиваний и показы волны; онбординг покажется снова.
Лайки, дизлайки и история остаются.
responses:
"204":
description: Сброшено
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/quota:
get:
operationId: getUploadQuota
@@ -435,6 +496,35 @@ paths:
$ref: "#/components/schemas/UploadFilePage"
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/unseen:
get:
operationId: unseenUploadDecisions
tags: [uploads]
summary: Сколько решений по своим предложениям с прошлого визита (moderator, admin)
responses:
"200":
description: Число
content:
application/json:
schema:
type: object
required: [count]
properties:
count:
type: integer
minimum: 0
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/seen:
post:
operationId: markUploadsSeen
tags: [uploads]
summary: Отметить свои предложения просмотренными
responses:
"204":
description: Отмечено
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/files/{id}:
parameters:
- $ref: "#/components/parameters/EntityId"
@@ -519,6 +609,65 @@ paths:
$ref: "#/components/schemas/UploadFile"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/uploads/files/{id}/stream:
get:
operationId: streamUpload
tags: [uploads]
security: []
summary: Прослушать файл из staging (подпись, как у стрима трека)
description: Ссылку даёт `stream_url` файла очереди. Range поддерживается.
parameters:
- $ref: "#/components/parameters/EntityId"
- name: exp
in: query
required: true
schema:
type: integer
format: int64
- name: sig
in: query
required: true
schema:
type: string
responses:
"200":
description: Аудио из staging
content:
audio/*:
schema:
type: string
format: binary
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/uploads/batches/{id}:
parameters:
- $ref: "#/components/parameters/EntityId"
patch:
operationId: updateUploadBatch
tags: [uploads]
summary: Правка метаданных и обложки на всю пачку (admin)
description: Исполнитель, альбом и год применяются к файлам в pending_review и import_failed. Обложка встраивается при импорте.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UploadBatchPatch"
responses:
"200":
description: Обновлённые файлы пачки
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/UploadFile"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/uploads/review:
post:
operationId: reviewUploads
@@ -1006,6 +1155,24 @@ paths:
$ref: "#/components/schemas/SearchResults"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/library/analysis/retry:
post:
operationId: retryAnalysis
tags: [library]
summary: Вернуть в очередь треки, которые не удалось проанализировать (admin)
responses:
"200":
description: Сколько задач снова в очереди
content:
application/json:
schema:
type: object
required: [requeued]
properties:
requeued:
type: integer
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/search/reindex:
post:
operationId: reindexSearch
@@ -1256,6 +1423,38 @@ paths:
description: Снято
default:
$ref: "#/components/responses/Problem"
/api/v1/onboarding:
get:
operationId: getOnboarding
tags: [wave]
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора
description: |
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
responses:
"200":
description: Варианты и состояние
content:
application/json:
schema:
$ref: "#/components/schemas/Onboarding"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/onboarding:
put:
operationId: saveOnboarding
tags: [wave]
summary: Сохранить выбор онбординга (или пропуск — пустые списки)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/OnboardingChoice"
responses:
"204":
description: Сохранено
default:
$ref: "#/components/responses/Problem"
/api/v1/wave/options:
get:
operationId: getWaveOptions
@@ -1343,10 +1542,58 @@ paths:
$ref: "#/components/schemas/WaveBatch"
default:
$ref: "#/components/responses/Problem"
/api/v1/app/clients:
get:
operationId: getAppClients
tags: [app]
security: [] # публично: страница /download без входа
summary: Клиенты последних релизов (Android и десктоп)
description: |
Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
responses:
"200":
description: Доступные сборки
content:
application/json:
schema:
$ref: "#/components/schemas/AppClients"
default:
$ref: "#/components/responses/Problem"
/api/v1/app/download/{client}/{version}:
get:
operationId: downloadAppClient
tags: [app]
security: []
summary: Файл клиента (кэшируется на сервере, поддерживает Range)
parameters:
- name: client
in: path
required: true
schema:
$ref: "#/components/schemas/AppClient"
- name: version
in: path
required: true
schema:
type: string
pattern: '^\d+\.\d+\.\d+(-rc\.\d+)?$'
responses:
"200":
description: Установщик или APK
content:
application/octet-stream:
schema:
type: string
format: binary
default:
$ref: "#/components/responses/Problem"
/api/v1/app/android/latest:
get:
operationId: getAndroidLatest
tags: [app]
security: [] # публично: страница /download раздаёт приложение без входа
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
description: |
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
@@ -1364,6 +1611,7 @@ paths:
get:
operationId: downloadAndroidApk
tags: [app]
security: []
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
parameters:
- name: version
@@ -1382,6 +1630,82 @@ paths:
format: binary
default:
$ref: "#/components/responses/Problem"
/api/v1/me/playlists:
get:
tags: [collection]
operationId: listPlaylists
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistList'
post:
tags: [collection]
operationId: createPlaylist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistInput'
responses:
default:
$ref: '#/components/responses/Problem'
'201':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
/api/v1/me/playlists/{id}:
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
get:
tags: [collection]
operationId: getPlaylist
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
put:
tags: [collection]
operationId: updatePlaylist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistUpdate'
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
delete:
tags: [collection]
operationId: deletePlaylist
responses:
default:
$ref: '#/components/responses/Problem'
'204':
description: Deleted
components:
parameters:
SourceId:
@@ -1442,6 +1766,106 @@ components:
schema:
$ref: "#/components/schemas/Problem"
schemas:
PlaylistInput:
type: object
required: [name, description, track_ids]
properties:
name: { type: string, minLength: 1, maxLength: 120 }
description: { type: string, maxLength: 2000 }
track_ids:
type: array
maxItems: 2000
uniqueItems: true
items: { type: string, format: uuid }
PlaylistUpdate:
allOf:
- $ref: '#/components/schemas/PlaylistInput'
- type: object
required: [revision]
properties:
revision: { type: integer, minimum: 1 }
PlaylistSummary:
type: object
required: [id, name, description, revision, track_count, duration_ms, created_at, updated_at]
properties:
id: { type: string, format: uuid }
name: { type: string }
description: { type: string }
revision: { type: integer }
track_count: { type: integer }
duration_ms: { type: integer, format: int64 }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
Playlist:
allOf:
- $ref: '#/components/schemas/PlaylistSummary'
- type: object
required: [tracks]
properties:
tracks:
type: array
items: { $ref: '#/components/schemas/Track' }
PlaylistList:
type: object
required: [items]
properties:
items:
type: array
items: { $ref: '#/components/schemas/PlaylistSummary' }
Onboarding:
type: object
required: [required, artists, genres]
properties:
required:
type: boolean
artists:
type: array
items:
type: object
required: [id, name, track_count, cover_url, cover_colors]
properties:
id:
type: string
format: uuid
name:
type: string
track_count:
type: integer
cover_url:
type: string
nullable: true
description: Обложка самого большого альбома исполнителя
cover_colors:
type: array
items:
type: string
genres:
type: array
items:
type: object
required: [name, track_count]
properties:
name:
type: string
track_count:
type: integer
OnboardingChoice:
type: object
required: [artist_ids, genres]
properties:
artist_ids:
type: array
maxItems: 50
items:
type: string
format: uuid
genres:
type: array
maxItems: 30
items:
type: string
maxLength: 100
UserRef:
type: object
required: [id, username]
@@ -1499,6 +1923,9 @@ components:
type: string
duration_ms:
type: integer
album:
type: string
maxLength: 500
UploadPrecheckResult:
type: object
required: [files]
@@ -1612,7 +2039,7 @@ components:
nullable: true
UploadFile:
type: object
required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url]
required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, quality_upgrade, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url, stream_url]
properties:
id:
type: string
@@ -1686,6 +2113,9 @@ components:
enum: [sha256, metadata, fingerprint, null]
possible_duplicate:
type: boolean
quality_upgrade:
type: boolean
description: Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade
target_source_id:
type: string
format: uuid
@@ -1711,6 +2141,10 @@ components:
type: string
nullable: true
description: Только автору и пока файл в статусе uploading
stream_url:
type: string
nullable: true
description: Подписанная ссылка прослушивания из staging; null, если файла уже нет
UploadFilePage:
type: object
required: [items, next_cursor]
@@ -1730,6 +2164,23 @@ components:
target_source_id:
type: string
format: uuid
UploadBatchPatch:
type: object
description: Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки.
properties:
artist:
type: string
maxLength: 500
album:
type: string
maxLength: 500
year:
type: integer
nullable: true
cover_base64:
type: string
nullable: true
description: JPEG, PNG или WebP, не больше 8 МБ после декодирования
UploadReviewRequest:
type: object
required: [file_ids, decision]
@@ -1769,7 +2220,7 @@ components:
- $ref: "#/components/schemas/Problem"
UploadSettings:
type: object
required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id]
required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id, allow_quality_upgrade]
properties:
queue_limit_bytes:
type: integer
@@ -1785,6 +2236,9 @@ components:
type: string
format: uuid
nullable: true
allow_quality_upgrade:
type: boolean
description: Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли
UploadSettingsPatch:
type: object
description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано).
@@ -1808,6 +2262,8 @@ components:
format: uuid
nullable: true
description: null — без хранилища по умолчанию
allow_quality_upgrade:
type: boolean
HealthStatus:
type: object
required: [status]
@@ -1859,7 +2315,7 @@ components:
enum: [admin, moderator, user]
ClientType:
type: string
enum: [web, android]
enum: [web, android, desktop]
LoginRequest:
type: object
required: [username, password, client_type]
@@ -1879,7 +2335,7 @@ components:
properties:
refresh_token:
type: string
description: Только Android; веб передаёт токен в cookie
description: Android и desktop; веб передаёт токен в cookie
AuthTokens:
type: object
required: [access_token, token_type, expires_in, user]
@@ -1894,7 +2350,7 @@ components:
description: Срок жизни access-токена, секунды
refresh_token:
type: string
description: "Только для `client_type: android`"
description: "Для `client_type: android` и `client_type: desktop`"
user:
$ref: "#/components/schemas/Me"
UiPrefs:
@@ -1909,6 +2365,19 @@ components:
web_player:
type: string
enum: [bar, island]
search_position:
type: string
enum: [center, left, right]
description: Где в топбаре веба поиск (по умолчанию center)
search_collapse:
type: boolean
description: Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false)
home_activities:
type: boolean
description: Показывать на главной веба выбор занятия (по умолчанию true)
home_moods:
type: boolean
description: Показывать на главной веба выбор настроения (по умолчанию true)
mobile_player:
type: string
enum: [cover, wave]
@@ -1922,7 +2391,7 @@ components:
type: boolean
Me:
type: object
required: [id, username, display_name, role, locale, ui_prefs, must_change_password, must_change_credentials, created_at]
required: [id, username, display_name, role, reviews_uploads, locale, ui_prefs, must_change_password, must_change_credentials, created_at]
properties:
id:
type: string
@@ -1934,6 +2403,9 @@ components:
nullable: true
role:
$ref: "#/components/schemas/Role"
reviews_uploads:
type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
locale:
type: string
ui_prefs:
@@ -1974,7 +2446,7 @@ components:
type: string
AdminUser:
type: object
required: [id, username, display_name, role, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
required: [id, username, display_name, role, reviews_uploads, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
properties:
id:
type: string
@@ -1986,6 +2458,9 @@ components:
nullable: true
role:
$ref: "#/components/schemas/Role"
reviews_uploads:
type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
locale:
type: string
is_blocked:
@@ -2023,6 +2498,9 @@ components:
maxLength: 64
role:
$ref: "#/components/schemas/Role"
reviews_uploads:
type: boolean
description: Сразу выдать аудит загрузок. Может задать только админ
CreatedUser:
type: object
required: [user, temporary_password]
@@ -2035,6 +2513,9 @@ components:
UpdateUserRequest:
type: object
properties:
username:
type: string
description: Новый логин. Может менять только администратор, в том числе у себя
display_name:
type: string
maxLength: 64
@@ -2043,6 +2524,9 @@ components:
$ref: "#/components/schemas/Role"
is_blocked:
type: boolean
reviews_uploads:
type: boolean
description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя
TemporaryPassword:
type: object
required: [temporary_password]
@@ -2578,10 +3062,51 @@ components:
description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости
allOf:
- $ref: "#/components/schemas/TrackFeatures"
GeniusStats:
type: object
required: [configured, paused, running, total, pending, matched, lyrics, annotations, images, unmatched, errors, last_error, cooldown_until]
properties:
configured: { type: boolean }
paused: { type: boolean }
running: { type: boolean }
total: { type: integer }
pending: { type: integer }
matched: { type: integer }
lyrics: { type: integer }
annotations: { type: integer }
images: { type: integer }
unmatched: { type: integer }
errors: { type: integer }
last_error: { type: string }
cooldown_until: { type: string, format: date-time }
GeniusContent:
type: object
required: [status, lyrics, source_url, song_image_url, artist_image_url, description, annotations]
properties:
status: { type: string }
lyrics: { type: string }
source_url: { type: string }
song_image_url: { type: string }
artist_image_url: { type: string }
description: { type: string }
annotations:
type: array
items:
$ref: "#/components/schemas/GeniusAnnotation"
GeniusAnnotation:
type: object
required: [fragment, text, url, verified, authors]
properties:
fragment: { type: string }
text: { type: string }
url: { type: string }
verified: { type: boolean }
authors: { type: string }
Artist:
type: object
required: [id, name, album_count, track_count]
properties:
image_url: { type: string }
id:
type: string
format: uuid
@@ -2996,7 +3521,7 @@ components:
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
AnalysisStatus:
type: object
required: [queued, running, done, failed, analyzer_ver]
required: [queued, running, done, failed, analyzer_ver, top_error]
properties:
queued:
type: integer
@@ -3009,9 +3534,13 @@ components:
analyzer_ver:
type: string
nullable: true
top_error:
type: string
nullable: true
description: Самая частая ошибка среди неудавшихся — подсказка, что чинить
WaveMetrics:
type: object
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions]
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model]
description: Метрики волны за 7 дней
properties:
served:
@@ -3028,6 +3557,81 @@ components:
description: Лайков на 100 треков
sessions:
type: integer
by_model:
type: array
description: Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн
items:
$ref: '#/components/schemas/WaveModelMetrics'
WaveModelMetrics:
type: object
required: [model_ver, served, listen_through_rate, early_skip_rate, like_rate]
properties:
model_ver:
type: string
served:
type: integer
listen_through_rate:
type: number
nullable: true
early_skip_rate:
type: number
nullable: true
like_rate:
type: number
nullable: true
AppClient:
type: string
enum: [android, windows, linux, macos]
AppClientBuild:
type: object
required: [version, filename, size, download_url]
properties:
version:
type: string
filename:
type: string
size:
type: integer
format: int64
sha256:
type: string
download_url:
type: string
AppClientFile:
type: object
required: [client, filename, size, download_url]
properties:
client:
$ref: "#/components/schemas/AppClient"
version:
type: string
filename:
type: string
size:
type: integer
format: int64
sha256:
type: string
download_url:
type: string
older:
type: array
items:
$ref: "#/components/schemas/AppClientBuild"
AppClients:
type: object
required: [version, version_code, notes_md, clients]
properties:
version:
type: string
version_code:
type: integer
notes_md:
type: string
clients:
type: array
items:
$ref: "#/components/schemas/AppClientFile"
AndroidRelease:
type: object
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
+19
View File
@@ -0,0 +1,19 @@
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(fileURLToPath(import.meta.url));
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
let version = process.env.PIPU_VERSION ?? '0.0.0';
if (!process.env.PIPU_VERSION) {
try {
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
version = tag.replace(/^v/, '');
} catch {
// local untagged build
}
}
conf.version = version;
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
console.log(`desktop version ${version}`);
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@pipu/desktop",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tauri dev",
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles deb",
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
"tauri": "tauri",
"icons": "node ../../scripts/generate-icons.mjs"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "pipu-desktop"
version = "0.0.0"
edition = "2021"
[lib]
name = "pipu_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "pipu-desktop"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["net", "sync", "time"] }
axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
uuid = { version = "1", features = ["v4"] }
url = "2"
souvlaki = "0.8.3"
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6.3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "s"
panic = "abort"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "Only the bundled main window may invoke desktop integration.",
"windows": ["main"],
"permissions": ["core:default", "core:event:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+157
View File
@@ -0,0 +1,157 @@
mod media;
mod proxy;
mod store;
use media::Media;
use proxy::Proxy;
use serde::Serialize;
use std::sync::Mutex;
use tauri::{
image::Image,
include_image,
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, State,
};
struct AppState {
proxy: Proxy,
media: Media,
server: Mutex<Option<String>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Bootstrap {
proxy_base: String,
server: Option<String>,
}
fn show_main(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
#[tauri::command]
fn bootstrap(app: AppHandle, state: State<AppState>) -> Result<Bootstrap, String> {
Ok(Bootstrap {
proxy_base: state.proxy.base.clone(),
server: store::load_server(&app)?,
})
}
#[tauri::command]
async fn set_server(app: AppHandle, state: State<'_, AppState>, url: String) -> Result<String, String> {
let server = proxy::validate_server(&url)?;
state.proxy.check(&server).await?;
store::save_server(&app, Some(&server))?;
*state.proxy.server.write().map_err(|e| e.to_string())? = Some(server.clone());
*state.server.lock().map_err(|e| e.to_string())? = Some(server.clone());
Ok(server)
}
#[tauri::command]
fn clear_server(app: AppHandle, state: State<AppState>) -> Result<(), String> {
store::save_server(&app, None)?;
store::save_user(&app, None)?;
store::save_refresh(None)?;
*state.proxy.server.write().map_err(|e| e.to_string())? = None;
*state.server.lock().map_err(|e| e.to_string())? = None;
Ok(())
}
#[tauri::command]
fn token_get() -> Result<Option<String>, String> {
store::load_refresh()
}
#[tauri::command]
fn token_set(token: String) -> Result<(), String> {
store::save_refresh(Some(&token))
}
#[tauri::command]
fn token_clear() -> Result<(), String> {
store::save_refresh(None)
}
#[tauri::command]
fn user_get(app: AppHandle) -> Result<Option<String>, String> {
store::load_user(&app)
}
#[tauri::command]
fn user_set(app: AppHandle, json: String) -> Result<(), String> {
store::save_user(&app, Some(&json))
}
#[tauri::command]
fn user_clear(app: AppHandle) -> Result<(), String> {
store::save_user(&app, None)
}
#[tauri::command]
fn media_set(
state: State<AppState>,
title: String,
artist: String,
album: String,
cover_url: Option<String>,
playing: bool,
) -> Result<(), String> {
state.media.set(&title, &artist, &album, cover_url.as_deref(), playing)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
.plugin(tauri_plugin_window_state::Builder::default().build())
.setup(|app| {
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 media = Media::new();
if let Some(window) = app.get_webview_window("main") {
let _ = media.attach(app.handle().clone(), &window);
}
app.manage(AppState {
proxy,
media,
server: Mutex::new(saved),
});
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &quit])?;
let icon: Image = include_image!("icons/32x32.png");
TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => show_main(app),
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main(tray.app_handle());
}
})
.build(app)?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
])
.run(tauri::generate_context!())
.expect("desktop app failed to start");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
pipu_desktop_lib::run();
}
+62
View File
@@ -0,0 +1,62 @@
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, WebviewWindow};
pub struct Media {
controls: Mutex<Option<MediaControls>>,
}
impl Media {
pub fn new() -> Self {
Self { controls: Mutex::new(None) }
}
pub fn attach(&self, app: AppHandle, window: &WebviewWindow) -> Result<(), String> {
#[cfg(target_os = "windows")]
let hwnd = window.hwnd().ok().map(|h| h.0 as *mut std::ffi::c_void);
#[cfg(not(target_os = "windows"))]
let hwnd = {
let _ = window;
None
};
let mut controls = MediaControls::new(PlatformConfig {
dbus_name: "app.pipu.music.desktop",
display_name: "Pipu Music",
hwnd,
})
.map_err(|e| format!("{e:?}"))?;
let handle = app.clone();
controls
.attach(move |event| {
let name = match event {
MediaControlEvent::Play => "play",
MediaControlEvent::Pause => "pause",
MediaControlEvent::Toggle => "toggle",
MediaControlEvent::Next => "next",
MediaControlEvent::Previous => "previous",
MediaControlEvent::Stop => "stop",
_ => return,
};
let _ = handle.emit("media", name);
})
.map_err(|e| format!("{e:?}"))?;
*self.controls.lock().map_err(|e| e.to_string())? = Some(controls);
Ok(())
}
pub fn set(&self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, playing: bool) -> Result<(), String> {
let mut slot = self.controls.lock().map_err(|e| e.to_string())?;
let Some(controls) = slot.as_mut() else { return Ok(()); };
controls
.set_metadata(MediaMetadata {
title: Some(title),
artist: Some(artist),
album: Some(album),
cover_url,
duration: None,
})
.map_err(|e| format!("{e:?}"))?;
let playback = if playing { MediaPlayback::Playing { progress: None } } else { MediaPlayback::Paused { progress: None } };
controls.set_playback(playback).map_err(|e| format!("{e:?}"))
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Loopback-only streaming transport. The random per-process path is a capability;
//! it never leaves the app except for OS media artwork requests. No open proxy.
use axum::{body::Body, extract::{Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, Router};
use serde_json::Value;
use std::{sync::{Arc, RwLock}, time::Duration};
use url::Url;
#[derive(Clone)]
pub struct Proxy {
pub server: Arc<RwLock<Option<String>>>,
pub base: String,
secret: String,
authority: String,
client: reqwest::Client,
}
pub fn validate_server(raw: &str) -> Result<String, String> {
let url = Url::parse(raw.trim()).map_err(|_| "Invalid server URL")?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
|| !url.username().is_empty() || url.password().is_some()
|| url.query().is_some() || url.fragment().is_some()
{ return Err("Use an HTTP(S) server address without credentials, query or fragment".into()); }
Ok(url.as_str().trim_end_matches('/').to_string())
}
impl Proxy {
pub async fn start(server: Option<String>) -> Result<Self, String> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
let authority = listener.local_addr().map_err(|e| e.to_string())?.to_string();
let secret = uuid::Uuid::new_v4().simple().to_string();
let proxy = Self {
server: Arc::new(RwLock::new(server)),
base: format!("http://{authority}/{secret}"), secret, authority,
client: 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())?,
};
let router = Router::new().fallback(forward).with_state(proxy.clone());
tauri::async_runtime::spawn(async move {
if let Err(e) = axum::serve(listener, router).await { eprintln!("Desktop transport stopped: {e}"); }
});
Ok(proxy)
}
pub async fn check(&self, server: &str) -> Result<(), String> {
let response = self.client.get(format!("{server}/api/v1/server-info"))
.timeout(Duration::from_secs(10)).send().await.map_err(|_| "Server unavailable")?;
if !response.status().is_success() { return Err("Server unavailable".into()); }
let info: Value = response.json().await.map_err(|_| "Not a Pipu server")?;
if info.get("api_version").is_none() || info.get("name").is_none() { return Err("Not a Pipu server".into()); }
Ok(())
}
}
fn allowed_origin(origin: &str) -> bool {
matches!(origin, "tauri://localhost" | "http://tauri.localhost")
|| (cfg!(debug_assertions) && origin == "http://127.0.0.1:5173")
}
// Signed media and tus URLs are relative in the API. Rewrite only API URLs from
// the configured server; never route arbitrary URLs supplied in server content.
fn local_url(value: &str, server: &str, base: &str) -> Option<String> {
if value.starts_with("/api/") { return Some(format!("{base}{value}")); }
value.strip_prefix(server).filter(|p| p.starts_with("/api/"))
.map(|p| format!("{base}{p}"))
}
fn rewrite_json(value: &mut Value, server: &str, base: &str) {
match value {
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_json(v, server, base)),
Value::Object(fields) => for (key, v) in fields {
if matches!(key.as_str(), "stream_url" | "cover_url" | "tus_url" | "download_url") {
if let Some(url) = v.as_str().and_then(|s| local_url(s, server, base)) { *v = Value::String(url); }
} else { rewrite_json(v, server, base); }
},
_ => {}
}
}
fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
if let Some(origin) = origin {
headers.insert("access-control-allow-origin", HeaderValue::from_str(origin).unwrap());
headers.insert("vary", HeaderValue::from_static("Origin"));
}
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-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
}
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
let origin = request.headers().get("origin").and_then(|v| v.to_str().ok()).map(str::to_owned);
if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&proxy.authority)
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
{ return StatusCode::FORBIDDEN.into_response(); }
let prefix = format!("/{}/api/", proxy.secret);
let path = request.uri().path().to_owned();
let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
let tail = tail.to_owned();
// Prevent URL normalization escaping the configured API path, including encoded segments.
if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e")
|| tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f")
{ return StatusCode::BAD_REQUEST.into_response(); }
if origin.is_none() && !matches!(*request.method(), Method::GET | Method::HEAD) {
return StatusCode::FORBIDDEN.into_response();
}
let mut response = if request.method() == Method::OPTIONS {
StatusCode::NO_CONTENT.into_response()
} else {
match transfer(&proxy, request, tail).await {
Ok(response) => response,
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
}
};
cors(response.headers_mut(), origin.as_deref());
response
}
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
let url = format!("{server}/api/{tail}{query}");
let (parts, body) = request.into_parts();
let mut upstream = proxy.client.request(parts.method.clone(), url);
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
}
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
let status = upstream.status();
let mut headers = HeaderMap::new();
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
}
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
}
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
let body = if json && parts.method != Method::HEAD {
// Bound only JSON, never buffer a song or an upload in memory.
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
rewrite_json(&mut value, &server, &proxy.base);
headers.remove("content-length");
headers.remove("etag");
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
} else { Body::from_stream(upstream.bytes_stream()) };
let mut response = Response::new(body);
*response.status_mut() = status;
*response.headers_mut() = headers;
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_server_and_scopes_media_urls() {
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
rewrite_json(&mut value, "https://music.example", "http://local/key");
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
assert_eq!(value["tracks"][0]["title"], "/api/private");
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
const SERVICE: &str = "app.pipu.music.desktop";
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
let path = config_dir(app)?.join("server.txt");
match fs::read_to_string(path) {
Ok(s) => {
let s = s.trim().to_string();
Ok((!s.is_empty()).then_some(s))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.to_string()),
}
}
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
let path = config_dir(app)?.join("server.txt");
match server {
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
None => match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.to_string()),
},
}
}
fn refresh_entry() -> Result<keyring::Entry, String> {
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
}
pub fn load_refresh() -> Result<Option<String>, String> {
match refresh_entry()?.get_password() {
Ok(s) => Ok(Some(s)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
let entry = refresh_entry()?;
match token {
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
None => match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.to_string()),
},
}
}
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
let path = config_dir(app)?.join("user.json");
match fs::read_to_string(path) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.to_string()),
}
}
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
let path = config_dir(app)?.join("user.json");
match json {
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
None => match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.to_string()),
},
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Pipu Music",
"version": "0.0.0",
"identifier": "app.pipu.music.desktop",
"build": {
"beforeDevCommand": "pnpm --filter @pipu/web dev",
"devUrl": "http://127.0.0.1:5173",
"beforeBuildCommand": "pnpm --filter @pipu/web build",
"frontendDist": "../../web/dist"
},
"app": {
"withGlobalTauri": true,
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
}
},
"bundle": {
"active": true,
"targets": ["nsis", "appimage", "deb", "rpm"],
"category": "Music",
"shortDescription": "Self-hosted music player",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
"linux": {
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
"appimage": { "bundleMediaFramework": false }
},
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
}
}
+5
View File
@@ -0,0 +1,5 @@
appId: app.pipu.music
---
- runFlow: login.yaml
- tapOn: "Коллекция"
- assertVisible: "Лайки"
+15
View File
@@ -0,0 +1,15 @@
appId: app.pipu.music
---
- launchApp:
clearState: true
- assertVisible: "Адрес сервера"
- tapOn: "Адрес сервера"
- inputText: ${PIPU_E2E_SERVER}
- tapOn: "Подключиться"
- assertVisible: "Вход"
- tapOn: "Логин"
- inputText: ${PIPU_E2E_USER}
- tapOn: "Пароль"
- inputText: ${PIPU_E2E_PASSWORD}
- tapOn: "Войти"
- assertVisible: "Бегаю"
+10
View File
@@ -0,0 +1,10 @@
appId: app.pipu.music
---
- launchApp:
clearState: true
- assertVisible: "Адрес сервера|Server address"
- stopApp
- launchApp
- assertVisible: "Адрес сервера|Server address"
- openLink: "pipu://album/1"
- assertVisible: "Адрес сервера|Server address"
+6
View File
@@ -0,0 +1,6 @@
appId: app.pipu.music
---
- runFlow: login.yaml
- tapOn: "Профиль"
- assertVisible: "Проверить обновления"
- tapOn: "Проверить обновления"
+11 -1
View File
@@ -9,6 +9,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
name: 'Pipu Music',
slug: 'pipu-music',
scheme: 'pipu',
icon: './assets/icon.png',
version,
orientation: 'portrait',
userInterfaceStyle: 'automatic',
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
newArchEnabled: false,
backgroundColor: '#05070D',
android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
monochromeImage: './assets/monochrome-icon.png',
backgroundColor: '#05070D',
},
package: 'app.pipu.music',
versionCode,
edgeToEdgeEnabled: true,
@@ -30,7 +36,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'expo-secure-store',
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
// Android по умолчанию запрещает незашифрованный HTTP.
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
// Распаковываем .so при установке: SoLoader на x86_64 с ARM-трансляцией ищет
// внутри APK lib/x86_64 вместо arm64-v8a и не находит libreactnative.so.
['expo-build-properties', { android: { usesCleartextTraffic: true, useLegacyPackaging: true } }],
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
'./plugins/withArm64Only',
],
extra: { versionCode },
experiments: { typedRoutes: false },
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+4 -2
View File
@@ -7,7 +7,8 @@
"start": "expo start --dev-client",
"android": "expo run:android",
"prebuild": "expo prebuild --platform android --clean",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@expo/vector-icons": "^14.1.0",
@@ -42,7 +43,8 @@
},
"devDependencies": {
"@types/node": "^22.20.5",
"@types/react": "~19.0.10"
"@types/react": "~19.0.10",
"vitest": "^3.2.7"
},
"expo": {
"doctor": {
+18
View File
@@ -0,0 +1,18 @@
// Только arm64-v8a в APK. Наши нативные модули (Expo, Skia, Reanimated) собираются под одну ABI
// (-PreactNativeArchitectures=arm64-v8a в release.yml), а готовые библиотеки React Native приносят
// каталоги всех ABI. Без фильтра x86/x86_64-эмулятор и 32-битные устройства выбирают неполный каталог
// и падают при старте: «couldn't find DSO to load: libexpo-modules-core.so».
const { withAppBuildGradle } = require('expo/config-plugins');
const MARKER = '// pipu: arm64-v8a only';
module.exports = function withArm64Only(config) {
return withAppBuildGradle(config, (cfg) => {
if (cfg.modResults.contents.includes(MARKER)) return cfg;
cfg.modResults.contents = cfg.modResults.contents.replace(
/defaultConfig\s*\{/,
(m) => `${m}\n ${MARKER}\n ndk { abiFilters "arm64-v8a" }`,
);
return cfg;
});
};
@@ -1,4 +1,5 @@
import { unwrap, type Track } from '@pipu/core';
import { useRouter } from 'expo-router';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
const session = useSession();
const current = usePlayerState().track;
const [tab, setTab] = useState<Tab>('likes');
const router = useRouter();
const list = useInfiniteQuery({
queryKey: ['m-collection', tab],
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
const tabs = (
<View style={{ gap: 12 }}>
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
<Segmented<Tab>
value={tab}
onChange={setTab}
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
{ value: 'dislikes', label: t('collection.dislikes') },
]}
/>
</View>
);
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
+1 -1
View File
@@ -117,7 +117,7 @@ function Chips({
height: 38,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: on ? colors.accent : colors.surface2,
backgroundColor: on ? colors['accent-fill'] : colors.surface2,
borderWidth: 1,
borderColor: on ? colors.accent : colors.line,
}}
+10
View File
@@ -116,6 +116,16 @@ export default function ProfileScreen() {
onChange={(v) => void setPrefs({ mobile_player: v })}
/>
</Row>
<Row label={t('settings.normalize')}>
<Switch value={user.ui_prefs.normalize !== false} onValueChange={(v) => void setPrefs({ normalize: v })} />
</Row>
<Row label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}>
<Segmented
value={String([0, 2, 4, 6].includes(user.ui_prefs.crossfade_s ?? 0) ? (user.ui_prefs.crossfade_s ?? 0) : 0)}
options={['0', '2', '4', '6'].map((v) => ({ value: v, label: v }))}
onChange={(v) => void setPrefs({ crossfade_s: Number(v) })}
/>
</Row>
</Section>
<Section>
+23 -15
View File
@@ -1,6 +1,6 @@
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack, useRouter, useSegments } from 'expo-router';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import type { i18n as I18n } from 'i18next';
import { useEffect, useState } from 'react';
@@ -54,8 +54,6 @@ const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly stri
function Gate() {
const { server, session, ready } = useSessionCtx();
const auth = useAuthState();
const segments = useSegments();
const router = useRouter();
const { i18n } = useTranslation();
const user = auth.status === 'signed_in' ? auth.user : null;
const system = useColorScheme();
@@ -79,17 +77,6 @@ function Gate() {
return () => sub.remove();
}, []);
useEffect(() => {
if (!ready) return;
const first = segments[0] as string | undefined;
let target: string | null = null;
if (!server) target = first === 'connect' ? null : '/connect';
else if (!user) target = first === 'login' || first === 'connect' ? null : '/login';
else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup';
else if (first === 'connect' || first === 'login' || first === 'setup') target = '/';
if (target) router.replace(target as never);
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
if (!ready) return <Splash />;
return (
<ThemeContext.Provider value={theme}>
@@ -101,9 +88,30 @@ function Gate() {
function Screens() {
const colors = useColors();
const { server, session } = useSessionCtx();
const auth = useAuthState();
const connected = !!server && !!session;
const user = connected && auth.status === 'signed_in' ? auth.user : null;
const needsSetup = !!user && (user.must_change_credentials || user.must_change_password);
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
{/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */}
<Stack.Protected guard={!connected}>
<Stack.Screen name="connect" />
</Stack.Protected>
<Stack.Protected guard={connected && !user}>
<Stack.Screen name="login" />
</Stack.Protected>
<Stack.Protected guard={needsSetup}>
<Stack.Screen name="setup" />
</Stack.Protected>
<Stack.Protected guard={!!user && !needsSetup}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="album/[id]" />
<Stack.Screen name="artist/[id]" />
<Stack.Screen name="playlists" />
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack.Protected>
</Stack>
);
}
+28 -3
View File
@@ -1,9 +1,10 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
import { trackShareUrl } from '@pipu/core';
import { useUser } from '../lib/session';
import { useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller';
import { usePlayerState } from '../player/hooks';
import { useColors } from '../theme';
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
const router = useRouter();
const st = usePlayerState();
const prefs = useUser().ui_prefs;
const { server } = useSessionCtx();
const [sharing, setSharing] = useState(false);
const [barWidth, setBarWidth] = useState(1);
const track = st.track;
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
const duration = st.durationMs || track.duration_ms;
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 share = async () => {
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 (
<Screen>
@@ -45,7 +59,12 @@ export default function PlayerScreen() {
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
</Pressable>
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
<DownloadButton track={track} />
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
<Icon name="share" size={24} color={colors.text} />
</Pressable>
<DownloadButton track={track} />
</View>
</View>
<View style={{ alignItems: 'center' }}>
@@ -72,6 +91,12 @@ export default function PlayerScreen() {
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
{track.artists.map((a) => a.name).join(', ')}
</Text>
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
<Icon name="playlist-add" size={24} color={colors.text2} />
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
</Pressable>
</View>
<View style={{ gap: 6 }}>
+129
View File
@@ -0,0 +1,129 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
import { useSession, useUser } from '../lib/session';
import { player } from '../player/controller';
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
type Playlist = components['schemas']['Playlist'];
const input = (p: Playlist) => ({ name: p.name, description: p.description, revision: p.revision, track_ids: p.tracks.map(t => t.id) });
export default function PlaylistsScreen() {
const { id, track } = useLocalSearchParams<{ id?: string; track?: string }>();
return id ? <Detail key={id} id={id} /> : <List key={track ?? 'list'} track={track} />;
}
function List({ track }: { track?: string }) {
const session = useSession();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [create, setCreate] = useState(false);
const [search, setSearch] = useState('');
const list = useQuery({ queryKey: ['m-playlists', user.id], queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
const add = useMutation({ mutationFn: async (id: string) => {
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
const body = input(p); body.track_ids = [...new Set([...body.track_ids, track!])];
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
return <Screen><FlatList
data={list.data?.items.filter(p => p.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []}
keyExtractor={p => p.id} onRefresh={() => void list.refetch()} refreshing={list.isRefetching}
contentContainerStyle={{ padding: 16, gap: 12 }}
ListHeaderComponent={<View style={{ gap: 12 }}>
<Button title={t('form.close')} variant="ghost" onPress={() => router.back()} />
<H1>{t(track ? 'playlists.add' : 'playlists.title')}</H1>
<Button title={t('playlists.create')} onPress={() => setCreate(true)} disabled={add.isPending} />
<Field label={t('playlists.search')} value={search} onChangeText={setSearch} />
<ErrorBox>{list.error || add.error ? errorText(list.error ?? add.error) : null}</ErrorBox>
{list.isPending && <Muted>{t('playlists.loading')}</Muted>}
</View>}
ListEmptyComponent={list.isSuccess ? <Muted>{t(search ? 'playlists.no_results' : 'playlists.empty')}</Muted> : null}
renderItem={({ item }) => <Button title={`${item.name} · ${item.track_count}`} variant="secondary" disabled={add.isPending}
onPress={() => track ? add.mutate(item.id) : router.push({ pathname: '/playlists', params: { id: item.id } })} />}
/>{create && <Editor track={track} onClose={() => setCreate(false)} onSaved={p => {
setCreate(false); if (track) router.back(); else router.push({ pathname: '/playlists', params: { id: p.id } });
}} />}</Screen>;
}
function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; track?: string; onClose: () => void; onSaved: (p: Playlist) => void }) {
const session = useSession();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [original] = useState(playlist);
const [name, setName] = useState(playlist?.name ?? '');
const [description, setDescription] = useState(playlist?.description ?? '');
const save = useMutation({ mutationFn: async () => {
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? (track ? [track] : []) };
return original ? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
}, onSuccess: p => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); onSaved(p); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
return <Modal animationType="slide" onRequestClose={() => { if (!save.isPending) onClose(); }}><Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
<H1>{t(original ? 'playlists.edit' : 'playlists.create')}</H1>
<Field label={t('playlists.name')} value={name} onChangeText={setName} maxLength={120} autoFocus />
<Field label={t('playlists.description')} value={description} onChangeText={setDescription} maxLength={2000} multiline />
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
<Button title={t('playlists.save')} disabled={!name.trim()} busy={save.isPending} onPress={() => save.mutate()} />
<Button title={t('form.cancel')} variant="ghost" disabled={save.isPending} onPress={onClose} />
</ScrollView></Screen></Modal>;
}
function Detail({ id }: { id: string }) {
const session = useSession();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [edit, setEdit] = useState(false);
const [manage, setManage] = useState(false);
const [playError, setPlayError] = useState<unknown>();
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 p = query.data;
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'] }); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id } } })),
onSuccess: () => { cache.removeQueries({ queryKey: key }); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
const play = (shuffle: boolean) => {
const tracks = p?.tracks.filter(t => t.available) ?? [];
if (shuffle) for (let i=tracks.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [tracks[i],tracks[j]]=[tracks[j]!,tracks[i]!]; }
if (tracks.length) void player.playList(tracks,0,{ type:'collection', id, label:p?.name }).catch(setPlayError);
};
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);
};
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
<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>
{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>
<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('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.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
{text:t('form.cancel'),style:'cancel'}, {text:t('playlists.delete'),style:'destructive',onPress:()=>deletion.mutate()},
])} />
</>}
</View>}
ListEmptyComponent={p ? <View style={{padding:16}}><Muted>{t('playlists.empty_tracks')}</Muted></View> : null}
renderItem={({item,index}) => <View><TrackRow track={item} onPress={() => void player.playList(p!.tracks,index,{type:'collection',id,label:p!.name}).catch(setPlayError)} />
{manage && <View style={{padding:12,gap:6}}>
<Button title={t('playlists.move_up',{name:item.title})} variant="ghost" disabled={!index || save.isPending} onPress={() => move(index,-1)} />
<Button title={t('playlists.move_down',{name:item.title})} variant="ghost" disabled={index===p!.tracks.length-1 || save.isPending} onPress={() => move(index,1)} />
<Button title={t('playlists.remove_track',{name:item.title})} variant="danger" disabled={save.isPending} onPress={() => save.mutate(p!.tracks.filter(t => t.id!==item.id).map(t => t.id))} />
</View>}
</View>}
/>{edit && p && <Editor playlist={p} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}</Screen>;
}
+7
View File
@@ -0,0 +1,7 @@
export type ReleaseNeed = 'current' | 'optional' | 'required';
/** Нужно ли обновление: обязательное, если установленная версия ниже min_supported. */
export function releaseNeed(installed: number, versionCode: number, minSupported: number): ReleaseNeed {
if (versionCode <= installed) return 'current';
return installed < minSupported ? 'required' : 'optional';
}
+5 -2
View File
@@ -6,6 +6,8 @@ import * as IntentLauncher from 'expo-intent-launcher';
import * as SecureStore from 'expo-secure-store';
import { sha256 } from 'js-sha256';
import { releaseNeed } from './releaseNeed';
export type AndroidRelease = components['schemas']['AndroidRelease'];
/** Состояние самообновления (docs/mobile.md#самообновление). */
@@ -96,11 +98,12 @@ class Updater {
const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
const installed = installedVersionCode();
if (release.version_code > installed) {
const need = releaseNeed(installed, release.version_code, release.min_supported);
if (need !== 'current') {
this.set({
status: 'available',
release,
required: installed < release.min_supported,
required: need === 'required',
dismissed: false,
});
} else {
+6 -1
View File
@@ -272,7 +272,7 @@ class Downloads {
if (track.album.cover_url) {
const cover = this.cover(track.album.id);
const info = await FileSystem.getInfoAsync(cover);
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
}
const info = await FileSystem.getInfoAsync(this.file(id));
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
@@ -286,4 +286,9 @@ class Downloads {
}
}
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
export function sizedCover(url: string, size: number): string {
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
}
export const downloads = new Downloads();
+51 -20
View File
@@ -2,6 +2,7 @@ import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
PlayAttempt,
normalizeGain,
unwrap,
type AuthSession,
type PlayContext,
@@ -16,8 +17,12 @@ import TrackPlayer, {
} from 'react-native-track-player';
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
import { downloads } from '../offline/downloads';
import { downloads, sizedCover } from '../offline/downloads';
import { PersistentEvents } from '../offline/events';
import { offlineWave } from './offlineWave';
import { eventId } from './eventId';
export { offlineWave };
export interface WaveInfo {
sessionId: string;
@@ -45,15 +50,6 @@ const WAVE_REFILL_AT = 2;
const EARLY_SKIP_MS = 30_000;
const SAVE_EVERY_MS = 10_000;
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
return tracks
.filter((t) => t.reaction !== 'dislike')
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
.sort((a, b) => a.key - b.key)
.map((x) => x.t);
}
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
@@ -90,6 +86,7 @@ class Controller {
private lastSave = 0;
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
private setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>();
private state: PlayerState = {
@@ -117,7 +114,7 @@ class Controller {
this.server = server;
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
const sink = this.persistent;
this.events = sink ? new EventBuffer({ send: sink.send }) : null;
this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null;
void sink?.drain();
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
if (st.isConnected) void this.persistent?.drain();
@@ -158,6 +155,10 @@ class Controller {
this.lastPositionMs = ms;
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs });
if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save();
const cf = Math.min(6, Math.max(0, this.userPrefs()?.crossfade_s ?? 0));
const left = duration - position;
const fade = cf > 0 && left < cf ? Math.max(0, left / cf) : 1;
void this.applyVolume(this.state.track, fade);
});
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
this.finishAttempt(lastPosition);
@@ -166,6 +167,7 @@ class Controller {
if (!track) return;
this.attempt = new PlayAttempt(track, 0);
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
void this.applyVolume(track, 1);
this.events?.push({
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
context_id: this.state.context?.id,
@@ -189,6 +191,14 @@ class Controller {
if (!a) return;
this.attempt = null;
const pos = Math.round(lastPositionS * 1000);
if (this.neutralSwitch) {
this.neutralSwitch = false;
if (a.playedMs >= EARLY_SKIP_MS) {
this.events?.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events?.flush();
}
return;
}
const kind = ended ? 'play_end' : a.outcome(pos);
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events?.flush();
@@ -200,18 +210,32 @@ class Controller {
}
}
private userPrefs() {
const st = this.session?.state;
return st?.status === 'signed_in' ? st.user.ui_prefs : undefined;
}
/** Громкость 0..1: нормализация вниз к −14 LUFS и затухание кроссфейда в конце трека. */
private async applyVolume(track: Track | null, fade: number) {
const gain = Math.min(1, normalizeGain(track?.features?.loudness_lufs, this.userPrefs()?.normalize !== false) * fade);
await TrackPlayer.setVolume(gain).catch(() => undefined);
}
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
return tracks.map((t) => ({
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
duration: t.duration_ms / 1000,
}));
return tracks.map((t) => {
const cover = absolute(this.server, t.album.cover_url);
return {
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
duration: t.duration_ms / 1000,
};
});
}
async playList(tracks: Track[], index: number, context: PlayContext) {
@@ -316,6 +340,13 @@ class Controller {
);
await this.replaceUpcoming(res.tracks);
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
if (res.tracks.some(playable)) {
this.neutralSwitch = true;
await TrackPlayer.skipToNext().catch(() => {
this.neutralSwitch = false;
});
}
}
private leaveWave() {
+9
View File
@@ -0,0 +1,9 @@
import { uuidv7 } from '@pipu/core';
/** Hermes has no Web Crypto. Expo's installed native runtime supplies secure UUID randomness. */
export function eventId(now: number): string {
return uuidv7(now, () => {
const hex = globalThis.expo.uuidv4().replace(/-/g, '');
return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));
});
}
+10
View File
@@ -0,0 +1,10 @@
import type { Track } from '@pipu/core';
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
return tracks
.filter((t) => t.reaction !== 'dislike')
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
.sort((a, b) => a.key - b.key)
.map((x) => x.t);
}
+10 -4
View File
@@ -1,4 +1,5 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useRouter } from 'expo-router';
import { ApiError, type Track } from '@pipu/core';
import type { ComponentProps, ReactNode } from 'react';
import { useState, useSyncExternalStore } from 'react';
@@ -19,7 +20,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads';
import { downloads, sizedCover } from '../offline/downloads';
import { absolute } from '../player/controller';
import { radius, useColors, type Colors } from '../theme';
@@ -107,7 +108,7 @@ export function Button({
}) {
const s = useStyles();
const colors = useColors();
const bg = { primary: colors.accent, secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
const bg = { primary: colors['accent-fill'], secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
return (
<Pressable
@@ -160,7 +161,7 @@ export function Cover({
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined);
const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
const bg = album.cover_colors?.[0] ?? colors.surface3;
return (
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
@@ -199,6 +200,7 @@ export function TrackRow({
const colors = useColors();
const { t } = useTranslation();
const liked = track.reaction === 'like';
const router = useRouter();
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
return (
<Pressable
@@ -233,6 +235,10 @@ export function TrackRow({
</Pressable>
)}
{trailing}
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
<Icon name="playlist-add" size={22} />
</Pressable>
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
</Pressable>
@@ -282,7 +288,7 @@ export function Segmented<T extends string>({
onPress={() => onChange(o.value)}
accessibilityRole="tab"
accessibilityState={{ selected: value === o.value }}
style={[s.segment, value === o.value && { backgroundColor: colors.accent }]}
style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]}
>
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
</Pressable>
+19
View File
@@ -0,0 +1,19 @@
import { afterEach, expect, it, vi } from 'vitest';
import { EventBuffer } from '@pipu/core';
import { eventId } from '../src/player/eventId';
afterEach(() => vi.unstubAllGlobals());
it('queues restored-track events on Hermes without Web Crypto', () => {
vi.stubGlobal('crypto', undefined);
const nativeUuid = vi.fn()
.mockReturnValueOnce('00000000-0000-4fff-bfff-ffffffffffff')
.mockReturnValueOnce('00000000-0000-4fff-bfff-fffffffffffe');
vi.stubGlobal('expo', { uuidv4: nativeUuid });
const buffer = new EventBuffer({ send: vi.fn() }, () => 0x0190_0000_0000, undefined, eventId);
buffer.push({ type: 'play_start', track_id: 'restored-track' });
buffer.push({ type: 'skip', track_id: 'restored-track' });
expect(buffer.pending[0]?.id).toBe('01900000-0000-7fff-bfff-ffffffffffff');
expect(buffer.pending[1]?.id).not.toBe(buffer.pending[0]?.id);
expect(nativeUuid).toHaveBeenCalledTimes(2);
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { releaseNeed } from '../src/lib/releaseNeed';
import { offlineWave } from '../src/player/offlineWave';
import type { Track } from '@pipu/core';
function track(id: string, reaction: Track['reaction']): Track {
return {
id,
title: id,
duration_ms: 1000,
artists: [],
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
genres: [],
disc_no: 1,
track_no: 1,
available: true,
stream_url: null,
reaction,
features: null,
};
}
describe('offline wave', () => {
it('drops dislikes and keeps likes ahead of the rest', () => {
const tracks = [track('d', 'dislike'), track('n', null), track('l', 'like')];
const order = offlineWave(tracks, () => 0.5).map((t) => t.id);
expect(order).toEqual(['l', 'n']);
});
});
describe('update dialog decision', () => {
it('marks an older install below the minimum as required', () => {
expect(releaseNeed(10, 12, 11)).toBe('required');
expect(releaseNeed(11, 12, 11)).toBe('optional');
expect(releaseNeed(12, 12, 11)).toBe('current');
});
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
},
});
+2
View File
@@ -8,6 +8,8 @@ WORKDIR /repo
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# pnpm-патчи зависимостей (lockfile ссылается на них, без них --frozen-lockfile падает)
COPY patches/ patches/
COPY apps/web/package.json apps/web/
COPY apps/mobile/package.json apps/mobile/
COPY packages/api-client/package.json packages/api-client/
+119
View File
@@ -0,0 +1,119 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username: string) {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
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: 'Ещё', exact: true })).toBeVisible();
}
async function fits(page: Page) {
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
}
for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430, 'alice']] as const) {
test(`mobile ${width}: ${username} navigation, forms and search`, async ({ page }, info) => {
test.setTimeout(60_000);
await page.setViewportSize({ width, height: 844 });
await login(page, username);
await fits(page);
const bottom = page.getByRole('navigation', { name: 'Навигация' }).filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) });
await expect(bottom.getByRole('link')).toHaveCount(3);
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Навигация' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('link', { name: 'Дизлайки', exact: true })).toBeVisible();
if (username !== 'alice') {
await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
await expect(dialog.getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
await expect(dialog.getByRole('link', { name: 'Хранилища', exact: true })).toHaveCount(username === 'mobile-admin' ? 1 : 0);
await dialog.getByRole('link', { name: 'Пользователи', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
await fits(page);
await page.screenshot({ path: info.outputPath('users.png') });
await page.getByRole('button', { name: /Действия.*alice/ }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
const rect = await menu.boundingBox();
expect(rect!.x).toBeGreaterThanOrEqual(0);
expect(rect!.x + rect!.width).toBeLessThanOrEqual(width);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Создать пользователя', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.screenshot({ path: info.outputPath('create-user.png') });
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
} else {
await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
}
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible();
await fits(page);
await page.screenshot({ path: info.outputPath('settings.png') });
for (const name of ['Слева', 'По центру', 'Справа']) {
await page.getByRole('button', { name, exact: true }).click();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await expect(page.getByRole('option', { name: /Кино/ }).first()).toBeVisible();
await fits(page);
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Escape');
}
if (username !== 'alice') {
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible();
await expect(page.locator('audio')).toBeVisible();
await fits(page);
await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check();
await expect(page.getByRole('region', { name: 'Действия с выбранными' })).toBeVisible();
await fits(page);
await page.screenshot({ path: info.outputPath('upload-queue.png') });
await page.getByRole('region', { name: 'Действия с выбранными' }).getByRole('button', { name: 'Отклонить' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
}
if (username === 'mobile-admin') {
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
await expect(page.getByRole('article', { name: 'Музыкальное хранилище' })).toBeVisible();
await fits(page);
await page.getByRole('button', { name: 'Шаблоны путей', exact: true }).click();
await page.getByRole('button', { name: 'Добавить шаблон', exact: true }).click();
await fits(page);
await page.screenshot({ path: info.outputPath('patterns.png') });
}
});
}
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 740 });
await login(page, 'alice');
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 player = page.getByRole('contentinfo', { name: 'Плеер' });
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40);
await fits(page);
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'Остров', exact: true }).click();
const nav = page.getByRole('navigation').filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) });
await expect.poll(async () => {
const p = await player.boundingBox();
const n = await nav.boundingBox();
return p!.y + p!.height <= n!.y + 1;
}).toBe(true);
await player.getByRole('button', { name: 'Очередь', exact: true }).click();
await expect(page.getByRole('complementary', { name: 'Очередь', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('player-queue.png') });
});
+36
View File
@@ -0,0 +1,36 @@
import { expect, test } from '@playwright/test';
for (const width of [320, 1280]) {
test(`playlists at ${width}px`, 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 (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' });
await player.getByRole('button', { name: 'Добавить в плейлист', exact: true }).click();
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
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.screenshot({ path: info.outputPath('playlist.png') });
});
}
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
});
test('1. bootstrap admin changes credentials and lands on home', async ({ page }) => {
await page.goto('/login');
await expect(page.getByText(/Первый запуск/)).toBeVisible();
await page.getByLabel('Логин').fill('admin');
await page.getByRole('textbox', { name: 'Пароль' }).fill('admin-password');
await page.getByRole('button', { name: 'Войти' }).click();
await expect(page.getByRole('heading', { name: 'Смените логин и пароль' })).toBeVisible();
await page.getByLabel('Новый логин').fill('owner');
await page.getByRole('textbox', { name: 'Новый пароль' }).fill('a-strong-owner-pass');
await page.getByRole('textbox', { name: 'Повторите пароль' }).fill('a-strong-owner-pass');
await page.getByRole('button', { name: 'Сохранить и продолжить' }).click();
await expect(page.getByRole('button', { name: 'Бегаю' })).toBeVisible();
});
test('4. search suggest opens the artist and Enter opens search', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Логин').fill('alice');
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
await page.getByRole('button', { name: 'Войти' }).click();
const box = page.getByRole('combobox', { name: 'Поиск' });
await box.fill('кино');
await page.getByRole('option', { name: /Кино/ }).first().click();
await expect(page.getByRole('heading', { name: 'Кино' })).toBeVisible();
await box.fill('кино');
// A suggestion under the pointer must not become a keyboard selection.
const suggestion = page.getByRole('option', { name: /Кино/ }).first();
await suggestion.hover();
await expect(suggestion).toHaveAttribute('aria-selected', 'false');
await box.press('Enter');
await expect(page).toHaveURL(/\/search\?q=/);
});
test('settings expose crossfade and loudness normalization', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Логин').fill('alice');
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
await page.getByRole('button', { name: 'Войти' }).click();
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки' }).click();
await expect(page.getByText('Выравнивание громкости')).toBeVisible();
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
await slider.fill('4');
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
});
test('9. login page switches between Russian and English', async ({ page }) => {
await page.goto('/login');
await page.getByRole('button', { name: 'EN' }).click();
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
await page.getByRole('button', { name: 'RU' }).click();
await expect(page.getByRole('heading', { name: 'Вход' })).toBeVisible();
});
+3 -1
View File
@@ -2,9 +2,11 @@
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<title>pipu</title>
</head>
<body>
+6 -1
View File
@@ -8,7 +8,8 @@
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@fontsource/jetbrains-mono": "^5.2.6",
@@ -21,13 +22,16 @@
"@pipu/i18n": "workspace:*",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20",
"@tanstack/react-virtual": "^3.14.21",
"hash-wasm": "^4.12.0",
"music-metadata": "^12.2.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.3",
"tus-js-client": "^4.3.1"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
@@ -35,6 +39,7 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"axe-core": "^4.14.0",
"jsdom": "^26.1.0",
"vite": "^6.3.5",
"vitest": "^3.2.4"
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] },
webServer: {
command: 'pnpm exec vite --port 4173 --strictPort',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
env: { VITE_E2E: '1' },
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+17 -1
View File
@@ -1 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="g" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#9CC6FF"/><stop offset=".55" stop-color="#2F80FF"/><stop offset="1" stop-color="#3A1FA8"/></radialGradient></defs><circle cx="16" cy="16" r="14" fill="url(#g)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="orb" cx="28%" cy="18%" r="95%">
<stop stop-color="#9CC6FF"/>
<stop offset=".42" stop-color="#2F80FF"/>
<stop offset=".78" stop-color="#6354E8"/>
<stop offset="1" stop-color="#3A1FA8"/>
</radialGradient>
<linearGradient id="note" x1="0" y1="0" x2=".3" y2="1">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#DFEBFF"/>
</linearGradient>
</defs>
<circle cx="256" cy="256" r="240" fill="url(#orb)"/>
<path d="M208 348V164H270C356 164 356 280 270 280H208" fill="none" stroke="url(#note)" stroke-width="44" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="181" cy="348" rx="49" ry="36" fill="url(#note)" transform="rotate(-18 181 348)"/>
</svg>

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 840 B

+159 -3
View File
@@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query';
import { createMemoryHistory } from '@tanstack/react-router';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import axe from 'axe-core';
import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest';
@@ -117,7 +118,8 @@ describe('navigation by role', () => {
it('shows users but not the audit log to moderators', async () => {
const mod = makeUser('mod', 'moderator');
await renderApp([mod], '/', { signedInAs: mod });
const { user } = await renderApp([mod], '/', { signedInAs: mod });
await user.click(await screen.findByRole('button', { name: 'Админка' }));
expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
});
@@ -152,6 +154,39 @@ describe('users admin', () => {
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
});
it.each(['root', 'anna'])('lets admins change the username of %s', async (target) => {
const root = makeUser('root', 'admin');
const anna = makeUser('anna', 'user');
const { user, api, session } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
await user.click(await screen.findByRole('button', { name: `Действия с ${target}` }));
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
const input = within(dialog).getByLabelText('Логин');
await user.clear(input);
await user.type(input, `new.${target}`);
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(await screen.findByText(`@new.${target}`)).toBeInTheDocument();
expect(api.users.get(target === 'root' ? root.id : anna.id)?.username).toBe(`new.${target}`);
if (target === 'root') expect(session.state).toMatchObject({ status: 'signed_in', user: { username: 'new.root' } });
});
it('keeps the username dialog open when the new username is taken', async () => {
const root = makeUser('root', 'admin');
const anna = makeUser('anna', 'user');
const { user, api } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
await user.click(await screen.findByRole('button', { name: 'Действия с anna' }));
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
const input = within(dialog).getByLabelText('Логин');
await user.clear(input);
await user.type(input, 'ROOT');
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
expect(api.users.get(anna.id)?.username).toBe('anna');
expect(dialog).toBeInTheDocument();
});
it('offers moderators only what they may do', async () => {
const mod = makeUser('mod', 'moderator');
const anna = makeUser('anna', 'user');
@@ -164,7 +199,7 @@ describe('users admin', () => {
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']);
expect(items).toEqual(['Сбросить пароль', 'Сбросить рекомендации', 'Заблокировать']);
await user.keyboard('{Escape}');
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
@@ -246,11 +281,35 @@ describe('search box', () => {
});
describe('player', () => {
it('opens a shared track after login and copies its page URL from the player', async () => {
const alice = makeUser('alice', 'user');
const path = '/track/00000000-0000-4000-8000-000000000101';
const { user, router } = await renderApp([alice], path);
await screen.findByRole('heading', { name: 'Вход' });
await user.type(screen.getByLabelText('Логин'), alice.username);
await user.type(screen.getByLabelText('Пароль'), alice.password);
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
expect(router.state.location.pathname).toBe(path);
await user.click(screen.getByRole('button', { name: 'Слушать' }));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
});
it('shows a missing shared track without starting playback', async () => {
const alice = makeUser('alice', 'user');
await renderApp([alice], '/track/missing', { signedInAs: alice });
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
});
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!;
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
@@ -313,6 +372,31 @@ describe('my wave', () => {
});
});
describe('suggest tracks', () => {
it('warns that lossless formats are the ones to send', async () => {
const admin = makeUser('admin', 'admin');
await renderApp([admin], '/uploads/new', { signedInAs: admin });
expect(await screen.findByText(/Нужны FLAC, WAV или AIFF/)).toBeInTheDocument();
expect(screen.getByText(/MP3 — только в крайнем случае/)).toBeInTheDocument();
});
});
describe('accessibility', () => {
it('home and login have no serious axe violations', async () => {
const alice = makeUser('alice', 'user');
await renderApp([alice], '/', { signedInAs: alice });
await screen.findByRole('button', { name: 'Бегаю' });
const home = await axe.run(document.body, { resultTypes: ['violations'] });
expect(home.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
cleanup();
await renderApp([], '/login');
await screen.findByRole('heading', { name: 'Вход' });
const login = await axe.run(document.body, { resultTypes: ['violations'] });
expect(login.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
});
});
describe('safeRedirect', () => {
it.each([
['/settings', '/settings'],
@@ -323,3 +407,75 @@ describe('safeRedirect', () => {
expect(safeRedirect(input)).toBe(expected);
});
});
describe('playlists', () => {
it('adds the playing track from the player, keeping the selected track when playback advances', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
await screen.findByRole('heading', { name: 'Группа крови' });
await user.click(screen.getAllByRole('listitem')[0]!);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'Из плеера');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(api.playlists.size).toBe(1));
expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
// Simulate automatic advancement while the picker is open.
await user.click(within(bar).getByRole('button', { name: 'Следующий' }));
await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']);
});
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
const alice = makeUser('alice', 'user');
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'В дорогу');
await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'В дорогу' });
const id = [...api.playlists.keys()][0]!;
expect(api.playlists.get(id)?.description).toBe('Музыка для дороги');
await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } });
await screen.findByRole('heading', { name: 'Группа крови' });
await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!);
await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2));
await router.navigate({ to: '/playlist/$id', params: { id } });
await user.click(await screen.findByRole('button', { name: 'Управлять треками' }));
await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь'));
await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1));
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
await user.clear(screen.getByLabelText('Название'));
await user.type(screen.getByLabelText('Название'), 'Вечер');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'Вечер' });
await user.click(screen.getByRole('button', { name: 'Удалить плейлист' }));
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' }));
await screen.findByRole('heading', { name: 'Плейлисты' });
expect(api.playlists.size).toBe(0);
});
it('keeps stale editor open on conflict without overwriting newer changes', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'Первый');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'Первый' });
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
const item = [...api.playlists.values()][0]!;
api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' });
await user.type(screen.getByLabelText('Название'), ' изменён');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве');
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
});
});
+64 -17
View File
@@ -7,20 +7,26 @@ import {
redirect,
type ParsedLocation,
} from '@tanstack/react-router';
import { lazy } from 'react';
import { AuditPage } from '../pages/admin/AuditPage';
import { LibraryPage } from '../pages/admin/LibraryPage';
import { PatternsPage } from '../pages/admin/PatternsPage';
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
import { SuggestPage } from '../pages/uploads/SuggestPage';
import { SourcesPage } from '../pages/admin/SourcesPage';
import { UsersPage } from '../pages/admin/UsersPage';
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 PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage })));
const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage })));
const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage })));
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
import type { DesktopHandle } from '../desktop';
import { AlbumPage } from '../pages/AlbumPage';
import { ConnectPage } from '../pages/ConnectPage';
import { TrackPage } from '../pages/TrackPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage';
import { SearchPage, type SearchType } from '../pages/SearchPage';
import { SettingsPage } from '../pages/SettingsPage';
@@ -30,6 +36,7 @@ import { RouteError } from '../widgets/RouteError';
export interface RouterContext {
session: AuthSession;
desktop: DesktopHandle | null;
}
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
@@ -48,9 +55,12 @@ export function safeRedirect(target: unknown): string {
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
}
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
function requireUser(session: AuthSession, location: ParsedLocation, desktop: DesktopHandle | null): AuthUser {
const user = signedInUser(session);
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
if (!user) {
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
throw redirect({ to: '/login', search: { redirect: location.href } });
}
return user;
}
@@ -59,12 +69,34 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError,
});
// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
const downloadAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/download', component: AppDownloadPage });
const androidAppRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/android',
beforeLoad: () => {
throw redirect({ to: '/download' });
},
component: AppDownloadPage,
});
const connectRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/connect',
beforeLoad: ({ context }) => {
if (!context.desktop) throw redirect({ to: '/login' });
if (context.desktop.server) throw redirect({ to: '/login' });
},
component: ConnectPage,
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
beforeLoad: ({ context, search }) => {
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
const user = signedInUser(context.session);
if (!user) return;
const home = homeFor(user);
@@ -78,7 +110,7 @@ const setupAdminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/admin',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const user = requireUser(context.session, location, context.desktop);
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="admin" />,
@@ -88,7 +120,7 @@ const setupPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/password',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const user = requireUser(context.session, location, context.desktop);
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="password" />,
@@ -99,7 +131,7 @@ const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const user = requireUser(context.session, location, context.desktop);
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
return { user };
@@ -112,6 +144,9 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
@@ -221,7 +256,9 @@ const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/upl
const uploadQueueRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/uploads',
beforeLoad: adminOnly,
beforeLoad: ({ context }) => {
if (!context.user.reviews_uploads) throw redirect({ to: '/' });
},
component: UploadQueuePage,
});
@@ -233,11 +270,17 @@ const adminSettingsRoute = createRoute({
});
const routeTree = rootRoute.addChildren([
downloadAppRoute,
androidAppRoute,
connectRoute,
loginRoute,
setupAdminRoute,
setupPasswordRoute,
appRoute.addChildren([
homeRoute,
trackRoute,
playlistsRoute,
playlistRoute,
settingsRoute,
searchRoute,
historyRoute,
@@ -257,8 +300,12 @@ const routeTree = rootRoute.addChildren([
]),
]);
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
export function createAppRouter(
session: AuthSession,
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
) {
const { desktop = null, ...rest } = opts;
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
session.subscribe(() => void router.invalidate());
return router;
+28
View File
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createDesktop, isDesktop } from './desktop';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('desktop bridge', () => {
it('is inactive in the browser', () => {
expect(isDesktop()).toBe(false);
});
it('bootstraps through the Tauri invoke surface', async () => {
const invoke = vi.fn(async (cmd: string) => {
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
if (cmd === 'set_server') return 'https://music.example/pipu';
return null;
});
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
const desktop = await createDesktop();
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
expect(desktop?.server).toBe('https://music.example');
await desktop?.setServer('https://music.example/pipu');
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
});
});
+94
View File
@@ -0,0 +1,94 @@
import type { AuthUser, RefreshStore } from '@pipu/core';
import { createContext, createElement, useContext, type ReactNode } from 'react';
type Tauri = {
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
};
function api(): Tauri | undefined {
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
}
export function isDesktop(): boolean {
return api() !== undefined;
}
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const tauri = api();
if (!tauri) throw new Error('Tauri API unavailable');
return tauri.core.invoke(cmd, args) as Promise<T>;
}
export type DesktopHandle = {
proxyBase: string;
server: string | null;
setServer: (url: string) => Promise<string>;
clearServer: () => Promise<void>;
refreshStore: RefreshStore;
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
};
export const DesktopContext = createContext<DesktopHandle | null>(null);
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
return createElement(DesktopContext.Provider, { value }, children);
}
export function useDesktop(): DesktopHandle | null {
return useContext(DesktopContext);
}
let active: DesktopHandle | null = null;
export function desktopHandle(): DesktopHandle | null {
return active;
}
export async function createDesktop(): Promise<DesktopHandle | null> {
if (!isDesktop()) return null;
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
const handle: DesktopHandle = {
proxyBase: boot.proxyBase,
server: boot.server,
setServer: async (url) => {
const server = await invoke<string>('set_server', { url });
handle.server = server;
return server;
},
clearServer: async () => {
await invoke('clear_server');
handle.server = null;
},
refreshStore: {
get: () => invoke<string | null>('token_get'),
set: (token) => invoke('token_set', { token }),
clear: async () => {
await invoke('token_clear');
await invoke('user_clear');
},
getUser: async () => {
const json = await invoke<string | null>('user_get');
return json ? (JSON.parse(json) as AuthUser) : null;
},
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
},
listenMedia: async (handler) => {
const tauri = api();
if (!tauri) return () => undefined;
return tauri.event.listen('media', (e) => handler(e.payload));
},
setMedia: (info) => {
void invoke('media_set', {
title: info.title,
artist: info.artist,
album: info.album,
coverUrl: info.coverUrl ?? null,
playing: info.playing,
});
},
};
active = handle;
return handle;
}
+66 -2
View File
@@ -8,12 +8,18 @@
color: rgba(255, 255, 255, 0.85);
}
.cover img {
.coverImg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.35s ease;
}
.coverImgOn {
opacity: 1;
}
.round {
@@ -59,10 +65,52 @@
width: 128px;
}
.cardCoverWrap {
position: relative;
margin-bottom: 4px;
overflow: hidden;
border-radius: 12px;
}
.cardCover {
aspect-ratio: 1;
border-radius: 12px;
margin-bottom: 4px;
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card:hover .cardCover,
.card:focus-within .cardCover {
transform: scale(1.04);
}
.cardPlay {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 1;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: #fff;
color: #05070d;
opacity: 0;
transform: translateY(6px);
transition:
opacity 0.18s,
transform 0.18s;
}
.card:hover .cardPlay,
.card:focus-within .cardPlay,
.cardPlay:focus-visible {
opacity: 1;
transform: none;
}
.cardPlay:active {
transform: scale(0.94);
}
.cardTitle {
@@ -104,6 +152,12 @@
min-height: 56px;
padding: 6px 12px;
border-radius: 10px;
animation: pipuIn 0.2s ease-out both;
animation-delay: var(--stagger, 0ms);
}
.trackStatic {
animation: none;
}
.track:has(.trackAlbum) {
@@ -270,3 +324,13 @@
display: none;
}
}
@media (max-width: 767px) {
.cardPlay { opacity: 1; transform: none; }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
.end { gap: 4px; }
.like { opacity: 1; width: 44px; height: 44px; }
.track:has([data-playlist-actions]) .end { grid-column: 3 / -1; justify-content: flex-end; }
}
+144 -73
View File
@@ -1,8 +1,12 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { Link } from '@tanstack/react-router';
import { Fragment } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player';
@@ -26,6 +30,7 @@ function fallbackColors(id: string): string[] {
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
export function coverSrc(url: string, cssPx: number): string {
if (!url.includes('/api/v1/covers/')) return url;
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`;
@@ -56,7 +61,7 @@ export function Cover({
aria-hidden
>
{album.cover_url ? (
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
<CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
) : (
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
)}
@@ -80,10 +85,39 @@ export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
);
}
function CoverImg({ src }: { src: string }) {
const [ready, setReady] = useState(false);
return (
<img
src={src}
alt=""
loading="lazy"
className={cx(s.coverImg, ready && s.coverImgOn)}
onLoad={() => setReady(true)}
/>
);
}
export function AlbumCard({ album }: { album: AlbumSummary }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const play = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
void (async () => {
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
})();
};
return (
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
<Cover album={album} size="100%" className={s.cardCover} />
<span className={s.cardCoverWrap}>
<Cover album={album} size="100%" className={s.cardCover} />
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
<Icon src={playIcon} size={22} />
</button>
</span>
<span className={s.cardTitle}>{album.title}</span>
<span className={s.cardMeta}>
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
@@ -114,6 +148,7 @@ export function TrackList({
rowMeta,
lead,
disliked,
actions,
}: {
tracks: Track[];
showAlbum?: boolean;
@@ -125,86 +160,122 @@ export function TrackList({
lead?: (t: Track, i: number) => string;
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
disliked?: boolean;
actions?: (track: Track, index: number) => ReactNode;
}) {
const { t } = useTranslation();
const player = usePlayer();
const { track: current, playing } = usePlayerState();
const listRef = useRef<HTMLDivElement>(null);
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
const multiDisc = Boolean(numbered && discs.size > 1);
const virtual = tracks.length > 30 && !multiDisc;
const virtualizer = useVirtualizer({
count: virtual ? tracks.length : 0,
getScrollElement: () => listRef.current?.closest('main') ?? null,
estimateSize: () => 68,
overscan: 8,
});
const row = (tr: Track, i: number) => {
const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context);
const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i);
return (
<div
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
>
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
<span className={cx(s.num, disliked && s.dimmed)}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={cx(s.trackMain, disliked && s.dimmed)}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
{meta && <span className={s.rowMeta}> · {meta}</span>}
</span>
</span>
{showAlbum && (
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
{tr.album.title}
</Link>
)}
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
{disliked ? (
<button
type="button"
className={cx(s.like, s.liked)}
aria-label={t('player.undislike')}
onClick={() => void player.react(tr, null).catch(() => undefined)}
>
<Icon src={undoIcon} size={18} />
</button>
) : (
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
</div>
);
};
let lastDisc: number | null = null;
return (
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc;
lastDisc = disc;
const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context);
const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i);
return (
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div
ref={listRef}
className={s.tracks}
role="list"
aria-label={t('search.tracks')}
style={virtual ? { height: virtualizer.getTotalSize(), position: 'relative' } : undefined}
>
{virtual
? virtualizer.getVirtualItems().map((vi) => (
<div
role="row"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (!(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
>
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
<span className={cx(s.num, disliked && s.dimmed)}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={cx(s.trackMain, disliked && s.dimmed)}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
{meta && <span className={s.rowMeta}> · {meta}</span>}
</span>
</span>
{showAlbum && (
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
{tr.album.title}
</Link>
)}
<span className={s.end}>
{disliked ? (
<button
type="button"
className={cx(s.like, s.liked)}
aria-label={t('player.undislike')}
onClick={() => void player.react(tr, null).catch(() => undefined)}
>
<Icon src={undoIcon} size={18} />
</button>
) : (
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
{row(tracks[vi.index]!, vi.index)}
</div>
</Fragment>
);
})}
))
: tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = multiDisc && disc !== lastDisc;
lastDisc = disc;
return (
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
{row(tr, i)}
</Fragment>
);
})}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { playlistAddIcon } from '../shared/ui/icons';
import s from './catalog.module.css';
export type Playlist = components['schemas']['Playlist'];
export type PlaylistInput = components['schemas']['PlaylistInput'];
export function usePlaylists(enabled = true) {
const session = useSession();
return useQuery({ queryKey: ['playlists'], enabled,
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
}
export function usePlaylist(id: string) {
const session = useSession();
return useQuery({ queryKey: ['playlists', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
}
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
}
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
}) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const errorText = useErrorText();
const [original] = useState(playlist);
const [name, setName] = useState(playlist?.name ?? '');
const [description, setDescription] = useState(playlist?.description ?? '');
const save = useMutation({ mutationFn: async () => {
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? trackIds };
return original
? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
}, onSuccess: p => {
cache.setQueryData(['playlists', p.id], p);
void cache.invalidateQueries({ queryKey: ['playlists'] });
onSaved(p);
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
</form>
</Dialog>;
}
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const errorText = useErrorText();
const list = usePlaylists();
const [create, setCreate] = useState(false);
const add = useMutation({ mutationFn: async (id: string) => {
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
const body = playlistInput(p);
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
<div style={{ display: 'grid', gap: 8 }}>
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
</div>
</Dialog>;
});
export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
const { t } = useTranslation();
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
const close = useCallback(() => setSelectedIds(null), []);
return <>
{compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
</>;
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { needsFormatAttention } from './files';
describe('needsFormatAttention', () => {
it('marks everything except lossless formats', () => {
expect(needsFormatAttention('flac', 'a.mp3')).toBe(false);
expect(needsFormatAttention('WAV', 'track.wav')).toBe(false);
expect(needsFormatAttention(null, 'Кино/01.wav')).toBe(false);
expect(needsFormatAttention('aiff', 'a.aif')).toBe(false);
expect(needsFormatAttention(null, 'track.AIFF')).toBe(false);
expect(needsFormatAttention(null, 'track.wv')).toBe(false);
expect(needsFormatAttention('mp3', 'a.flac')).toBe(true);
expect(needsFormatAttention(null, 'folder/song.MP3')).toBe(true);
expect(needsFormatAttention(null, 'song.m4a')).toBe(true);
expect(needsFormatAttention(null, 'noext')).toBe(false);
});
});
+17
View File
@@ -8,6 +8,23 @@ export interface PickedFile {
/** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */
export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv'];
/** То, что просим присылать. MP3 и прочие — крайний случай, их помечает очередь. */
// Lossless — как в quality.go на сервере.
const PREFERRED_AUDIO = new Set(['flac', 'wav', 'aiff', 'aif', 'wv']);
export function audioExt(name: string): string {
const base = name.split(/[/\\]/).pop() ?? name;
const dot = base.lastIndexOf('.');
if (dot < 0 || dot === base.length - 1) return '';
return base.slice(dot + 1).toLowerCase();
}
/** Не lossless (FLAC, WAV, AIFF, WavPack): в очереди на добавление такой файл нужно выделить. */
export function needsFormatAttention(format: string | null | undefined, name: string): boolean {
const ext = (format || audioExt(name)).toLowerCase();
return ext !== '' && !PREFERRED_AUDIO.has(ext);
}
export function isAudio(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
return AUDIO_EXTENSIONS.includes(ext);
+140 -12
View File
@@ -17,15 +17,18 @@ export type Stage =
| 'uploading'
| 'done'
| 'rejected'
| 'error';
| 'error'
| 'resume';
export interface UploadItem {
id: string;
file: File;
file: File | null;
size: number;
relPath: string;
stage: Stage;
progress: number; // 0..1 текущего этапа (хэш или загрузка)
sha256?: string;
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
trackId?: string | null;
possibleDuplicate?: boolean;
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
@@ -44,6 +47,17 @@ export interface UploaderState {
const PARALLEL = 4;
const CHUNK = 8 << 20;
const PENDING_KEY = 'pipu.uploads.pending';
interface PendingUpload {
id: string;
name: string;
size: number;
relPath: string;
sha256: string;
tusUrl: string;
uploadFileId: string;
}
let seq = 0;
@@ -56,7 +70,9 @@ export class Uploader {
private uploads = new Map<string, tus.Upload>();
private refreshing: Promise<unknown> | null = null;
constructor(private readonly session: AuthSession) {}
constructor(private readonly session: AuthSession) {
this.restore();
}
subscribe = (fn: () => void) => {
this.listeners.add(fn);
@@ -89,8 +105,8 @@ export class Uploader {
continue;
}
// Тот же путь и размер уже в списке — не добавляем второй раз.
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue;
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 });
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 });
}
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
this.hashQueue.push(...fresh.map((it) => it.id));
@@ -101,6 +117,7 @@ export class Uploader {
if (this.state.busy) return;
this.hashQueue = this.hashQueue.filter((x) => x !== id);
this.set({ items: this.state.items.filter((it) => it.id !== id) });
this.persist();
}
/** Очистить список (после завершения или чтобы начать заново). */
@@ -108,6 +125,7 @@ export class Uploader {
if (this.state.busy) return;
this.hashQueue = [];
this.set({ items: [], skipped: 0, batchId: null, error: null });
this.persist();
}
private hashWorker(): Worker {
@@ -125,6 +143,7 @@ export class Uploader {
if (!it) continue;
try {
const sha256 = await this.hash(it);
await this.readTags(it);
this.patch(id, { sha256, stage: 'checking', progress: 1 });
} catch (err) {
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
@@ -137,13 +156,15 @@ export class Uploader {
}
private hash(it: UploadItem): Promise<string> {
const file = it.file;
if (!file) return Promise.reject(new Error('no file'));
const w = this.hashWorker();
return new Promise((resolve, reject) => {
const onMessage = (e: MessageEvent<HashResponse>) => {
const m = e.data;
if (m.id !== it.id) return;
if (m.type === 'progress') {
this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) });
this.patch(it.id, { progress: m.done / Math.max(it.size, 1) });
return;
}
w.removeEventListener('message', onMessage);
@@ -151,7 +172,7 @@ export class Uploader {
else reject(new Error(m.message));
};
w.addEventListener('message', onMessage);
w.postMessage({ id: it.id, file: it.file } satisfies HashRequest);
w.postMessage({ id: it.id, file } satisfies HashRequest);
});
}
@@ -162,7 +183,17 @@ export class Uploader {
try {
const res = unwrap(
await this.session.client.POST('/api/v1/uploads/precheck', {
body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) },
body: {
files: pending.map((it) => ({
client_id: it.id,
size: it.size,
sha256: it.sha256!,
artist: it.tags?.artist,
title: it.tags?.title,
album: it.tags?.album,
duration_ms: it.tags?.duration_ms,
})),
},
}),
);
for (const r of res.files) {
@@ -192,9 +223,9 @@ export class Uploader {
comment: comment.trim() || undefined,
files: ready.map((it) => ({
client_id: it.id,
name: it.file.name,
name: it.file?.name ?? it.relPath,
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
size: it.file.size,
size: it.size,
sha256: it.sha256!,
})),
},
@@ -209,6 +240,7 @@ export class Uploader {
queue.push({ id: f.client_id, url: f.tus_url });
}
}
this.persist();
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} catch (err) {
this.set({ error: err });
@@ -239,9 +271,10 @@ export class Uploader {
private upload(id: string, url: string): Promise<void> {
const it = this.item(id);
if (!it) return Promise.resolve();
if (!it?.file) return Promise.resolve();
const file = it.file;
return new Promise((resolve) => {
const up = new tus.Upload(it.file, {
const up = new tus.Upload(file, {
uploadUrl: url,
chunkSize: CHUNK,
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
@@ -264,6 +297,7 @@ export class Uploader {
onSuccess: () => {
this.uploads.delete(id);
this.patch(id, { stage: 'done', progress: 1 });
this.persist();
resolve();
},
onError: (err) => {
@@ -276,6 +310,100 @@ export class Uploader {
up.start();
});
}
private async readTags(it: UploadItem) {
if (!it.file) return;
try {
const { parseBlob } = await import('music-metadata');
const meta = await parseBlob(it.file);
const duration = meta.format.duration;
this.patch(it.id, {
tags: {
artist: meta.common.artist,
title: meta.common.title,
album: meta.common.album,
duration_ms: duration ? Math.round(duration * 1000) : undefined,
},
});
} catch {
// Теги — подсказка для предпроверки; без них сервер прочитает их сам.
}
}
/** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
private persist() {
const pending: PendingUpload[] = this.state.items
.filter((it): it is UploadItem & { sha256: string; tusUrl: string; uploadFileId: string } =>
Boolean(it.tusUrl && it.sha256 && it.uploadFileId && it.stage !== 'done'),
)
.map((it) => ({
id: it.id,
name: it.file?.name ?? it.relPath,
size: it.size,
relPath: it.relPath,
sha256: it.sha256,
tusUrl: it.tusUrl,
uploadFileId: it.uploadFileId,
}));
try {
if (pending.length) localStorage.setItem(PENDING_KEY, JSON.stringify(pending));
else localStorage.removeItem(PENDING_KEY);
} catch {
// Приватный режим — докачка после перезагрузки недоступна.
}
}
private restore() {
try {
const raw = localStorage.getItem(PENDING_KEY);
if (!raw) return;
const rows = JSON.parse(raw) as PendingUpload[];
if (!Array.isArray(rows) || !rows.length) return;
this.state = {
...this.state,
items: rows.map((r) => ({
id: r.id,
file: null,
size: r.size,
relPath: r.relPath,
stage: 'resume' as const,
progress: 0,
sha256: r.sha256,
tusUrl: r.tusUrl,
uploadFileId: r.uploadFileId,
})),
};
} catch {
localStorage.removeItem(PENDING_KEY);
}
}
/** Приложить заново те же файлы и продолжить tus с принятого смещения. */
async continueWith(files: PickedFile[]) {
const waiting = this.state.items.filter((it) => it.stage === 'resume' && it.tusUrl);
if (!waiting.length) return;
const queue: { id: string; url: string }[] = [];
for (const picked of files) {
const it = waiting.find((w) => w.size === picked.file.size && (w.relPath === picked.relPath || w.relPath.endsWith(picked.file.name)));
if (!it) continue;
this.patch(it.id, { file: picked.file, stage: 'hashing', progress: 0 });
const sha = await this.hash(this.item(it.id)!);
if (sha !== it.sha256) {
this.patch(it.id, { stage: 'error', message: 'sha256' });
continue;
}
this.patch(it.id, { stage: 'uploading', progress: 0 });
queue.push({ id: it.id, url: it.tusUrl! });
}
if (!queue.length) return;
this.set({ busy: true });
try {
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} finally {
this.set({ busy: false });
this.persist();
}
}
}
let instance: { session: AuthSession; uploader: Uploader } | null = null;
+16 -3
View File
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
import { App } from './App';
import { createAppRouter } from './app/router';
import { applyTheme } from './app/session';
import { createDesktop, DesktopProvider } from './desktop';
const LOCALE_KEY = 'pipu.locale';
@@ -48,6 +49,10 @@ function deviceName(): string {
}
applyTheme(undefined);
if (import.meta.env.VITE_E2E === '1') {
const { installE2EFetch } = await import('./test/e2eBridge');
installE2EFetch();
}
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
document.documentElement.lang = i18n.language;
i18n.on('languageChanged', (l) => {
@@ -59,15 +64,23 @@ i18n.on('languageChanged', (l) => {
}
});
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
const desktop = await createDesktop();
const session = new AuthSession({
clientType: desktop ? 'desktop' : 'web',
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
baseUrl: desktop?.proxyBase,
refreshStore: desktop?.refreshStore,
});
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
await session.restore();
const router = createAppRouter(session);
const router = createAppRouter(session, { desktop });
createRoot(document.getElementById('root')!).render(
<StrictMode>
<I18nextProvider i18n={i18n}>
<App session={session} router={router} />
<DesktopProvider value={desktop}>
<App session={session} router={router} />
</DesktopProvider>
</I18nextProvider>
</StrictMode>,
);
+29 -16
View File
@@ -4,11 +4,12 @@ import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { Cover, TrackList, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
@@ -22,29 +23,41 @@ export function AlbumPage({ id }: { id: string }) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
});
if (album.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (album.isError) return <NotFound error={album.error} />;
const a = album.data;
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
const ctx = { type: 'album' as const, id, label: a?.title };
if (!a) return null;
const colors = a.cover_colors.length === 3 ? a.cover_colors : null;
const ctx = { type: 'album' as const, id, label: a.title };
return (
<div className={s.page}>
<header className={c.hero}>
{a.cover_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(a.cover_url, 300)} alt="" />
</span>
)}
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
<Cover album={a ?? { id }} size={200} />
<Cover album={a} size={200} className={c.heroCover} />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.album')}</span>
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
<Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name}
</Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
</span>
)}
{a && a.tracks.some((tr) => tr.available) && (
<h1 className={c.heroTitle}>{a.title}</h1>
<span className={c.heroMeta}>
<Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name}
</Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
</span>
{a.tracks.some((tr) => tr.available) && (
<div className={c.heroActions}>
<AddToPlaylist trackIds={a.tracks.map((tr) => tr.id)} />
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
@@ -65,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) {
)}
</div>
</header>
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
<TrackList tracks={a.tracks} numbered context={ctx} />
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatBytes } from '../shared/lib/format';
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
/**
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
* Сборки — до 8 последних релизов Gitea через сервер; первым предлагается клиент текущей ОС.
*/
export function AppDownloadPage() {
const { t, i18n } = useTranslation(['auth', 'common']);
const session = useSession();
const [copied, setCopied] = useState(false);
const detected = useMemo(() => detectAppClient(), []);
const latest = useQuery({
queryKey: ['app-clients'],
queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
staleTime: 10 * 60_000,
});
const server = location.origin;
const copy = () =>
void navigator.clipboard?.writeText(server).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
const data = latest.data;
const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c]));
const primary = detected ? files[detected] : undefined;
const others = otherAppClients(detected).filter((id) => files[id]);
return (
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
<h1>{t('app.title')}</h1>
<p className={s.lead}>{t('app.subtitle')}</p>
{latest.isPending ? (
<p role="status">{t('common:loading')}</p>
) : latest.isError ? (
<div className={s.hint}>{t('app.unavailable')}</div>
) : data ? (
<>
{primary ? (
<>
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: primary.version ?? data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
<OlderVersions items={primary.older ?? []} />
</>
) : detected ? (
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
) : null}
{(primary || others.length > 0) && (
<ol className={s.steps}>
{detected && <li>{t(`app.step_install.${detected}`)}</li>}
<li>
{t('app.step_server')}
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
<code>{server}</code>
<Icon src={copied ? checkIcon : copyIcon} size={16} />
</button>
</li>
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
</ol>
)}
{primary?.sha256 && (
<details className={s.details}>
<summary>{t('app.checksum')}</summary>
<code>{primary.sha256}</code>
</details>
)}
{others.length > 0 && (
<div className={s.otherClients}>
<p className={s.otherTitle}>{t('app.other')}</p>
{others.map((id) => {
const file = files[id]!;
return (
<div key={id} className={s.otherBlock}>
<a className={s.downloadAlt} href={file.download_url} download={file.filename}>
{t(`app.download.${id}`)}
<span>
{t('app.version', { version: file.version ?? data.version })} · {formatBytes(file.size, i18n.language)}
</span>
</a>
<OlderVersions items={file.older ?? []} />
</div>
);
})}
</div>
)}
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
</>
) : (
<div className={s.hint}>{t('app.unavailable')}</div>
)}
</AuthLayout>
);
}
function OlderVersions({ items }: { items: { version: string; filename?: string; size: number; download_url: string }[] }) {
const { t, i18n } = useTranslation(['auth', 'common']);
if (items.length === 0) return null;
return (
<details className={s.older}>
<summary>{t('app.older')}</summary>
{items.map((b) => (
<a key={b.version} className={s.downloadAlt} href={b.download_url} download={b.filename}>
{t('app.version', { version: b.version })}
<span>{formatBytes(b.size, i18n.language)}</span>
</a>
))}
</details>
);
}
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
const { t } = useTranslation('auth');
return (
<a className={s.download} href={href} download>
<Icon src={downloadIcon} size={22} />
<span>
{t(`app.download.${client}`)}
<span className={s.downloadMeta}>{meta}</span>
</span>
</a>
);
}
+35 -26
View File
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
import { AlbumGrid, Cover, TrackList, coverSrc } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { playIcon, waveIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
@@ -29,42 +29,51 @@ export function ArtistPage({ id }: { id: string }) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
});
if (artist.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (artist.isError) return <NotFound error={artist.error} />;
const a = artist.data;
if (!a) return null;
const tracks = top.data ?? [];
const ctx = { type: 'artist' as const, id, label: a?.name };
const ctx = { type: 'artist' as const, id, label: a.name };
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
return (
<div className={s.page}>
<header className={c.hero}>
<Cover album={{ id }} size={168} round />
{a.image_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(a.image_url, 300)} alt="" />
</span>
)}
<Cover album={{ id, cover_url: a.image_url }} size={168} round className={c.heroCover} />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.artist')}</span>
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</span>
)}
{a && (
<div className={c.heroActions}>
<Button
variant="primary"
className={c.pill}
icon={waveIcon}
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
>
{t('catalog.artist_wave')}
<h1 className={c.heroTitle}>{a.name}</h1>
<span className={c.heroMeta}>
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</span>
<div className={c.heroActions}>
<Button
variant="primary"
className={c.pill}
icon={waveIcon}
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
>
{t('catalog.artist_wave')}
</Button>
{tracks.length > 0 && (
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
{tracks.length > 0 && (
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
)}
</div>
)}
)}
</div>
</div>
</header>
{tracks.length > 0 && (
+6 -1
View File
@@ -2,16 +2,21 @@ import type { ReactNode } from 'react';
import { Logo } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import { WaveButton } from '../widgets/wave/WaveButton';
import s from './auth.module.css';
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
/** Экран без каркаса: декоративная волна и стеклянная карточка по центру. */
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
const theme = typeof document !== 'undefined' && document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
return (
<div className={s.screen}>
<div className={s.backdrop} aria-hidden>
<span className={s.blob1} />
<span className={s.blob2} />
<span className={s.blob3} />
<div className={s.waveDecor}>
<WaveButton interactive={false} size={620} label="" theme={theme} />
</div>
</div>
<header className={s.top}>
<Logo />
+18 -3
View File
@@ -10,7 +10,7 @@ import { useCollectionCounts } from '../entities/collection';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
import { Button, Dialog, EmptyState, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import c from './catalog.module.css';
import s from './page.module.css';
@@ -109,7 +109,12 @@ function History() {
</div>
)}
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
{history.isPending && <PageSkeleton />}
{history.isSuccess && items.length === 0 && (
<EmptyState icon={historyFillIcon} action={<Link to="/">{t('catalog.back_home')}</Link>}>
{t('collection.empty_history')}
</EmptyState>
)}
{groups.map((g) => (
<section key={g.key} className={c.section}>
<h2 className={c.day}>{g.title}</h2>
@@ -189,8 +194,18 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
</div>
)}
<FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isPending && <PageSkeleton />}
{list.isSuccess && tracks.length === 0 && (
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
<EmptyState
icon={reaction === 'like' ? likeFillIcon : dislikeFillIcon}
action={
<Link to="/search" search={{ q: '' }}>
{t('playlists.find_tracks')}
</Link>
}
>
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
</EmptyState>
)}
{tracks.length > 0 && (
<TrackList
+55
View File
@@ -0,0 +1,55 @@
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useRouter } from '@tanstack/react-router';
import { useDesktop } from '../desktop';
import { Button, Field, FormError } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
export function ConnectPage() {
const { t } = useTranslation();
const desktop = useDesktop();
const router = useRouter();
const [address, setAddress] = useState(desktop?.server ?? '');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!desktop) return;
setBusy(true);
setError(null);
try {
await desktop.setServer(address);
await router.invalidate();
await router.navigate({ to: '/login' });
} catch (err) {
const text = err instanceof Error ? err.message : '';
setError(/Invalid|query|fragment|credentials/i.test(text) ? t('server.invalid_address') : t('server.offline'));
setBusy(false);
}
};
return (
<AuthLayout>
<h1>{t('server.title')}</h1>
<p className={s.lead}>{t('app.tagline')}</p>
<form onSubmit={(e) => void submit(e)} noValidate>
<Field
label={t('server.address')}
value={address}
onChange={setAddress}
autoComplete="url"
autoCapitalize="none"
spellCheck={false}
required
/>
<FormError>{error}</FormError>
<Button type="submit" variant="primary" className={s.submit} busy={busy} disabled={!address.trim()}>
{t('mobile.connect')}
</Button>
</form>
</AuthLayout>
);
}
+59 -18
View File
@@ -1,5 +1,5 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -21,9 +21,10 @@ import {
waveSunIcon,
waveWeekendIcon,
} from '../shared/ui/icons';
import { cx, FormError, Icon } from '../shared/ui/ui';
import { cx, FormError, Icon, Skeleton } from '../shared/ui/ui';
import { WaveButton } from '../widgets/wave/WaveButton';
import s from './home.module.css';
import { Onboarding } from './Onboarding';
/** Иконки Material Symbols по именам из /wave/options. */
const ICONS: Record<string, string> = {
@@ -44,8 +45,35 @@ const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
type Tr = (key: string) => string;
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
export function HomePage() {
const session = useSession();
const queryClient = useQueryClient();
const onboarding = useQuery({
queryKey: ['onboarding'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: Infinity,
});
if (onboarding.isPending) {
return (
<div className={s.home}>
<Skeleton round style={{ width: 320, height: 320, maxWidth: '70vw', maxHeight: '70vw' }} />
</div>
);
}
if (onboarding.data?.required) {
return (
<Onboarding
options={onboarding.data}
onDone={() => queryClient.setQueryData(['onboarding'], { ...onboarding.data, required: false })}
/>
);
}
return <WaveHome />;
}
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
function WaveHome() {
const { t } = useTranslation();
const tr = t as unknown as Tr;
const session = useSession();
@@ -63,6 +91,8 @@ export function HomePage() {
staleTime: Infinity,
});
const showActivities = user.ui_prefs.home_activities !== false;
const showMoods = user.ui_prefs.home_moods !== false;
const waveOn = st.wave !== null;
const playing = waveOn && st.playing;
const track = waveOn ? st.track : null;
@@ -95,6 +125,7 @@ export function HomePage() {
<WaveButton
variant={user.ui_prefs.wave_variant ?? 'orb'}
playing={playing}
readAudio={player.readAudio}
bpm={track?.features?.bpm ?? undefined}
energy={track?.features?.energy ?? undefined}
colors={colors}
@@ -112,22 +143,32 @@ export function HomePage() {
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
<FormError>{error ? errorText(error) : null}</FormError>
{options.data && (
{options.isPending && (showActivities || showMoods) && (
<div className={s.picker}>
<Chips
title={t('wave.activity_label')}
options={options.data.activities}
value={activity}
label={(k) => tr(`wave.activities.${k}`)}
onPick={(k) => choose('activity', k)}
/>
<Chips
title={t('wave.mood_label')}
options={options.data.moods}
value={mood}
label={(k) => tr(`wave.moods.${k}`)}
onPick={(k) => choose('mood', k)}
/>
<Skeleton style={{ width: 280, height: 38, borderRadius: 999 }} />
<Skeleton style={{ width: 240, height: 38, borderRadius: 999 }} />
</div>
)}
{options.data && (showActivities || showMoods) && (
<div className={s.picker}>
{showActivities && (
<Chips
title={t('wave.activity_label')}
options={options.data.activities}
value={activity}
label={(k) => tr(`wave.activities.${k}`)}
onPick={(k) => choose('activity', k)}
/>
)}
{showMoods && (
<Chips
title={t('wave.mood_label')}
options={options.data.moods}
value={mood}
label={(k) => tr(`wave.moods.${k}`)}
onPick={(k) => choose('mood', k)}
/>
)}
</div>
)}
</div>
+17 -1
View File
@@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useDesktop } from '../desktop';
import { useErrorText } from '../shared/lib/errors';
import { Button, Field, FormError } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
@@ -11,6 +12,7 @@ import s from './auth.module.css';
export function LoginPage() {
const { t } = useTranslation(['auth', 'common']);
const session = useSession();
const desktop = useDesktop();
const errorText = useErrorText();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -37,7 +39,21 @@ export function LoginPage() {
};
return (
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
<AuthLayout
footer={
info.data ? (
<>
pipu {info.data.version}
{desktop ? null : (
<>
{' · '}
<a href="/download">{t('app.link')}</a>
</>
)}
</>
) : undefined
}
>
<h1>{t('login.title')}</h1>
<p className={s.lead}>{t('login.subtitle')}</p>
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
+113
View File
@@ -0,0 +1,113 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { checkIcon } from '../shared/ui/icons';
import { Button, cx, FormError, Icon } from '../shared/ui/ui';
import s from './onboarding.module.css';
type Options = components['schemas']['Onboarding'];
/** Минимум исполнителей, чтобы волне было от чего оттолкнуться. */
const MIN_ARTISTS = 3;
/**
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
*/
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [artists, setArtists] = useState<Set<string>>(new Set());
const [genres, setGenres] = useState<Set<string>>(new Set());
const save = useMutation({
mutationFn: async (skip: boolean) =>
unwrap(
await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] },
}),
),
onSuccess: onDone,
});
// Функциональное обновление: быстрые нажатия подряд не теряют выбор.
const toggle = (update: (fn: (prev: Set<string>) => Set<string>) => void, key: string) =>
update((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0);
return (
<div className={s.page}>
<header className={s.head}>
<h1>{t('onboarding.title')}</h1>
<p>{t('onboarding.subtitle')}</p>
</header>
<section aria-label={t('onboarding.artists')}>
<h2 className={s.label}>{t('onboarding.artists')}</h2>
<div className={s.artists}>
{options.artists.map((a) => {
const on = artists.has(a.id);
return (
<button
key={a.id}
type="button"
className={cx(s.artist, on && s.on)}
aria-pressed={on}
onClick={() => toggle(setArtists, a.id)}
>
<span className={s.avatar}>
<Cover album={{ id: a.id, cover_url: a.cover_url, cover_colors: a.cover_colors }} size={96} round />
{on && (
<span className={s.check}>
<Icon src={checkIcon} size={22} />
</span>
)}
</span>
<span className={s.name}>{a.name}</span>
</button>
);
})}
</div>
</section>
{options.genres.length > 0 && (
<section aria-label={t('onboarding.genres')}>
<h2 className={s.label}>{t('onboarding.genres')}</h2>
<div className={s.genres}>
{options.genres.map((g) => (
<button
key={g.name}
type="button"
className={s.genre}
aria-pressed={genres.has(g.name)}
onClick={() => toggle(setGenres, g.name)}
>
{g.name}
</button>
))}
</div>
</section>
)}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<div className={s.actions}>
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}>
{t('onboarding.skip')}
</Button>
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}>
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
</Button>
</div>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { useState, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
import type { components } from '@pipu/api-client';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon } from '../shared/ui/icons';
import c from './catalog.module.css';
import s from './page.module.css';
import p from './playlists.module.css';
export function PlaylistsPage() {
const { t } = useTranslation();
const list = usePlaylists();
const errorText = useErrorText();
const navigate = useNavigate();
const [create, setCreate] = useState(false);
const [search, setSearch] = useState('');
const items = list.data?.items.filter(x => x.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? [];
return <div className={s.page}>
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
<FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isPending && <PageSkeleton />}
{list.isSuccess && !items.length && (
<EmptyState
icon={search ? searchIcon : playlistAddIcon}
action={!search ? <Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button> : undefined}
>
{t(search ? 'playlists.no_results' : 'playlists.empty')}
</EmptyState>
)}
<div className={p.grid}>{items.map(item => <PlaylistCard key={item.id} item={item} />)}</div>
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
</div>;
}
export function PlaylistPage({ id }: { id: string }) {
const { t } = useTranslation();
const query = usePlaylist(id);
const errorText = useErrorText();
return <div className={s.page}>
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <PageSkeleton />}
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
</div>;
}
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const player = usePlayer();
const navigate = useNavigate();
const errorText = useErrorText();
const [edit, setEdit] = useState(false);
const [remove, setRemove] = useState(false);
const [manage, setManage] = useState(false);
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}', {
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
})), onSuccess: data => { cache.setQueryData(['playlists', playlist.id], data); void cache.invalidateQueries({ queryKey: ['playlists'] }); },
onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } } })),
onSuccess: () => { cache.removeQueries({ queryKey: ['playlists', playlist.id] }); void cache.invalidateQueries({ queryKey: ['playlists'] }); void navigate({ to: '/collection/playlists' }); } });
const play = (shuffle: boolean) => {
const tracks = playlist.tracks.filter(x => x.available);
if (shuffle) for (let i = tracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracks[i], tracks[j]] = [tracks[j]!, tracks[i]!]; }
if (tracks.length) player.playList(tracks, 0, context);
};
const move = (index: number, delta: number) => {
const ids = playlist.tracks.map(x => x.id);
[ids[index], ids[index + delta]] = [ids[index + delta]!, ids[index]!];
save.mutate(ids);
};
return <>
<header className={c.colHero}>
<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>
<p className={p.description}>{playlist.description}</p>
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
</div>
</header>
<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 icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</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>
</div>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
{!playlist.tracks.length && (
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
{t('playlists.empty_tracks')}
</EmptyState>
)}
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
actions={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 === 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>
</span> : undefined} />
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
{remove && (
<Dialog
title={t('playlists.delete')}
onClose={() => {
if (!deletion.isPending) setRemove(false);
}}
footer={
<>
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
</>
}
>
<p>{t('playlists.delete_confirm', { name: playlist.name })}</p>
<FormError>{deletion.error ? errorText(deletion.error) : null}</FormError>
</Dialog>
)}
</>;
}
function PlaylistCard({ item }: { item: components['schemas']['PlaylistSummary'] }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const play = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
void (async () => {
const full = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id: item.id } } }));
const tracks = full.tracks.filter((x) => x.available);
if (tracks.length) player.playList(tracks, 0, { type: 'collection', id: item.id, label: item.name });
})();
};
return (
<Link to="/playlist/$id" params={{ id: item.id }} className={p.card}>
<span className={p.coverWrap}>
<Cover album={item} size="100%" />
{item.track_count > 0 && (
<button type="button" className={p.play} aria-label={t('catalog.play')} onClick={play}>
<Icon src={playIcon} size={22} />
</button>
)}
</span>
<strong>{item.name}</strong>
<span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
{item.description && <span className={p.description}>{item.description}</span>}
</Link>
);
}
+7 -2
View File
@@ -6,7 +6,8 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { Button, FormError } from '../shared/ui/ui';
import { searchIcon } from '../shared/ui/icons';
import { Button, EmptyState, FormError, PageSkeleton } from '../shared/ui/ui';
import s from './page.module.css';
import c from './catalog.module.css';
@@ -68,7 +69,11 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) {
</div>
<FormError>{results.error ? errorText(results.error) : null}</FormError>
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
{query === '' && <EmptyState icon={searchIcon}>{t('search.type_query')}</EmptyState>}
{query !== '' && results.isPending && <PageSkeleton />}
{empty && (
<EmptyState icon={searchIcon}>{t('search.nothing')}</EmptyState>
)}
{artists.length > 0 && (
<section className={c.section}>
+90
View File
@@ -1,10 +1,12 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../app/session';
import { useDesktop } from '../desktop';
import { useErrorText, useFieldError } from '../shared/lib/errors';
import { formatDateTime } from '../shared/lib/format';
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
@@ -126,6 +128,55 @@ function AppearanceSection() {
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.search_position')}</span>
<Segmented<'left' | 'center' | 'right'>
label={t('settings.search_position')}
value={user.ui_prefs.search_position ?? 'center'}
onChange={(v) => void setPrefs({ search_position: v })}
options={[
{ value: 'left', label: t('settings.search_left') },
{ value: 'center', label: t('settings.search_center') },
{ value: 'right', label: t('settings.search_right') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.search_collapse')}</span>
<Segmented<'on' | 'off'>
label={t('settings.search_collapse')}
value={user.ui_prefs.search_collapse === true ? 'on' : 'off'}
onChange={(v) => void setPrefs({ search_collapse: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.home_activities')}</span>
<Segmented<'on' | 'off'>
label={t('settings.home_activities')}
value={user.ui_prefs.home_activities === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ home_activities: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.home_moods')}</span>
<Segmented<'on' | 'off'>
label={t('settings.home_moods')}
value={user.ui_prefs.home_moods === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ home_moods: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'>
@@ -138,6 +189,30 @@ function AppearanceSection() {
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.normalize')}</span>
<Segmented<'on' | 'off'>
label={t('settings.normalize')}
value={user.ui_prefs.normalize === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ normalize: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}</span>
<input
type="range"
min={0}
max={6}
step={1}
aria-label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}
value={user.ui_prefs.crossfade_s ?? 0}
onChange={(e) => void setPrefs({ crossfade_s: Number(e.target.value) })}
/>
</div>
<div className={s.settingRow}>
<span>{t('language.label')}</span>
<LanguageSwitch />
@@ -224,6 +299,8 @@ function PasswordSection() {
function SessionsSection() {
const { t, i18n } = useTranslation();
const session = useSession();
const desktop = useDesktop();
const navigate = useNavigate();
const queryClient = useQueryClient();
const errorText = useErrorText();
const sessions = useQuery({
@@ -260,6 +337,19 @@ function SessionsSection() {
</div>
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
<div>
{desktop && (
<Button
onClick={() => {
void (async () => {
await desktop.clearServer();
await session.logout();
await navigate({ to: '/connect' });
})();
}}
>
{t('mobile.change_server')}
</Button>
)}
<Button variant="danger" onClick={() => void session.logout(true)}>
{t('settings.logout_all')}
</Button>
+36
View File
@@ -0,0 +1,36 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { FormError } from '../shared/ui/ui';
import s from './page.module.css';
import g from './genius.module.css';
export function TrackGenius({ id }: { id: string }) {
const session = useSession();
const { t } = useTranslation();
const errorText = useErrorText();
const query = useQuery({
queryKey: ['track-genius', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}/genius', { params: { path: { id } } })),
refetchInterval: (q) => ['pending', 'processing'].includes(q.state.data?.status ?? '') ? 10000 : false,
});
const data = query.data;
if (data?.status === 'disabled') return null;
return <section className={s.section}>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p>{t('genius.loading')}</p>}
{data?.description && <><h2>{t('genius.about')}</h2><p className={g.lyrics}>{data.description}</p></>}
<div className={s.head}><h2>{t('genius.lyrics')}</h2>{data?.source_url && <a href={data.source_url} target="_blank" rel="noopener noreferrer">Genius ↗</a>}</div>
{data && (data.lyrics ? <div className={g.lyrics}>{data.lyrics}</div> : <p className={s.subtitle}>{['pending', 'processing'].includes(data.status) ? t('genius.pending_text') : t('genius.no_lyrics')}</p>)}
{!!data?.annotations.length && <>
<h2>{t('genius.explanations')}</h2>
{data.annotations.map((a, i) => <details className={g.annotation} key={`${a.url}-${i}`}>
<summary>{a.fragment}{a.verified && <span className={g.badge}>{t('genius.verified')}</span>}</summary>
<p className={g.lyrics}>{a.text}</p>
<p className={s.subtitle}>{a.authors}{a.url && <> · <a href={a.url} target="_blank" rel="noopener noreferrer">Genius ↗</a></>}</p>
</details>)}
</>}
</section>;
}
+80
View File
@@ -0,0 +1,80 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { pauseIcon, playIcon } from '../shared/ui/icons';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
import { TrackGenius } from './TrackGenius';
export function TrackPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const st = usePlayerState();
const track = useQuery({
queryKey: ['track', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
});
if (track.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (track.isError) return <NotFound error={track.error} />;
const tr = track.data;
if (!tr) return null;
const ctx = { type: 'queue' as const };
const current = st.track?.id === tr.id;
const playing = current && st.playing;
return (
<div className={s.page}>
<div className={c.listen}>
<header className={c.hero}>
{tr.album.cover_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
</span>
)}
<Cover album={tr.album} size={280} className={c.heroCover} />
<div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr.title}</h1>
<span className={c.heroMeta}>
<ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}
</span>
<Link to="/album/$id" params={{ id: tr.album.id }}>
{tr.album.title}
</Link>
<div className={c.heroActions}>
{tr.available && (
<Button
variant="primary"
className={c.pill}
icon={playing ? pauseIcon : playIcon}
onClick={() => (current ? player.toggle() : player.playList([tr], 0, ctx))}
>
{t(playing ? 'player.pause' : 'catalog.play')}
</Button>
)}
<AddToPlaylist trackIds={[tr.id]} />
</div>
</div>
</header>
<div className={c.listenLyrics}>
<TrackGenius id={id} />
</div>
</div>
</div>
);
}
+4 -3
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { formatDateTime } from '../../shared/lib/format';
import { Button, FormError } from '../../shared/ui/ui';
import { Button, EmptyState, FormError, PageSkeleton } from '../../shared/ui/ui';
import s from '../page.module.css';
type Entry = components['schemas']['AuditEntry'];
@@ -15,6 +15,7 @@ type Entry = components['schemas']['AuditEntry'];
function details(e: Entry, roleName: (r: string) => string): string {
const p = e.payload ?? {};
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
if (e.action === 'user.username_change') return `${String(p.from)} → ${String(p.to)}`;
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
(v): v is string => typeof v === 'string' && v !== '',
);
@@ -66,8 +67,8 @@ export function AuditPage() {
))}
</tbody>
</table>
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
{list.isSuccess && entries.length === 0 && <EmptyState>{t('audit.empty')}</EmptyState>}
{list.isPending && <PageSkeleton />}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
+46
View File
@@ -0,0 +1,46 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import g from '../genius.module.css';
export function GeniusPanel() {
const { t } = useTranslation();
const session = useSession();
const queries = useQueryClient();
const errorText = useErrorText();
const stats = useQuery({
queryKey: ['genius-stats'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/genius')),
refetchInterval: 5000,
});
const control = useMutation({
mutationFn: async (action: 'start' | 'pause' | 'retry') =>
unwrap(await session.client.POST('/api/v1/admin/genius', { body: { action } })),
onSuccess: (data) => { queries.setQueryData(['genius-stats'], data); },
});
const data = stats.data;
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');
return <section className={s.section} aria-label="Genius">
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
<p className={s.subtitle}>{t('genius.description')}</p>
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
{data && <>
{!data.configured && <p>{t('genius.configure')}</p>}
<dl className={g.stats}>
{(['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>)}
</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>}
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
<div className={g.actions}>
<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>
</div>
</>}
</section>;
}
+52 -4
View File
@@ -1,5 +1,6 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
@@ -31,6 +32,11 @@ export function LibraryPage() {
const reindex = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
});
const queryClient = useQueryClient();
const retry = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
});
const d = status.data;
const tiles = d
? ([
@@ -76,7 +82,19 @@ export function LibraryPage() {
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
})}
/>
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>}
{d.analysis.failed > 0 && (
<div className={l.retryRow}>
<span className={l.scanError}>
{t('library.analysis_failed', { count: d.analysis.failed })}
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
</span>
{admin && (
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')}
</Button>
)}
</div>
)}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
</section>
)}
@@ -99,6 +117,33 @@ export function LibraryPage() {
</div>
))}
</div>
{d.wave.by_model.length > 1 && (
<div className={s.tableWrap}>
<table className={s.table}>
<caption className={s.muted}>{t('library.wave_by_model')}</caption>
<thead>
<tr>
<th scope="col">{t('library.wave_model')}</th>
<th scope="col">{t('library.wave_served')}</th>
<th scope="col">{t('library.wave_listen')}</th>
<th scope="col">{t('library.wave_early_skip')}</th>
<th scope="col">{t('library.wave_likes')}</th>
</tr>
</thead>
<tbody>
{d.wave.by_model.map((m) => (
<tr key={m.model_ver}>
<td>{m.model_ver || '—'}</td>
<td>{m.served.toLocaleString()}</td>
<td>{pct(m.listen_through_rate)}</td>
<td>{pct(m.early_skip_rate)}</td>
<td>{m.like_rate === null ? '—' : m.like_rate.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{d && d.sources.length > 0 && (
@@ -133,10 +178,13 @@ const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
const value = total > 0 ? Math.round((done / total) * 100) : 0;
const id = useId();
return (
<div className={l.scan}>
<div className={l.scanText}>{label}</div>
<div className={l.bar} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
<div className={l.scanText} id={id}>
{label}
</div>
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${value}%` }} />
</div>
</div>
+4 -2
View File
@@ -1,4 +1,5 @@
import type { components } from '@pipu/api-client';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../../shared/lib/format';
@@ -16,14 +17,15 @@ export function StatusBadge({ status }: { status: SourceStatus }) {
/** Прогресс идущего скана или итог последнего. */
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
const { t, i18n } = useTranslation('admin');
const id = useId();
if (scan?.running) {
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
return (
<div className={s.scan}>
<div className={s.scanText}>
<div className={s.scanText} id={id}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
</div>
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${pct}%` }} />
</div>
</div>
+10 -3
View File
@@ -5,10 +5,11 @@ import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useSession, useUser } from '../../app/session';
import { GeniusPanel } from './GeniusPanel';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
import { Button, Dialog, EmptyState, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus';
@@ -19,6 +20,7 @@ type TestResult = components['schemas']['SourceTestResult'];
const SCAN_POLL_MS = 2000;
export function SourcesPage() {
const user = useUser();
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
@@ -43,7 +45,12 @@ export function SourcesPage() {
</Button>
</div>
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
{user.role === 'admin' && <GeniusPanel />}
{sources.isSuccess && sources.data.length === 0 && (
<EmptyState icon={folderIcon} action={<Button variant="primary" onClick={() => setEditing('new')}>{t('sources.add')}</Button>}>
{t('sources.empty')}
</EmptyState>
)}
<div className={l.list}>
{sources.data?.map((src) => (
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
+115 -7
View File
@@ -5,10 +5,11 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { needsFormatAttention } from '../../features/uploads/files';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
import { checkIcon, closeIcon, editIcon, inboxIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, cx, Dialog, EmptyState, Field, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
import u from '../uploads/uploads.module.css';
@@ -35,6 +36,7 @@ export function UploadQueuePage() {
const [onlyPossible, setOnlyPossible] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<UploadFile | null>(null);
const [batchEdit, setBatchEdit] = useState<string | null>(null);
const [rejecting, setRejecting] = useState(false);
const list = useInfiniteQuery({
@@ -121,7 +123,7 @@ export function UploadQueuePage() {
{failed.length > 0 && (
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
)}
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
{batches.map((files) => (
<BatchCard
@@ -131,6 +133,7 @@ export function UploadQueuePage() {
selected={selected}
onToggle={toggle}
onEdit={setEditing}
onEditBatch={setBatchEdit}
onRetry={(id) => retry.mutate(id)}
/>
))}
@@ -158,6 +161,17 @@ export function UploadQueuePage() {
</div>
)}
{batchEdit && (
<BatchEditDialog
batchId={batchEdit}
onClose={() => setBatchEdit(null)}
onSaved={() => {
setBatchEdit(null);
void refresh();
}}
/>
)}
{editing && (
<EditDialog
file={editing}
@@ -194,6 +208,7 @@ function BatchCard({
selected,
onToggle,
onEdit,
onEditBatch,
onRetry,
}: {
files: UploadFile[];
@@ -201,6 +216,7 @@ function BatchCard({
selected: Set<string>;
onToggle: (ids: string[], on: boolean) => void;
onEdit: (f: UploadFile) => void;
onEditBatch: (batchId: string) => void;
onRetry: (id: string) => void;
}) {
const { t, i18n } = useTranslation('admin');
@@ -218,6 +234,12 @@ function BatchCard({
<span>{formatDateTime(first.created_at, 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 style={{ flex: 1 }} />
{selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
{t('uploads.edit_batch')}
</Button>
)}
</div>
{files.map((f) => (
<QueueRow
@@ -288,7 +310,13 @@ function QueueRow({
<div className={u.meta}>
<div className={u.flags}>
<UploadStatusBadge status={f.status} />
{needsFormatAttention(f.format, f.original_name) && (
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
{t('uploads.flag_attention')}
</span>
)}
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
{f.matched_album_id ? (
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
) : (
@@ -302,6 +330,9 @@ function QueueRow({
)}
</div>
<div className={u.rowActions}>
{f.stream_url && (
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
)}
{editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
<Icon src={editIcon} size={18} />
@@ -319,6 +350,75 @@ function QueueRow({
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const [artist, setArtist] = useState('');
const [album, setAlbum] = useState('');
const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined);
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/uploads/batches/{id}', {
params: { path: { id: batchId } },
body: {
...(artist.trim() ? { artist: artist.trim() } : {}),
...(album.trim() ? { album: album.trim() } : {}),
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
...(cover !== undefined ? { cover_base64: cover } : {}),
},
}),
),
onSuccess: onSaved,
});
const pickCover = (file: File | undefined) => {
if (!file) {
setCover(undefined);
return;
}
const reader = new FileReader();
reader.onload = () => {
const data = String(reader.result ?? '');
const comma = data.indexOf(',');
setCover(comma >= 0 ? data.slice(comma + 1) : data);
};
reader.readAsDataURL(file);
};
return (
<Dialog
title={t('uploads.batch_edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
<label className={s.sessions}>
<span>{t('uploads.batch_cover')}</span>
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
</label>
<Button variant="ghost" onClick={() => setCover(null)}>
{t('uploads.batch_cover_clear')}
</Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</div>
</Dialog>
);
}
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
@@ -452,7 +552,7 @@ export function UploadSettingsSection() {
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string } | null>(null);
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
useEffect(() => {
if (settings.data) setForm(toForm(settings.data));
}, [settings.data]);
@@ -468,6 +568,7 @@ export function UploadSettingsSection() {
max_file_bytes: gb(f.maxFile),
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
default_source_id: f.source || null,
allow_quality_upgrade: f.upgrade,
},
}),
);
@@ -484,7 +585,7 @@ export function UploadSettingsSection() {
return (
<section className={s.section}>
<h2>{t('uploads.settings_title')}</h2>
<div className={u.grid}>
<div className={u.settingsGrid}>
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
<Field
label={t('uploads.set_per_user')}
@@ -494,8 +595,8 @@ export function UploadSettingsSection() {
inputMode="decimal"
/>
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
<label className={s.sessions}>
<span>{t('uploads.set_source')}</span>
<label>
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
<option value="">{t('uploads.set_source_auto')}</option>
{sources.data
@@ -507,6 +608,12 @@ export function UploadSettingsSection() {
))}
</select>
</label>
<div>
<label className={u.settingsCheck}>
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
<span>{t('uploads.set_upgrade')}</span>
</label>
</div>
</div>
<p className={s.note}>{t('uploads.set_note')}</p>
<div className={s.toolbar}>
@@ -528,5 +635,6 @@ function toForm(st: Settings) {
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
maxFile: gb(st.max_file_bytes),
source: st.default_source_id ?? '',
upgrade: st.allow_quality_upgrade,
};
}
+102 -14
View File
@@ -8,15 +8,17 @@ import { useSession, useUser } from '../../app/session';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatRelative } from '../../shared/lib/format';
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, searchIcon } from '../../shared/ui/icons';
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
import { Avatar, Button, cx, Dialog, EmptyState, Field, FormError, Icon, Menu, PageSkeleton, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css';
type AdminUser = components['schemas']['AdminUser'];
const ROLES: Role[] = ['admin', 'moderator', 'user'];
type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser };
const TITLES = { reset: 'reset_password', recs: 'reset_recommendations', block: 'block', delete: 'delete' } as const;
type Confirm = { kind: 'reset' | 'recs' | 'block' | 'delete'; user: AdminUser };
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
@@ -44,9 +46,11 @@ export function UsersPage() {
const [role, setRole] = useState<Role | null>(null);
const query = useDebounced(q.trim(), 200);
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState<AdminUser | null>(null);
const [confirm, setConfirm] = useState<Confirm | null>(null);
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
const [actionError, setActionError] = useState<unknown>(null);
const [recsDone, setRecsDone] = useState<string | null>(null);
const list = useInfiniteQuery({
queryKey: ['admin-users', query, role],
@@ -79,6 +83,14 @@ export function UsersPage() {
},
onError: setActionError,
});
const resetRecs = useMutation({
mutationFn: async (u: AdminUser) =>
unwrap(
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: u.id } } }),
),
onSuccess: (_, u) => setRecsDone(u.username),
onError: setActionError,
});
const remove = useMutation({
mutationFn: async (u: AdminUser) =>
unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })),
@@ -91,7 +103,9 @@ export function UsersPage() {
const { kind, user } = confirm;
setConfirm(null);
setActionError(null);
setRecsDone(null);
if (kind === 'reset') reset.mutate(user);
if (kind === 'recs') resetRecs.mutate(user);
if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } });
if (kind === 'delete') remove.mutate(user);
};
@@ -99,8 +113,16 @@ export function UsersPage() {
const actionsFor = (u: AdminUser): MenuItem[] => {
const items: MenuItem[] = [];
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
if (me.role === 'admin')
items.push({ label: t('users.change_username'), onSelect: () => setRenaming(u) });
if (can('reset_password'))
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
if (can('reset_recommendations'))
items.push({
label: t('users.reset_recommendations'),
icon: refreshIcon,
onSelect: () => setConfirm({ kind: 'recs', user: u }),
});
if (can('block'))
items.push(
u.is_blocked
@@ -113,6 +135,11 @@ export function UsersPage() {
label: t('users.make_role', { role: t(`common:roles.${r}`) }),
onSelect: () => patch.mutate({ id: u.id, body: { role: r } }),
});
if (me.role === 'admin')
items.push({
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }),
});
if (can('delete'))
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
return items;
@@ -154,9 +181,10 @@ export function UsersPage() {
</div>
<FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError>
{recsDone && <p className={s.ok}>{t('users.recs_done', { username: recsDone })}</p>}
<div className={s.tableWrap}>
<table className={s.table}>
<table className={cx(s.table, s.mobileCards)}>
<thead>
<tr>
<th>{t('users.col_user')}</th>
@@ -172,7 +200,7 @@ export function UsersPage() {
const st = status(u);
return (
<tr key={u.id}>
<td>
<td data-label={t('users.col_user')}>
<div className={s.userCell}>
<Avatar name={u.display_name ?? u.username} />
<div>
@@ -184,16 +212,17 @@ export function UsersPage() {
</div>
</div>
</td>
<td>
<td data-label={t('users.col_role')}>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
</td>
<td>
<td data-label={t('users.col_status')}>
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
</td>
<td className={s.muted}>
<td className={s.muted} data-label={t('users.col_last_login')}>
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
</td>
<td>
<td className={s.cardAction}>
{items.length > 0 && (
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
)}
@@ -203,8 +232,8 @@ export function UsersPage() {
})}
</tbody>
</table>
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
{list.isSuccess && users.length === 0 && <EmptyState icon={searchIcon}>{t('users.empty')}</EmptyState>}
{list.isPending && <PageSkeleton />}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
@@ -224,16 +253,28 @@ export function UsersPage() {
/>
)}
{renaming && (
<ChangeUsernameDialog
user={renaming}
onClose={() => setRenaming(null)}
onSaved={async (updated) => {
if (updated.id === me.id) session.setUser({ ...me, username: updated.username });
setRenaming(null);
await refresh();
}}
/>
)}
{confirm && (
<Dialog
title={t(`users.${confirm.kind === 'reset' ? 'reset_password' : confirm.kind}`)}
title={t(`users.${TITLES[confirm.kind]}`)}
onClose={() => setConfirm(null)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirm(null)}>
{t('common:form.cancel')}
</Button>
<Button variant={confirm.kind === 'reset' ? 'primary' : 'danger'} onClick={runConfirm}>
<Button variant={confirm.kind === 'block' || confirm.kind === 'delete' ? 'danger' : 'primary'} onClick={runConfirm}>
{t('users.confirm')}
</Button>
</>
@@ -265,12 +306,18 @@ function CreateUserDialog({
const [username, setUsername] = useState('');
const [name, setName] = useState('');
const [role, setRole] = useState<Role>('user');
const [reviews, setReviews] = useState(false);
const create = useMutation({
mutationFn: async () =>
unwrap(
await session.client.POST('/api/v1/admin/users', {
body: { username: username.trim(), display_name: name.trim() || undefined, role },
body: {
username: username.trim(),
display_name: name.trim() || undefined,
role,
reviews_uploads: actorRole === 'admin' && reviews ? true : undefined,
},
}),
),
onSuccess: (r) => onCreated(r.user.username, r.temporary_password),
@@ -322,6 +369,13 @@ function CreateUserDialog({
</select>
</label>
)}
{actorRole === 'admin' && (
<label className={s.sessions} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={reviews} onChange={(e) => setReviews(e.target.checked)} />
<span>{t('create.review_uploads')}</span>
</label>
)}
{actorRole === 'admin' && <p className={s.note}>{t('create.review_uploads_hint')}</p>}
<p className={s.note}>{t('create.password_note')}</p>
<FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError>
</form>
@@ -329,6 +383,40 @@ function CreateUserDialog({
);
}
function ChangeUsernameDialog({ user, onClose, onSaved }: {
user: AdminUser;
onClose: () => void;
onSaved: (user: AdminUser) => Promise<void>;
}) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const fieldError = useFieldError();
const [username, setUsername] = useState(user.username);
const save = useMutation({
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', {
params: { path: { id: user.id } }, body: { username: username.trim() },
})),
onSuccess: onSaved,
});
const error = fieldError(save.error, 'username');
return (
<Dialog title={t('users.change_username')} onClose={onClose} footer={
<>
<Button variant="ghost" onClick={onClose}>{t('common:form.cancel')}</Button>
<Button type="submit" form="change-username" variant="primary" busy={save.isPending}
disabled={!username.trim() || username.trim() === user.username}>{t('common:settings.save')}</Button>
</>
}>
<form id="change-username" className={s.sessions} noValidate onSubmit={(e) => { e.preventDefault(); save.mutate(); }}>
<Field label={t('create.username')} value={username} onChange={setUsername} autoCapitalize="none"
spellCheck={false} hint={t('create.username_hint')} error={error} mono />
<FormError>{save.error && !error ? errorText(save.error) : null}</FormError>
</form>
</Dialog>
);
}
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const [copied, setCopied] = useState(false);
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff. Show more