Compare commits

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:10:03 +03:00
93 changed files with 3543 additions and 471 deletions

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
# Rust-часть десктопа: прокси и проверки URL. Полная сборка инсталлятора — локально
# (`pnpm --filter @pipu/desktop bundle`): на раннере нет Windows и WebKitGTK.
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
# (Linux .deb и Windows NSIS через cargo-xwin на ubuntu-latest).
name: ci / desktop
on:
+5 -1
View File
@@ -57,5 +57,9 @@ jobs:
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium
# git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
- run: |
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
pnpm --filter @pipu/web exec playwright install chromium
- run: pnpm --filter @pipu/web test:e2e
+130 -5
View File
@@ -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
+3
View File
@@ -41,3 +41,6 @@ apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/tauri.release.json
apps/desktop/release/
# Intermediate platform icon renders (final assets live under apps/).
/artifacts/icon-build/
+165 -1
View File
@@ -22,6 +22,51 @@ tags:
security:
- bearerAuth: []
paths:
/api/v1/admin/genius:
get:
operationId: getGeniusStats
tags: [admin]
responses:
"200":
description: Persistent enrichment statistics (admin only)
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusStats"
post:
operationId: controlGenius
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action]
properties:
action:
type: string
enum: [start, pause, retry]
responses:
"200":
description: Updated statistics
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusStats"
/api/v1/tracks/{id}/genius:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: getTrackGenius
tags: [catalog]
responses:
"200":
description: Cached lyrics and annotations; never makes an upstream request
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusContent"
/healthz:
get:
operationId: getHealthz
@@ -1497,11 +1542,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]
+8 -6
View File
@@ -5,12 +5,14 @@ import { fileURLToPath } from 'node:url';
const root = dirname(fileURLToPath(import.meta.url));
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
let version = '0.0.0';
try {
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
version = tag.replace(/^v/, '').replace(/-rc\.\d+$/, '');
} catch {
// local untagged build
let version = process.env.PIPU_VERSION ?? '0.0.0';
if (!process.env.PIPU_VERSION) {
try {
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
version = tag.replace(/^v/, '');
} catch {
// local untagged build
}
}
conf.version = version;
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
+4 -1
View File
@@ -6,8 +6,11 @@
"scripts": {
"dev": "tauri dev",
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles deb",
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
"tauri": "tauri",
"icons": "tauri icon ../web/public/favicon.svg"
"icons": "node ../../scripts/generate-icons.mjs"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.0"
+3 -1
View File
@@ -38,5 +38,7 @@ keyring = { version = "3.6.3", features = ["apple-native"] }
[profile.release]
strip = true
lto = "thin"
lto = true
codegen-units = 1
opt-level = "s"
panic = "abort"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+1 -1
View File
@@ -126,7 +126,7 @@ pub fn run() {
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &quit])?;
let icon: Image = include_image!("../icons/32x32.png");
let icon: Image = include_image!("icons/32x32.png");
TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
+4 -2
View File
@@ -95,7 +95,9 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
{ return StatusCode::FORBIDDEN.into_response(); }
let prefix = format!("/{}/api/", proxy.secret);
let Some(tail) = request.uri().path().strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
let path = request.uri().path().to_owned();
let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
let tail = tail.to_owned();
// Prevent URL normalization escaping the configured API path, including encoded segments.
if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e")
|| tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f")
@@ -106,7 +108,7 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
let mut response = if request.method() == Method::OPTIONS {
StatusCode::NO_CONTENT.into_response()
} else {
match transfer(&proxy, request, tail.to_owned()).await {
match transfer(&proxy, request, tail).await {
Ok(response) => response,
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
}
+1 -1
View File
@@ -24,7 +24,7 @@
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
"linux": {
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
"appimage": { "bundleMediaFramework": true }
"appimage": { "bundleMediaFramework": false }
},
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
}
+6
View File
@@ -9,6 +9,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
name: 'Pipu Music',
slug: 'pipu-music',
scheme: 'pipu',
icon: './assets/icon.png',
version,
orientation: 'portrait',
userInterfaceStyle: 'automatic',
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
newArchEnabled: false,
backgroundColor: '#05070D',
android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
monochromeImage: './assets/monochrome-icon.png',
backgroundColor: '#05070D',
},
package: 'app.pipu.music',
versionCode,
edgeToEdgeEnabled: true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+6
View File
@@ -91,6 +91,12 @@ export default function PlayerScreen() {
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
{track.artists.map((a) => a.name).join(', ')}
</Text>
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
<Icon name="playlist-add" size={24} color={colors.text2} />
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
</Pressable>
</View>
<View style={{ gap: 6 }}>
+6 -1
View File
@@ -272,7 +272,7 @@ class Downloads {
if (track.album.cover_url) {
const cover = this.cover(track.album.id);
const info = await FileSystem.getInfoAsync(cover);
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
}
const info = await FileSystem.getInfoAsync(this.file(id));
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
@@ -286,4 +286,9 @@ class Downloads {
}
}
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
export function sizedCover(url: string, size: number): string {
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
}
export const downloads = new Downloads();
+13 -10
View File
@@ -17,7 +17,7 @@ import TrackPlayer, {
} from 'react-native-track-player';
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
import { downloads } from '../offline/downloads';
import { downloads, sizedCover } from '../offline/downloads';
import { PersistentEvents } from '../offline/events';
import { offlineWave } from './offlineWave';
import { eventId } from './eventId';
@@ -224,15 +224,18 @@ class Controller {
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
return tracks.map((t) => ({
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
duration: t.duration_ms / 1000,
}));
return tracks.map((t) => {
const cover = absolute(this.server, t.album.cover_url);
return {
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
duration: t.duration_ms / 1000,
};
});
}
async playList(tracks: Track[], index: number, context: PlayContext) {
+2 -2
View File
@@ -20,7 +20,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads';
import { downloads, sizedCover } from '../offline/downloads';
import { absolute } from '../player/controller';
import { radius, useColors, type Colors } from '../theme';
@@ -161,7 +161,7 @@ export function Cover({
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined);
const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
const bg = album.cover_colors?.[0] ?? colors.surface3;
return (
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
+3 -1
View File
@@ -17,7 +17,9 @@ for (const width of [320, 1280]) {
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' });
await player.getByRole('button', { name: 'Добавить в плейлист', exact: true }).click();
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<title>pipu</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 840 B

+20
View File
@@ -409,6 +409,26 @@ describe('safeRedirect', () => {
});
describe('playlists', () => {
it('adds the playing track from the player, keeping the selected track when playback advances', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
await screen.findByRole('heading', { name: 'Группа крови' });
await user.click(screen.getAllByRole('listitem')[0]!);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'Из плеера');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(api.playlists.size).toBe(1));
expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
// Simulate automatic advancement while the picker is open.
await user.click(within(bar).getByRole('button', { name: 'Следующий' }));
await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']);
});
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
const alice = makeUser('alice', 'user');
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
+11 -2
View File
@@ -69,8 +69,16 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError,
});
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
const downloadAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/download', component: AppDownloadPage });
const androidAppRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/android',
beforeLoad: () => {
throw redirect({ to: '/download' });
},
component: AppDownloadPage,
});
const connectRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -262,6 +270,7 @@ const adminSettingsRoute = createRoute({
});
const routeTree = rootRoute.addChildren([
downloadAppRoute,
androidAppRoute,
connectRoute,
loginRoute,
+1
View File
@@ -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}`;
+10 -9
View File
@@ -1,12 +1,12 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { queueIcon } from '../shared/ui/icons';
import { playlistAddIcon } from '../shared/ui/icons';
import s from './catalog.module.css';
export type Playlist = components['schemas']['Playlist'];
@@ -61,7 +61,7 @@ export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
}
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
@@ -84,14 +84,15 @@ function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: ()
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
</div>
</Dialog>;
}
});
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
const close = useCallback(() => setSelectedIds(null), []);
return <>
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
{compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
</>;
}
+67 -32
View File
@@ -1,25 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatBytes } from '../shared/lib/format';
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
/**
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
* Сборки — последний релиз 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>
);
}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -47,7 +47,7 @@ export function LoginPage() {
{desktop ? null : (
<>
{' · '}
<a href="/android">{t('app.link')}</a>
<a href="/download">{t('app.link')}</a>
</>
)}
</>
+36
View File
@@ -0,0 +1,36 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { FormError } from '../shared/ui/ui';
import s from './page.module.css';
import g from './genius.module.css';
export function TrackGenius({ id }: { id: string }) {
const session = useSession();
const { t } = useTranslation();
const errorText = useErrorText();
const query = useQuery({
queryKey: ['track-genius', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}/genius', { params: { path: { id } } })),
refetchInterval: (q) => ['pending', 'processing'].includes(q.state.data?.status ?? '') ? 10000 : false,
});
const data = query.data;
if (data?.status === 'disabled') return null;
return <section className={s.section}>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p>{t('genius.loading')}</p>}
{data?.description && <><h2>{t('genius.about')}</h2><p className={g.lyrics}>{data.description}</p></>}
<div className={s.head}><h2>{t('genius.lyrics')}</h2>{data?.source_url && <a href={data.source_url} target="_blank" rel="noopener noreferrer">Genius ↗</a>}</div>
{data && (data.lyrics ? <div className={g.lyrics}>{data.lyrics}</div> : <p className={s.subtitle}>{['pending', 'processing'].includes(data.status) ? t('genius.pending_text') : t('genius.no_lyrics')}</p>)}
{!!data?.annotations.length && <>
<h2>{t('genius.explanations')}</h2>
{data.annotations.map((a, i) => <details className={g.annotation} key={`${a.url}-${i}`}>
<summary>{a.fragment}{a.verified && <span className={g.badge}>{t('genius.verified')}</span>}</summary>
<p className={g.lyrics}>{a.text}</p>
<p className={s.subtitle}>{a.authors}{a.url && <> · <a href={a.url} target="_blank" rel="noopener noreferrer">Genius ↗</a></>}</p>
</details>)}
</>}
</section>;
}
+2
View File
@@ -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>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import g from '../genius.module.css';
export function GeniusPanel() {
const { t } = useTranslation();
const session = useSession();
const queries = useQueryClient();
const errorText = useErrorText();
const stats = useQuery({
queryKey: ['genius-stats'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/genius')),
refetchInterval: 5000,
});
const control = useMutation({
mutationFn: async (action: 'start' | 'pause' | 'retry') =>
unwrap(await session.client.POST('/api/v1/admin/genius', { body: { action } })),
onSuccess: (data) => { queries.setQueryData(['genius-stats'], data); },
});
const data = stats.data;
const cooling = data && Date.parse(data.cooldown_until) > Date.now();
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : t('genius.waiting');
return <section className={s.section} aria-label="Genius">
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
<p className={s.subtitle}>{t('genius.description')}</p>
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
{data && <>
{!data.configured && <p>{t('genius.configure')}</p>}
<dl className={g.stats}>
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{data[key].toLocaleString()}</dd></div>)}
</dl>
{data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>}
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
<div className={g.actions}>
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
</div>
</>}
</section>;
}
+4 -1
View File
@@ -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) => (
+35 -1
View File
@@ -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;
}
+8
View File
@@ -0,0 +1,8 @@
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; margin: 0; }
.stats dt { color: var(--text2); font-size: 13px; }
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
.lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
.annotation summary { cursor: pointer; line-height: 1.6; }
.badge { display: inline-block; margin-left: 12px; font-size: 12px; color: var(--text2); }
+2
View File
@@ -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; } }
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { detectAppClient, otherAppClients } from './platform';
describe('detectAppClient', () => {
it('picks the native client from the user agent', () => {
expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android');
expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos');
expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull();
});
it('lists the remaining platforms after the detected one', () => {
expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']);
});
});
+17
View File
@@ -0,0 +1,17 @@
export type AppClient = 'android' | 'windows' | 'linux' | 'macos';
const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android'];
/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */
export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null {
if (/Android/i.test(ua)) return 'android';
if (/iPhone|iPad|iPod/i.test(ua)) return null;
if (/Windows/i.test(ua)) return 'windows';
if (/Mac OS X|Macintosh/i.test(ua)) return 'macos';
if (/Linux|X11/i.test(ua)) return 'linux';
return null;
}
export function otherAppClients(primary: AppClient | null): AppClient[] {
return ORDER.filter((c) => c !== primary);
}
+1
View File
@@ -70,4 +70,5 @@ export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_e
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
+3 -2
View File
@@ -21,9 +21,10 @@
}
.logoDot {
display: inline-block;
flex: none;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
background: url('/favicon.svg') center / contain no-repeat;
}
.button {
+25
View File
@@ -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 -2
View File
@@ -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() },
]}
/>
+5 -1
View File
@@ -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 {
+2
View File
@@ -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>
+4
View File
@@ -44,6 +44,10 @@ LOG_LEVEL=info
SUPPORTED_LOCALES=ru,en
MIN_ANDROID_VERSION_CODE=0
# Genius: empty token disables enrichment. Queue and controls: Admin → Sources.
GENIUS_ACCESS_TOKEN=
GENIUS_REQUEST_INTERVAL=2s
# --- Самообновление Android (M5) ---
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
+1
View File
@@ -15,6 +15,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
| [genius.md](genius.md) | Genius: токен, поиск метаданных, парсинг текстов и проверочная команда |
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
| [web.md](web.md) | Веб-клиент |
+4 -2
View File
@@ -102,8 +102,10 @@
прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа
и в меню профиля.
## 7. Наблюдение
+2
View File
@@ -106,6 +106,8 @@
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
| Метод | Путь | Описание |
|---|---|---|
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url}]}` — 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) |
+1 -1
View File
@@ -76,7 +76,7 @@
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
| `TRANSCODE_CONCURRENCY` | `2` | |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/download` |
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
| `LOG_LEVEL` | `info` | |
+7 -1
View File
@@ -43,7 +43,13 @@
| `overlay` | `rgba(3,5,10,.66)` | `rgba(20,30,60,.32)` | Подложка диалогов |
| `glass` | `rgba(12,16,28,.78)` | `rgba(255,255,255,.84)` | Стеклянные карточки (вход, диалоги) + `backdrop-filter: blur(28px) saturate(1.3)` |
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — радиальный градиент `#9CC6FF → #2F80FF → #3A1FA8`.
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
и монохромный слои с отступами для масок лаунчера; фон — `#05070D`.
После изменения значков установленные приложения нужно пересобрать и обновить.
### Типографика
+3 -1
View File
@@ -12,4 +12,6 @@ pnpm --filter @pipu/desktop dev
pnpm --filter @pipu/desktop bundle
```
На Windows получается NSIS-установщик, на Linux — AppImage, deb и rpm. Зависимости Linux: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
На Windows получается NSIS-установщик (~3 МБ). На Linux в релиз идёт только `.deb` (~4 МБ): AppImage тащит WebKitGTK и раздувается до ~80 МБ. Зависимости пакета: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
Релиз по тегу собирает Linux `.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea.
+83
View File
@@ -0,0 +1,83 @@
# Genius: поиск метаданных и получение текста
Реализован Go-провайдер `services/api/internal/genius` и отдельная команда
`services/api/cmd/genius`. Поиск через API возвращает кандидатов, ссылки на изображения
и исполнителя. Текст выбранной песни извлекается из HTML страницы: из контейнеров
`data-lyrics-container` либо из `songPage.lyricsData.body.html` во встроенном состоянии.
JavaScript не исполняется. Используется HTML-парсер `golang.org/x/net/html`.
Это проверяемый первый этап: команда работает отдельно от сервера. Автоматическое
дополнение каталога, хранение текстов в БД, аннотации и отображение в плеере пока
не подключены. Поэтому переменная ниже пока нужна именно процессу команды,
а не Docker Compose или веб-клиенту.
## Почему Go
Основной API Pipu написан на Go. Небольшой провайдер можно подключить к нему без
отдельного Python-сервиса. LyricsGenius удобен для Python, но собственный адаптер
позволяет явно контролировать выбор версии песни, сетевые ограничения и ошибки.
HTML Genius не является стабильным API: при изменении разметки парсер потребует обновления.
## Получение токена
1. Войти или зарегистрироваться на https://genius.com.
2. Открыть https://genius.com/api-clients и создать новый API Client.
3. Указать название, например `Pipu Music`, и URL своего приложения. Если форма
требует Redirect URI, указать URL под своим контролем; для чтения с Client Access
Token OAuth-переходы и обработчик callback в Pipu не нужны.
4. В созданном клиенте нажать **Generate Access Token**.
5. Сохранить **Client Access Token** в переменной `GENIUS_ACCESS_TOKEN` процесса.
Client ID и Client Secret вместо него не подходят.
Токен нужен для поиска через `api.genius.com`. Получение HTML по уже известному
URL не требует токена и не отправляет заголовок Authorization. Не помещать токен
в git, клиентские переменные `VITE_*`, логи или переписку.
Источник: https://lyricsgenius.readthedocs.io/en/master/setup.html.
## Проверка в PowerShell 7
Из корня репозитория:
```powershell
Set-Location services/api
$env:GENIUS_ACCESS_TOKEN = Read-Host 'Genius Client Access Token' -MaskInput
go run ./cmd/genius -search 'Исполнитель Название песни'
```
Выбрать нужного исполнителя и версию песни из результатов. Команда намеренно
не считает первый результат правильным. Затем подставить возвращённый `url`:
```powershell
go run ./cmd/genius -url 'https://genius.com/Artist-song-lyrics'
Remove-Item Env:GENIUS_ACCESS_TOKEN
```
URL во втором примере — шаблон. Результат — JSON с `text`, `source_url` и временем
получения `fetched_at`. Это обычный текст без временной синхронизации.
## Ограничения и дальнейшее подключение
- HTTP 401, 403, 404, 429 и другие ошибки возвращаются явно; `HTTPError` сохраняет
`Retry-After`. Автоматических повторов и обхода блокировок нет.
- Таймаут запроса — 15 секунд, предел ответа — 8 MiB. Разрешён только HTTPS
на Genius; перенаправления на другой хост запрещены.
- Отсутствие распознаваемого текста возвращает `ErrLyricsUnavailable`; HTML
блокировки или навигация не становятся текстом песни.
- Перед интеграцией в каталог нужны кэш результатов, лимит частоты запросов,
сохранение выбранного Genius ID и обработка временных ошибок без потери старого текста.
- Токен API не предоставляет лицензию на тексты. Документация LyricsGenius
предупреждает, что парсинг противоречит условиям Genius; отображление и хранение
в публичном сервисе требуют отдельной проверки разрешений:
https://lyricsgenius.readthedocs.io/en/latest/how_it_works.html.
## Тесты
```powershell
go test ./internal/genius ./cmd/genius
```
Тесты используют синтетические тексты и подмену HTTP: переносы, HTML-сущности,
служебные элементы, встроенное состояние, ошибки HTTP, проверка URL и
отсутствие токена при запросе страницы. Реальный доступ к Genius проверяется
отдельно из сети сервера с собственным токеном.
+1 -1
View File
@@ -246,7 +246,7 @@ curl -s https://music.example.com/readyz
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
ссылка `https://<ваш-домен>/android`.
ссылка `https://<ваш-домен>/download`.
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
+1 -1
View File
@@ -99,7 +99,7 @@
## Самообновление
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция.
Приложение распространяется только через Gitea Releases (не Google Play).
+1 -1
View File
@@ -22,7 +22,7 @@
```
apps/web/src/
├── app/ # провайдеры, роутер, глобальные стили, гарды
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
├── pages/ # login, setup-admin, setup-password, download, home, search, artist, album,
│ # collection/{playlists,history,likes,dislikes}, playlist, track, settings,
│ # admin/{users,sources,library,upload,upload-queue,settings}
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
+1
View File
@@ -6,6 +6,7 @@
"node": ">=22"
},
"scripts": {
"icons": "node scripts/generate-icons.mjs",
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
+235
View File
@@ -4,6 +4,40 @@
*/
export interface paths {
"/api/v1/admin/genius": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getGeniusStats"];
put?: never;
post: operations["controlGenius"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tracks/{id}/genius": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
get: operations["getTrackGenius"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/healthz": {
parameters: {
query?: never;
@@ -1293,6 +1327,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;
+31 -7
View File
@@ -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"
}
}
+9 -1
View File
@@ -1,4 +1,12 @@
{
"genius": {
"description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.",
"loading": "Loading…", "disabled": "Not configured", "paused": "Paused", "cooldown": "Waiting after Genius rate limit", "running": "Processing", "waiting": "Waiting for new tracks",
"configure": "Add GENIUS_ACCESS_TOKEN to deploy/.env and recreate the API container.",
"total": "Total tracks", "pending": "Queued", "matched": "Matched on Genius", "lyrics": "Lyrics", "annotations": "With annotations", "images": "With artwork", "unmatched": "No exact match", "errors": "Errors / incomplete data",
"last_error": "Last error", "token_error": "Genius rejected the token. Check it and restart the API.", "access_error": "Genius restricted access. Processing is paused.", "no_lyrics": "Lyrics are currently unavailable.", "request_error": "Some data could not be retrieved. A retry is scheduled automatically.", "resume_at": "Retry no earlier than",
"start": "Resume", "pause": "Pause", "retry": "Retry errors and unmatched", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius"
},
"app": {
"name": "pipu",
"tagline": "Your music from your own server"
@@ -41,7 +49,7 @@
"menu": "Profile menu",
"settings": "Settings",
"logout": "Sign out",
"android_app": "Android app"
"download_apps": "Download apps"
},
"roles": {
"admin": "Administrator",
+31 -7
View File
@@ -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": "Войти в веб-версию"
}
}
+9 -1
View File
@@ -1,4 +1,12 @@
{
"genius": {
"description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.",
"loading": "Загрузка…", "disabled": "Не подключён", "paused": "Приостановлено", "cooldown": "Ожидание после ограничения Genius", "running": "Обработка", "waiting": "Ожидание новых треков",
"configure": "Добавьте GENIUS_ACCESS_TOKEN в deploy/.env и пересоздайте контейнер API.",
"total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные",
"last_error": "Последняя ошибка", "token_error": "Genius отклонил токен. Проверьте его и перезапустите API.", "access_error": "Genius ограничил доступ. Обработка приостановлена.", "no_lyrics": "Текст пока недоступен.", "request_error": "Не удалось получить все данные. Повтор запланирован автоматически.", "resume_at": "Повтор не раньше",
"start": "Продолжить", "pause": "Приостановить", "retry": "Повторить ошибки и ненайденные", "pending_text": "Трек ожидает обработки Genius.", "about": "О песне", "explanations": "Пояснения к песне", "verified": "Подтверждено в Genius"
},
"app": {
"name": "pipu",
"tagline": "Ваша музыка с вашего сервера"
@@ -41,7 +49,7 @@
"menu": "Меню профиля",
"settings": "Настройки",
"logout": "Выйти",
"android_app": "Приложение для Android"
"download_apps": "Скачать приложения"
},
"roles": {
"admin": "Администратор",
+51
View File
@@ -0,0 +1,51 @@
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
// All platform assets derive from the editable web SVG. Requires workspace dependencies.
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const desktop = join(root, 'apps/desktop');
const require = createRequire(join(desktop, 'package.json'));
const cli = require.resolve('@tauri-apps/cli/tauri.js');
const scratch = join(root, 'artifacts/icon-build');
const source = join(root, 'apps/web/public/favicon.svg');
const svg = readFileSync(source, 'utf8');
const body = svg.replace(/<svg[^>]*>/, '').replace('</svg>', '');
mkdirSync(scratch, { recursive: true });
function render(input, output, sizes = []) {
mkdirSync(output, { recursive: true });
const result = spawnSync(process.execPath, [cli, 'icon', input, '--output', output,
...sizes.flatMap(size => ['--png', String(size)])], { cwd: desktop, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`Icon generation failed: ${input}`);
}
function variant(name, contents, size, destination) {
const input = join(scratch, `${name}.svg`);
const output = join(scratch, name);
writeFileSync(input, `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">${contents}</svg>\n`);
render(input, output, [size]);
mkdirSync(dirname(destination), { recursive: true });
copyFileSync(join(output, `${size}x${size}.png`), destination);
}
const native = join(scratch, 'desktop');
render(source, native);
for (const name of ['32x32.png', '64x64.png', '128x128.png', '128x128@2x.png', 'icon.png', 'icon.ico', 'icon.icns']) {
copyFileSync(join(native, name), join(desktop, 'src-tauri/icons', name));
}
copyFileSync(join(native, '32x32.png'), join(root, 'apps/web/public/favicon-32.png'));
const background = '<rect width="512" height="512" fill="#05070D"/>';
variant('touch', background + body, 180, join(root, 'apps/web/public/apple-touch-icon.png'));
variant('mobile', background + body, 1024, join(root, 'apps/mobile/assets/icon.png'));
// The complete orb fits inside Android's central 66/108 safe-zone circle.
variant('adaptive', `<g transform="translate(92.16 92.16) scale(.64)">${body}</g>`, 1024,
join(root, 'apps/mobile/assets/adaptive-icon.png'));
const mono = body.replace(/<defs>[\s\S]*?<\/defs>/, '').replace(/<circle[^>]*\/>/, '')
.replaceAll('url(#note)', '#FFFFFF');
variant('monochrome', `<g transform="translate(51.2 51.2) scale(.8)">${mono}</g>`, 1024,
join(root, 'apps/mobile/assets/monochrome-icon.png'));
console.log('Updated desktop, Android, and browser icons.');
+100 -15
View File
@@ -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])) {
+35
View File
@@ -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, /Предварительная версия/);
});
+5
View File
@@ -21,6 +21,7 @@ import (
"pipu/api/internal/appupdate"
"pipu/api/internal/auth"
"pipu/api/internal/buildinfo"
"pipu/api/internal/genius"
"pipu/api/internal/library"
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/db"
@@ -105,6 +106,8 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
})
libStore := library.NewStore(pool)
geniusSvc := genius.NewService(pool, cfg.GeniusAccessToken, cfg.GeniusRequestInterval, log)
go geniusSvc.Run(ctx)
board := library.NewProgressBoard()
scanner := library.NewScanner(libStore, prober, board, log)
agent := library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken)
@@ -132,6 +135,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
covers.Run(ctx)
indexer.Trigger()
fingerprints.Trigger(ctx)
geniusSvc.Wake()
}
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
fingerprints.Trigger(ctx)
@@ -168,6 +172,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log)
wh.Warm = pb.Prewarm
return server.NewRouter(server.Deps{
Genius: genius.NewHandler(geniusSvc),
Log: log,
System: sys,
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
+39
View File
@@ -0,0 +1,39 @@
// Command genius searches metadata or retrieves lyrics from an explicitly selected Genius URL.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"pipu/api/internal/genius"
)
func main() {
query := flag.String("search", "", "search candidates by artist and title (requires GENIUS_ACCESS_TOKEN)")
page := flag.String("url", "", "retrieve lyrics from a selected https://genius.com/...-lyrics URL")
flag.Parse()
if (*query == "") == (*page == "") || flag.NArg() != 0 {
fmt.Fprintln(os.Stderr, "provide exactly one of -search or -url")
os.Exit(2)
}
client := genius.New(os.Getenv("GENIUS_ACCESS_TOKEN"))
var result any
var err error
if *query != "" {
result, err = client.Search(context.Background(), *query)
} else {
result, err = client.FetchLyrics(context.Background(), *page)
}
if err == nil {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
err = encoder.Encode(result)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+1
View File
@@ -17,6 +17,7 @@ require (
github.com/redis/go-redis/v9 v9.10.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
golang.org/x/sys v0.33.0
golang.org/x/text v0.25.0
)
File diff suppressed because it is too large. Load diff
+212 -52
View File
@@ -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))
+1
View File
@@ -566,5 +566,6 @@ func TestPublicRoutes(t *testing.T) {
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/clients", nil).Code, "download page is public")
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
}
+4 -2
View File
@@ -55,8 +55,9 @@ var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true,
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
// Клиенты раздаются по ссылке /download без входа: в файлах нет ничего, кроме приложения.
"GET /api/v1/app/android/latest": true,
"GET /api/v1/app/clients": true,
}
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
@@ -93,7 +94,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/"))
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx))
return
+176
View File
@@ -0,0 +1,176 @@
// Package genius retrieves song metadata from the API and plain lyrics from public HTML.
package genius
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
var ErrLyricsUnavailable = errors.New("genius: lyrics absent or page markup unsupported")
// HTTPError preserves throttling information without exposing response bodies or credentials.
type HTTPError struct {
StatusCode int
RetryAfter string
}
func (e *HTTPError) Error() string { return fmt.Sprintf("genius: HTTP %d", e.StatusCode) }
type Artist struct {
ID int64 `json:"id"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
}
type Song struct {
ID int64 `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
ImageURL string `json:"song_art_image_url"`
ThumbURL string `json:"song_art_image_thumbnail_url"`
HeaderURL string `json:"header_image_url"`
Artist Artist `json:"primary_artist"`
Description struct {
Plain string `json:"plain"`
} `json:"description"`
Album struct {
Name string `json:"name"`
ImageURL string `json:"cover_art_url"`
ThumbURL string `json:"cover_art_thumbnail_url"`
} `json:"album"`
}
type Lyrics struct {
Text string `json:"text"`
SourceURL string `json:"source_url"`
FetchedAt time.Time `json:"fetched_at"`
}
type Client struct {
token string
http *http.Client
mu sync.Mutex
next time.Time
interval time.Duration
}
// SetInterval must be called before sharing the client with workers.
func (c *Client) SetInterval(d time.Duration) { c.interval = max(d, time.Second) }
func New(token string) *Client {
return &Client{token: strings.TrimSpace(token), http: &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("genius: too many redirects")
}
// Do not forward credentials or fetch content outside the initial host.
if req.URL.Scheme != "https" || req.URL.Host != via[0].URL.Host || req.URL.User != nil {
return errors.New("genius: refused redirect")
}
return nil
},
}}
}
// Search returns candidates; callers must select a version rather than assume the first hit matches.
func (c *Client) Search(ctx context.Context, query string) ([]Song, error) {
if c.token == "" {
return nil, errors.New("genius: GENIUS_ACCESS_TOKEN is required for search")
}
if strings.TrimSpace(query) == "" {
return nil, errors.New("genius: empty search query")
}
body, err := c.get(ctx, "https://api.genius.com/search?q="+url.QueryEscape(query), true)
if err != nil {
return nil, err
}
var result struct {
Response struct {
Hits []struct {
Type string `json:"type"`
Result Song `json:"result"`
} `json:"hits"`
} `json:"response"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("genius: decode search: %w", err)
}
songs := make([]Song, 0, len(result.Response.Hits))
for _, hit := range result.Response.Hits {
if hit.Type == "song" {
songs = append(songs, hit.Result)
}
}
return songs, nil
}
// FetchLyrics accepts a selected public song URL. The token is never sent to the HTML site.
func (c *Client) FetchLyrics(ctx context.Context, rawURL string) (Lyrics, error) {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme != "https" || u.Host != "genius.com" || u.User != nil || !strings.HasSuffix(u.Path, "-lyrics") {
return Lyrics{}, errors.New("genius: expected https://genius.com/<song>-lyrics")
}
u.RawQuery, u.Fragment = "", ""
body, err := c.get(ctx, u.String(), false)
if err != nil {
return Lyrics{}, err
}
text, err := parseLyrics(body)
if err != nil {
return Lyrics{}, err
}
return Lyrics{Text: text, SourceURL: u.String(), FetchedAt: time.Now().UTC()}, nil
}
func (c *Client) get(ctx context.Context, endpoint string, api bool) ([]byte, error) {
c.mu.Lock()
wait := time.Until(c.next)
c.next = time.Now().Add(max(wait, 0) + c.interval)
c.mu.Unlock()
if wait > 0 {
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "PipuMusic/1.0")
if api {
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
} else {
req.Header.Set("Accept", "text/html")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("genius: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &HTTPError{StatusCode: resp.StatusCode, RetryAfter: resp.Header.Get("Retry-After")}
}
const maxBody = 8 << 20
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
if err != nil {
return nil, err
}
if len(body) > maxBody {
return nil, errors.New("genius: response exceeds 8 MiB")
}
return body, nil
}
@@ -0,0 +1,92 @@
package genius
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type transportFunc func(*http.Request) (*http.Response, error)
func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestLyricsFormatting(t *testing.T) {
text, err := parseLyrics([]byte(`<header>Not lyrics</header><div data-lyrics-container="true"><div class="LyricsHeader-x">Title</div>[Куплет 1]<br>Привет &amp; <a href="/annotation">мир</a><br><br>Next <b>line</b><span data-exclude-from-selection="true">Embed</span><script>bad()</script></div><div data-lyrics-container="true">[Припев]<br>Again<button>Share</button></div>`))
require.NoError(t, err)
require.Equal(t, "[Куплет 1]\nПривет & мир\n\nNext line\n\n[Припев]\nAgain", text)
}
func TestUnavailable(t *testing.T) {
for _, page := range []string{`<h1>Just a moment</h1>`, `<div data-lyrics-container="true"> </div>`, `<script>window.__PRELOADED_STATE__ = JSON.parse('broken')</script>`} {
_, err := parseLyrics([]byte(page))
require.ErrorIs(t, err, ErrLyricsUnavailable)
}
}
func TestEmbeddedLyrics(t *testing.T) {
text, err := parseLyrics([]byte(`<script>window.__PRELOADED_STATE__ = JSON.parse('{"songPage":{"lyricsData":{"body":{"html":"<p>[Verse]<br>It\'s \\"fine\\" &amp; good<br>Привет</p>"}}}}')</script>`))
require.NoError(t, err)
require.Equal(t, "[Verse]\nIt's \"fine\" & good\nПривет", text)
}
func TestURLValidation(t *testing.T) {
c := New("secret")
c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network call"); return nil, nil })
for _, u := range []string{"https://evil.test/song-lyrics", "http://genius.com/song-lyrics", "https://genius.com:443/song-lyrics", "https://secret@genius.com/song-lyrics", "https://genius.com/artists/test"} {
_, err := c.FetchLyrics(context.Background(), u)
require.Error(t, err)
}
}
func TestRequests(t *testing.T) {
c := New("secret")
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
body := `<div data-lyrics-container="true">Synthetic test text</div>`
if r.URL.Host == "api.genius.com" {
require.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
require.Equal(t, "artist & title", r.URL.Query().Get("q"))
body = `{"response":{"hits":[{"type":"song","result":{"id":12,"title":"Example","url":"https://genius.com/example-lyrics","primary_artist":{"name":"Artist"}}}]}}`
} else {
require.Empty(t, r.Header.Get("Authorization"))
require.Empty(t, r.URL.RawQuery)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
songs, err := c.Search(context.Background(), "artist & title")
require.NoError(t, err)
require.Len(t, songs, 1)
lyrics, err := c.FetchLyrics(context.Background(), songs[0].URL+"?tracking=1")
require.NoError(t, err)
require.Equal(t, "Synthetic test text", lyrics.Text)
require.Equal(t, songs[0].URL, lyrics.SourceURL)
require.False(t, lyrics.FetchedAt.IsZero())
}
func TestHTTPFailures(t *testing.T) {
for _, status := range []int{401, 403, 404, 429, 500} {
c := New("secret")
c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: status, Header: http.Header{"Retry-After": {"60"}}, Body: io.NopCloser(strings.NewReader("private upstream body"))}, nil
})
_, err := c.Search(context.Background(), "test")
var httpErr *HTTPError
require.True(t, errors.As(err, &httpErr))
require.Equal(t, status, httpErr.StatusCode)
require.Equal(t, "60", httpErr.RetryAfter)
require.NotContains(t, err.Error(), "private")
}
}
func TestRedirects(t *testing.T) {
c := New("secret")
first, _ := http.NewRequest("GET", "https://api.genius.com/search", nil)
for _, endpoint := range []string{"http://api.genius.com/search", "https://genius.com/song-lyrics", "https://evil.test/"} {
req, _ := http.NewRequest("GET", endpoint, nil)
require.Error(t, c.http.CheckRedirect(req, []*http.Request{first}))
}
}
+98
View File
@@ -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
}
+174
View File
@@ -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"))
}
+181
View File
@@ -0,0 +1,181 @@
package genius
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"unicode"
"pipu/api/internal/api/gen"
)
func normalize(s string) string {
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }), " ")
}
// Exact normalized identity; retain version qualifiers such as live/remix.
func matchSong(songs []Song, artist, title string) (Song, bool) {
var matched Song
for _, s := range songs {
if normalize(s.Title) == normalize(title) && normalize(s.Artist.Name) == normalize(artist) {
if matched.ID != 0 && matched.ID != s.ID {
return Song{}, false
}
matched = s
}
}
return matched, matched.ID != 0
}
func imageURL(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Scheme != "https" || u.User != nil || strings.Contains(strings.ToLower(u.Path), "default") {
return ""
}
switch u.Host {
case "images.genius.com", "images.rapgenius.com", "t2.genius.com":
return u.String()
default:
return ""
}
}
func firstImage(urls ...string) string {
for _, raw := range urls {
if u := imageURL(raw); u != "" {
return u
}
}
return ""
}
// songArtwork prefers the full song resource: search hits often omit album art and the artist image.
func songArtwork(song Song) (songImage, artistImage, albumImage string) {
return firstImage(song.ImageURL, song.ThumbURL, song.HeaderURL), imageURL(song.Artist.ImageURL), firstImage(song.Album.ImageURL, song.Album.ThumbURL)
}
func urlPath(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return ""
}
return u.Path
}
func sourceURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host != "genius.com" || u.User != nil {
return ""
}
return u.String()
}
func (c *Client) Song(ctx context.Context, id int64) (Song, error) {
if id <= 0 {
return Song{}, errors.New("genius: song id required")
}
body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/songs/%d?text_format=plain", id), true)
if err != nil {
return Song{}, err
}
var data struct {
Response struct {
Song Song `json:"song"`
} `json:"response"`
}
if err = json.Unmarshal(body, &data); err != nil {
return Song{}, fmt.Errorf("genius: decode song: %w", err)
}
if data.Response.Song.ID == 0 {
return Song{}, errors.New("genius: song missing")
}
return data.Response.Song, nil
}
func (c *Client) Artist(ctx context.Context, id int64) (Artist, error) {
if id <= 0 {
return Artist{}, errors.New("genius: artist id required")
}
body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/artists/%d", id), true)
if err != nil {
return Artist{}, err
}
var data struct {
Response struct {
Artist Artist `json:"artist"`
} `json:"response"`
}
if err = json.Unmarshal(body, &data); err != nil {
return Artist{}, fmt.Errorf("genius: decode artist: %w", err)
}
if data.Response.Artist.ID == 0 {
return Artist{}, errors.New("genius: artist missing")
}
return data.Response.Artist, nil
}
func (c *Client) Annotations(ctx context.Context, id int64) ([]gen.GeniusAnnotation, error) {
out := []gen.GeniusAnnotation{}
var seen int64
haveSeen := false
for page := 1; ; page++ {
body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/referents?song_id=%d&text_format=plain&per_page=50&page=%d", id, page), true)
if err != nil {
return nil, err
}
var data struct {
Response struct {
Referents []struct {
ID int64 `json:"id"`
Fragment string `json:"fragment"`
Annotations []struct {
Body struct {
Plain string `json:"plain"`
} `json:"body"`
URL string `json:"url"`
Verified bool `json:"verified"`
Authors []struct {
User struct {
Name string `json:"name"`
} `json:"user"`
} `json:"authors"`
} `json:"annotations"`
} `json:"referents"`
} `json:"response"`
}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("genius: decode annotations: %w", err)
}
refs := data.Response.Referents
if len(refs) == 0 || (haveSeen && refs[0].ID == seen) {
return out, nil
}
if refs[0].ID != 0 {
seen = refs[0].ID
haveSeen = true
}
for _, r := range refs {
for _, a := range r.Annotations {
if a.Body.Plain == "" {
continue
}
names := []string{}
for _, author := range a.Authors {
if author.User.Name != "" {
names = append(names, author.User.Name)
}
}
out = append(out, gen.GeniusAnnotation{Fragment: r.Fragment, Text: a.Body.Plain, Url: sourceURL(a.URL), Verified: a.Verified, Authors: strings.Join(names, ", ")})
}
}
if len(refs) < 50 {
return out, nil
}
if page == 20 {
return nil, fmt.Errorf("genius: annotation pagination limit")
}
}
}
@@ -0,0 +1,97 @@
package genius
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestMatchSong(t *testing.T) {
songs := []Song{
{ID: 1, Title: "Alright (Live)", Artist: Artist{Name: "Kendrick Lamar"}},
{ID: 2, Title: "Alright!", Artist: Artist{Name: "Kendrick Lamar"}},
{ID: 3, Title: "Alright", Artist: Artist{Name: "Someone Else"}},
}
got, ok := matchSong(songs, "kendrick-lamar", "Alright")
require.True(t, ok)
require.Equal(t, int64(2), got.ID)
_, ok = matchSong(append(songs, Song{ID: 4, Title: "Alright", Artist: Artist{Name: "Kendrick Lamar"}}), "Kendrick Lamar", "Alright")
require.False(t, ok)
_, ok = matchSong(songs, "Kendrick Lamar", "Alright Remix")
require.False(t, ok)
}
func TestImageURL(t *testing.T) {
require.Equal(t, "https://images.genius.com/cover.jpg", imageURL("https://images.genius.com/cover.jpg"))
require.Equal(t, "https://images.rapgenius.com/a.png", imageURL("https://images.rapgenius.com/a.png"))
for _, raw := range []string{
"http://images.genius.com/a.jpg",
"https://evil.test/a.jpg",
"https://images.genius.com/default_avatar.png",
"https://user@images.genius.com/a.jpg",
"",
} {
require.Empty(t, imageURL(raw))
}
song := Song{
ImageURL: "https://images.genius.com/default_cover_image.png",
ThumbURL: "https://images.genius.com/song.jpg",
Artist: Artist{ImageURL: "https://images.genius.com/artist.jpg"},
}
song.Album.ImageURL = "https://images.genius.com/album.jpg"
songImage, artistImage, albumImage := songArtwork(song)
require.Equal(t, "https://images.genius.com/song.jpg", songImage)
require.Equal(t, "https://images.genius.com/artist.jpg", artistImage)
require.Equal(t, "https://images.genius.com/album.jpg", albumImage)
}
func TestSongAndArtist(t *testing.T) {
c := New("secret")
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
require.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
body := `{"response":{"artist":{"id":9,"name":"Artist","image_url":"https://images.genius.com/artist.jpg"}}}`
if strings.Contains(r.URL.Path, "/songs/") {
require.Equal(t, "plain", r.URL.Query().Get("text_format"))
body = `{"response":{"song":{"id":12,"title":"Example","url":"https://genius.com/example-lyrics","song_art_image_url":"https://images.genius.com/song.jpg","primary_artist":{"id":9,"name":"Artist"},"album":{"name":"Album","cover_art_url":"https://images.genius.com/album.jpg"},"description":{"plain":"About the song"}}}}`
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
song, err := c.Song(context.Background(), 12)
require.NoError(t, err)
require.Equal(t, "About the song", song.Description.Plain)
require.Equal(t, "Album", song.Album.Name)
artist, err := c.Artist(context.Background(), 9)
require.NoError(t, err)
require.Equal(t, "https://images.genius.com/artist.jpg", artist.ImageURL)
_, err = c.Song(context.Background(), 0)
require.Error(t, err)
}
func TestAnnotations(t *testing.T) {
c := New("secret")
pages := 0
one := `{"id":1,"fragment":"line","annotations":[{"body":{"plain":"means this"},"url":"https://genius.com/annotations/1","verified":true,"authors":[{"user":{"name":"Editor"}}]}]}`
full := `{"response":{"referents":[` + strings.TrimRight(strings.Repeat(one+",", 50), ",") + `]}}`
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
pages++
require.Equal(t, "plain", r.URL.Query().Get("text_format"))
body := full
if pages > 1 {
body = `{"response":{"referents":[` + one + `]}}`
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
notes, err := c.Annotations(context.Background(), 12)
require.NoError(t, err)
require.Equal(t, 2, pages)
require.Len(t, notes, 50)
require.Equal(t, "line", notes[0].Fragment)
require.Equal(t, "means this", notes[0].Text)
require.Equal(t, "Editor", notes[0].Authors)
require.True(t, notes[0].Verified)
require.Equal(t, "https://genius.com/annotations/1", notes[0].Url)
}
+272
View File
@@ -0,0 +1,272 @@
package genius
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"pipu/api/internal/api/gen"
)
type Service struct {
pool *pgxpool.Pool
client *Client
configured bool
log *slog.Logger
wake chan struct{}
}
func NewService(pool *pgxpool.Pool, token string, interval time.Duration, log *slog.Logger) *Service {
client := New(token)
client.SetInterval(interval)
return &Service{pool: pool, client: client, configured: strings.TrimSpace(token) != "", log: log, wake: make(chan struct{}, 1)}
}
func (s *Service) Wake() {
select {
case s.wake <- struct{}{}:
default:
}
}
// Run discovers new tracks, including imports, without making catalog requests wait for Genius.
func (s *Service) Run(ctx context.Context) {
if !s.configured {
return
}
timer := time.NewTicker(30 * time.Second)
defer timer.Stop()
for {
if err := s.drain(ctx); err != nil && ctx.Err() == nil {
s.log.Error("genius worker", "err", err)
}
select {
case <-ctx.Done():
return
case <-timer.C:
case <-s.wake:
}
}
}
func (s *Service) drain(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `INSERT INTO genius_tracks(track_id) SELECT id FROM tracks ON CONFLICT DO NOTHING`)
if err != nil {
return err
}
for ctx.Err() == nil {
var id uuid.UUID
// Atomic claim and persisted lease recover interrupted work after a restart.
err := s.pool.QueryRow(ctx, `UPDATE genius_tracks SET status='processing', locked_until=now()+interval '10 minutes', attempts=attempts+1
WHERE track_id=(SELECT track_id FROM genius_tracks WHERE status IN ('pending','processing','error','partial')
AND next_attempt<=now() AND locked_until<=now()
AND EXISTS(SELECT 1 FROM genius_state WHERE NOT paused AND cooldown_until<=now())
ORDER BY next_attempt FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING track_id`).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
jobCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
content, albumImage, err := s.enrich(jobCtx, id)
cancel()
if ctx.Err() != nil {
return ctx.Err()
}
status, message, next := "ready", "", time.Now().Add(30*24*time.Hour)
if content.SourceUrl == "" {
status = "unmatched"
}
if err != nil {
message = errorCode(err)
status = "error"
if content.SourceUrl != "" {
status = "partial"
}
next = time.Now().Add(6 * time.Hour)
}
content.Status = status
body, marshalErr := json.Marshal(content)
if marshalErr != nil {
return marshalErr
}
tx, txErr := s.pool.Begin(ctx)
if txErr != nil {
return txErr
}
saveErr := func() error {
if _, e := tx.Exec(ctx, `UPDATE genius_tracks SET status=$2,content=$3,last_error=$4,next_attempt=$5,
locked_until='1970-01-01',updated_at=now() WHERE track_id=$1`, id, status, body, message, next); e != nil {
return e
}
if content.ArtistImageUrl != "" {
if _, e := tx.Exec(ctx, `UPDATE artists SET genius_image_url=$2 WHERE id=(SELECT artist_id FROM track_artists
WHERE track_id=$1 AND role='main' ORDER BY position LIMIT 1) AND image_path IS NULL AND genius_image_url IS NULL`, id, content.ArtistImageUrl); e != nil {
return e
}
}
if albumImage != "" {
if _, e := tx.Exec(ctx, `UPDATE albums SET genius_cover_url=$2 WHERE id=(SELECT album_id FROM tracks WHERE id=$1)
AND cover_path IS NULL AND genius_cover_url IS NULL`, id, albumImage); e != nil {
return e
}
}
if err != nil {
pause := false
cooldown := time.Now()
var he *HTTPError
if errors.As(err, &he) {
pause = he.StatusCode == 401 || he.StatusCode == 403
if he.StatusCode == 429 {
cooldown = time.Now().Add(retryDelay(he.RetryAfter))
}
}
if _, e := tx.Exec(ctx, `UPDATE genius_state SET last_error=$1, paused=paused OR $2,
cooldown_until=GREATEST(cooldown_until,$3)`, message, pause, cooldown); e != nil {
return e
}
}
return tx.Commit(ctx)
}()
_ = tx.Rollback(ctx)
if saveErr != nil {
return saveErr
}
}
return ctx.Err()
}
func emptyContent() gen.GeniusContent {
return gen.GeniusContent{Status: "pending", Annotations: []gen.GeniusAnnotation{}}
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > n {
return strings.TrimSpace(string(r[:n]))
}
return s
}
func (s *Service) enrich(ctx context.Context, id uuid.UUID) (gen.GeniusContent, string, error) {
content := emptyContent()
var title, artist, album string
var old []byte
err := s.pool.QueryRow(ctx, `SELECT t.title, COALESCE((SELECT a.name FROM track_artists ta JOIN artists a ON a.id=ta.artist_id
WHERE ta.track_id=t.id AND ta.role='main' ORDER BY ta.position LIMIT 1),''), al.title, g.content
FROM tracks t JOIN albums al ON al.id=t.album_id JOIN genius_tracks g ON g.track_id=t.id WHERE t.id=$1`, id).Scan(&title, &artist, &album, &old)
if err != nil {
return content, "", err
}
if err = json.Unmarshal(old, &content); err != nil {
return content, "", err
}
if artist == "" || title == "" {
return content, "", nil
}
songs, err := s.client.Search(ctx, artist+" "+title)
if err != nil {
return content, "", err
}
song, ok := matchSong(songs, artist, title)
if !ok {
return content, "", nil
}
content.SourceUrl = sourceURL(song.URL)
content.SongImageUrl, content.ArtistImageUrl, _ = songArtwork(song)
details, err := s.client.Song(ctx, song.ID)
if err != nil {
return content, "", err
}
if u := sourceURL(details.URL); u != "" {
content.SourceUrl = u
}
songImage, artistImage, albumImage := songArtwork(details)
if songImage != "" {
content.SongImageUrl = songImage
}
if artistImage != "" {
content.ArtistImageUrl = artistImage
}
if normalize(details.Album.Name) != normalize(album) {
albumImage = ""
}
if text := clip(details.Description.Plain, 8000); text != "" {
content.Description = text
}
if content.ArtistImageUrl == "" && details.Artist.ID > 0 {
geniusArtist, artistErr := s.client.Artist(ctx, details.Artist.ID)
if artistErr != nil {
var he *HTTPError
if !errors.As(artistErr, &he) || he.StatusCode != http.StatusNotFound {
return content, albumImage, artistErr
}
} else {
content.ArtistImageUrl = imageURL(geniusArtist.ImageURL)
}
}
pageURL := song.URL
if candidate := sourceURL(details.URL); strings.HasSuffix(urlPath(candidate), "-lyrics") {
pageURL = candidate
}
annotations, err := s.client.Annotations(ctx, song.ID)
if err != nil {
return content, albumImage, err
}
content.Annotations = annotations
lyrics, err := s.client.FetchLyrics(ctx, pageURL)
if err != nil {
return content, albumImage, err
}
content.Lyrics = lyrics.Text
return content, albumImage, nil
}
func errorCode(err error) string {
var he *HTTPError
if errors.As(err, &he) {
return "http_" + strconv.Itoa(he.StatusCode)
}
if errors.Is(err, ErrLyricsUnavailable) {
return "lyrics_unavailable"
}
return "request_failed"
}
func retryDelay(raw string) time.Duration {
if seconds, err := strconv.Atoi(raw); err == nil {
return max(time.Minute, min(time.Duration(seconds)*time.Second, 24*time.Hour))
}
if date, err := http.ParseTime(raw); err == nil {
return max(time.Minute, min(time.Until(date), 24*time.Hour))
}
return 15 * time.Minute
}
func (s *Service) Stats(ctx context.Context) (gen.GeniusStats, error) {
out := gen.GeniusStats{Configured: s.configured}
err := s.pool.QueryRow(ctx, `SELECT paused,last_error,cooldown_until FROM genius_state`).Scan(&out.Paused, &out.LastError, &out.CooldownUntil)
if err != nil {
return out, err
}
err = s.pool.QueryRow(ctx, `SELECT count(*),
count(*) FILTER(WHERE g.track_id IS NULL OR g.status IN ('pending','processing')),
count(*) FILTER(WHERE COALESCE(g.content->>'source_url','')<>''),
count(*) FILTER(WHERE COALESCE(g.content->>'lyrics','')<>''),
count(*) FILTER(WHERE jsonb_array_length(COALESCE(g.content->'annotations','[]'))>0),
count(*) FILTER(WHERE COALESCE(g.content->>'song_image_url','')<>'' OR COALESCE(g.content->>'artist_image_url','')<>''),
count(*) FILTER(WHERE g.status='unmatched'),
count(*) FILTER(WHERE g.status IN ('error','partial')),
COALESCE(bool_or(g.status='processing' AND g.locked_until>now()),false)
FROM tracks t LEFT JOIN genius_tracks g ON g.track_id=t.id`).Scan(&out.Total, &out.Pending, &out.Matched, &out.Lyrics, &out.Annotations, &out.Images, &out.Unmatched, &out.Errors, &out.Running)
return out, err
}
+15 -13
View File
@@ -18,13 +18,14 @@ type Ref struct {
// AlbumRow — альбом для карточек и страниц.
type AlbumRow struct {
ID uuid.UUID
Title string
Year *int
CoverPath *string
CoverColors []string
Artist Ref
TrackCount int
ID uuid.UUID
Title string
Year *int
CoverPath *string
ExternalCoverURL *string
CoverColors []string
Artist Ref
TrackCount int
}
// TrackRow — трек с исполнителями, альбомом и жанрами.
@@ -47,6 +48,7 @@ type TrackRow struct {
// ArtistRow — исполнитель со счётчиками.
type ArtistRow struct {
ImageURL *string
ID uuid.UUID
Name string
AlbumCount int
@@ -59,7 +61,7 @@ const trackSelect = `
SELECT t.id, t.title, t.duration_ms, t.disc_no, t.track_no,
COALESCE((SELECT json_agg(json_build_object('id', a.id, 'name', a.name) ORDER BY ta.role DESC, ta.position)
FROM track_artists ta JOIN artists a ON a.id = ta.artist_id WHERE ta.track_id = t.id), '[]'),
al.id, al.title, al.year, al.cover_path, COALESCE(al.cover_colors, '{}'), aa.id, aa.name,
al.id, al.title, al.year, al.cover_path, COALESCE(al.genius_cover_url, (SELECT NULLIF(content->>'song_image_url','') FROM genius_tracks WHERE track_id=t.id)), COALESCE(al.cover_colors, '{}'), aa.id, aa.name,
COALESCE((SELECT array_agg(g.name ORDER BY g.name) FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE tg.track_id = t.id), '{}'),
EXISTS (SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id
@@ -76,7 +78,7 @@ func scanTrack(row pgx.Row) (TrackRow, error) {
artists []byte
)
err := row.Scan(&t.ID, &t.Title, &t.DurationMs, &t.DiscNo, &t.TrackNo, &artists,
&t.Album.ID, &t.Album.Title, &t.Album.Year, &t.Album.CoverPath, &t.Album.CoverColors,
&t.Album.ID, &t.Album.Title, &t.Album.Year, &t.Album.CoverPath, &t.Album.ExternalCoverURL, &t.Album.CoverColors,
&t.Album.Artist.ID, &t.Album.Artist.Name, &t.Genres, &t.Available, &t.Analyzed, &t.BPM, &t.Energy,
&t.LoudnessLUFS)
if errors.Is(err, pgx.ErrNoRows) {
@@ -147,13 +149,13 @@ func (st *Store) AlbumTracks(ctx context.Context, albumID uuid.UUID) ([]TrackRow
}
const albumSelect = `
SELECT al.id, al.title, al.year, al.cover_path, COALESCE(al.cover_colors, '{}'), aa.id, aa.name,
SELECT al.id, al.title, al.year, al.cover_path, al.genius_cover_url, COALESCE(al.cover_colors, '{}'), aa.id, aa.name,
(SELECT count(*) FROM tracks t WHERE t.album_id = al.id)
FROM albums al JOIN artists aa ON aa.id = al.album_artist_id`
func scanAlbum(row pgx.Row) (AlbumRow, error) {
var a AlbumRow
err := row.Scan(&a.ID, &a.Title, &a.Year, &a.CoverPath, &a.CoverColors, &a.Artist.ID, &a.Artist.Name, &a.TrackCount)
err := row.Scan(&a.ID, &a.Title, &a.Year, &a.CoverPath, &a.ExternalCoverURL, &a.CoverColors, &a.Artist.ID, &a.Artist.Name, &a.TrackCount)
if errors.Is(err, pgx.ErrNoRows) {
return AlbumRow{}, ErrNotFound
}
@@ -194,10 +196,10 @@ func (st *Store) ArtistAlbums(ctx context.Context, artistID uuid.UUID) ([]AlbumR
func (st *Store) Artist(ctx context.Context, id uuid.UUID) (ArtistRow, error) {
var a ArtistRow
err := st.pool.QueryRow(ctx, `
SELECT a.id, a.name,
SELECT a.id, a.name, a.genius_image_url,
(SELECT count(*) FROM albums al WHERE al.album_artist_id = a.id),
(SELECT count(DISTINCT ta.track_id) FROM track_artists ta WHERE ta.artist_id = a.id)
FROM artists a WHERE a.id = $1`, id).Scan(&a.ID, &a.Name, &a.AlbumCount, &a.TrackCount)
FROM artists a WHERE a.id = $1`, id).Scan(&a.ID, &a.Name, &a.ImageURL, &a.AlbumCount, &a.TrackCount)
if errors.Is(err, pgx.ErrNoRows) {
return ArtistRow{}, ErrNotFound
}
+4 -1
View File
@@ -393,6 +393,9 @@ func (d DTO) albumRef(a AlbumRow) gen.AlbumRef {
// coverURL — подписанная ссылка на обложку без размера (клиент добавляет &size=); nil — обложки нет.
func (d DTO) coverURL(a AlbumRow) *string {
if a.CoverPath == nil {
return a.ExternalCoverURL
}
if a.CoverPath == nil || d.Signer == nil {
return nil
}
@@ -442,7 +445,7 @@ func (h *Handler) GetArtist(ctx context.Context, req gen.GetArtistRequestObject)
if err != nil {
return nil, catalogErr(err)
}
return gen.GetArtist200JSONResponse{Id: a.ID, Name: a.Name, AlbumCount: a.AlbumCount, TrackCount: a.TrackCount}, nil
return gen.GetArtist200JSONResponse{Id: a.ID, Name: a.Name, ImageUrl: a.ImageURL, AlbumCount: a.AlbumCount, TrackCount: a.TrackCount}, nil
}
// ListArtistAlbums реализует gen.StrictServerInterface.
@@ -52,7 +52,9 @@ type API struct {
TranscodeConcurrency int `env:"TRANSCODE_CONCURRENCY" envDefault:"0"`
TranscodeCacheLimitGB int `env:"TRANSCODE_CACHE_LIMIT_GB" envDefault:"50"`
RecommenderURL string `env:"RECOMMENDER_URL" envDefault:"http://recommender:8000"`
RecommenderURL string `env:"RECOMMENDER_URL" envDefault:"http://recommender:8000"`
GeniusAccessToken string `env:"GENIUS_ACCESS_TOKEN"`
GeniusRequestInterval time.Duration `env:"GENIUS_REQUEST_INTERVAL" envDefault:"2s"`
MeiliURL string `env:"MEILI_URL" envDefault:"http://meilisearch:7700"`
MeiliMasterKey string `env:"MEILI_MASTER_KEY"`
+5 -1
View File
@@ -11,6 +11,7 @@ import (
"pipu/api/internal/api/gen"
"pipu/api/internal/appupdate"
"pipu/api/internal/auth"
"pipu/api/internal/genius"
"pipu/api/internal/library"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/playback"
@@ -22,6 +23,7 @@ import (
// Deps — модули, реализующие части контракта API.
type Deps struct {
Genius *genius.Handler
Log *slog.Logger
System *system.Handler
Auth *auth.Handler
@@ -44,6 +46,7 @@ type Deps struct {
// По мере появления модулей (catalog, ...) они встраиваются сюда.
// Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler.
type (
geniusHandler = genius.Handler
systemHandler = system.Handler
authHandler = auth.Handler
libraryHandler = library.Handler
@@ -55,6 +58,7 @@ type (
)
type handlers struct {
*geniusHandler
*systemHandler
*authHandler
*libraryHandler
@@ -85,7 +89,7 @@ func NewRouter(d Deps) http.Handler {
d.TUS.Mount(r)
}
strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
strict := gen.NewStrictHandlerWithOptions(handlers{geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
[]gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: httpx.RequestError,
ResponseErrorHandlerFunc: httpx.ResponseError(log),
+26
View File
@@ -0,0 +1,26 @@
-- +goose Up
CREATE TABLE genius_state (
id boolean PRIMARY KEY DEFAULT true CHECK (id),
paused boolean NOT NULL DEFAULT false,
last_error text NOT NULL DEFAULT '',
cooldown_until timestamptz NOT NULL DEFAULT '1970-01-01'
);
INSERT INTO genius_state (id) VALUES (true);
CREATE TABLE genius_tracks (
track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','processing','ready','partial','unmatched','error')),
content jsonb NOT NULL DEFAULT '{}',
attempts integer NOT NULL DEFAULT 0,
last_error text NOT NULL DEFAULT '',
next_attempt timestamptz NOT NULL DEFAULT now(),
locked_until timestamptz NOT NULL DEFAULT '1970-01-01',
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX genius_queue_idx ON genius_tracks (next_attempt) WHERE status IN ('pending','processing','error','partial');
ALTER TABLE artists ADD COLUMN genius_image_url text;
ALTER TABLE albums ADD COLUMN genius_cover_url text;
-- +goose Down
ALTER TABLE albums DROP COLUMN genius_cover_url;
ALTER TABLE artists DROP COLUMN genius_image_url;
DROP TABLE genius_tracks;
DROP TABLE genius_state;