Compare commits

...
12 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 / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
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
110 changed files with 4897 additions and 625 deletions

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
# Rust-часть десктопа: прокси и проверки URL. Полная сборка инсталлятора — локально
# (`pnpm --filter @pipu/desktop bundle`): на раннере нет Windows и WebKitGTK.
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
# (Linux .deb и Windows NSIS через cargo-xwin на ubuntu-latest).
name: ci / desktop
on:
+5 -1
View File
@@ -57,5 +57,9 @@ jobs:
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium
# 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
+147 -5
View File
@@ -1,6 +1,8 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: 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):
@@ -132,9 +134,10 @@ jobs:
# Постоянный кэш 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" /tmp/gradle.log; then
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
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
@@ -196,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
+3
View File
@@ -41,3 +41,6 @@ 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/
+187 -1
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
@@ -1497,11 +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: [] # публично: страница /android раздаёт приложение без входа
security: [] # публично: страница /download раздаёт приложение без входа
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
description: |
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
@@ -2970,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
@@ -3446,6 +3579,59 @@ components:
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]
+8 -6
View File
@@ -5,12 +5,14 @@ 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 = '0.0.0';
try {
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
version = tag.replace(/^v/, '').replace(/-rc\.\d+$/, '');
} catch {
// local untagged build
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));
+4 -1
View File
@@ -6,8 +6,11 @@
"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": "tauri icon ../web/public/favicon.svg"
"icons": "node ../../scripts/generate-icons.mjs"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.0"
+3 -1
View File
@@ -38,5 +38,7 @@ keyring = { version = "3.6.3", features = ["apple-native"] }
[profile.release]
strip = true
lto = "thin"
lto = true
codegen-units = 1
opt-level = "s"
panic = "abort"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+1 -1
View File
@@ -126,7 +126,7 @@ pub fn run() {
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");
let icon: Image = include_image!("icons/32x32.png");
TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
+4 -2
View File
@@ -95,7 +95,9 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
{ return StatusCode::FORBIDDEN.into_response(); }
let prefix = format!("/{}/api/", proxy.secret);
let Some(tail) = request.uri().path().strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
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")
@@ -106,7 +108,7 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
let mut response = if request.method() == Method::OPTIONS {
StatusCode::NO_CONTENT.into_response()
} else {
match transfer(&proxy, request, tail.to_owned()).await {
match transfer(&proxy, request, tail).await {
Ok(response) => response,
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
}
+1 -1
View File
@@ -24,7 +24,7 @@
"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": true }
"appimage": { "bundleMediaFramework": false }
},
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
}
+6
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,
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

+6
View File
@@ -91,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 }}>
+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();
+13 -10
View File
@@ -17,7 +17,7 @@ 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';
@@ -224,15 +224,18 @@ class Controller {
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) {
+2 -2
View File
@@ -20,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';
@@ -161,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' }}>
+3 -1
View File
@@ -17,7 +17,9 @@ for (const width of [320, 1280]) {
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('button', { name: 'Добавить в плейлист', exact: true }).first().click();
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();
+2
View File
@@ -5,6 +5,8 @@
<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>
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

+21 -1
View File
@@ -291,7 +291,7 @@ describe('player', () => {
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
expect(router.state.location.pathname).toBe(path);
await user.click(screen.getByRole('listitem'));
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('Ссылка на трек скопирована');
@@ -409,6 +409,26 @@ describe('safeRedirect', () => {
});
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 });
+11 -2
View File
@@ -69,8 +69,16 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError,
});
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
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,
@@ -262,6 +270,7 @@ const adminSettingsRoute = createRoute({
});
const routeTree = rootRoute.addChildren([
downloadAppRoute,
androidAppRoute,
connectRoute,
loginRoute,
+57 -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) {
@@ -272,6 +326,7 @@
}
@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; }
+37 -4
View File
@@ -1,10 +1,12 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { Link } from '@tanstack/react-router';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, type ReactNode } from 'react';
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';
@@ -28,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}`;
@@ -58,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} />
)}
@@ -82,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(' · ')}
@@ -154,7 +186,8 @@ export function TrackList({
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
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();
+10 -9
View File
@@ -1,12 +1,12 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
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 { queueIcon } from '../shared/ui/icons';
import { playlistAddIcon } from '../shared/ui/icons';
import s from './catalog.module.css';
export type Playlist = components['schemas']['Playlist'];
@@ -61,7 +61,7 @@ export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
}
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
@@ -84,14 +84,15 @@ function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: ()
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
</div>
</Dialog>;
}
});
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
const close = useCallback(() => setSelectedIds(null), []);
return <>
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
{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} />}
</>;
}
+28 -17
View File
@@ -4,12 +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';
@@ -23,30 +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)} />
<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>
@@ -67,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>
);
}
+93 -32
View File
@@ -1,25 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
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';
/**
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
* Публичная страница приложений (/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: ['android-latest'],
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
queryKey: ['app-clients'],
queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
staleTime: 10 * 60_000,
});
const server = location.origin;
@@ -28,39 +30,69 @@ export function AppDownloadPage() {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
const r = latest.data;
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}>Android</span>
<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 ? null : r ? (
{latest.isPending ? (
<p role="status">{t('common:loading')}</p>
) : latest.isError ? (
<div className={s.hint}>{t('app.unavailable')}</div>
) : data ? (
<>
<a className={s.download} href={r.download_url} download>
<Icon src={downloadIcon} size={22} />
<span>
{t('app.download')}
<span className={s.downloadMeta}>
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
</span>
</span>
</a>
<ol className={s.steps}>
<li>{t('app.step_install')}</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>
<li>{t('app.step_updates')}</li>
</ol>
<details className={s.details}>
<summary>{t('app.checksum')}</summary>
<code>{r.sha256}</code>
</details>
{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>
@@ -68,3 +100,32 @@ export function AppDownloadPage() {
</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
+14 -1
View File
@@ -21,7 +21,7 @@ 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';
@@ -54,6 +54,13 @@ export function HomePage() {
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
@@ -136,6 +143,12 @@ function WaveHome() {
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
<FormError>{error ? errorText(error) : null}</FormError>
{options.isPending && (showActivities || showMoods) && (
<div className={s.picker}>
<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 && (
+1 -1
View File
@@ -47,7 +47,7 @@ export function LoginPage() {
{desktop ? null : (
<>
{' · '}
<a href="/android">{t('app.link')}</a>
<a href="/download">{t('app.link')}</a>
</>
)}
</>
+67 -16
View File
@@ -1,16 +1,17 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
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, Field, FormError } from '../shared/ui/ui';
import { playIcon, shuffleIcon } from '../shared/ui/icons';
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';
@@ -27,13 +28,16 @@ export function PlaylistsPage() {
<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 && <p role="status">{t('playlists.loading')}</p>}
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
<Cover album={item} size="100%" />
<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>)}</div>
{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>;
}
@@ -45,7 +49,7 @@ export function PlaylistPage({ id }: { id: string }) {
return <div className={s.page}>
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
{query.isPending && <PageSkeleton />}
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
</div>;
}
@@ -93,7 +97,11 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
<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 && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
{!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>
@@ -101,9 +109,52 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
<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>}
{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}>
+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>;
}
+54 -12
View File
@@ -4,35 +4,77 @@ import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
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}>
<header className={c.hero}>
{tr && <Cover album={tr.album} size={200} />}
<div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
{tr && <>
<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.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>
</header>
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
</div>
</div>
);
}
+3 -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'];
@@ -67,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>;
}
+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)} />
+3 -3
View File
@@ -8,8 +8,8 @@ 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';
@@ -123,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
+3 -3
View File
@@ -9,7 +9,7 @@ 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, refreshIcon, searchIcon } from '../../shared/ui/icons';
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
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'];
@@ -232,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()}>
+64 -1
View File
@@ -20,6 +20,15 @@
filter: blur(30px);
}
.waveDecor {
position: absolute;
left: 50%;
top: 46%;
transform: translate(-50%, -50%);
opacity: 0.55;
filter: blur(0);
}
.blob1 {
width: 62vmax;
height: 62vmax;
@@ -165,7 +174,7 @@
color: var(--text2);
}
/* Страница Android-приложения (/android). */
/* Страница приложений (/download). */
.download {
display: flex;
align-items: center;
@@ -238,6 +247,60 @@
font-family: var(--font-mono);
}
.otherClients {
display: flex;
flex-direction: column;
gap: 8px;
}
.otherBlock {
display: flex;
flex-direction: column;
gap: 8px;
}
.otherTitle {
margin: 8px 0 0;
color: var(--text3);
font-size: 12px;
font-weight: 600;
}
.downloadAlt {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-lg);
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.downloadAlt span {
color: var(--text3);
font-size: 12px;
font-weight: 500;
}
.older {
font-size: 13px;
color: var(--text3);
}
.older summary {
cursor: pointer;
font-weight: 600;
}
.older .downloadAlt {
margin-top: 8px;
}
.link {
align-self: center;
}
+41 -2
View File
@@ -60,12 +60,23 @@
.heroBg {
position: absolute;
inset: 0;
opacity: 0.35;
inset: -20%;
opacity: 0.4;
filter: blur(60px);
pointer-events: none;
}
.heroBg img {
width: 100%;
height: 100%;
object-fit: cover;
}
.heroCover {
position: relative;
z-index: 1;
}
.tint {
position: absolute;
inset: 0;
@@ -81,12 +92,40 @@
.heroBody {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.listen {
display: grid;
gap: 28px;
}
.listenLyrics {
min-width: 0;
}
@media (min-width: 900px) {
.listen {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
align-items: start;
}
.listen .hero {
flex-direction: column;
align-items: stretch;
margin: 0;
padding: 0;
}
.listen .heroTitle {
font-size: clamp(28px, 3vw, 40px);
}
}
.kind {
font-size: 12px;
font-weight: 700;
+8
View File
@@ -0,0 +1,8 @@
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; margin: 0; }
.stats dt { color: var(--text2); font-size: 13px; }
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
.lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
.annotation summary { cursor: pointer; line-height: 1.6; }
.badge { display: inline-block; margin-left: 12px; font-size: 12px; color: var(--text2); }
+4
View File
@@ -98,6 +98,10 @@
background: var(--surface3);
}
.chip:active {
transform: scale(0.97);
}
.chipOn,
.chipOn:hover {
background: var(--accent-fill);
+13 -4
View File
@@ -1,12 +1,21 @@
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface); animation: pipuIn 0.2s ease-out both; }
.coverWrap { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; }
.coverWrap > :first-child { width: 100%; height: 100%; }
.card strong { overflow-wrap: anywhere; }
.card span { font-size: 13px; }
.card:hover { background: #ffffff10; }
.card:hover { background: var(--hover); }
.play {
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 .play, .card:focus-within .play, .play:focus-visible { opacity: 1; transform: none; }
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.actions { display: flex; align-items: center; }
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
.actions button:disabled { opacity: .3; cursor: default; }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
.hero { overflow-wrap: anywhere; }
@media (max-width: 600px) { .hero { flex-direction: column; align-items: flex-start; gap: 16px; } .hero > div { width: 100%; } }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } .play { opacity: 1; transform: none; } }
+6 -2
View File
@@ -8,7 +8,7 @@ import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { formatBytes, formatDateTime } from '../../shared/lib/format';
import { addIcon } from '../../shared/ui/icons';
import { Button, cx, FormError } from '../../shared/ui/ui';
import { Button, cx, EmptyState, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
import u from './uploads.module.css';
@@ -86,7 +86,11 @@ export function MyUploadsPage() {
</Link>
</div>
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.mine_empty')}</div>}
{list.isSuccess && items.length === 0 && (
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
{t('uploads.mine_empty')}
</EmptyState>
)}
{items.length > 0 && (
<div className={s.tableWrap}>
<table className={s.table}>
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { detectAppClient, otherAppClients } from './platform';
describe('detectAppClient', () => {
it('picks the native client from the user agent', () => {
expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android');
expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos');
expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull();
});
it('lists the remaining platforms after the detected one', () => {
expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']);
});
});
+17
View File
@@ -0,0 +1,17 @@
export type AppClient = 'android' | 'windows' | 'linux' | 'macos';
const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android'];
/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */
export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null {
if (/Android/i.test(ua)) return 'android';
if (/iPhone|iPad|iPod/i.test(ua)) return null;
if (/Windows/i.test(ua)) return 'windows';
if (/Mac OS X|Macintosh/i.test(ua)) return 'macos';
if (/Linux|X11/i.test(ua)) return 'linux';
return null;
}
export function otherAppClients(primary: AppClient | null): AppClient[] {
return ORDER.filter((c) => c !== primary);
}
+1
View File
@@ -70,4 +70,5 @@ export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_e
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
+118 -3
View File
@@ -21,9 +21,10 @@
}
.logoDot {
display: inline-block;
flex: none;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
background: url('/favicon.svg') center / contain no-repeat;
}
.button {
@@ -40,7 +41,12 @@
transition:
background 0.15s,
box-shadow 0.15s,
opacity 0.15s;
opacity 0.15s,
transform 0.12s;
}
.button:active:not(:disabled) {
transform: scale(0.97);
}
.button:disabled {
@@ -52,6 +58,115 @@
cursor: progress;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: pipuSpin 0.6s linear infinite;
}
@keyframes pipuSpin {
to {
transform: rotate(360deg);
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
text-align: center;
color: var(--text3);
}
.emptyIcon {
color: var(--text3);
opacity: 0.7;
}
.emptyText {
margin: 0;
max-width: 28rem;
font-size: 15px;
line-height: 1.45;
}
.empty a,
.empty button {
color: var(--accent2);
font-weight: 600;
text-decoration: none;
}
.empty a:hover {
text-decoration: underline;
}
.skeleton {
display: block;
background: var(--surface3);
border-radius: 8px;
animation: pipuShimmer 1.2s ease-in-out infinite;
}
.skeletonRound {
border-radius: 50%;
}
@keyframes pipuShimmer {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
.pageSkel {
display: flex;
flex-direction: column;
gap: 16px;
}
.skelHero {
display: flex;
align-items: flex-end;
gap: 24px;
margin-bottom: 8px;
}
.skelCover {
width: 160px;
height: 160px;
border-radius: 12px;
flex: none;
}
.skelCopy {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
padding-bottom: 8px;
}
.skelRow {
display: flex;
align-items: center;
gap: 14px;
}
.primary {
background: var(--accent-fill);
color: #fff;
+57 -1
View File
@@ -4,6 +4,7 @@ import {
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type InputHTMLAttributes,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
@@ -52,7 +53,7 @@ export function Button({ variant = 'secondary', icon, busy, className, children,
aria-busy={busy || undefined}
className={cx(s.button, s[variant], className)}
>
{icon && <Icon src={icon} size={18} />}
{busy ? <span className={s.spinner} aria-hidden /> : icon ? <Icon src={icon} size={18} /> : null}
{children}
</button>
);
@@ -283,6 +284,61 @@ export function Menu({
);
}
/** Пустой список: иконка, текст и действие (docs/design.md#5). */
export function EmptyState({
icon,
children,
action,
}: {
icon?: string;
children: ReactNode;
action?: ReactNode;
}) {
return (
<div className={s.empty} role="status">
{icon && <Icon src={icon} size={40} className={s.emptyIcon} />}
<p className={s.emptyText}>{children}</p>
{action}
</div>
);
}
export function Skeleton({
className,
style,
round,
}: {
className?: string;
style?: CSSProperties;
round?: boolean;
}) {
return <span className={cx(s.skeleton, round && s.skeletonRound, className)} style={style} aria-hidden />;
}
/** Каркас героя и строк списка, пока страница ещё грузится. */
export function PageSkeleton() {
const { t } = useTranslation();
return (
<div className={s.pageSkel} role="status" aria-busy="true" aria-label={t('loading')}>
<div className={s.skelHero}>
<Skeleton className={s.skelCover} />
<div className={s.skelCopy}>
<Skeleton style={{ width: 72, height: 12 }} />
<Skeleton style={{ width: '55%', height: 28 }} />
<Skeleton style={{ width: '38%', height: 14 }} />
</div>
</div>
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className={s.skelRow}>
<Skeleton style={{ width: 40, height: 40 }} />
<Skeleton style={{ flex: 1, height: 14 }} />
<Skeleton style={{ width: 36, height: 12 }} />
</div>
))}
</div>
);
}
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
const initials = name
.split(/[\s._-]+/)
+31
View File
@@ -116,6 +116,34 @@ export function fakeApi(initial: FakeUser[]) {
min_android_version_code: 0,
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
});
case 'GET /api/v1/app/clients':
return json({
version: '1.3.0',
version_code: 1_030_099,
notes_md: '',
clients: [
{
client: 'windows',
filename: 'pipu-music_1.3.0_x64-setup.exe',
size: 20_000_000,
download_url: '/api/v1/app/download/windows/1.3.0',
older: [{ version: '1.2.0', filename: 'pipu-music_1.2.0_x64-setup.exe', size: 19_000_000, download_url: '/api/v1/app/download/windows/1.2.0' }],
},
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.deb', size: 4_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
],
});
case 'GET /api/v1/app/android/latest':
return json({
version: '1.3.0',
version_code: 1_030_099,
notes_md: '',
size: 12_000_000,
sha256: 'ab'.repeat(32),
download_url: '/api/v1/app/android/download/1.3.0',
min_supported: 0,
});
case 'POST /api/v1/auth/login': {
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
if (!u) return problem(401, 'auth.invalid_credentials');
@@ -280,6 +308,9 @@ export function fakeApi(initial: FakeUser[]) {
users.set(target.id, target);
return json(admin(target));
}
if (req.method === 'GET' && /^\/api\/v1\/tracks\/[^/]+\/genius$/.test(path)) {
return json({ status: 'disabled', lyrics: '', source_url: '', song_image_url: '', artist_image_url: '', description: '', annotations: [] });
}
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
if (trackPath && req.method === 'GET') {
+21
View File
@@ -227,12 +227,33 @@
color: var(--accent2);
}
.skip {
position: absolute;
left: 12px;
top: 8px;
z-index: 50;
padding: 8px 14px;
border-radius: 10px;
background: var(--elev);
color: var(--text);
font-weight: 600;
text-decoration: none;
transform: translateY(-120%);
}
.skip:focus {
transform: none;
}
.content {
grid-area: content;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 32px 36px;
}
.routeIn {
animation: pipuIn 0.2s ease-out;
}
+25 -9
View File
@@ -1,11 +1,12 @@
import { queueIcon } from '../shared/ui/icons';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { Suspense, useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
import { useDesktop } from '../desktop';
import { useCollectionCounts } from '../entities/collection';
import {
moreIcon,
@@ -25,11 +26,11 @@ import {
dislikeFillIcon,
dislikeIcon,
settingsIcon,
androidIcon,
downloadIcon,
chevronRightIcon,
storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui';
import { Avatar, cx, Dialog, Icon, Logo, Menu, PageSkeleton } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
import { Ambient } from './Ambient';
@@ -155,6 +156,7 @@ function Shell() {
const { t } = useTranslation();
const user = useUser();
const session = useSession();
const desktop = useDesktop();
const navigate = useNavigate();
const name = user.display_name ?? user.username;
const staff = user.role !== 'user';
@@ -224,6 +226,9 @@ function Shell() {
return (
<div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />}
<a href="#content" className={s.skip}>
{t('nav.skip')}
</a>
<header className={s.top}>
<div className={s.topLeft}>
<Link to="/" className={s.logoLink}>
@@ -252,7 +257,9 @@ function Shell() {
}
items={[
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
...(!desktop
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
: []),
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
]}
/>
@@ -280,17 +287,26 @@ function Shell() {
</Dialog>
)}
<main className={s.content}>
<Suspense fallback={null}>
<Outlet />
</Suspense>
</main>
<ShellMain />
<PlayerBar variant={island ? 'island' : 'bar'} />
</div>
);
}
function ShellMain() {
const key = useRouterState({ select: (st) => st.location.pathname });
return (
<main id="content" className={s.content} tabIndex={-1}>
<div key={key} className={s.routeIn}>
<Suspense fallback={<PageSkeleton />}>
<Outlet />
</Suspense>
</div>
</main>
);
}
/** Третье пятно фона — в цвете обложки текущего трека. */
function ShellAmbient() {
const player = usePlayer();
+120 -3
View File
@@ -149,6 +149,115 @@ a.source:hover {
transform: scale(1.06);
}
.play:active {
transform: scale(0.94);
}
.nowArt {
position: relative;
display: grid;
place-items: center;
flex: none;
padding: 0;
border-radius: 10px;
}
.nowEq {
position: absolute;
right: -2px;
bottom: -2px;
padding: 2px 4px;
border-radius: 6px;
background: var(--surface);
}
.coverLive {
animation: pipuCoverPulse 2.4s ease-in-out infinite;
}
@keyframes pipuCoverPulse {
50% {
filter: brightness(1.12);
}
}
.queueScrim,
.sheetScrim {
position: fixed;
inset: 0;
z-index: 15;
background: var(--overlay);
animation: pipuIn 0.18s ease-out;
}
.sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 16;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-height: min(92dvh, 760px);
padding: 12px 20px calc(24px + env(safe-area-inset-bottom, 0px));
border-radius: 24px 24px 0 0;
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
animation: pipuIn 0.22s ease-out;
}
.sheetHead {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-weight: 600;
}
.sheetCover {
width: min(240px, 56vw) !important;
height: min(240px, 56vw) !important;
border-radius: 16px;
}
.sheetMeta {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
min-width: 0;
max-width: 100%;
}
.sheetTitle {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
color: inherit;
text-decoration: none;
}
.sheetControls,
.sheetExtra {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
}
.sheet .progress {
max-width: none;
}
.sheet .volume {
flex: 1;
max-width: 160px;
}
.play[aria-busy='true'] {
opacity: 0.7;
}
@@ -218,6 +327,10 @@ a.source:hover {
}
@media (max-width: 767px) {
.now {
cursor: pointer;
}
.bar {
grid-template-columns: minmax(0, 1fr) auto;
padding: 0 12px;
@@ -228,7 +341,7 @@ a.source:hover {
.right,
.controls > :first-child,
.controls > :last-child,
.now > button {
.now > button:not(.nowArt) {
display: none;
}
}
@@ -237,7 +350,7 @@ a.source:hover {
position: fixed;
right: 16px;
bottom: 100px;
z-index: 6;
z-index: 16;
display: flex;
flex-direction: column;
width: min(380px, calc(100% - 32px));
@@ -311,13 +424,17 @@ a.source:hover {
.controls { gap: 2px; }
.play, .icon { width: 44px; height: 44px; }
.right { display: flex; gap: 0; }
.right > :not(.queueButton):not(.shareButton) { display: none; }
.right > :not(.queueButton):not(.shareButton):not(.playlistButton) { display: none; }
.error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px;
padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; }
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 359px) {
.now > :first-child { display: none; }
/* share + очередь + три кнопки управления не оставляют месту название трека (e2e 320px). */
.shareButton { display: none; }
}
.shareStatus {
+166 -5
View File
@@ -3,7 +3,8 @@ import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog';
import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { shareTrack } from '../shared/lib/share';
@@ -36,6 +37,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
const [sharing, setSharing] = useState(false);
@@ -96,8 +99,24 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
return (
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<div className={s.now}>
<Cover album={track.album} size={52} />
<div
className={s.now}
onClick={
narrow
? (e) => {
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
}
: undefined
}
>
{narrow ? (
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
</button>
) : (
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
)}
<div className={s.meta}>
<span className={s.title}>{track.title}</span>
<span className={s.artists}>
@@ -178,6 +197,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
</div>
<div className={s.right}>
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
@@ -230,12 +250,150 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
{sheetOpen && narrow && (
<NowPlayingSheet
variant={variant}
onClose={() => setSheetOpen(false)}
share={share}
sharing={sharing}
/>
)}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
</footer>
);
}
function useNarrow() {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia?.('(max-width: 767px)');
if (!mq) return;
const sync = () => setNarrow(mq.matches);
sync();
mq.addEventListener?.('change', sync);
return () => mq.removeEventListener?.('change', sync);
}, []);
return narrow;
}
function NowPlayingSheet({
variant,
onClose,
share,
sharing,
}: {
variant: 'bar' | 'island';
onClose: () => void;
share: () => Promise<void>;
sharing: boolean;
}) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const track = st.track;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
if (!track) return null;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const repeatLabel = t(`player.repeat_${st.repeat}`);
return createPortal(
<div className={s.sheetScrim} onClick={onClose}>
<div
className={cx(s.sheet, variant === 'island' && s.sheetIsland)}
role="dialog"
aria-modal="true"
aria-label={t('player.now_playing')}
onClick={(e) => e.stopPropagation()}
>
<div className={s.sheetHead}>
<span>{t('player.now_playing')}</span>
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} />
</button>
</div>
<Cover album={track.album} size={240} className={cx(s.sheetCover, st.playing && s.coverLive)} />
<div className={s.sheetMeta}>
<Link to="/track/$id" params={{ id: track.id }} className={s.sheetTitle} onClick={onClose}>
{track.title}
</Link>
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
</span>
</div>
<div className={s.progress}>
<span className={s.time}>{formatDuration(st.positionMs)}</span>
<input
type="range"
className={s.range}
min={0}
max={st.durationMs || track.duration_ms}
step={1000}
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
aria-label={t('player.progress')}
onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
/>
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
</div>
<div className={s.sheetControls}>
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<Icon src={prevIcon} size={28} />
</button>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<Icon src={nextIcon} size={28} />
</button>
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button>
</div>
<div className={s.sheetExtra}>
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
<Icon src={shuffleIcon} size={22} />
</button>
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
</button>
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={22} />
</button>
<input
type="range"
className={cx(s.range, s.volume)}
min={0}
max={1}
step={0.01}
value={st.muted ? 0 : st.volume}
aria-label={t('player.volume')}
onChange={(e) => player.setVolume(Number(e.target.value))}
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
</div>
</div>,
document.body,
);
}
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
const { t } = useTranslation();
@@ -254,7 +412,9 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
}, [onClose]);
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
return createPortal(
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<>
<div className={s.queueScrim} onClick={onClose} />
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<div className={s.drawerHead}>
<span>{t('player.queue')}</span>
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
@@ -280,7 +440,8 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
))}
</div>
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
</aside>,
</aside>
</>,
document.body,
);
}
+14 -7
View File
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { searchIcon } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
@@ -36,6 +37,7 @@ export function SearchBox({
}) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const navigate = useNavigate();
const listId = useId();
const inputRef = useRef<HTMLInputElement>(null);
@@ -118,8 +120,7 @@ export function SearchBox({
title: tr.title,
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
cover: tr.album,
// Воспроизведение — M3; пока трек открывает свой альбом.
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
go: () => player.playList([tr], 0, { type: 'search', id: debounced, label: debounced }),
});
}
@@ -149,7 +150,8 @@ export function SearchBox({
}
};
const showList = open && debounced !== '' && data !== undefined;
const showPopover = open && debounced !== '';
const showList = showPopover && data !== undefined;
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
const optionId = (i: number) => `${listId}-${i}`;
@@ -188,12 +190,17 @@ export function SearchBox({
<kbd className={s.kbd} aria-hidden>
/
</kbd>
{showList && (
{showPopover && (
<div className={s.popover}>
{data.corrected_query && (
{showPopover && !showList && (
<div className={s.nothing} role="status">
{t('search.searching')}
</div>
)}
{data?.corrected_query && (
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
)}
<div id={listId} role="listbox" aria-label={t('search.label')}>
{showList && <div id={listId} role="listbox" aria-label={t('search.label')}>
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
{groups.map((g) => {
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
@@ -221,7 +228,7 @@ export function SearchBox({
</div>
);
})}
</div>
</div>}
<button
type="button"
className={s.all}
+9
View File
@@ -129,6 +129,15 @@ export function WaveButton(props: WaveButtonProps) {
aria-pressed={interactive ? !!props.playing : undefined}
aria-hidden={interactive ? undefined : true}
onMouseMove={interactive ? onMove : undefined}
onFocus={interactive ? () => (s.hoverT = 1) : undefined}
onBlur={
interactive
? () => {
s.hoverT = 0;
s.pressT = 0;
}
: undefined
}
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
onMouseLeave={
interactive
+4
View File
@@ -44,6 +44,10 @@ LOG_LEVEL=info
SUPPORTED_LOCALES=ru,en
MIN_ANDROID_VERSION_CODE=0
# Genius: empty token disables enrichment. Queue and controls: Admin → Sources.
GENIUS_ACCESS_TOKEN=
GENIUS_REQUEST_INTERVAL=2s
# --- Самообновление Android (M5) ---
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
+1
View File
@@ -15,6 +15,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
| [genius.md](genius.md) | Genius: токен, поиск метаданных, парсинг текстов и проверочная команда |
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
| [web.md](web.md) | Веб-клиент |
+6 -2
View File
@@ -102,8 +102,12 @@
прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. После каждого релиза CI
оставляет файлы только у последних 8 релизов и последние 8 прогонов Actions (теги и заметки не удаляет).
Та же страница — внизу экрана входа
и в меню профиля.
## 7. Наблюдение
+2
View File
@@ -106,6 +106,8 @@
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
| Метод | Путь | Описание |
|---|---|---|
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url, older:[{version, filename, size, download_url}]}]}` — до 8 релизов на платформу |
| GET | `/app/download/{client}/{version}` | файл клиента этой версии (прокси / кэш релиза Gitea) |
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
+1 -1
View File
@@ -76,7 +76,7 @@
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
| `TRANSCODE_CONCURRENCY` | `2` | |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/download` |
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
| `LOG_LEVEL` | `info` | |
+7 -1
View File
@@ -43,7 +43,13 @@
| `overlay` | `rgba(3,5,10,.66)` | `rgba(20,30,60,.32)` | Подложка диалогов |
| `glass` | `rgba(12,16,28,.78)` | `rgba(255,255,255,.84)` | Стеклянные карточки (вход, диалоги) + `backdrop-filter: blur(28px) saturate(1.3)` |
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — радиальный градиент `#9CC6FF → #2F80FF → #3A1FA8`.
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
и монохромный слои с отступами для масок лаунчера; фон — `#05070D`.
После изменения значков установленные приложения нужно пересобрать и обновить.
### Типографика
+3 -1
View File
@@ -12,4 +12,6 @@ pnpm --filter @pipu/desktop dev
pnpm --filter @pipu/desktop bundle
```
На Windows получается NSIS-установщик, на Linux — AppImage, deb и rpm. Зависимости Linux: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
На Windows получается NSIS-установщик (~3 МБ). На Linux в релиз идёт только `.deb` (~4 МБ): AppImage тащит WebKitGTK и раздувается до ~80 МБ. Зависимости пакета: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
Релиз по тегу собирает Linux `.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea.
+83
View File
@@ -0,0 +1,83 @@
# Genius: поиск метаданных и получение текста
Реализован Go-провайдер `services/api/internal/genius` и отдельная команда
`services/api/cmd/genius`. Поиск через API возвращает кандидатов, ссылки на изображения
и исполнителя. Текст выбранной песни извлекается из HTML страницы: из контейнеров
`data-lyrics-container` либо из `songPage.lyricsData.body.html` во встроенном состоянии.
JavaScript не исполняется. Используется HTML-парсер `golang.org/x/net/html`.
Это проверяемый первый этап: команда работает отдельно от сервера. Автоматическое
дополнение каталога, хранение текстов в БД, аннотации и отображение в плеере пока
не подключены. Поэтому переменная ниже пока нужна именно процессу команды,
а не Docker Compose или веб-клиенту.
## Почему Go
Основной API Pipu написан на Go. Небольшой провайдер можно подключить к нему без
отдельного Python-сервиса. LyricsGenius удобен для Python, но собственный адаптер
позволяет явно контролировать выбор версии песни, сетевые ограничения и ошибки.
HTML Genius не является стабильным API: при изменении разметки парсер потребует обновления.
## Получение токена
1. Войти или зарегистрироваться на https://genius.com.
2. Открыть https://genius.com/api-clients и создать новый API Client.
3. Указать название, например `Pipu Music`, и URL своего приложения. Если форма
требует Redirect URI, указать URL под своим контролем; для чтения с Client Access
Token OAuth-переходы и обработчик callback в Pipu не нужны.
4. В созданном клиенте нажать **Generate Access Token**.
5. Сохранить **Client Access Token** в переменной `GENIUS_ACCESS_TOKEN` процесса.
Client ID и Client Secret вместо него не подходят.
Токен нужен для поиска через `api.genius.com`. Получение HTML по уже известному
URL не требует токена и не отправляет заголовок Authorization. Не помещать токен
в git, клиентские переменные `VITE_*`, логи или переписку.
Источник: https://lyricsgenius.readthedocs.io/en/master/setup.html.
## Проверка в PowerShell 7
Из корня репозитория:
```powershell
Set-Location services/api
$env:GENIUS_ACCESS_TOKEN = Read-Host 'Genius Client Access Token' -MaskInput
go run ./cmd/genius -search 'Исполнитель Название песни'
```
Выбрать нужного исполнителя и версию песни из результатов. Команда намеренно
не считает первый результат правильным. Затем подставить возвращённый `url`:
```powershell
go run ./cmd/genius -url 'https://genius.com/Artist-song-lyrics'
Remove-Item Env:GENIUS_ACCESS_TOKEN
```
URL во втором примере — шаблон. Результат — JSON с `text`, `source_url` и временем
получения `fetched_at`. Это обычный текст без временной синхронизации.
## Ограничения и дальнейшее подключение
- HTTP 401, 403, 404, 429 и другие ошибки возвращаются явно; `HTTPError` сохраняет
`Retry-After`. Автоматических повторов и обхода блокировок нет.
- Таймаут запроса — 15 секунд, предел ответа — 8 MiB. Разрешён только HTTPS
на Genius; перенаправления на другой хост запрещены.
- Отсутствие распознаваемого текста возвращает `ErrLyricsUnavailable`; HTML
блокировки или навигация не становятся текстом песни.
- Перед интеграцией в каталог нужны кэш результатов, лимит частоты запросов,
сохранение выбранного Genius ID и обработка временных ошибок без потери старого текста.
- Токен API не предоставляет лицензию на тексты. Документация LyricsGenius
предупреждает, что парсинг противоречит условиям Genius; отображление и хранение
в публичном сервисе требуют отдельной проверки разрешений:
https://lyricsgenius.readthedocs.io/en/latest/how_it_works.html.
## Тесты
```powershell
go test ./internal/genius ./cmd/genius
```
Тесты используют синтетические тексты и подмену HTTP: переносы, HTML-сущности,
служебные элементы, встроенное состояние, ошибки HTTP, проверка URL и
отсутствие токена при запросе страницы. Реальный доступ к Genius проверяется
отдельно из сети сервера с собственным токеном.
+1 -1
View File
@@ -246,7 +246,7 @@ curl -s https://music.example.com/readyz
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
ссылка `https://<ваш-домен>/android`.
ссылка `https://<ваш-домен>/download`.
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
+2 -2
View File
@@ -99,13 +99,13 @@
## Самообновление
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, предыдущие версии (до 8 последних релизов, сколько есть), адрес сервера и инструкция.
Приложение распространяется только через Gitea Releases (не Google Play).
**Поток:**
1. При запуске (не чаще раза в 6 ч) и вручную в профиле: `GET /api/v1/app/android/latest` на **сервере Pipu**.
Сервер сам опрашивает Gitea API (`GET /api/v1/repos/{owner}/{repo}/releases/latest`) с серверным токеном,
Сервер сам опрашивает Gitea API (список релизов, до 8 с файлами; fallback — `/releases/latest`) с серверным токеном,
кэширует ответ на 10 мин и APK на диске. Так:
- мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
- версия приложения согласована с версией сервера (`min_supported` из `server-info`).
+1 -1
View File
@@ -22,7 +22,7 @@
```
apps/web/src/
├── app/ # провайдеры, роутер, глобальные стили, гарды
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
├── pages/ # login, setup-admin, setup-password, download, home, search, artist, album,
│ # collection/{playlists,history,likes,dislikes}, playlist, track, settings,
│ # admin/{users,sources,library,upload,upload-queue,settings}
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
+1
View File
@@ -6,6 +6,7 @@
"node": ">=22"
},
"scripts": {
"icons": "node scripts/generate-icons.mjs",
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
+246
View File
@@ -4,6 +4,40 @@
*/
export interface paths {
"/api/v1/admin/genius": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getGeniusStats"];
put?: never;
post: operations["controlGenius"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tracks/{id}/genius": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
get: operations["getTrackGenius"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/healthz": {
parameters: {
query?: never;
@@ -1293,6 +1327,45 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/app/clients": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Клиенты последних релизов (Android и десктоп)
* @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
* У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
* Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
*/
get: operations["getAppClients"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/app/download/{client}/{version}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Файл клиента (кэшируется на сервере, поддерживает Range) */
get: operations["downloadAppClient"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/app/android/latest": {
parameters: {
query?: never;
@@ -2030,7 +2103,40 @@ export interface components {
/** @description Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости */
features: components["schemas"]["TrackFeatures"] | null;
};
GeniusStats: {
configured: boolean;
paused: boolean;
running: boolean;
total: number;
pending: number;
matched: number;
lyrics: number;
annotations: number;
images: number;
unmatched: number;
errors: number;
last_error: string;
/** Format: date-time */
cooldown_until: string;
};
GeniusContent: {
status: string;
lyrics: string;
source_url: string;
song_image_url: string;
artist_image_url: string;
description: string;
annotations: components["schemas"]["GeniusAnnotation"][];
};
GeniusAnnotation: {
fragment: string;
text: string;
url: string;
verified: boolean;
authors: string;
};
Artist: {
image_url?: string;
/** Format: uuid */
id: string;
name: string;
@@ -2249,6 +2355,32 @@ export interface components {
early_skip_rate: number | null;
like_rate: number | null;
};
/** @enum {string} */
AppClient: "android" | "windows" | "linux" | "macos";
AppClientBuild: {
version: string;
filename: string;
/** Format: int64 */
size: number;
sha256?: string;
download_url: string;
};
AppClientFile: {
client: components["schemas"]["AppClient"];
version?: string;
filename: string;
/** Format: int64 */
size: number;
sha256?: string;
download_url: string;
older?: components["schemas"]["AppClientBuild"][];
};
AppClients: {
version: string;
version_code: number;
notes_md: string;
clients: components["schemas"]["AppClientFile"][];
};
AndroidRelease: {
/** @example 0.6.1 */
version: string;
@@ -2326,6 +2458,75 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
getGeniusStats: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Persistent enrichment statistics (admin only) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GeniusStats"];
};
};
};
};
controlGenius: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @enum {string} */
action: "start" | "pause" | "retry";
};
};
};
responses: {
/** @description Updated statistics */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GeniusStats"];
};
};
};
};
getTrackGenius: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Cached lyrics and annotations; never makes an upstream request */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GeniusContent"];
};
};
};
};
getHealthz: {
parameters: {
query?: never;
@@ -4189,6 +4390,51 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
getAppClients: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Доступные сборки */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AppClients"];
};
};
default: components["responses"]["Problem"];
};
};
downloadAppClient: {
parameters: {
query?: never;
header?: never;
path: {
client: components["schemas"]["AppClient"];
version: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Установщик или APK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
default: components["responses"]["Problem"];
};
};
getAndroidLatest: {
parameters: {
query?: never;
+32 -7
View File
@@ -22,17 +22,42 @@
"logout": "Sign out"
},
"app": {
"link": "Android app",
"title": "pipu for Android",
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
"download": "Download APK",
"link": "Download apps",
"badge": "Apps",
"title": "pipu on your devices",
"subtitle": "Clients for this server: phone, desktop and the web. The build for your system comes first.",
"platform": {
"android": "Android",
"windows": "Windows",
"linux": "Linux",
"macos": "macOS"
},
"download": {
"android": "Download APK",
"windows": "Download for Windows",
"linux": "Download .deb",
"macos": "Download for macOS"
},
"version": "version {{version}}",
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.",
"other": "Other platforms",
"older": "Previous versions",
"step_install": {
"android": "Open the downloaded file. If Android asks, allow installs from this source.",
"windows": "Run the installer and follow the steps. pipu will appear in the Start menu.",
"linux": "Install the package: sudo apt install ./pipu-music-….deb. Needs WebKitGTK 4.1 and GStreamer.",
"macos": "Open the disk image and drag pipu into Applications."
},
"step_server": "Launch pipu and enter the server address:",
"copy_server": "Copy the server address",
"step_updates": "Sign in with your account. The app offers new versions by itself.",
"step_updates": {
"android": "Sign in with your account. The app offers new versions by itself.",
"windows": "Sign in with your account. Get a new version from this page.",
"linux": "Sign in with your account. Get a new version from this page.",
"macos": "Sign in with your account. Get a new version from this page."
},
"checksum": "SHA-256 checksum",
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).",
"unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).",
"unavailable_platform": "The latest release does not include a build for {{platform}} yet.",
"to_login": "Sign in to the web app"
}
}
+15 -2
View File
@@ -1,4 +1,12 @@
{
"genius": {
"description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.",
"loading": "Loading…", "disabled": "Not configured", "paused": "Paused", "cooldown": "Waiting after Genius rate limit", "running": "Processing", "waiting": "Waiting for new tracks",
"configure": "Add GENIUS_ACCESS_TOKEN to deploy/.env and recreate the API container.",
"total": "Total tracks", "pending": "Queued", "matched": "Matched on Genius", "lyrics": "Lyrics", "annotations": "With annotations", "images": "With artwork", "unmatched": "No exact match", "errors": "Errors / incomplete data",
"last_error": "Last error", "token_error": "Genius rejected the token. Check it and restart the API.", "access_error": "Genius restricted access. Processing is paused.", "no_lyrics": "Lyrics are currently unavailable.", "request_error": "Some data could not be retrieved. A retry is scheduled automatically.", "resume_at": "Retry no earlier than",
"start": "Resume", "pause": "Pause", "retry": "Retry errors and unmatched", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius"
},
"app": {
"name": "pipu",
"tagline": "Your music from your own server"
@@ -19,6 +27,7 @@
"en": "English"
},
"nav": {
"skip": "Skip to content",
"more": "More",
"main": "Navigation",
"home": "Home",
@@ -41,7 +50,7 @@
"menu": "Profile menu",
"settings": "Settings",
"logout": "Sign out",
"android_app": "Android app"
"download_apps": "Download apps"
},
"roles": {
"admin": "Administrator",
@@ -88,7 +97,9 @@
"source_queue": "Queue",
"queue": "Queue",
"source_wave_seed": "Artist wave · {{label}}",
"queue_wave": "The wave never ends: next tracks are picked as you listen"
"queue_wave": "The wave never ends: next tracks are picked as you listen",
"now_playing": "Now playing",
"expand": "Open player"
},
"settings": {
"title": "Settings",
@@ -153,6 +164,8 @@
"tracks": "Tracks",
"all": "All",
"nothing": "Nothing found",
"searching": "Searching…",
"type_query": "Type a query in the search field above",
"corrected": "Showing results for “{{query}}”",
"show_all": "All results",
"title": "Search: {{query}}",
+32 -7
View File
@@ -22,17 +22,42 @@
"logout": "Выйти"
},
"app": {
"link": "Приложение для Android",
"title": "pipu для Android",
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
"download": "Скачать APK",
"link": "Скачать приложения",
"badge": "Приложения",
"title": "pipu на ваших устройствах",
"subtitle": "Клиенты для этого сервера: телефон, компьютер и веб. Сначала предлагается сборка под вашу систему.",
"platform": {
"android": "Android",
"windows": "Windows",
"linux": "Linux",
"macos": "macOS"
},
"download": {
"android": "Скачать APK",
"windows": "Скачать для Windows",
"linux": "Скачать .deb",
"macos": "Скачать для macOS"
},
"version": "версия {{version}}",
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"other": "Другие платформы",
"older": "Предыдущие версии",
"step_install": {
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
"linux": "Установите пакет: sudo apt install ./pipu-music-….deb. Нужны WebKitGTK 4.1 и GStreamer.",
"macos": "Откройте образ и перетащите pipu в Программы."
},
"step_server": "Запустите pipu и введите адрес сервера:",
"copy_server": "Скопировать адрес сервера",
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
"step_updates": {
"android": "Войдите своим логином. Новые версии приложение предложит установить само.",
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.",
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.",
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы."
},
"checksum": "Контрольная сумма SHA-256",
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
"unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
"unavailable_platform": "В последнем релизе пока нет сборки для {{platform}}.",
"to_login": "Войти в веб-версию"
}
}
+15 -2
View File
@@ -1,4 +1,12 @@
{
"genius": {
"description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.",
"loading": "Загрузка…", "disabled": "Не подключён", "paused": "Приостановлено", "cooldown": "Ожидание после ограничения Genius", "running": "Обработка", "waiting": "Ожидание новых треков",
"configure": "Добавьте GENIUS_ACCESS_TOKEN в deploy/.env и пересоздайте контейнер API.",
"total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные",
"last_error": "Последняя ошибка", "token_error": "Genius отклонил токен. Проверьте его и перезапустите API.", "access_error": "Genius ограничил доступ. Обработка приостановлена.", "no_lyrics": "Текст пока недоступен.", "request_error": "Не удалось получить все данные. Повтор запланирован автоматически.", "resume_at": "Повтор не раньше",
"start": "Продолжить", "pause": "Приостановить", "retry": "Повторить ошибки и ненайденные", "pending_text": "Трек ожидает обработки Genius.", "about": "О песне", "explanations": "Пояснения к песне", "verified": "Подтверждено в Genius"
},
"app": {
"name": "pipu",
"tagline": "Ваша музыка с вашего сервера"
@@ -19,6 +27,7 @@
"en": "English"
},
"nav": {
"skip": "К содержимому",
"more": "Ещё",
"main": "Навигация",
"home": "Главная",
@@ -41,7 +50,7 @@
"menu": "Меню профиля",
"settings": "Настройки",
"logout": "Выйти",
"android_app": "Приложение для Android"
"download_apps": "Скачать приложения"
},
"roles": {
"admin": "Администратор",
@@ -88,7 +97,9 @@
"source_queue": "Очередь",
"queue": "Очередь",
"source_wave_seed": "Волна по исполнителю · {{label}}",
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
"now_playing": "Сейчас играет",
"expand": "Открыть плеер"
},
"settings": {
"title": "Настройки",
@@ -153,6 +164,8 @@
"tracks": "Треки",
"all": "Всё",
"nothing": "Ничего не нашли",
"searching": "Ищем…",
"type_query": "Введите запрос в поиске сверху",
"corrected": "Показаны результаты для «{{query}}»",
"show_all": "Все результаты",
"title": "Поиск: {{query}}",
+51
View File
@@ -0,0 +1,51 @@
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
// All platform assets derive from the editable web SVG. Requires workspace dependencies.
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const desktop = join(root, 'apps/desktop');
const require = createRequire(join(desktop, 'package.json'));
const cli = require.resolve('@tauri-apps/cli/tauri.js');
const scratch = join(root, 'artifacts/icon-build');
const source = join(root, 'apps/web/public/favicon.svg');
const svg = readFileSync(source, 'utf8');
const body = svg.replace(/<svg[^>]*>/, '').replace('</svg>', '');
mkdirSync(scratch, { recursive: true });
function render(input, output, sizes = []) {
mkdirSync(output, { recursive: true });
const result = spawnSync(process.execPath, [cli, 'icon', input, '--output', output,
...sizes.flatMap(size => ['--png', String(size)])], { cwd: desktop, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`Icon generation failed: ${input}`);
}
function variant(name, contents, size, destination) {
const input = join(scratch, `${name}.svg`);
const output = join(scratch, name);
writeFileSync(input, `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">${contents}</svg>\n`);
render(input, output, [size]);
mkdirSync(dirname(destination), { recursive: true });
copyFileSync(join(output, `${size}x${size}.png`), destination);
}
const native = join(scratch, 'desktop');
render(source, native);
for (const name of ['32x32.png', '64x64.png', '128x128.png', '128x128@2x.png', 'icon.png', 'icon.ico', 'icon.icns']) {
copyFileSync(join(native, name), join(desktop, 'src-tauri/icons', name));
}
copyFileSync(join(native, '32x32.png'), join(root, 'apps/web/public/favicon-32.png'));
const background = '<rect width="512" height="512" fill="#05070D"/>';
variant('touch', background + body, 180, join(root, 'apps/web/public/apple-touch-icon.png'));
variant('mobile', background + body, 1024, join(root, 'apps/mobile/assets/icon.png'));
// The complete orb fits inside Android's central 66/108 safe-zone circle.
variant('adaptive', `<g transform="translate(92.16 92.16) scale(.64)">${body}</g>`, 1024,
join(root, 'apps/mobile/assets/adaptive-icon.png'));
const mono = body.replace(/<defs>[\s\S]*?<\/defs>/, '').replace(/<circle[^>]*\/>/, '')
.replaceAll('url(#note)', '#FFFFFF');
variant('monochrome', `<g transform="translate(51.2 51.2) scale(.8)">${mono}</g>`, 1024,
join(root, 'apps/mobile/assets/monochrome-icon.png'));
console.log('Updated desktop, Android, and browser icons.');
+170 -15
View File
@@ -4,6 +4,7 @@
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
// node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
// node scripts/release.mjs prune — файлы только у последних 8 релизов, старые прогоны Actions
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
// PUBLIC_URL — внешний адрес Gitea для ссылок в тексте (job ходит в API по внутреннему адресу, и html_url
@@ -11,7 +12,7 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -29,6 +30,16 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
export const STATUS_START = '<!-- android-status -->';
export const STATUS_END = '<!-- /android-status -->';
export const DESKTOP_PLATFORMS = ['linux', 'windows'];
/** Сколько последних опубликованных релизов оставляем с файлами (APK, .deb, NSIS). */
export const KEEP_RELEASE_FILES = 8;
/** Сколько последних прогонов Actions оставляем (логи/статусы). */
export const KEEP_ACTION_RUNS = 8;
export function desktopStatusMarks(platform) {
if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`);
return { start: `<!-- desktop-${platform}-status -->`, end: `<!-- /desktop-${platform}-status -->` };
}
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
export function groupCommits(commits) {
@@ -76,16 +87,62 @@ export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
}
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
export function replaceAndroidStatus(body, block) {
const start = body.indexOf(STATUS_START);
const end = body.indexOf(STATUS_END);
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
/** Блок статуса сборки десктопа в тексте релиза (отдельный для linux и windows). */
export function renderDesktopStatus({ platform, status, runUrl, files }) {
const label = platform === 'windows' ? 'Windows' : 'Linux';
const pending = platform === 'windows' ? 'NSIS-установщик' : '.deb';
const lines =
status === 'success'
? [
`✅ **Десктоп (${label}):** собрано — ${files.map((f) => `\`${f.name}\` (${formatSize(f.size)})`).join(', ')}.`,
]
: status === 'cancelled'
? [
`⏹ **Десктоп (${label}):** сборка отменена — [лог сборки](${runUrl}).`,
'Файлы появятся здесь после перезапуска сборки.',
]
: status === 'failure'
? [
`❌ **Десктоп (${label}):** сборка не удалась — [лог сборки](${runUrl}).`,
'Остальные клиенты от этого не зависят; установщик появится после перезапуска сборки.',
]
: [
`⏳ **Десктоп (${label}):** собирается — [ход сборки](${runUrl}).`,
`${pending} появится в этом релизе автоматически.`,
];
const { start, end } = desktopStatusMarks(platform);
return [start, ...lines.map((l) => `> ${l}`), end].join('\n');
}
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
function replaceBlock(body, startMark, endMark, block) {
const start = body.indexOf(startMark);
const end = body.indexOf(endMark);
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
return body.slice(0, start) + block + body.slice(end + endMark.length);
}
/** Заменяет блок статуса Android в тексте релиза (или дописывает его в начало, если блока нет). */
export function replaceAndroidStatus(body, block) {
return replaceBlock(body, STATUS_START, STATUS_END, block);
}
export function replaceDesktopStatus(body, platform, block) {
const { start, end } = desktopStatusMarks(platform);
return replaceBlock(body, start, end, block);
}
/** Текст релиза: статусы сборок, изменения, обновление сервера, установка клиентов. */
export function renderNotes({
version,
tag,
prevTag,
prerelease,
repoUrl,
groups,
androidStatus,
desktopLinuxStatus,
desktopWindowsStatus,
}) {
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
const out = [];
@@ -93,7 +150,10 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
if (prerelease) {
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
}
out.push(androidStatus, '', '## Что изменилось', '');
out.push(androidStatus, '');
if (desktopLinuxStatus) out.push(desktopLinuxStatus, '');
if (desktopWindowsStatus) out.push(desktopWindowsStatus, '');
out.push('## Что изменилось', '');
if (groups.breaking.length) {
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
}
@@ -124,11 +184,14 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
'',
`Ожидается \`"version":"${version}"\`.`,
'',
'## Android',
'## Клиенты',
'',
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
'Скачайте с этой страницы или с `/download` на сервере (подскажет сборку под вашу систему).',
'',
`- **Android:** \`pipu-music-${version}.apk\` — откройте на телефоне; Android попросит разрешить установку из этого источника.`,
`- **Windows:** \`pipu-music-${version}-windows-setup.exe\`.`,
`- **Linux:** \`pipu-music-${version}-linux.deb\` — \`sudo apt install ./pipu-music-${version}-linux.deb\` (нужны WebKitGTK 4.1 и GStreamer).`,
`- **Проверка APK:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
'',
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
'',
@@ -136,6 +199,35 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
return out.join('\n');
}
function publishedAt(item) {
return Date.parse(item.published_at || item.created_at || item.started_at || 0);
}
/** Релизы, с которых снимаем вложения: черновики не трогаем, текущий тег всегда оставляем. */
export function staleReleases(releases, { keep = KEEP_RELEASE_FILES, currentTag } = {}) {
const published = releases.filter((r) => !r.draft);
published.sort((a, b) => publishedAt(b) - publishedAt(a));
const keepIds = new Set(published.slice(0, keep).map((r) => r.id));
const current = published.find((r) => r.tag_name === currentTag);
if (current) keepIds.add(current.id);
return published.filter((r) => !keepIds.has(r.id) && (r.assets?.length ?? 0) > 0);
}
/** Завершённые прогоны Actions, которые можно удалить (текущий прогон не трогаем). */
export function staleActionRuns(runs, { keep = KEEP_ACTION_RUNS, currentRunId } = {}) {
const done = runs.filter((r) => r.status === 'completed' && String(r.id) !== String(currentRunId ?? ''));
done.sort((a, b) => publishedAt(b) - publishedAt(a));
const keepIds = new Set(done.slice(0, keep).map((r) => r.id));
return done.filter((r) => !keepIds.has(r.id));
}
function actionRunsFrom(body) {
if (Array.isArray(body)) return body;
if (Array.isArray(body?.workflow_runs)) return body.workflow_runs;
if (Array.isArray(body?.runs)) return body.runs;
return [];
}
// ---------------------------------------------------------------------------------------------------------
// CLI
@@ -202,6 +294,8 @@ async function create() {
repoUrl,
groups: groupCommits(commitsBetween(prevTag, tag)),
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl }),
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl }),
});
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
const saved = release
@@ -210,6 +304,41 @@ async function create() {
console.log(`${release ? 'обновлён' : 'создан'} релиз ${saved.html_url}`);
}
async function listPages(path, itemsOf) {
const all = [];
for (let page = 1; page <= 40; page += 1) {
const sep = path.includes('?') ? '&' : '?';
const body = await api('GET', `${path}${sep}limit=50&page=${page}`);
const batch = itemsOf(body);
if (!batch.length) break;
all.push(...batch);
if (batch.length < 50) break;
}
return all;
}
async function prune() {
const tag = process.env.TAG;
const currentRunId = process.env.GITHUB_RUN_ID;
const releases = await listPages('/releases', (body) => (Array.isArray(body) ? body : []));
let files = 0;
for (const rel of staleReleases(releases, { currentTag: tag })) {
for (const a of rel.assets ?? []) {
await api('DELETE', `/releases/${rel.id}/assets/${a.id}`);
files += 1;
console.log(`удалён файл ${rel.tag_name}/${a.name}`);
}
}
const runs = await listPages('/actions/runs', actionRunsFrom);
let statuses = 0;
for (const run of staleActionRuns(runs, { currentRunId })) {
await api('DELETE', `/actions/runs/${run.id}`);
statuses += 1;
console.log(`удалён прогон Actions ${run.id} (${run.path ?? run.name ?? run.event ?? ''})`);
}
console.log(`очистка: файлов ${files}, прогонов ${statuses}`);
}
async function upload(release, file) {
const name = basename(file);
for (const old of release.assets ?? []) {
@@ -242,13 +371,39 @@ async function android(status, apkPath) {
console.log(`статус Android: ${status}`);
}
async function desktop(platform, status, paths) {
const { release, runUrl } = await context();
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
const files = [];
if (status === 'success') {
if (!paths.length) throw new Error('desktop success: укажите файлы');
for (const file of paths) {
const data = readFileSync(file);
files.push({ name: basename(file), size: data.length });
const sum = `${file}.sha256`;
if (!existsSync(sum)) writeFileSync(sum, `${createHash('sha256').update(data).digest('hex')}\n`);
await upload(release, file);
if (existsSync(sum)) await upload(release, sum);
}
}
const block = renderDesktopStatus({ platform, status, runUrl, files });
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceDesktopStatus(release.body ?? '', platform, block) } });
console.log(`статус десктопа (${platform}): ${status}`);
}
async function main() {
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'create') return create();
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
return android(args[0], args[1]);
}
throw new Error('использование: release.mjs create | android building|failure|cancelled | android success <file.apk>');
if (cmd === 'desktop' && DESKTOP_PLATFORMS.includes(args[0]) && ['building', 'success', 'failure', 'cancelled'].includes(args[1])) {
return desktop(args[0], args[1], args.slice(2));
}
if (cmd === 'prune') return prune();
throw new Error(
'использование: release.mjs create | android building|failure|cancelled | android success <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...> | prune',
);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
+70
View File
@@ -4,8 +4,13 @@ import { test } from 'node:test';
import {
groupCommits,
renderAndroidStatus,
renderDesktopStatus,
renderNotes,
replaceAndroidStatus,
replaceDesktopStatus,
desktopStatusMarks,
staleActionRuns,
staleReleases,
STATUS_END,
STATUS_START,
} from './release.mjs';
@@ -77,6 +82,34 @@ test('adds a status block when the body has none', () => {
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
});
test('desktop status blocks', () => {
const linuxMarks = desktopStatusMarks('linux');
const winMarks = desktopStatusMarks('windows');
const building = renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' });
assert.ok(building.startsWith(linuxMarks.start) && building.endsWith(linuxMarks.end));
assert.match(building, /Десктоп \(Linux\):.*собирается — \[ход сборки\]\(https:\/\/run\)/);
const ok = renderDesktopStatus({
platform: 'linux',
status: 'success',
files: [{ name: 'pipu-music-1.4.0-linux.deb', size: 4 * 1024 * 1024 }],
});
assert.match(ok, /`pipu-music-1\.4\.0-linux\.deb` \(4,0 МБ\)/);
assert.equal(replaceDesktopStatus(`a\n\n${building}\n\nb`, 'linux', ok).includes(building), false);
const winBuilding = renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' });
assert.ok(winBuilding.startsWith(winMarks.start) && winBuilding.endsWith(winMarks.end));
const winOk = renderDesktopStatus({
platform: 'windows',
status: 'success',
files: [{ name: 'pipu-music-1.4.0-windows-setup.exe', size: 12 * 1024 * 1024 }],
});
const both = `${building}\n\n${winBuilding}`;
const patched = replaceDesktopStatus(both, 'windows', winOk);
assert.match(patched, /Linux/);
assert.match(patched, /windows-setup\.exe/);
assert.doesNotMatch(patched, /Десктоп \(Windows\):.*собирается/);
});
test('release notes', () => {
const notes = renderNotes({
version: '0.2.0',
@@ -86,16 +119,53 @@ test('release notes', () => {
repoUrl,
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' }),
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' }),
});
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
assert.match(notes, /\.\/update\.sh v0\.2\.0/);
assert.match(notes, /"version":"0\.2\.0"/);
assert.match(notes, /pipu-music-0\.2\.0\.apk/);
assert.match(notes, /pipu-music-0\.2\.0-windows-setup\.exe/);
assert.match(notes, /pipu-music-0\.2\.0-linux\.deb/);
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
assert.doesNotMatch(notes, /Предварительная версия/);
});
test('keeps files on the newest eight releases', () => {
const releases = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
tag_name: `v1.0.${i}`,
draft: false,
published_at: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
assets: i === 0 ? [] : [{ id: 100 + i, name: `a${i}.apk` }],
}));
const stale = staleReleases(releases, { keep: 8, currentTag: 'v1.0.9' });
assert.deepEqual(
stale.map((r) => r.tag_name).sort(),
['v1.0.1'],
);
});
test('does not delete the current Actions run or unfinished jobs', () => {
const runs = [
{ id: 20, status: 'completed', created_at: '2026-01-20T00:00:00Z' },
{ id: 19, status: 'in_progress', created_at: '2026-01-19T00:00:00Z' },
...Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
status: 'completed',
created_at: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
})),
];
const stale = staleActionRuns(runs, { keep: 8, currentRunId: 20 });
assert.equal(stale.some((r) => r.id === 20 || r.id === 19), false);
assert.deepEqual(
stale.map((r) => r.id).sort((a, b) => a - b),
[1, 2, 3, 4],
);
});
test('release notes for a first prerelease without changes', () => {
const notes = renderNotes({
version: '0.1.0-rc.1',
+5
View File
@@ -21,6 +21,7 @@ import (
"pipu/api/internal/appupdate"
"pipu/api/internal/auth"
"pipu/api/internal/buildinfo"
"pipu/api/internal/genius"
"pipu/api/internal/library"
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/db"
@@ -105,6 +106,8 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
})
libStore := library.NewStore(pool)
geniusSvc := genius.NewService(pool, cfg.GeniusAccessToken, cfg.GeniusRequestInterval, log)
go geniusSvc.Run(ctx)
board := library.NewProgressBoard()
scanner := library.NewScanner(libStore, prober, board, log)
agent := library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken)
@@ -132,6 +135,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
covers.Run(ctx)
indexer.Trigger()
fingerprints.Trigger(ctx)
geniusSvc.Wake()
}
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
fingerprints.Trigger(ctx)
@@ -168,6 +172,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log)
wh.Warm = pb.Prewarm
return server.NewRouter(server.Deps{
Genius: genius.NewHandler(geniusSvc),
Log: log,
System: sys,
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
+39
View File
@@ -0,0 +1,39 @@
// Command genius searches metadata or retrieves lyrics from an explicitly selected Genius URL.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"pipu/api/internal/genius"
)
func main() {
query := flag.String("search", "", "search candidates by artist and title (requires GENIUS_ACCESS_TOKEN)")
page := flag.String("url", "", "retrieve lyrics from a selected https://genius.com/...-lyrics URL")
flag.Parse()
if (*query == "") == (*page == "") || flag.NArg() != 0 {
fmt.Fprintln(os.Stderr, "provide exactly one of -search or -url")
os.Exit(2)
}
client := genius.New(os.Getenv("GENIUS_ACCESS_TOKEN"))
var result any
var err error
if *query != "" {
result, err = client.Search(context.Background(), *query)
} else {
result, err = client.FetchLyrics(context.Background(), *page)
}
if err == nil {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
err = encoder.Encode(result)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+1
View File
@@ -17,6 +17,7 @@ require (
github.com/redis/go-redis/v9 v9.10.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
golang.org/x/sys v0.33.0
golang.org/x/text v0.25.0
)
File diff suppressed because it is too large. Load diff
+371 -72
View File
@@ -1,5 +1,5 @@
// Package appupdate — самообновление Android-приложения через сервер (docs/mobile.md#самообновление):
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK.
// Package appupdate — раздача клиентов через сервер (docs/mobile.md#самообновление):
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и файлы.
package appupdate
import (
@@ -15,6 +15,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
@@ -25,11 +26,14 @@ import (
"pipu/api/internal/platform/httpx"
)
// CodeUnavailable — обновления не настроены или релиза с APK нет.
// CodeUnavailable — обновления не настроены или релиза с клиентами нет.
const CodeUnavailable = "app.update_unavailable"
const cacheTTL = 10 * time.Minute
// keepClientReleases — сколько последних релизов с файлами отдаём на /download (как prune в CI).
const keepClientReleases = 8
var tagRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$`)
// VersionCode — versionCode по тегу; та же формула, что в scripts/version.mjs:
@@ -68,19 +72,30 @@ type asset struct {
}
type release struct {
Tag string `json:"tag_name"`
Body string `json:"body"`
Assets []asset `json:"assets"`
Tag string `json:"tag_name"`
Body string `json:"body"`
Draft bool `json:"draft"`
PublishedAt time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
Assets []asset `json:"assets"`
}
// Latest — сведения о последней версии.
type clientFile struct {
Kind, Name, URL, ChecksumURL, SHA256 string
Size int64
}
// Latest — сведения о последней версии и файлах клиентов.
type Latest struct {
Version string
VersionCode int
Notes string
Size int64
SHA256 string
apkURL string
Files map[string]clientFile
}
func (l *Latest) android() (clientFile, bool) {
f, ok := l.Files["android"]
return f, ok && f.SHA256 != ""
}
// Handler реализует app-часть gen.StrictServerInterface.
@@ -90,9 +105,14 @@ type Handler struct {
log *slog.Logger
mu sync.Mutex
cached *Latest
cached *catalog
cachedAt time.Time
dl sync.Mutex // одна загрузка APK за раз
dl sync.Mutex
}
type catalog struct {
latest *Latest
all []*Latest
}
// NewHandler создаёт обработчик.
@@ -100,7 +120,6 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler {
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
}
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
func (h *Handler) configured() bool {
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
@@ -125,8 +144,71 @@ func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
return res, nil
}
// latest — последний релиз с APK (кэш на 10 минут).
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
func classifyAsset(name string) string {
n := strings.ToLower(name)
if strings.HasSuffix(n, ".sha256") {
return ""
}
switch {
case strings.HasSuffix(n, ".apk"):
return "android"
case strings.HasSuffix(n, ".exe"), strings.HasSuffix(n, ".msi"):
return "windows"
case strings.HasSuffix(n, ".appimage"), strings.HasSuffix(n, ".deb"), strings.HasSuffix(n, ".rpm"):
return "linux"
case strings.HasSuffix(n, ".dmg"), strings.Contains(n, ".app.tar"):
return "macos"
default:
return ""
}
}
func assetRank(kind, name string) int {
n := strings.ToLower(name)
switch kind {
case "windows":
if strings.Contains(n, "setup") {
return 2
}
return 1
case "linux":
switch {
case strings.HasSuffix(n, ".deb"):
return 3
case strings.HasSuffix(n, ".rpm"):
return 2
default:
return 1
}
default:
return 1
}
}
func pickClientFiles(assets []asset) map[string]clientFile {
sums := map[string]string{}
for _, a := range assets {
if strings.HasSuffix(strings.ToLower(a.Name), ".sha256") {
sums[strings.TrimSuffix(a.Name, ".sha256")] = a.URL
sums[strings.TrimSuffix(a.Name, ".SHA256")] = a.URL
}
}
out := map[string]clientFile{}
for _, a := range assets {
kind := classifyAsset(a.Name)
if kind == "" {
continue
}
cur, ok := out[kind]
if ok && assetRank(kind, a.Name) <= assetRank(kind, cur.Name) {
continue
}
out[kind] = clientFile{Kind: kind, Name: a.Name, URL: a.URL, Size: a.Size, ChecksumURL: sums[a.Name]}
}
return out
}
func (h *Handler) catalog(ctx context.Context) (*catalog, error) {
if !h.configured() {
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
}
@@ -138,53 +220,213 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
base := strings.TrimRight(h.cfg.GiteaURL, "/")
res, err := h.get(ctx, base+"/api/v1/repos/"+h.cfg.Repo+"/releases/latest")
repo := h.cfg.Repo
rels, err := h.listReleases(ctx, base, repo)
if err != nil {
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
if h.cached != nil {
return h.cached, nil // устаревший ответ лучше, чем никакого
return h.cached, nil
}
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
}
var rel release
err = json.NewDecoder(res.Body).Decode(&rel)
_ = res.Body.Close()
if err != nil {
return nil, fmt.Errorf("gitea: decode release: %w", err)
}
version, code, err := VersionCode(rel.Tag)
if err != nil {
return nil, apperr.NotFound(CodeUnavailable, err.Error())
}
name := "pipu-music-" + version + ".apk"
var apk, sum *asset
for i := range rel.Assets {
switch rel.Assets[i].Name {
case name:
apk = &rel.Assets[i]
case name + ".sha256":
sum = &rel.Assets[i]
var all []*Latest
for _, rel := range rels {
if rel.Draft {
continue
}
l := h.parseRelease(ctx, rel)
if l == nil {
continue
}
all = append(all, l)
if len(all) >= keepClientReleases {
break
}
}
if apk == nil || sum == nil {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
if len(all) == 0 {
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
}
sres, err := h.get(ctx, sum.URL)
cat := &catalog{latest: all[0], all: all}
h.cached = cat
h.cachedAt = time.Now()
return cat, nil
}
func (h *Handler) listReleases(ctx context.Context, base, repo string) ([]release, error) {
res, err := h.get(ctx, base+"/api/v1/repos/"+repo+"/releases?limit=20")
if err == nil {
var rels []release
decErr := json.NewDecoder(res.Body).Decode(&rels)
_ = res.Body.Close()
if decErr != nil {
return nil, fmt.Errorf("gitea: decode releases: %w", decErr)
}
sort.SliceStable(rels, func(i, j int) bool {
ti, tj := rels[i].PublishedAt, rels[j].PublishedAt
if ti.IsZero() {
ti = rels[i].CreatedAt
}
if tj.IsZero() {
tj = rels[j].CreatedAt
}
return ti.After(tj)
})
return rels, nil
}
res, err = h.get(ctx, base+"/api/v1/repos/"+repo+"/releases/latest")
if err != nil {
return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable")
return nil, err
}
var rel release
decErr := json.NewDecoder(res.Body).Decode(&rel)
_ = res.Body.Close()
if decErr != nil {
return nil, fmt.Errorf("gitea: decode release: %w", decErr)
}
return []release{rel}, nil
}
func (h *Handler) parseRelease(ctx context.Context, rel release) *Latest {
version, code, err := VersionCode(rel.Tag)
if err != nil {
return nil
}
files := pickClientFiles(rel.Assets)
if len(files) == 0 {
return nil
}
if apk, ok := files["android"]; ok {
if apk.ChecksumURL == "" {
delete(files, "android")
} else if digest, err := h.readDigest(ctx, apk.ChecksumURL); err != nil {
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err, "version", version)
delete(files, "android")
} else {
apk.SHA256 = digest
files["android"] = apk
}
}
if len(files) == 0 {
return nil
}
return &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
}
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
cat, err := h.catalog(ctx)
if err != nil {
return nil, err
}
return cat.latest, nil
}
func (c *catalog) release(version string) *Latest {
for _, l := range c.all {
if l.Version == version {
return l
}
}
return nil
}
func (c *catalog) keepNames() map[string]struct{} {
out := map[string]struct{}{}
for _, l := range c.all {
for _, f := range l.Files {
out[f.Name] = struct{}{}
}
}
return out
}
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
sres, err := h.get(ctx, url)
if err != nil {
return "", err
}
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
_ = sres.Body.Close()
if err != nil {
return nil, err
return "", err
}
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
if len(digest) != 64 {
return nil, apperr.NotFound(CodeUnavailable, "malformed checksum")
return "", fmt.Errorf("malformed checksum")
}
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Size: apk.Size, SHA256: digest, apkURL: apk.URL}
h.cachedAt = time.Now()
return h.cached, nil
return digest, nil
}
func (h *Handler) fileURL(kind, version string) string {
return "/api/v1/app/download/" + kind + "/" + version
}
// GetAppClients реализует gen.StrictServerInterface.
func (h *Handler) GetAppClients(ctx context.Context, _ gen.GetAppClientsRequestObject) (gen.GetAppClientsResponseObject, error) {
cat, err := h.catalog(ctx)
if err != nil {
return nil, err
}
l := cat.latest
order := []string{"windows", "macos", "linux", "android"}
clients := make([]gen.AppClientFile, 0, len(order))
for _, kind := range order {
item, ok := h.clientWithOlder(cat, kind)
if !ok {
continue
}
clients = append(clients, item)
}
if len(clients) == 0 {
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
}
return gen.GetAppClients200JSONResponse{
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients,
}, nil
}
func (h *Handler) clientWithOlder(cat *catalog, kind string) (gen.AppClientFile, bool) {
var head *Latest
var file clientFile
var older []gen.AppClientBuild
for _, rel := range cat.all {
f, ok := rel.Files[kind]
if !ok {
continue
}
if head == nil {
head, file = rel, f
continue
}
older = append(older, gen.AppClientBuild{
Version: rel.Version,
Filename: f.Name,
Size: f.Size,
DownloadUrl: h.fileURL(kind, rel.Version),
Sha256: shaPtr(f.SHA256),
})
}
if head == nil {
return gen.AppClientFile{}, false
}
item := gen.AppClientFile{
Client: gen.AppClient(kind),
Version: &head.Version,
Filename: file.Name,
Size: file.Size,
DownloadUrl: h.fileURL(kind, head.Version),
Sha256: shaPtr(file.SHA256),
}
if len(older) > 0 {
item.Older = &older
}
return item, true
}
func shaPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
// GetAndroidLatest реализует gen.StrictServerInterface.
@@ -193,15 +435,18 @@ func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRe
if err != nil {
return nil, err
}
f, ok := l.android()
if !ok {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
}
return gen.GetAndroidLatest200JSONResponse{
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: l.Size, Sha256: l.SHA256,
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: f.Size, Sha256: f.SHA256,
DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported,
}, nil
}
// ensure скачивает APK в кэш (если его там нет) и сверяет SHA-256 из релиза.
func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
dst := filepath.Join(h.cfg.Dir, "pipu-music-"+l.Version+".apk")
func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) {
dst := filepath.Join(h.cfg.Dir, f.Name)
if _, err := os.Stat(dst); err == nil {
return dst, nil
}
@@ -213,12 +458,12 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil {
return "", err
}
res, err := h.get(ctx, l.apkURL)
res, err := h.get(ctx, f.URL)
if err != nil {
return "", err
}
defer func() { _ = res.Body.Close() }()
tmp, err := os.CreateTemp(h.cfg.Dir, ".apk-*")
tmp, err := os.CreateTemp(h.cfg.Dir, ".dl-*")
if err != nil {
return "", err
}
@@ -231,51 +476,101 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
if err := tmp.Close(); err != nil {
return "", err
}
if got := hex.EncodeToString(hash.Sum(nil)); got != l.SHA256 {
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, l.SHA256)
if f.SHA256 != "" {
if got := hex.EncodeToString(hash.Sum(nil)); got != f.SHA256 {
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, f.SHA256)
}
}
if err := os.Rename(tmp.Name(), dst); err != nil {
return "", err
}
h.cleanup(dst)
h.cleanup(f.Name)
return dst, nil
}
// cleanup оставляет в кэше только текущий APK.
func (h *Handler) cleanup(keep string) {
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*.apk"))
for _, m := range matches {
if m != keep {
_ = os.Remove(m)
func (h *Handler) cleanup(keepName string) {
keep := map[string]struct{}{keepName: {}}
h.mu.Lock()
if h.cached != nil {
for name := range h.cached.keepNames() {
keep[name] = struct{}{}
}
}
h.mu.Unlock()
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
for _, m := range matches {
if _, ok := keep[filepath.Base(m)]; ok {
continue
}
_ = os.Remove(m)
}
}
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
func versionFromName(name string) string {
m := regexp.MustCompile(`(\d+\.\d+\.\d+(?:-rc\.\d+)?)`).FindStringSubmatch(name)
if m == nil {
return ""
}
return m[1]
}
func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileResponse, error) {
f, ok := l.Files[kind]
if !ok {
return fileResponse{}, apperr.NotFound(CodeUnavailable, "client build is unavailable")
}
path, err := h.ensure(ctx, f)
if err != nil {
h.log.ErrorContext(ctx, "app update: download failed", "err", err, "client", kind)
return fileResponse{}, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the file")
}
return fileResponse{path: path, name: f.Name, req: httpx.RequestFrom(ctx)}, nil
}
// DownloadAndroidApk реализует gen.StrictServerInterface. Версия должна быть в каталоге (до 8 релизов).
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
l, err := h.latest(ctx)
cat, err := h.catalog(ctx)
if err != nil {
return nil, err
}
if req.Version != l.Version {
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
l := cat.release(req.Version)
if l == nil {
return nil, apperr.NotFound(CodeUnavailable, "this version is no longer available")
}
path, err := h.ensure(ctx, l)
if err != nil {
h.log.ErrorContext(ctx, "app update: apk download failed", "err", err)
return nil, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the APK")
if _, ok := l.android(); !ok {
return nil, apperr.NotFound(CodeUnavailable, "client build is unavailable")
}
return apkResponse{path: path, name: filepath.Base(path), req: httpx.RequestFrom(ctx)}, nil
return h.serveFile(ctx, l, "android")
}
type apkResponse struct {
// DownloadAppClient реализует gen.StrictServerInterface.
func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, error) {
cat, err := h.catalog(ctx)
if err != nil {
return nil, err
}
l := cat.release(req.Version)
if l == nil {
return nil, apperr.NotFound(CodeUnavailable, "this version is no longer available")
}
return h.serveFile(ctx, l, string(req.Client))
}
type fileResponse struct {
path string
name string
req *http.Request
}
// VisitDownloadAndroidApkResponse реализует gen.DownloadAndroidApkResponseObject (Range — для докачки).
func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
func (a fileResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
return a.write(w)
}
func (a fileResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error {
return a.write(w)
}
func (a fileResponse) write(w http.ResponseWriter) error {
f, err := os.Open(a.path)
if err != nil {
return err
@@ -288,7 +583,11 @@ func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) erro
if a.req == nil {
return errors.New("appupdate: request missing in context")
}
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
ctype := "application/octet-stream"
if strings.HasSuffix(strings.ToLower(a.name), ".apk") {
ctype = "application/vnd.android.package-archive"
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`)
http.ServeContent(w, a.req, a.name, info.ModTime(), f)
return nil
@@ -10,12 +10,31 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
)
func TestPickClientFiles(t *testing.T) {
files := pickClientFiles([]asset{
{Name: "pipu-music-1.3.0.apk", Size: 10},
{Name: "pipu-music-1.3.0.apk.sha256", Size: 65, URL: "https://gitea/sum"},
{Name: "pipu-music_1.3.0_x64-setup.exe", Size: 20},
{Name: "pipu-music_1.3.0_amd64.deb", Size: 11},
{Name: "pipu-music_1.3.0_amd64.AppImage", Size: 30},
{Name: "pipu-music_1.3.0_aarch64.dmg", Size: 40},
})
assert.Equal(t, "pipu-music-1.3.0.apk", files["android"].Name)
assert.Equal(t, "https://gitea/sum", files["android"].ChecksumURL)
assert.Equal(t, "pipu-music_1.3.0_x64-setup.exe", files["windows"].Name)
assert.Equal(t, "pipu-music_1.3.0_amd64.deb", files["linux"].Name)
assert.Equal(t, "pipu-music_1.3.0_aarch64.dmg", files["macos"].Name)
}
func TestVersionCode(t *testing.T) {
cases := map[string]int{"v0.6.1": 60_199, "1.2.0-rc.3": 1_020_003, "v10.0.0": 10_000_099}
for tag, want := range cases {
@@ -35,19 +54,30 @@ func TestLatestAndDownload(t *testing.T) {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "token secret", r.Header.Get("Authorization"))
assets := []map[string]any{
{"name": "pipu-music-0.6.1.apk", "size": len(apk), "browser_download_url": srv.URL + "/apk"},
{"name": "pipu-music-0.6.1.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum"},
}
switch r.URL.Path {
case "/api/v1/repos/o/r/releases":
_ = json.NewEncoder(w).Encode([]map[string]any{
{"tag_name": "v0.6.1", "body": "## Новое", "draft": false, "published_at": "2026-01-02T00:00:00Z", "assets": assets},
{"tag_name": "v0.6.0", "body": "", "draft": false, "published_at": "2026-01-01T00:00:00Z", "assets": []map[string]any{
{"name": "pipu-music-0.6.0.apk", "size": 8, "browser_download_url": srv.URL + "/apk-old"},
{"name": "pipu-music-0.6.0.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum-old"},
{"name": "pipu-music-0.6.0-windows-setup.exe", "size": 9, "browser_download_url": srv.URL + "/exe-old"},
}},
})
case "/api/v1/repos/o/r/releases/latest":
_ = json.NewEncoder(w).Encode(map[string]any{
"tag_name": "v0.6.1", "body": "## Новое",
"assets": []map[string]any{
{"name": "pipu-music-0.6.1.apk", "size": len(apk), "browser_download_url": srv.URL + "/apk"},
{"name": "pipu-music-0.6.1.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum"},
},
"tag_name": "v0.6.1", "body": "## Новое", "assets": assets,
})
case "/apk":
_, _ = w.Write(apk)
case "/sum":
_, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + "\n"))
case "/sum-old":
_, _ = w.Write([]byte(strings.Repeat("ab", 32) + "\n"))
default:
http.NotFound(w, r)
}
@@ -60,18 +90,38 @@ func TestLatestAndDownload(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "0.6.1", l.Version)
assert.Equal(t, 60_199, l.VersionCode)
assert.Equal(t, hex.EncodeToString(sum[:]), l.SHA256)
android := l.Files["android"]
assert.Equal(t, hex.EncodeToString(sum[:]), android.SHA256)
path, err := h.ensure(context.Background(), l)
path, err := h.ensure(context.Background(), android)
require.NoError(t, err)
got, _ := os.ReadFile(path)
assert.Equal(t, apk, got)
// Подменённый файл не пройдёт сверку.
require.NoError(t, os.Remove(path))
l.SHA256 = "00" + l.SHA256[2:]
_, err = h.ensure(context.Background(), l)
android.SHA256 = "00" + android.SHA256[2:]
_, err = h.ensure(context.Background(), android)
assert.ErrorContains(t, err, "checksum mismatch")
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
assert.True(t, os.IsNotExist(statErr))
out, err := h.GetAppClients(context.Background(), gen.GetAppClientsRequestObject{})
require.NoError(t, err)
body := out.(gen.GetAppClients200JSONResponse)
var androidClient, windowsClient *gen.AppClientFile
for i := range body.Clients {
switch body.Clients[i].Client {
case gen.AppClientAndroid:
androidClient = &body.Clients[i]
case gen.AppClientWindows:
windowsClient = &body.Clients[i]
}
}
require.NotNil(t, androidClient)
require.NotNil(t, androidClient.Older)
assert.Equal(t, "0.6.0", (*androidClient.Older)[0].Version)
require.NotNil(t, windowsClient)
assert.Contains(t, windowsClient.Filename, "0.6.0")
assert.True(t, windowsClient.Older == nil || len(*windowsClient.Older) == 0)
}
+1
View File
@@ -566,5 +566,6 @@ func TestPublicRoutes(t *testing.T) {
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/clients", nil).Code, "download page is public")
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
}
+4 -2
View File
@@ -55,8 +55,9 @@ var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true,
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
// Клиенты раздаются по ссылке /download без входа: в файлах нет ничего, кроме приложения.
"GET /api/v1/app/android/latest": true,
"GET /api/v1/app/clients": true,
}
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
@@ -93,7 +94,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/"))
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx))
return
+176
View File
@@ -0,0 +1,176 @@
// Package genius retrieves song metadata from the API and plain lyrics from public HTML.
package genius
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
var ErrLyricsUnavailable = errors.New("genius: lyrics absent or page markup unsupported")
// HTTPError preserves throttling information without exposing response bodies or credentials.
type HTTPError struct {
StatusCode int
RetryAfter string
}
func (e *HTTPError) Error() string { return fmt.Sprintf("genius: HTTP %d", e.StatusCode) }
type Artist struct {
ID int64 `json:"id"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
}
type Song struct {
ID int64 `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
ImageURL string `json:"song_art_image_url"`
ThumbURL string `json:"song_art_image_thumbnail_url"`
HeaderURL string `json:"header_image_url"`
Artist Artist `json:"primary_artist"`
Description struct {
Plain string `json:"plain"`
} `json:"description"`
Album struct {
Name string `json:"name"`
ImageURL string `json:"cover_art_url"`
ThumbURL string `json:"cover_art_thumbnail_url"`
} `json:"album"`
}
type Lyrics struct {
Text string `json:"text"`
SourceURL string `json:"source_url"`
FetchedAt time.Time `json:"fetched_at"`
}
type Client struct {
token string
http *http.Client
mu sync.Mutex
next time.Time
interval time.Duration
}
// SetInterval must be called before sharing the client with workers.
func (c *Client) SetInterval(d time.Duration) { c.interval = max(d, time.Second) }
func New(token string) *Client {
return &Client{token: strings.TrimSpace(token), http: &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("genius: too many redirects")
}
// Do not forward credentials or fetch content outside the initial host.
if req.URL.Scheme != "https" || req.URL.Host != via[0].URL.Host || req.URL.User != nil {
return errors.New("genius: refused redirect")
}
return nil
},
}}
}
// Search returns candidates; callers must select a version rather than assume the first hit matches.
func (c *Client) Search(ctx context.Context, query string) ([]Song, error) {
if c.token == "" {
return nil, errors.New("genius: GENIUS_ACCESS_TOKEN is required for search")
}
if strings.TrimSpace(query) == "" {
return nil, errors.New("genius: empty search query")
}
body, err := c.get(ctx, "https://api.genius.com/search?q="+url.QueryEscape(query), true)
if err != nil {
return nil, err
}
var result struct {
Response struct {
Hits []struct {
Type string `json:"type"`
Result Song `json:"result"`
} `json:"hits"`
} `json:"response"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("genius: decode search: %w", err)
}
songs := make([]Song, 0, len(result.Response.Hits))
for _, hit := range result.Response.Hits {
if hit.Type == "song" {
songs = append(songs, hit.Result)
}
}
return songs, nil
}
// FetchLyrics accepts a selected public song URL. The token is never sent to the HTML site.
func (c *Client) FetchLyrics(ctx context.Context, rawURL string) (Lyrics, error) {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme != "https" || u.Host != "genius.com" || u.User != nil || !strings.HasSuffix(u.Path, "-lyrics") {
return Lyrics{}, errors.New("genius: expected https://genius.com/<song>-lyrics")
}
u.RawQuery, u.Fragment = "", ""
body, err := c.get(ctx, u.String(), false)
if err != nil {
return Lyrics{}, err
}
text, err := parseLyrics(body)
if err != nil {
return Lyrics{}, err
}
return Lyrics{Text: text, SourceURL: u.String(), FetchedAt: time.Now().UTC()}, nil
}
func (c *Client) get(ctx context.Context, endpoint string, api bool) ([]byte, error) {
c.mu.Lock()
wait := time.Until(c.next)
c.next = time.Now().Add(max(wait, 0) + c.interval)
c.mu.Unlock()
if wait > 0 {
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "PipuMusic/1.0")
if api {
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
} else {
req.Header.Set("Accept", "text/html")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("genius: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &HTTPError{StatusCode: resp.StatusCode, RetryAfter: resp.Header.Get("Retry-After")}
}
const maxBody = 8 << 20
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
if err != nil {
return nil, err
}
if len(body) > maxBody {
return nil, errors.New("genius: response exceeds 8 MiB")
}
return body, nil
}
@@ -0,0 +1,92 @@
package genius
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type transportFunc func(*http.Request) (*http.Response, error)
func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestLyricsFormatting(t *testing.T) {
text, err := parseLyrics([]byte(`<header>Not lyrics</header><div data-lyrics-container="true"><div class="LyricsHeader-x">Title</div>[Куплет 1]<br>Привет &amp; <a href="/annotation">мир</a><br><br>Next <b>line</b><span data-exclude-from-selection="true">Embed</span><script>bad()</script></div><div data-lyrics-container="true">[Припев]<br>Again<button>Share</button></div>`))
require.NoError(t, err)
require.Equal(t, "[Куплет 1]\nПривет & мир\n\nNext line\n\n[Припев]\nAgain", text)
}
func TestUnavailable(t *testing.T) {
for _, page := range []string{`<h1>Just a moment</h1>`, `<div data-lyrics-container="true"> </div>`, `<script>window.__PRELOADED_STATE__ = JSON.parse('broken')</script>`} {
_, err := parseLyrics([]byte(page))
require.ErrorIs(t, err, ErrLyricsUnavailable)
}
}
func TestEmbeddedLyrics(t *testing.T) {
text, err := parseLyrics([]byte(`<script>window.__PRELOADED_STATE__ = JSON.parse('{"songPage":{"lyricsData":{"body":{"html":"<p>[Verse]<br>It\'s \\"fine\\" &amp; good<br>Привет</p>"}}}}')</script>`))
require.NoError(t, err)
require.Equal(t, "[Verse]\nIt's \"fine\" & good\nПривет", text)
}
func TestURLValidation(t *testing.T) {
c := New("secret")
c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network call"); return nil, nil })
for _, u := range []string{"https://evil.test/song-lyrics", "http://genius.com/song-lyrics", "https://genius.com:443/song-lyrics", "https://secret@genius.com/song-lyrics", "https://genius.com/artists/test"} {
_, err := c.FetchLyrics(context.Background(), u)
require.Error(t, err)
}
}
func TestRequests(t *testing.T) {
c := New("secret")
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
body := `<div data-lyrics-container="true">Synthetic test text</div>`
if r.URL.Host == "api.genius.com" {
require.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
require.Equal(t, "artist & title", r.URL.Query().Get("q"))
body = `{"response":{"hits":[{"type":"song","result":{"id":12,"title":"Example","url":"https://genius.com/example-lyrics","primary_artist":{"name":"Artist"}}}]}}`
} else {
require.Empty(t, r.Header.Get("Authorization"))
require.Empty(t, r.URL.RawQuery)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
songs, err := c.Search(context.Background(), "artist & title")
require.NoError(t, err)
require.Len(t, songs, 1)
lyrics, err := c.FetchLyrics(context.Background(), songs[0].URL+"?tracking=1")
require.NoError(t, err)
require.Equal(t, "Synthetic test text", lyrics.Text)
require.Equal(t, songs[0].URL, lyrics.SourceURL)
require.False(t, lyrics.FetchedAt.IsZero())
}
func TestHTTPFailures(t *testing.T) {
for _, status := range []int{401, 403, 404, 429, 500} {
c := New("secret")
c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: status, Header: http.Header{"Retry-After": {"60"}}, Body: io.NopCloser(strings.NewReader("private upstream body"))}, nil
})
_, err := c.Search(context.Background(), "test")
var httpErr *HTTPError
require.True(t, errors.As(err, &httpErr))
require.Equal(t, status, httpErr.StatusCode)
require.Equal(t, "60", httpErr.RetryAfter)
require.NotContains(t, err.Error(), "private")
}
}
func TestRedirects(t *testing.T) {
c := New("secret")
first, _ := http.NewRequest("GET", "https://api.genius.com/search", nil)
for _, endpoint := range []string{"http://api.genius.com/search", "https://genius.com/song-lyrics", "https://evil.test/"} {
req, _ := http.NewRequest("GET", endpoint, nil)
require.Error(t, c.http.CheckRedirect(req, []*http.Request{first}))
}
}
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more