Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83b604437c | ||
|
|
73ad61e7a5 | ||
|
|
fb20258489 | ||
|
|
78c006184b | ||
|
|
d3116aba96 | ||
|
|
b7518aa450 | ||
|
|
eb8031b126 | ||
|
|
e4b841a8d8 | ||
|
|
597067689a | ||
|
|
efe78d7e07 |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
|
||||
# (Linux .deb и Windows NSIS через cargo-xwin на ubuntu-latest).
|
||||
name: ci / desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- '.gitea/workflows/ci-desktop.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- '.gitea/workflows/ci-desktop.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: rust (desktop proxy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
|
||||
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
|
||||
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
# Релиз по тегу 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).
|
||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||
#
|
||||
# Секреты (Settings → Actions → Secrets):
|
||||
@@ -132,9 +133,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 +198,126 @@ 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
|
||||
@@ -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/
|
||||
@@ -1,6 +1,6 @@
|
||||
# pipu-music
|
||||
|
||||
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
|
||||
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
|
||||
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
||||
|
||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||
@@ -15,6 +15,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
|
||||
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
||||
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
||||
| `apps/web` | Веб-клиент (React + Vite) |
|
||||
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
|
||||
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
||||
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
||||
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
||||
|
||||
@@ -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,57 @@ 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.
|
||||
Нет настроек 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 +3061,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 +3578,38 @@ components:
|
||||
like_rate:
|
||||
type: number
|
||||
nullable: true
|
||||
AppClient:
|
||||
type: string
|
||||
enum: [android, windows, linux, macos]
|
||||
AppClientFile:
|
||||
type: object
|
||||
required: [client, filename, size, download_url]
|
||||
properties:
|
||||
client:
|
||||
$ref: "#/components/schemas/AppClient"
|
||||
filename:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
format: int64
|
||||
sha256:
|
||||
type: string
|
||||
download_url:
|
||||
type: string
|
||||
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]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
|
||||
let version = process.env.PIPU_VERSION ?? '0.0.0';
|
||||
if (!process.env.PIPU_VERSION) {
|
||||
try {
|
||||
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
|
||||
version = tag.replace(/^v/, '');
|
||||
} catch {
|
||||
// local untagged build
|
||||
}
|
||||
}
|
||||
conf.version = version;
|
||||
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
|
||||
console.log(`desktop version ${version}`);
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@pipu/desktop",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
|
||||
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles deb",
|
||||
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
|
||||
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
|
||||
"tauri": "tauri",
|
||||
"icons": "node ../../scripts/generate-icons.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "pipu-desktop"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "pipu_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "pipu-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||
axum = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
url = "2"
|
||||
souvlaki = "0.8.3"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["apple-native"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "Only the bundled main window may invoke desktop integration.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "core:event:default"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,157 @@
|
||||
mod media;
|
||||
mod proxy;
|
||||
mod store;
|
||||
|
||||
use media::Media;
|
||||
use proxy::Proxy;
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{
|
||||
image::Image,
|
||||
include_image,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager, State,
|
||||
};
|
||||
|
||||
struct AppState {
|
||||
proxy: Proxy,
|
||||
media: Media,
|
||||
server: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Bootstrap {
|
||||
proxy_base: String,
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
fn show_main(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bootstrap(app: AppHandle, state: State<AppState>) -> Result<Bootstrap, String> {
|
||||
Ok(Bootstrap {
|
||||
proxy_base: state.proxy.base.clone(),
|
||||
server: store::load_server(&app)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_server(app: AppHandle, state: State<'_, AppState>, url: String) -> Result<String, String> {
|
||||
let server = proxy::validate_server(&url)?;
|
||||
state.proxy.check(&server).await?;
|
||||
store::save_server(&app, Some(&server))?;
|
||||
*state.proxy.server.write().map_err(|e| e.to_string())? = Some(server.clone());
|
||||
*state.server.lock().map_err(|e| e.to_string())? = Some(server.clone());
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_server(app: AppHandle, state: State<AppState>) -> Result<(), String> {
|
||||
store::save_server(&app, None)?;
|
||||
store::save_user(&app, None)?;
|
||||
store::save_refresh(None)?;
|
||||
*state.proxy.server.write().map_err(|e| e.to_string())? = None;
|
||||
*state.server.lock().map_err(|e| e.to_string())? = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_get() -> Result<Option<String>, String> {
|
||||
store::load_refresh()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_set(token: String) -> Result<(), String> {
|
||||
store::save_refresh(Some(&token))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_clear() -> Result<(), String> {
|
||||
store::save_refresh(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_get(app: AppHandle) -> Result<Option<String>, String> {
|
||||
store::load_user(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_set(app: AppHandle, json: String) -> Result<(), String> {
|
||||
store::save_user(&app, Some(&json))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_clear(app: AppHandle) -> Result<(), String> {
|
||||
store::save_user(&app, None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn media_set(
|
||||
state: State<AppState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: String,
|
||||
cover_url: Option<String>,
|
||||
playing: bool,
|
||||
) -> Result<(), String> {
|
||||
state.media.set(&title, &artist, &album, cover_url.as_deref(), playing)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.setup(|app| {
|
||||
let saved = store::load_server(app.handle())?;
|
||||
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||
let media = Media::new();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = media.attach(app.handle().clone(), &window);
|
||||
}
|
||||
app.manage(AppState {
|
||||
proxy,
|
||||
media,
|
||||
server: Mutex::new(saved),
|
||||
});
|
||||
|
||||
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&open, &quit])?;
|
||||
let icon: Image = include_image!("icons/32x32.png");
|
||||
TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => show_main(app),
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("desktop app failed to start");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
pipu_desktop_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, WebviewWindow};
|
||||
|
||||
pub struct Media {
|
||||
controls: Mutex<Option<MediaControls>>,
|
||||
}
|
||||
|
||||
impl Media {
|
||||
pub fn new() -> Self {
|
||||
Self { controls: Mutex::new(None) }
|
||||
}
|
||||
|
||||
pub fn attach(&self, app: AppHandle, window: &WebviewWindow) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let hwnd = window.hwnd().ok().map(|h| h.0 as *mut std::ffi::c_void);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let hwnd = {
|
||||
let _ = window;
|
||||
None
|
||||
};
|
||||
let mut controls = MediaControls::new(PlatformConfig {
|
||||
dbus_name: "app.pipu.music.desktop",
|
||||
display_name: "Pipu Music",
|
||||
hwnd,
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let handle = app.clone();
|
||||
controls
|
||||
.attach(move |event| {
|
||||
let name = match event {
|
||||
MediaControlEvent::Play => "play",
|
||||
MediaControlEvent::Pause => "pause",
|
||||
MediaControlEvent::Toggle => "toggle",
|
||||
MediaControlEvent::Next => "next",
|
||||
MediaControlEvent::Previous => "previous",
|
||||
MediaControlEvent::Stop => "stop",
|
||||
_ => return,
|
||||
};
|
||||
let _ = handle.emit("media", name);
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
*self.controls.lock().map_err(|e| e.to_string())? = Some(controls);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set(&self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, playing: bool) -> Result<(), String> {
|
||||
let mut slot = self.controls.lock().map_err(|e| e.to_string())?;
|
||||
let Some(controls) = slot.as_mut() else { return Ok(()); };
|
||||
controls
|
||||
.set_metadata(MediaMetadata {
|
||||
title: Some(title),
|
||||
artist: Some(artist),
|
||||
album: Some(album),
|
||||
cover_url,
|
||||
duration: None,
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let playback = if playing { MediaPlayback::Playing { progress: None } } else { MediaPlayback::Paused { progress: None } };
|
||||
controls.set_playback(playback).map_err(|e| format!("{e:?}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Loopback-only streaming transport. The random per-process path is a capability;
|
||||
//! it never leaves the app except for OS media artwork requests. No open proxy.
|
||||
use axum::{body::Body, extract::{Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, Router};
|
||||
use serde_json::Value;
|
||||
use std::{sync::{Arc, RwLock}, time::Duration};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Proxy {
|
||||
pub server: Arc<RwLock<Option<String>>>,
|
||||
pub base: String,
|
||||
secret: String,
|
||||
authority: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
pub fn validate_server(raw: &str) -> Result<String, String> {
|
||||
let url = Url::parse(raw.trim()).map_err(|_| "Invalid server URL")?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
|
||||
|| !url.username().is_empty() || url.password().is_some()
|
||||
|| url.query().is_some() || url.fragment().is_some()
|
||||
{ return Err("Use an HTTP(S) server address without credentials, query or fragment".into()); }
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
impl Proxy {
|
||||
pub async fn start(server: Option<String>) -> Result<Self, String> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
|
||||
let authority = listener.local_addr().map_err(|e| e.to_string())?.to_string();
|
||||
let secret = uuid::Uuid::new_v4().simple().to_string();
|
||||
let proxy = Self {
|
||||
server: Arc::new(RwLock::new(server)),
|
||||
base: format!("http://{authority}/{secret}"), secret, authority,
|
||||
client: reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.read_timeout(Duration::from_secs(60))
|
||||
.build().map_err(|e| e.to_string())?,
|
||||
};
|
||||
let router = Router::new().fallback(forward).with_state(proxy.clone());
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, router).await { eprintln!("Desktop transport stopped: {e}"); }
|
||||
});
|
||||
Ok(proxy)
|
||||
}
|
||||
|
||||
pub async fn check(&self, server: &str) -> Result<(), String> {
|
||||
let response = self.client.get(format!("{server}/api/v1/server-info"))
|
||||
.timeout(Duration::from_secs(10)).send().await.map_err(|_| "Server unavailable")?;
|
||||
if !response.status().is_success() { return Err("Server unavailable".into()); }
|
||||
let info: Value = response.json().await.map_err(|_| "Not a Pipu server")?;
|
||||
if info.get("api_version").is_none() || info.get("name").is_none() { return Err("Not a Pipu server".into()); }
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_origin(origin: &str) -> bool {
|
||||
matches!(origin, "tauri://localhost" | "http://tauri.localhost")
|
||||
|| (cfg!(debug_assertions) && origin == "http://127.0.0.1:5173")
|
||||
}
|
||||
|
||||
// Signed media and tus URLs are relative in the API. Rewrite only API URLs from
|
||||
// the configured server; never route arbitrary URLs supplied in server content.
|
||||
fn local_url(value: &str, server: &str, base: &str) -> Option<String> {
|
||||
if value.starts_with("/api/") { return Some(format!("{base}{value}")); }
|
||||
value.strip_prefix(server).filter(|p| p.starts_with("/api/"))
|
||||
.map(|p| format!("{base}{p}"))
|
||||
}
|
||||
|
||||
fn rewrite_json(value: &mut Value, server: &str, base: &str) {
|
||||
match value {
|
||||
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_json(v, server, base)),
|
||||
Value::Object(fields) => for (key, v) in fields {
|
||||
if matches!(key.as_str(), "stream_url" | "cover_url" | "tus_url" | "download_url") {
|
||||
if let Some(url) = v.as_str().and_then(|s| local_url(s, server, base)) { *v = Value::String(url); }
|
||||
} else { rewrite_json(v, server, base); }
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
|
||||
if let Some(origin) = origin {
|
||||
headers.insert("access-control-allow-origin", HeaderValue::from_str(origin).unwrap());
|
||||
headers.insert("vary", HeaderValue::from_static("Origin"));
|
||||
}
|
||||
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
||||
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
||||
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
|
||||
}
|
||||
|
||||
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
let origin = request.headers().get("origin").and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&proxy.authority)
|
||||
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
|
||||
{ return StatusCode::FORBIDDEN.into_response(); }
|
||||
let prefix = format!("/{}/api/", proxy.secret);
|
||||
let path = request.uri().path().to_owned();
|
||||
let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
|
||||
let tail = tail.to_owned();
|
||||
// Prevent URL normalization escaping the configured API path, including encoded segments.
|
||||
if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e")
|
||||
|| tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f")
|
||||
{ return StatusCode::BAD_REQUEST.into_response(); }
|
||||
if origin.is_none() && !matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let mut response = if request.method() == Method::OPTIONS {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
match transfer(&proxy, request, tail).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||
}
|
||||
};
|
||||
cors(response.headers_mut(), origin.as_deref());
|
||||
response
|
||||
}
|
||||
|
||||
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
|
||||
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
|
||||
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
|
||||
let url = format!("{server}/api/{tail}{query}");
|
||||
let (parts, body) = request.into_parts();
|
||||
let mut upstream = proxy.client.request(parts.method.clone(), url);
|
||||
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
|
||||
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
|
||||
}
|
||||
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
|
||||
let status = upstream.status();
|
||||
let mut headers = HeaderMap::new();
|
||||
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
|
||||
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
|
||||
}
|
||||
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
|
||||
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
|
||||
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
|
||||
}
|
||||
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
|
||||
let body = if json && parts.method != Method::HEAD {
|
||||
// Bound only JSON, never buffer a song or an upload in memory.
|
||||
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
|
||||
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
rewrite_json(&mut value, &server, &proxy.base);
|
||||
headers.remove("content-length");
|
||||
headers.remove("etag");
|
||||
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
|
||||
} else { Body::from_stream(upstream.bytes_stream()) };
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = headers;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_server_and_scopes_media_urls() {
|
||||
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
|
||||
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
|
||||
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
|
||||
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
|
||||
rewrite_json(&mut value, "https://music.example", "http://local/key");
|
||||
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
|
||||
assert_eq!(value["tracks"][0]["title"], "/api/private");
|
||||
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const SERVICE: &str = "app.pipu.music.desktop";
|
||||
|
||||
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
|
||||
let path = config_dir(app)?.join("server.txt");
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => {
|
||||
let s = s.trim().to_string();
|
||||
Ok((!s.is_empty()).then_some(s))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
|
||||
let path = config_dir(app)?.join("server.txt");
|
||||
match server {
|
||||
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||
None => match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_entry() -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_refresh() -> Result<Option<String>, String> {
|
||||
match refresh_entry()?.get_password() {
|
||||
Ok(s) => Ok(Some(s)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
|
||||
let entry = refresh_entry()?;
|
||||
match token {
|
||||
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
|
||||
None => match entry.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
|
||||
let path = config_dir(app)?.join("user.json");
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => Ok(Some(s)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
|
||||
let path = config_dir(app)?.join("user.json");
|
||||
match json {
|
||||
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||
None => match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Pipu Music",
|
||||
"version": "0.0.0",
|
||||
"identifier": "app.pipu.music.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm --filter @pipu/web dev",
|
||||
"devUrl": "http://127.0.0.1:5173",
|
||||
"beforeBuildCommand": "pnpm --filter @pipu/web build",
|
||||
"frontendDist": "../../web/dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis", "appimage", "deb", "rpm"],
|
||||
"category": "Music",
|
||||
"shortDescription": "Self-hosted music player",
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
|
||||
"linux": {
|
||||
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
|
||||
"appimage": { "bundleMediaFramework": false }
|
||||
},
|
||||
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -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 }}>
|
||||
|
||||
@@ -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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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 |
@@ -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 });
|
||||
|
||||
@@ -18,7 +18,9 @@ const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((
|
||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
||||
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||
import type { DesktopHandle } from '../desktop';
|
||||
import { AlbumPage } from '../pages/AlbumPage';
|
||||
import { ConnectPage } from '../pages/ConnectPage';
|
||||
import { TrackPage } from '../pages/TrackPage';
|
||||
import { ArtistPage } from '../pages/ArtistPage';
|
||||
import { CollectionPage } from '../pages/CollectionPage';
|
||||
@@ -34,6 +36,7 @@ import { RouteError } from '../widgets/RouteError';
|
||||
|
||||
export interface RouterContext {
|
||||
session: AuthSession;
|
||||
desktop: DesktopHandle | null;
|
||||
}
|
||||
|
||||
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||
@@ -52,9 +55,12 @@ export function safeRedirect(target: unknown): string {
|
||||
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
||||
}
|
||||
|
||||
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
|
||||
function requireUser(session: AuthSession, location: ParsedLocation, desktop: DesktopHandle | null): AuthUser {
|
||||
const user = signedInUser(session);
|
||||
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||
if (!user) {
|
||||
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
|
||||
throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -63,8 +69,26 @@ 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,
|
||||
path: '/connect',
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.desktop) throw redirect({ to: '/login' });
|
||||
if (context.desktop.server) throw redirect({ to: '/login' });
|
||||
},
|
||||
component: ConnectPage,
|
||||
});
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
@@ -72,6 +96,7 @@ const loginRoute = createRoute({
|
||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
||||
beforeLoad: ({ context, search }) => {
|
||||
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
|
||||
const user = signedInUser(context.session);
|
||||
if (!user) return;
|
||||
const home = homeFor(user);
|
||||
@@ -85,7 +110,7 @@ const setupAdminRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/setup/admin',
|
||||
beforeLoad: ({ context, location }) => {
|
||||
const user = requireUser(context.session, location);
|
||||
const user = requireUser(context.session, location, context.desktop);
|
||||
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
||||
},
|
||||
component: () => <SetupPage variant="admin" />,
|
||||
@@ -95,7 +120,7 @@ const setupPasswordRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/setup/password',
|
||||
beforeLoad: ({ context, location }) => {
|
||||
const user = requireUser(context.session, location);
|
||||
const user = requireUser(context.session, location, context.desktop);
|
||||
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
||||
},
|
||||
component: () => <SetupPage variant="password" />,
|
||||
@@ -106,7 +131,7 @@ const appRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'app',
|
||||
beforeLoad: ({ context, location }) => {
|
||||
const user = requireUser(context.session, location);
|
||||
const user = requireUser(context.session, location, context.desktop);
|
||||
const home = homeFor(user);
|
||||
if (home !== '/') throw redirect({ to: home });
|
||||
return { user };
|
||||
@@ -245,7 +270,9 @@ const adminSettingsRoute = createRoute({
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
downloadAppRoute,
|
||||
androidAppRoute,
|
||||
connectRoute,
|
||||
loginRoute,
|
||||
setupAdminRoute,
|
||||
setupPasswordRoute,
|
||||
@@ -273,8 +300,12 @@ const routeTree = rootRoute.addChildren([
|
||||
]),
|
||||
]);
|
||||
|
||||
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
||||
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
||||
export function createAppRouter(
|
||||
session: AuthSession,
|
||||
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
|
||||
) {
|
||||
const { desktop = null, ...rest } = opts;
|
||||
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
|
||||
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||
session.subscribe(() => void router.invalidate());
|
||||
return router;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDesktop, isDesktop } from './desktop';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('desktop bridge', () => {
|
||||
it('is inactive in the browser', () => {
|
||||
expect(isDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
it('bootstraps through the Tauri invoke surface', async () => {
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
|
||||
if (cmd === 'set_server') return 'https://music.example/pipu';
|
||||
return null;
|
||||
});
|
||||
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
|
||||
const desktop = await createDesktop();
|
||||
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
|
||||
expect(desktop?.server).toBe('https://music.example');
|
||||
await desktop?.setServer('https://music.example/pipu');
|
||||
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
|
||||
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||
import { createContext, createElement, useContext, type ReactNode } from 'react';
|
||||
|
||||
type Tauri = {
|
||||
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
|
||||
};
|
||||
|
||||
function api(): Tauri | undefined {
|
||||
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
|
||||
}
|
||||
|
||||
export function isDesktop(): boolean {
|
||||
return api() !== undefined;
|
||||
}
|
||||
|
||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const tauri = api();
|
||||
if (!tauri) throw new Error('Tauri API unavailable');
|
||||
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||
}
|
||||
|
||||
export type DesktopHandle = {
|
||||
proxyBase: string;
|
||||
server: string | null;
|
||||
setServer: (url: string) => Promise<string>;
|
||||
clearServer: () => Promise<void>;
|
||||
refreshStore: RefreshStore;
|
||||
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
|
||||
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
|
||||
};
|
||||
|
||||
export const DesktopContext = createContext<DesktopHandle | null>(null);
|
||||
|
||||
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
|
||||
return createElement(DesktopContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
export function useDesktop(): DesktopHandle | null {
|
||||
return useContext(DesktopContext);
|
||||
}
|
||||
|
||||
let active: DesktopHandle | null = null;
|
||||
|
||||
export function desktopHandle(): DesktopHandle | null {
|
||||
return active;
|
||||
}
|
||||
|
||||
export async function createDesktop(): Promise<DesktopHandle | null> {
|
||||
if (!isDesktop()) return null;
|
||||
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
|
||||
const handle: DesktopHandle = {
|
||||
proxyBase: boot.proxyBase,
|
||||
server: boot.server,
|
||||
setServer: async (url) => {
|
||||
const server = await invoke<string>('set_server', { url });
|
||||
handle.server = server;
|
||||
return server;
|
||||
},
|
||||
clearServer: async () => {
|
||||
await invoke('clear_server');
|
||||
handle.server = null;
|
||||
},
|
||||
refreshStore: {
|
||||
get: () => invoke<string | null>('token_get'),
|
||||
set: (token) => invoke('token_set', { token }),
|
||||
clear: async () => {
|
||||
await invoke('token_clear');
|
||||
await invoke('user_clear');
|
||||
},
|
||||
getUser: async () => {
|
||||
const json = await invoke<string | null>('user_get');
|
||||
return json ? (JSON.parse(json) as AuthUser) : null;
|
||||
},
|
||||
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
|
||||
},
|
||||
listenMedia: async (handler) => {
|
||||
const tauri = api();
|
||||
if (!tauri) return () => undefined;
|
||||
return tauri.event.listen('media', (e) => handler(e.payload));
|
||||
},
|
||||
setMedia: (info) => {
|
||||
void invoke('media_set', {
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
album: info.album,
|
||||
coverUrl: info.coverUrl ?? null,
|
||||
playing: info.playing,
|
||||
});
|
||||
},
|
||||
};
|
||||
active = handle;
|
||||
return handle;
|
||||
}
|
||||
@@ -28,6 +28,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}`;
|
||||
|
||||
@@ -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} />}
|
||||
</>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
||||
import { App } from './App';
|
||||
import { createAppRouter } from './app/router';
|
||||
import { applyTheme } from './app/session';
|
||||
import { createDesktop, DesktopProvider } from './desktop';
|
||||
|
||||
const LOCALE_KEY = 'pipu.locale';
|
||||
|
||||
@@ -63,15 +64,23 @@ i18n.on('languageChanged', (l) => {
|
||||
}
|
||||
});
|
||||
|
||||
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
|
||||
const desktop = await createDesktop();
|
||||
const session = new AuthSession({
|
||||
clientType: desktop ? 'desktop' : 'web',
|
||||
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
||||
baseUrl: desktop?.proxyBase,
|
||||
refreshStore: desktop?.refreshStore,
|
||||
});
|
||||
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||
await session.restore();
|
||||
const router = createAppRouter(session);
|
||||
const router = createAppRouter(session, { desktop });
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<App session={session} router={router} />
|
||||
<DesktopProvider value={desktop}>
|
||||
<App session={session} router={router} />
|
||||
</DesktopProvider>
|
||||
</I18nextProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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): ссылку можно отправить человеку.
|
||||
* Сборки — последний релиз 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,59 @@ 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 ? null : 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: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||
) : 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 (
|
||||
<a key={id} className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
||||
{t(`app.download.${id}`)}
|
||||
<span>
|
||||
{t('app.version', { version: data.version })} · {formatBytes(file.size, i18n.language)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
|
||||
</>
|
||||
) : (
|
||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||
@@ -68,3 +90,16 @@ export function AppDownloadPage() {
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function ArtistPage({ id }: { id: string }) {
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
<Cover album={{ id }} size={168} round />
|
||||
<Cover album={{ id, cover_url: a?.image_url }} size={168} round />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('catalog.artist')}</span>
|
||||
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
|
||||
import { useDesktop } from '../desktop';
|
||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
export function ConnectPage() {
|
||||
const { t } = useTranslation();
|
||||
const desktop = useDesktop();
|
||||
const router = useRouter();
|
||||
const [address, setAddress] = useState(desktop?.server ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!desktop) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await desktop.setServer(address);
|
||||
await router.invalidate();
|
||||
await router.navigate({ to: '/login' });
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : '';
|
||||
setError(/Invalid|query|fragment|credentials/i.test(text) ? t('server.invalid_address') : t('server.offline'));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1>{t('server.title')}</h1>
|
||||
<p className={s.lead}>{t('app.tagline')}</p>
|
||||
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||
<Field
|
||||
label={t('server.address')}
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
autoComplete="url"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
<Button type="submit" variant="primary" className={s.submit} busy={busy} disabled={!address.trim()}>
|
||||
{t('mobile.connect')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { useDesktop } from '../desktop';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
@@ -11,6 +12,7 @@ import s from './auth.module.css';
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const errorText = useErrorText();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -41,7 +43,13 @@ export function LoginPage() {
|
||||
footer={
|
||||
info.data ? (
|
||||
<>
|
||||
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
||||
pipu {info.data.version}
|
||||
{desktop ? null : (
|
||||
<>
|
||||
{' · '}
|
||||
<a href="/download">{t('app.link')}</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession, useUser } from '../app/session';
|
||||
import { useDesktop } from '../desktop';
|
||||
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||
import { formatDateTime } from '../shared/lib/format';
|
||||
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||
@@ -297,6 +299,8 @@ function PasswordSection() {
|
||||
function SessionsSection() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const sessions = useQuery({
|
||||
@@ -333,6 +337,19 @@ function SessionsSection() {
|
||||
</div>
|
||||
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||
<div>
|
||||
{desktop && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await desktop.clearServer();
|
||||
await session.logout();
|
||||
await navigate({ to: '/connect' });
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('mobile.change_server')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||
{t('settings.logout_all')}
|
||||
</Button>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { formatDuration } from '../shared/lib/format';
|
||||
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();
|
||||
@@ -33,6 +34,7 @@ export function TrackPage({ id }: { id: string }) {
|
||||
</div>
|
||||
</header>
|
||||
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
|
||||
{tr && <TrackGenius id={id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -5,7 +5,8 @@ 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';
|
||||
@@ -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,6 +45,7 @@ export function SourcesPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
|
||||
{user.role === 'admin' && <GeniusPanel />}
|
||||
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
|
||||
<div className={l.list}>
|
||||
{sources.data?.map((src) => (
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
/* Страница Android-приложения (/android). */
|
||||
/* Страница приложений (/download). */
|
||||
.download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -238,6 +238,40 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.otherClients {
|
||||
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;
|
||||
}
|
||||
|
||||
.link {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -9,4 +9,6 @@
|
||||
.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; }
|
||||
.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; } }
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type Track,
|
||||
} from '@pipu/core';
|
||||
|
||||
import { desktopHandle } from '../desktop';
|
||||
|
||||
export type { PlayContext, Track };
|
||||
|
||||
export interface WaveInfo {
|
||||
@@ -121,6 +123,7 @@ export class Player {
|
||||
};
|
||||
this.bindAudio();
|
||||
this.bindMediaSession();
|
||||
this.bindDesktopMedia();
|
||||
}
|
||||
|
||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||
@@ -137,9 +140,13 @@ export class Player {
|
||||
|
||||
getState = () => this.state;
|
||||
|
||||
private lastDesktop: { id?: string; playing?: boolean } = {};
|
||||
private desktopUnlisten: (() => void) | null = null;
|
||||
|
||||
private update(patch: Partial<PlayerState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
for (const fn of this.listeners) fn();
|
||||
this.syncDesktopMedia();
|
||||
}
|
||||
|
||||
private syncQueue() {
|
||||
@@ -678,6 +685,33 @@ export class Player {
|
||||
|
||||
// --- Media Session ---
|
||||
|
||||
private bindDesktopMedia() {
|
||||
void desktopHandle()?.listenMedia((action) => {
|
||||
if (action === 'play') this.play();
|
||||
else if (action === 'pause' || action === 'stop') this.pause();
|
||||
else if (action === 'toggle') this.toggle();
|
||||
else if (action === 'next') this.next();
|
||||
else if (action === 'previous') this.prev();
|
||||
}).then((stop) => {
|
||||
this.desktopUnlisten = stop;
|
||||
});
|
||||
}
|
||||
|
||||
private syncDesktopMedia() {
|
||||
const desktop = desktopHandle();
|
||||
const track = this.state.track;
|
||||
if (!desktop || !track) return;
|
||||
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.state.playing) return;
|
||||
this.lastDesktop = { id: track.id, playing: this.state.playing };
|
||||
desktop.setMedia({
|
||||
title: track.title,
|
||||
artist: track.artists.map((a) => a.name).join(', '),
|
||||
album: track.album.title,
|
||||
coverUrl: track.album.cover_url,
|
||||
playing: this.state.playing,
|
||||
});
|
||||
}
|
||||
|
||||
private bindMediaSession() {
|
||||
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||
if (!ms) return;
|
||||
@@ -855,6 +889,8 @@ export class Player {
|
||||
this.overlap.load();
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
this.timers = [];
|
||||
this.desktopUnlisten?.();
|
||||
this.desktopUnlisten = null;
|
||||
void this.events.flush();
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 {
|
||||
|
||||
@@ -116,6 +116,28 @@ 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' },
|
||||
{ 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 +302,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') {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,7 +26,7 @@ import {
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
settingsIcon,
|
||||
androidIcon,
|
||||
downloadIcon,
|
||||
chevronRightIcon,
|
||||
storageFillIcon,
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||
@@ -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';
|
||||
@@ -252,7 +254,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() },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -311,13 +311,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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ArtistLinks, Cover } 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';
|
||||
@@ -178,6 +179,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## Суть
|
||||
|
||||
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
||||
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
||||
Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
||||
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
||||
|
||||
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
||||
@@ -16,7 +16,7 @@
|
||||
## Доступ и учётные записи
|
||||
|
||||
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
||||
В мобильном приложении на этом экране также указывается **адрес сервера**.
|
||||
В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**.
|
||||
- **Регистрации нет.** Учётные записи создаёт администрация.
|
||||
- Роли:
|
||||
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
||||
|
||||
@@ -15,9 +15,11 @@ 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) | Веб-клиент |
|
||||
| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) |
|
||||
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
|
||||
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
||||
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
||||
|
||||
@@ -102,8 +102,10 @@
|
||||
прочитайте заметки к релизу на странице релизов Gitea.
|
||||
|
||||
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
||||
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
|
||||
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
|
||||
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
|
||||
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
|
||||
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа
|
||||
и в меню профиля.
|
||||
|
||||
## 7. Наблюдение
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@
|
||||
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url}]}` — Android, Windows, Linux, macOS |
|
||||
| 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) |
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
- Всё разворачивается на одном сервере через Docker Compose.
|
||||
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
|
||||
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
|
||||
- Клиенты: веб (SPA) и Android-приложение.
|
||||
- Клиенты: веб (SPA), десктоп (Tauri) и Android-приложение.
|
||||
|
||||
## 2. Компоненты
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌────────────────┐
|
||||
Web SPA ─────┐ │ │ │ │
|
||||
├──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
|
||||
Desktop ─────┼──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
|
||||
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
|
||||
│ X-Accel) │ │ │──► Meilisearch
|
||||
└──────┬───────┘ └───┬────────┬───┘
|
||||
@@ -82,6 +82,7 @@
|
||||
pipu-music/
|
||||
├── apps/
|
||||
│ ├── web/ # React + Vite SPA
|
||||
│ ├── desktop/ # Tauri 2: то же SPA в нативном окне
|
||||
│ └── mobile/ # React Native (Expo) Android
|
||||
├── packages/ # общий TypeScript-код (pnpm workspaces)
|
||||
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
|
||||
|
||||
@@ -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` | |
|
||||
|
||||
@@ -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`.
|
||||
После изменения значков установленные приложения нужно пересобрать и обновить.
|
||||
|
||||
### Типографика
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Десктоп (`apps/desktop`)
|
||||
|
||||
Windows, Linux и macOS: тот же веб-клиент в окне [Tauri 2](https://tauri.app/). Запросы к API идут через локальный прокси на `127.0.0.1`, чтобы плеер не открывал чужой origin. Refresh-токен хранится в системной связке ключей. Тип клиента — `desktop`.
|
||||
|
||||
При первом запуске нужно указать адрес сервера (как в Android). Дальше вход тот же, что в вебе. Кнопка «Другой сервер» в настройках забывает адрес и токен. Трея: левый клик открывает окно; меню — открыть и выход. Клавиши медиаплеера ОС управляют воспроизведением.
|
||||
|
||||
Плейлисты, ссылка на трек и остальной интерфейс совпадают с вебом. Сборка подставляет версию из git-тега.
|
||||
|
||||
```bash
|
||||
pnpm --filter @pipu/desktop icons
|
||||
pnpm --filter @pipu/desktop dev
|
||||
pnpm --filter @pipu/desktop bundle
|
||||
```
|
||||
|
||||
На 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.
|
||||
@@ -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 проверяется
|
||||
отдельно из сети сервера с собственным токеном.
|
||||
@@ -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, как в этой инструкции.
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
## Самообновление
|
||||
|
||||
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
|
||||
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция.
|
||||
|
||||
Приложение распространяется только через Gitea Releases (не Google Play).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,44 @@ 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.
|
||||
* Нет настроек 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 +2102,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 +2354,22 @@ export interface components {
|
||||
early_skip_rate: number | null;
|
||||
like_rate: number | null;
|
||||
};
|
||||
/** @enum {string} */
|
||||
AppClient: "android" | "windows" | "linux" | "macos";
|
||||
AppClientFile: {
|
||||
client: components["schemas"]["AppClient"];
|
||||
filename: string;
|
||||
/** Format: int64 */
|
||||
size: number;
|
||||
sha256?: string;
|
||||
download_url: string;
|
||||
};
|
||||
AppClients: {
|
||||
version: string;
|
||||
version_code: number;
|
||||
notes_md: string;
|
||||
clients: components["schemas"]["AppClientFile"][];
|
||||
};
|
||||
AndroidRelease: {
|
||||
/** @example 0.6.1 */
|
||||
version: string;
|
||||
@@ -2326,6 +2447,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 +4379,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;
|
||||
|
||||
@@ -22,17 +22,41 @@
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -41,7 +49,7 @@
|
||||
"menu": "Profile menu",
|
||||
"settings": "Settings",
|
||||
"logout": "Sign out",
|
||||
"android_app": "Android app"
|
||||
"download_apps": "Download apps"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Administrator",
|
||||
|
||||
@@ -22,17 +22,41 @@
|
||||
"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": "Другие платформы",
|
||||
"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": "Войти в веб-версию"
|
||||
}
|
||||
}
|
||||
@@ -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": "Ваша музыка с вашего сервера"
|
||||
@@ -41,7 +49,7 @@
|
||||
"menu": "Меню профиля",
|
||||
"settings": "Настройки",
|
||||
"logout": "Выйти",
|
||||
"android_app": "Приложение для Android"
|
||||
"download_apps": "Скачать приложения"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Администратор",
|
||||
|
||||
@@ -18,6 +18,12 @@ importers:
|
||||
specifier: 5.8.3
|
||||
version: 5.8.3
|
||||
|
||||
apps/desktop:
|
||||
devDependencies:
|
||||
'@tauri-apps/cli':
|
||||
specifier: ^2.9.0
|
||||
version: 2.12.3
|
||||
|
||||
apps/mobile:
|
||||
dependencies:
|
||||
'@expo/vector-icons':
|
||||
@@ -116,7 +122,7 @@ importers:
|
||||
version: 19.0.14
|
||||
vitest:
|
||||
specifier: ^3.2.7
|
||||
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
@@ -1458,6 +1464,82 @@ packages:
|
||||
'@tanstack/virtual-core@3.19.6':
|
||||
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.12.3':
|
||||
resolution: {integrity: sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.12.3':
|
||||
resolution: {integrity: sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.12.3':
|
||||
resolution: {integrity: sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.12.3':
|
||||
resolution: {integrity: sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.12.3':
|
||||
resolution: {integrity: sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.12.3':
|
||||
resolution: {integrity: sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.12.3':
|
||||
resolution: {integrity: sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.12.3':
|
||||
resolution: {integrity: sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.12.3':
|
||||
resolution: {integrity: sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.12.3':
|
||||
resolution: {integrity: sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.12.3':
|
||||
resolution: {integrity: sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli@2.12.3':
|
||||
resolution: {integrity: sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg==}
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@testing-library/dom@10.4.2':
|
||||
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5560,6 +5642,53 @@ snapshots:
|
||||
|
||||
'@tanstack/virtual-core@3.19.6': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.12.3':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli@2.12.3':
|
||||
optionalDependencies:
|
||||
'@tauri-apps/cli-darwin-arm64': 2.12.3
|
||||
'@tauri-apps/cli-darwin-x64': 2.12.3
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.12.3
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 2.12.3
|
||||
'@tauri-apps/cli-linux-arm64-musl': 2.12.3
|
||||
'@tauri-apps/cli-linux-riscv64-gnu': 2.12.3
|
||||
'@tauri-apps/cli-linux-x64-gnu': 2.12.3
|
||||
'@tauri-apps/cli-linux-x64-musl': 2.12.3
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 2.12.3
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.12.3
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.12.3
|
||||
|
||||
'@testing-library/dom@10.4.2':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -8118,7 +8247,7 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2):
|
||||
vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
@@ -8188,7 +8317,7 @@ snapshots:
|
||||
lightningcss: 1.27.0
|
||||
terser: 5.51.2
|
||||
|
||||
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2):
|
||||
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.7
|
||||
@@ -8211,7 +8340,7 @@ snapshots:
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.5
|
||||
|
||||
@@ -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.');
|
||||
@@ -11,7 +11,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 +29,12 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
|
||||
|
||||
export const STATUS_START = '<!-- android-status -->';
|
||||
export const STATUS_END = '<!-- /android-status -->';
|
||||
export const DESKTOP_PLATFORMS = ['linux', 'windows'];
|
||||
|
||||
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 +82,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 +145,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 +179,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).`,
|
||||
'',
|
||||
@@ -202,6 +260,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
|
||||
@@ -242,13 +302,38 @@ 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));
|
||||
}
|
||||
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...>',
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||
|
||||
@@ -4,8 +4,11 @@ import { test } from 'node:test';
|
||||
import {
|
||||
groupCommits,
|
||||
renderAndroidStatus,
|
||||
renderDesktopStatus,
|
||||
renderNotes,
|
||||
replaceAndroidStatus,
|
||||
replaceDesktopStatus,
|
||||
desktopStatusMarks,
|
||||
STATUS_END,
|
||||
STATUS_START,
|
||||
} from './release.mjs';
|
||||
@@ -77,6 +80,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,12 +117,16 @@ 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, /Предварительная версия/);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package appupdate — самообновление Android-приложения через сервер (docs/mobile.md#самообновление):
|
||||
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK.
|
||||
// Package appupdate — раздача клиентов через сервер (docs/mobile.md#самообновление):
|
||||
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и файлы.
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
"pipu/api/internal/platform/httpx"
|
||||
)
|
||||
|
||||
// CodeUnavailable — обновления не настроены или релиза с APK нет.
|
||||
// CodeUnavailable — обновления не настроены или релиза с клиентами нет.
|
||||
const CodeUnavailable = "app.update_unavailable"
|
||||
|
||||
const cacheTTL = 10 * time.Minute
|
||||
@@ -73,14 +73,22 @@ type release struct {
|
||||
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.
|
||||
@@ -92,7 +100,7 @@ type Handler struct {
|
||||
mu sync.Mutex
|
||||
cached *Latest
|
||||
cachedAt time.Time
|
||||
dl sync.Mutex // одна загрузка APK за раз
|
||||
dl sync.Mutex
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик.
|
||||
@@ -100,7 +108,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,7 +132,70 @@ func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// latest — последний релиз с APK (кэш на 10 минут).
|
||||
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) latest(ctx context.Context) (*Latest, error) {
|
||||
if !h.configured() {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
|
||||
@@ -142,7 +212,7 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
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")
|
||||
}
|
||||
@@ -156,35 +226,77 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
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]
|
||||
files := pickClientFiles(rel.Assets)
|
||||
if len(files) == 0 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
|
||||
}
|
||||
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)
|
||||
delete(files, "android")
|
||||
} else {
|
||||
apk.SHA256 = digest
|
||||
files["android"] = apk
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||
}
|
||||
}
|
||||
if apk == nil || sum == nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||
}
|
||||
sres, err := h.get(ctx, sum.URL)
|
||||
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
|
||||
h.cachedAt = time.Now()
|
||||
return h.cached, nil
|
||||
}
|
||||
|
||||
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
|
||||
sres, err := h.get(ctx, url)
|
||||
if err != nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable")
|
||||
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) {
|
||||
l, err := h.latest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
order := []string{"windows", "macos", "linux", "android"}
|
||||
clients := make([]gen.AppClientFile, 0, len(l.Files))
|
||||
for _, kind := range order {
|
||||
f, ok := l.Files[kind]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := gen.AppClientFile{
|
||||
Client: gen.AppClient(kind),
|
||||
Filename: f.Name,
|
||||
Size: f.Size,
|
||||
DownloadUrl: h.fileURL(kind, l.Version),
|
||||
}
|
||||
if f.SHA256 != "" {
|
||||
item.Sha256 = &f.SHA256
|
||||
}
|
||||
clients = append(clients, item)
|
||||
}
|
||||
return gen.GetAppClients200JSONResponse{
|
||||
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAndroidLatest реализует gen.StrictServerInterface.
|
||||
@@ -193,15 +305,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 +328,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,26 +346,53 @@ 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"))
|
||||
func (h *Handler) cleanup(keepName string) {
|
||||
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
|
||||
ver := versionFromName(keepName)
|
||||
for _, m := range matches {
|
||||
if m != keep {
|
||||
base := filepath.Base(m)
|
||||
if ver != "" && strings.Contains(base, ver) {
|
||||
continue
|
||||
}
|
||||
if base != keepName {
|
||||
_ = os.Remove(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 || (kind == "android" && f.SHA256 == "") {
|
||||
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. Отдаётся только последняя версия.
|
||||
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
@@ -260,22 +402,36 @@ func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroi
|
||||
if req.Version != l.Version {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is 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")
|
||||
}
|
||||
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) {
|
||||
l, err := h.latest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Version != l.Version {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is 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 +444,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
|
||||
|
||||
@@ -16,6 +16,22 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -60,17 +76,18 @@ 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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>Привет & <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\\" & 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}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type Handler struct{ svc *Service }
|
||||
|
||||
func NewHandler(s *Service) *Handler { return &Handler{svc: s} }
|
||||
|
||||
func requireAdmin(ctx context.Context) error {
|
||||
p, ok := auth.PrincipalFrom(ctx)
|
||||
if !ok {
|
||||
return apperr.Unauthorized("auth.unauthorized", "authentication required")
|
||||
}
|
||||
if p.Role != auth.RoleAdmin {
|
||||
return apperr.Forbidden("auth.forbidden", "administrator required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetGeniusStats(ctx context.Context, _ gen.GetGeniusStatsRequestObject) (gen.GetGeniusStatsResponseObject, error) {
|
||||
if err := requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats, err := h.svc.Stats(ctx)
|
||||
return gen.GetGeniusStats200JSONResponse(stats), err
|
||||
}
|
||||
|
||||
func (h *Handler) ControlGenius(ctx context.Context, req gen.ControlGeniusRequestObject) (gen.ControlGeniusResponseObject, error) {
|
||||
if err := requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !h.svc.configured {
|
||||
return nil, apperr.Conflict("genius.disabled", "configure GENIUS_ACCESS_TOKEN in deploy/.env")
|
||||
}
|
||||
if req.Body == nil {
|
||||
return nil, apperr.Validation("action", "validation.required", "action required", nil)
|
||||
}
|
||||
tx, err := h.svc.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
switch string(req.Body.Action) {
|
||||
case "pause":
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=true`)
|
||||
case "start":
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=false,last_error=''`)
|
||||
case "retry":
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_tracks SET status='pending',next_attempt=now(),last_error='' WHERE status IN ('error','partial','unmatched') AND locked_until<=now()`)
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=false,last_error=''`)
|
||||
}
|
||||
default:
|
||||
return nil, apperr.Validation("action", "validation.invalid", "unknown action", nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.svc.Wake()
|
||||
stats, err := h.svc.Stats(ctx)
|
||||
return gen.ControlGenius200JSONResponse(stats), err
|
||||
}
|
||||
|
||||
func (h *Handler) GetTrackGenius(ctx context.Context, req gen.GetTrackGeniusRequestObject) (gen.GetTrackGeniusResponseObject, error) {
|
||||
if _, ok := auth.PrincipalFrom(ctx); !ok {
|
||||
return nil, apperr.Unauthorized("auth.unauthorized", "authentication required")
|
||||
}
|
||||
content := emptyContent()
|
||||
var body []byte
|
||||
err := h.svc.pool.QueryRow(ctx, `SELECT COALESCE(g.content,'{}'),COALESCE(g.status,'pending') FROM tracks t LEFT JOIN genius_tracks g ON g.track_id=t.id WHERE t.id=$1`, req.Id).Scan(&body, &content.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, apperr.NotFound("catalog.not_found", "track not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := content.Status
|
||||
if err = json.Unmarshal(body, &content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content.Status = status
|
||||
if !h.svc.configured && status == "pending" {
|
||||
content.Status = "disabled"
|
||||
}
|
||||
return gen.GetTrackGenius200JSONResponse(content), nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// Parse only designated lyrics containers, never the entire page (navigation, ads, annotations).
|
||||
func parseLyrics(body []byte) (string, error) {
|
||||
doc, err := html.Parse(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var blocks []string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if excluded(n) {
|
||||
return
|
||||
}
|
||||
if attr(n, "data-lyrics-container") == "true" {
|
||||
if text := nodeText(n); text != "" {
|
||||
blocks = append(blocks, text)
|
||||
}
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
if len(blocks) == 0 {
|
||||
if text := embeddedLyrics(doc); text != "" {
|
||||
return text, nil
|
||||
}
|
||||
return "", ErrLyricsUnavailable
|
||||
}
|
||||
return strings.Join(blocks, "\n\n"), nil
|
||||
}
|
||||
|
||||
func attr(n *html.Node, name string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == name {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func excluded(n *html.Node) bool {
|
||||
return n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style" || n.Data == "button" ||
|
||||
attr(n, "data-exclude-from-selection") == "true" || attr(n, "aria-hidden") == "true" ||
|
||||
strings.Contains(attr(n, "class"), "LyricsHeader"))
|
||||
}
|
||||
|
||||
var statePattern = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*JSON\.parse\('((?:[^'\\]|\\.)*)'\)`)
|
||||
|
||||
// Read a JSON string literal only; never evaluate JavaScript from a remote page.
|
||||
func embeddedLyrics(doc *html.Node) string {
|
||||
var result string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if result != "" {
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "script" && n.FirstChild != nil {
|
||||
match := statePattern.FindStringSubmatch(n.FirstChild.Data)
|
||||
if len(match) == 2 {
|
||||
decoded, ok := decodeJSString(match[1])
|
||||
if ok {
|
||||
var state struct {
|
||||
SongPage struct {
|
||||
LyricsData struct {
|
||||
Body struct {
|
||||
HTML string `json:"html"`
|
||||
} `json:"body"`
|
||||
} `json:"lyricsData"`
|
||||
} `json:"songPage"`
|
||||
}
|
||||
if json.Unmarshal([]byte(decoded), &state) == nil {
|
||||
fragment, err := html.Parse(strings.NewReader(strings.ReplaceAll(state.SongPage.LyricsData.Body.HTML, "\n", "")))
|
||||
if err == nil {
|
||||
result = nodeText(fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return result
|
||||
}
|
||||
|
||||
func decodeJSString(raw string) (string, bool) {
|
||||
var quoted strings.Builder
|
||||
quoted.WriteByte('"')
|
||||
for i := 0; i < len(raw); i++ {
|
||||
switch raw[i] {
|
||||
case '"':
|
||||
quoted.WriteString(`\"`)
|
||||
case '\\':
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return "", false
|
||||
}
|
||||
switch raw[i] {
|
||||
case '\'':
|
||||
quoted.WriteByte('\'')
|
||||
case 'x':
|
||||
if i+2 >= len(raw) {
|
||||
return "", false
|
||||
}
|
||||
quoted.WriteString(`\u00`)
|
||||
quoted.WriteString(raw[i+1 : i+3])
|
||||
i += 2
|
||||
default:
|
||||
quoted.WriteByte('\\')
|
||||
quoted.WriteByte(raw[i])
|
||||
}
|
||||
default:
|
||||
quoted.WriteByte(raw[i])
|
||||
}
|
||||
}
|
||||
quoted.WriteByte('"')
|
||||
var decoded string
|
||||
err := json.Unmarshal([]byte(quoted.String()), &decoded)
|
||||
return decoded, err == nil
|
||||
}
|
||||
|
||||
func nodeText(root *html.Node) string {
|
||||
var out strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if excluded(n) {
|
||||
return
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
out.WriteString(n.Data)
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "br" {
|
||||
out.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
block := n.Type == html.ElementNode && (n.Data == "p" || n.Data == "div") && n != root
|
||||
if block {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
if block {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
lines := strings.Split(strings.ReplaceAll(out.String(), "\r", ""), "\n")
|
||||
clean := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.Join(strings.Fields(line), " ")
|
||||
if line == "" && (len(clean) == 0 || clean[len(clean)-1] == "") {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(clean, "\n"))
|
||||
}
|
||||