Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83b604437c | ||
|
|
73ad61e7a5 | ||
|
|
fb20258489 | ||
|
|
78c006184b | ||
|
|
d3116aba96 | ||
|
|
b7518aa450 | ||
|
|
eb8031b126 | ||
|
|
e4b841a8d8 | ||
|
|
597067689a |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -57,5 +57,9 @@ jobs:
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium
|
||||
# git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
|
||||
- run: |
|
||||
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
|
||||
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
|
||||
pnpm --filter @pipu/web exec playwright install chromium
|
||||
- run: pnpm --filter @pipu/web test:e2e
|
||||
@@ -1,6 +1,7 @@
|
||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
|
||||
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
|
||||
# и переписывает статус: собран / не удалась (scripts/release.mjs).
|
||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
|
||||
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
|
||||
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
||||
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
|
||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||
#
|
||||
# Секреты (Settings → Actions → Secrets):
|
||||
@@ -132,9 +133,10 @@ jobs:
|
||||
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
||||
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
||||
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
||||
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
|
||||
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
|
||||
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
|
||||
echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
|
||||
./gradlew --stop || true
|
||||
find ~/.gradle -name "*.lock" -delete || true
|
||||
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
||||
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||||
else
|
||||
@@ -196,3 +198,126 @@ jobs:
|
||||
- name: Статус в релизе — сборка отменена
|
||||
if: cancelled()
|
||||
run: node scripts/release.mjs android cancelled
|
||||
|
||||
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
|
||||
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
|
||||
desktop:
|
||||
name: desktop (Linux и Windows в релиз)
|
||||
needs: [release, android]
|
||||
if: always() && needs.release.result == 'success'
|
||||
timeout-minutes: 90
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
PIPU_VERSION: ${{ needs.release.outputs.version }}
|
||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
VERSION: ${{ needs.release.outputs.version }}
|
||||
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
XWIN_CACHE_DIR: /opt/ci-cache/xwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Статус в релизе — собирается
|
||||
run: |
|
||||
node scripts/release.mjs desktop linux building
|
||||
node scripts/release.mjs desktop windows building
|
||||
- name: Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||
libdbus-1-dev libsecret-1-dev patchelf \
|
||||
nsis lld llvm clang
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
~/.cargo/bin/cargo-xwin
|
||||
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
|
||||
restore-keys: cargo-desktop-
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Сборка deb
|
||||
id: linux
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm bundle:linux
|
||||
bundle=src-tauri/target/release/bundle
|
||||
shopt -s nullglob
|
||||
for f in "$bundle"/deb/*.deb; do
|
||||
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||
done
|
||||
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||
rm -rf src-tauri/target/release
|
||||
- name: Linux в релиз
|
||||
id: linux-upload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
f="pipu-music-${PIPU_VERSION}-linux.deb"
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "::error::нет $f после сборки"
|
||||
exit 1
|
||||
fi
|
||||
node scripts/release.mjs desktop linux success "$f"
|
||||
- name: Windows toolchain
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
if ! command -v cargo-xwin >/dev/null; then
|
||||
cargo install --locked cargo-xwin
|
||||
fi
|
||||
if [ ! -d /opt/ci-cache ]; then
|
||||
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
|
||||
mkdir -p "$HOME/.cache/xwin"
|
||||
else
|
||||
mkdir -p /opt/ci-cache/xwin
|
||||
fi
|
||||
- name: Сборка NSIS
|
||||
id: win
|
||||
if: ${{ !cancelled() }}
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm bundle:windows:cross
|
||||
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
|
||||
shopt -s nullglob
|
||||
copied=
|
||||
for f in "$bundle"/*-setup.exe; do
|
||||
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||
copied=1
|
||||
done
|
||||
if [ -z "$copied" ]; then
|
||||
echo "::error::нет NSIS setup.exe после сборки в $bundle"
|
||||
ls -la "$bundle" || true
|
||||
exit 1
|
||||
fi
|
||||
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||
- name: Windows в релиз
|
||||
id: win-upload
|
||||
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
|
||||
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||
- name: Статус Linux — сборка не удалась
|
||||
if: failure() && steps.linux-upload.outcome != 'success'
|
||||
run: node scripts/release.mjs desktop linux failure
|
||||
- name: Статус Windows — сборка не удалась
|
||||
if: failure() && steps.win-upload.outcome != 'success'
|
||||
run: node scripts/release.mjs desktop windows failure
|
||||
- name: Статус в релизе — сборка отменена
|
||||
if: cancelled()
|
||||
run: |
|
||||
node scripts/release.mjs desktop linux cancelled
|
||||
node scripts/release.mjs desktop windows cancelled
|
||||
@@ -41,3 +41,6 @@ apps/desktop/src-tauri/target/
|
||||
apps/desktop/src-tauri/gen/
|
||||
apps/desktop/src-tauri/tauri.release.json
|
||||
apps/desktop/release/
|
||||
|
||||
# Intermediate platform icon renders (final assets live under apps/).
|
||||
/artifacts/icon-build/
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 46 KiB |
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
name: 'Pipu Music',
|
||||
slug: 'pipu-music',
|
||||
scheme: 'pipu',
|
||||
icon: './assets/icon.png',
|
||||
version,
|
||||
orientation: 'portrait',
|
||||
userInterfaceStyle: 'automatic',
|
||||
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
newArchEnabled: false,
|
||||
backgroundColor: '#05070D',
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/adaptive-icon.png',
|
||||
monochromeImage: './assets/monochrome-icon.png',
|
||||
backgroundColor: '#05070D',
|
||||
},
|
||||
package: 'app.pipu.music',
|
||||
versionCode,
|
||||
edgeToEdgeEnabled: true,
|
||||
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -91,6 +91,12 @@ export default function PlayerScreen() {
|
||||
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
||||
{track.artists.map((a) => a.name).join(', ')}
|
||||
</Text>
|
||||
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
|
||||
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
|
||||
<Icon name="playlist-add" size={24} color={colors.text2} />
|
||||
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: 6 }}>
|
||||
|
||||
@@ -272,7 +272,7 @@ class Downloads {
|
||||
if (track.album.cover_url) {
|
||||
const cover = this.cover(track.album.id);
|
||||
const info = await FileSystem.getInfoAsync(cover);
|
||||
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
|
||||
if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
|
||||
}
|
||||
const info = await FileSystem.getInfoAsync(this.file(id));
|
||||
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
|
||||
@@ -286,4 +286,9 @@ class Downloads {
|
||||
}
|
||||
}
|
||||
|
||||
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
|
||||
export function sizedCover(url: string, size: number): string {
|
||||
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
|
||||
}
|
||||
|
||||
export const downloads = new Downloads();
|
||||
@@ -17,7 +17,7 @@ import TrackPlayer, {
|
||||
} from 'react-native-track-player';
|
||||
|
||||
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
||||
import { downloads } from '../offline/downloads';
|
||||
import { downloads, sizedCover } from '../offline/downloads';
|
||||
import { PersistentEvents } from '../offline/events';
|
||||
import { offlineWave } from './offlineWave';
|
||||
import { eventId } from './eventId';
|
||||
@@ -224,15 +224,18 @@ class Controller {
|
||||
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
||||
const q = await quality();
|
||||
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||
return tracks.map((t) => ({
|
||||
id: t.id,
|
||||
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
||||
title: t.title,
|
||||
artist: t.artists.map((a) => a.name).join(', '),
|
||||
album: t.album.title,
|
||||
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
|
||||
duration: t.duration_ms / 1000,
|
||||
}));
|
||||
return tracks.map((t) => {
|
||||
const cover = absolute(this.server, t.album.cover_url);
|
||||
return {
|
||||
id: t.id,
|
||||
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
||||
title: t.title,
|
||||
artist: t.artists.map((a) => a.name).join(', '),
|
||||
album: t.album.title,
|
||||
artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
|
||||
duration: t.duration_ms / 1000,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async playList(tracks: Track[], index: number, context: PlayContext) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { useSessionCtx } from '../lib/session';
|
||||
import { downloads } from '../offline/downloads';
|
||||
import { downloads, sizedCover } from '../offline/downloads';
|
||||
import { absolute } from '../player/controller';
|
||||
import { radius, useColors, type Colors } from '../theme';
|
||||
|
||||
@@ -161,7 +161,7 @@ export function Cover({
|
||||
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
|
||||
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
|
||||
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
|
||||
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined);
|
||||
const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
|
||||
const bg = album.cover_colors?.[0] ?? colors.surface3;
|
||||
return (
|
||||
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
||||
|
||||
@@ -17,7 +17,9 @@ for (const width of [320, 1280]) {
|
||||
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
|
||||
await page.getByRole('listitem').first().click();
|
||||
const player = page.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await player.getByRole('button', { name: 'Добавить в плейлист', exact: true }).click();
|
||||
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
||||
<title>pipu</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="g" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#9CC6FF"/><stop offset=".55" stop-color="#2F80FF"/><stop offset="1" stop-color="#3A1FA8"/></radialGradient></defs><circle cx="16" cy="16" r="14" fill="url(#g)"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<radialGradient id="orb" cx="28%" cy="18%" r="95%">
|
||||
<stop stop-color="#9CC6FF"/>
|
||||
<stop offset=".42" stop-color="#2F80FF"/>
|
||||
<stop offset=".78" stop-color="#6354E8"/>
|
||||
<stop offset="1" stop-color="#3A1FA8"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="note" x1="0" y1="0" x2=".3" y2="1">
|
||||
<stop stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#DFEBFF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="256" cy="256" r="240" fill="url(#orb)"/>
|
||||
<path d="M208 348V164H270C356 164 356 280 270 280H208" fill="none" stroke="url(#note)" stroke-width="44" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<ellipse cx="181" cy="348" rx="49" ry="36" fill="url(#note)" transform="rotate(-18 181 348)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 840 B |
@@ -409,6 +409,26 @@ describe('safeRedirect', () => {
|
||||
});
|
||||
|
||||
describe('playlists', () => {
|
||||
it('adds the playing track from the player, keeping the selected track when playback advances', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||
await screen.findByRole('heading', { name: 'Группа крови' });
|
||||
await user.click(screen.getAllByRole('listitem')[0]!);
|
||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||
await user.type(screen.getByLabelText('Название'), 'Из плеера');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(api.playlists.size).toBe(1));
|
||||
expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови');
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
|
||||
// Simulate automatic advancement while the picker is open.
|
||||
await user.click(within(bar).getByRole('button', { name: 'Следующий' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']);
|
||||
});
|
||||
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,6 +28,7 @@ function fallbackColors(id: string): string[] {
|
||||
|
||||
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
||||
export function coverSrc(url: string, cssPx: number): string {
|
||||
if (!url.includes('/api/v1/covers/')) return url;
|
||||
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
|
||||
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
||||
return `${url}&size=${size}`;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||
import { queueIcon } from '../shared/ui/icons';
|
||||
import { playlistAddIcon } from '../shared/ui/icons';
|
||||
import s from './catalog.module.css';
|
||||
|
||||
export type Playlist = components['schemas']['Playlist'];
|
||||
@@ -61,7 +61,7 @@ export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
|
||||
}
|
||||
|
||||
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
||||
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
||||
const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
@@ -84,14 +84,15 @@ function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: ()
|
||||
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
});
|
||||
|
||||
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
|
||||
export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
|
||||
const close = useCallback(() => setSelectedIds(null), []);
|
||||
return <>
|
||||
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
|
||||
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
|
||||
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
|
||||
{compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
|
||||
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
|
||||
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
|
||||
</>;
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { formatBytes } from '../shared/lib/format';
|
||||
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
|
||||
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||
import { Icon } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
/**
|
||||
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
|
||||
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
|
||||
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
|
||||
* Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС.
|
||||
*/
|
||||
export function AppDownloadPage() {
|
||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const detected = useMemo(() => detectAppClient(), []);
|
||||
const latest = useQuery({
|
||||
queryKey: ['android-latest'],
|
||||
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
|
||||
queryKey: ['app-clients'],
|
||||
queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
const server = location.origin;
|
||||
@@ -28,39 +30,59 @@ export function AppDownloadPage() {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
const r = latest.data;
|
||||
const data = latest.data;
|
||||
const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c]));
|
||||
const primary = detected ? files[detected] : undefined;
|
||||
const others = otherAppClients(detected).filter((id) => files[id]);
|
||||
|
||||
return (
|
||||
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||
<span className={s.badge}>Android</span>
|
||||
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
|
||||
<h1>{t('app.title')}</h1>
|
||||
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||
{latest.isPending ? null : r ? (
|
||||
{latest.isPending ? null : data ? (
|
||||
<>
|
||||
<a className={s.download} href={r.download_url} download>
|
||||
<Icon src={downloadIcon} size={22} />
|
||||
<span>
|
||||
{t('app.download')}
|
||||
<span className={s.downloadMeta}>
|
||||
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<ol className={s.steps}>
|
||||
<li>{t('app.step_install')}</li>
|
||||
<li>
|
||||
{t('app.step_server')}
|
||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||
<code>{server}</code>
|
||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||
</button>
|
||||
</li>
|
||||
<li>{t('app.step_updates')}</li>
|
||||
</ol>
|
||||
<details className={s.details}>
|
||||
<summary>{t('app.checksum')}</summary>
|
||||
<code>{r.sha256}</code>
|
||||
</details>
|
||||
{primary ? (
|
||||
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||
) : detected ? (
|
||||
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
|
||||
) : null}
|
||||
{(primary || others.length > 0) && (
|
||||
<ol className={s.steps}>
|
||||
{detected && <li>{t(`app.step_install.${detected}`)}</li>}
|
||||
<li>
|
||||
{t('app.step_server')}
|
||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||
<code>{server}</code>
|
||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||
</button>
|
||||
</li>
|
||||
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
|
||||
</ol>
|
||||
)}
|
||||
{primary?.sha256 && (
|
||||
<details className={s.details}>
|
||||
<summary>{t('app.checksum')}</summary>
|
||||
<code>{primary.sha256}</code>
|
||||
</details>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div className={s.otherClients}>
|
||||
<p className={s.otherTitle}>{t('app.other')}</p>
|
||||
{others.map((id) => {
|
||||
const file = files[id]!;
|
||||
return (
|
||||
<a key={id} className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
||||
{t(`app.download.${id}`)}
|
||||
<span>
|
||||
{t('app.version', { version: data.version })} · {formatBytes(file.size, i18n.language)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
|
||||
</>
|
||||
) : (
|
||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||
@@ -68,3 +90,16 @@ export function AppDownloadPage() {
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
|
||||
const { t } = useTranslation('auth');
|
||||
return (
|
||||
<a className={s.download} href={href} download>
|
||||
<Icon src={downloadIcon} size={22} />
|
||||
<span>
|
||||
{t(`app.download.${client}`)}
|
||||
<span className={s.downloadMeta}>{meta}</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function ArtistPage({ id }: { id: string }) {
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
<Cover album={{ id }} size={168} round />
|
||||
<Cover album={{ id, cover_url: a?.image_url }} size={168} round />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('catalog.artist')}</span>
|
||||
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function LoginPage() {
|
||||
{desktop ? null : (
|
||||
<>
|
||||
{' · '}
|
||||
<a href="/android">{t('app.link')}</a>
|
||||
<a href="/download">{t('app.link')}</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { FormError } from '../shared/ui/ui';
|
||||
import s from './page.module.css';
|
||||
import g from './genius.module.css';
|
||||
|
||||
export function TrackGenius({ id }: { id: string }) {
|
||||
const session = useSession();
|
||||
const { t } = useTranslation();
|
||||
const errorText = useErrorText();
|
||||
const query = useQuery({
|
||||
queryKey: ['track-genius', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}/genius', { params: { path: { id } } })),
|
||||
refetchInterval: (q) => ['pending', 'processing'].includes(q.state.data?.status ?? '') ? 10000 : false,
|
||||
});
|
||||
const data = query.data;
|
||||
if (data?.status === 'disabled') return null;
|
||||
return <section className={s.section}>
|
||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||
{query.isPending && <p>{t('genius.loading')}</p>}
|
||||
{data?.description && <><h2>{t('genius.about')}</h2><p className={g.lyrics}>{data.description}</p></>}
|
||||
<div className={s.head}><h2>{t('genius.lyrics')}</h2>{data?.source_url && <a href={data.source_url} target="_blank" rel="noopener noreferrer">Genius ↗</a>}</div>
|
||||
{data && (data.lyrics ? <div className={g.lyrics}>{data.lyrics}</div> : <p className={s.subtitle}>{['pending', 'processing'].includes(data.status) ? t('genius.pending_text') : t('genius.no_lyrics')}</p>)}
|
||||
{!!data?.annotations.length && <>
|
||||
<h2>{t('genius.explanations')}</h2>
|
||||
{data.annotations.map((a, i) => <details className={g.annotation} key={`${a.url}-${i}`}>
|
||||
<summary>{a.fragment}{a.verified && <span className={g.badge}>{t('genius.verified')}</span>}</summary>
|
||||
<p className={g.lyrics}>{a.text}</p>
|
||||
<p className={s.subtitle}>{a.authors}{a.url && <> · <a href={a.url} target="_blank" rel="noopener noreferrer">Genius ↗</a></>}</p>
|
||||
</details>)}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { formatDuration } from '../shared/lib/format';
|
||||
import { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
import { TrackGenius } from './TrackGenius';
|
||||
|
||||
export function TrackPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -33,6 +34,7 @@ export function TrackPage({ id }: { id: string }) {
|
||||
</div>
|
||||
</header>
|
||||
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
|
||||
{tr && <TrackGenius id={id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, FormError } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import g from '../genius.module.css';
|
||||
|
||||
export function GeniusPanel() {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const queries = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const stats = useQuery({
|
||||
queryKey: ['genius-stats'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/genius')),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const control = useMutation({
|
||||
mutationFn: async (action: 'start' | 'pause' | 'retry') =>
|
||||
unwrap(await session.client.POST('/api/v1/admin/genius', { body: { action } })),
|
||||
onSuccess: (data) => { queries.setQueryData(['genius-stats'], data); },
|
||||
});
|
||||
const data = stats.data;
|
||||
const cooling = data && Date.parse(data.cooldown_until) > Date.now();
|
||||
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : t('genius.waiting');
|
||||
return <section className={s.section} aria-label="Genius">
|
||||
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
|
||||
<p className={s.subtitle}>{t('genius.description')}</p>
|
||||
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
|
||||
{data && <>
|
||||
{!data.configured && <p>{t('genius.configure')}</p>}
|
||||
<dl className={g.stats}>
|
||||
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
|
||||
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{data[key].toLocaleString()}</dd></div>)}
|
||||
</dl>
|
||||
{data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>}
|
||||
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
|
||||
<div className={g.actions}>
|
||||
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
|
||||
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
|
||||
</div>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
import { GeniusPanel } from './GeniusPanel';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
|
||||
@@ -19,6 +20,7 @@ type TestResult = components['schemas']['SourceTestResult'];
|
||||
const SCAN_POLL_MS = 2000;
|
||||
|
||||
export function SourcesPage() {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
@@ -43,6 +45,7 @@ export function SourcesPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
|
||||
{user.role === 'admin' && <GeniusPanel />}
|
||||
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
|
||||
<div className={l.list}>
|
||||
{sources.data?.map((src) => (
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
/* Страница Android-приложения (/android). */
|
||||
/* Страница приложений (/download). */
|
||||
.download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -238,6 +238,40 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.otherClients {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.otherTitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.downloadAlt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.downloadAlt span {
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; margin: 0; }
|
||||
.stats dt { color: var(--text2); font-size: 13px; }
|
||||
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
|
||||
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
|
||||
.annotation summary { cursor: pointer; line-height: 1.6; }
|
||||
.badge { display: inline-block; margin-left: 12px; font-size: 12px; color: var(--text2); }
|
||||
@@ -9,4 +9,6 @@
|
||||
.actions { display: flex; align-items: center; }
|
||||
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
|
||||
.actions button:disabled { opacity: .3; cursor: default; }
|
||||
.hero { overflow-wrap: anywhere; }
|
||||
@media (max-width: 600px) { .hero { flex-direction: column; align-items: flex-start; gap: 16px; } .hero > div { width: 100%; } }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectAppClient, otherAppClients } from './platform';
|
||||
|
||||
describe('detectAppClient', () => {
|
||||
it('picks the native client from the user agent', () => {
|
||||
expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android');
|
||||
expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
|
||||
expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos');
|
||||
expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
|
||||
expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the remaining platforms after the detected one', () => {
|
||||
expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export type AppClient = 'android' | 'windows' | 'linux' | 'macos';
|
||||
|
||||
const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android'];
|
||||
|
||||
/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */
|
||||
export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null {
|
||||
if (/Android/i.test(ua)) return 'android';
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return null;
|
||||
if (/Windows/i.test(ua)) return 'windows';
|
||||
if (/Mac OS X|Macintosh/i.test(ua)) return 'macos';
|
||||
if (/Linux|X11/i.test(ua)) return 'linux';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function otherAppClients(primary: AppClient | null): AppClient[] {
|
||||
return ORDER.filter((c) => c !== primary);
|
||||
}
|
||||
@@ -70,4 +70,5 @@ export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_e
|
||||
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
|
||||
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
|
||||
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
|
||||
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
|
||||
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
|
||||
@@ -21,9 +21,10 @@
|
||||
}
|
||||
|
||||
.logoDot {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
|
||||
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
|
||||
background: url('/favicon.svg') center / contain no-repeat;
|
||||
}
|
||||
|
||||
.button {
|
||||
|
||||
@@ -116,6 +116,28 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
min_android_version_code: 0,
|
||||
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
|
||||
});
|
||||
case 'GET /api/v1/app/clients':
|
||||
return json({
|
||||
version: '1.3.0',
|
||||
version_code: 1_030_099,
|
||||
notes_md: '',
|
||||
clients: [
|
||||
{ client: 'windows', filename: 'pipu-music_1.3.0_x64-setup.exe', size: 20_000_000, download_url: '/api/v1/app/download/windows/1.3.0' },
|
||||
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.deb', size: 4_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
|
||||
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
|
||||
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
|
||||
],
|
||||
});
|
||||
case 'GET /api/v1/app/android/latest':
|
||||
return json({
|
||||
version: '1.3.0',
|
||||
version_code: 1_030_099,
|
||||
notes_md: '',
|
||||
size: 12_000_000,
|
||||
sha256: 'ab'.repeat(32),
|
||||
download_url: '/api/v1/app/android/download/1.3.0',
|
||||
min_supported: 0,
|
||||
});
|
||||
case 'POST /api/v1/auth/login': {
|
||||
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
|
||||
if (!u) return problem(401, 'auth.invalid_credentials');
|
||||
@@ -280,6 +302,9 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
users.set(target.id, target);
|
||||
return json(admin(target));
|
||||
}
|
||||
if (req.method === 'GET' && /^\/api\/v1\/tracks\/[^/]+\/genius$/.test(path)) {
|
||||
return json({ status: 'disabled', lyrics: '', source_url: '', song_image_url: '', artist_image_url: '', description: '', annotations: [] });
|
||||
}
|
||||
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
||||
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
|
||||
if (trackPath && req.method === 'GET') {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession, useUser } from '../app/session';
|
||||
import { useDesktop } from '../desktop';
|
||||
import { useCollectionCounts } from '../entities/collection';
|
||||
import {
|
||||
moreIcon,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
settingsIcon,
|
||||
androidIcon,
|
||||
downloadIcon,
|
||||
chevronRightIcon,
|
||||
storageFillIcon,
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||
@@ -155,6 +156,7 @@ function Shell() {
|
||||
const { t } = useTranslation();
|
||||
const user = useUser();
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const navigate = useNavigate();
|
||||
const name = user.display_name ?? user.username;
|
||||
const staff = user.role !== 'user';
|
||||
@@ -252,7 +254,9 @@ function Shell() {
|
||||
}
|
||||
items={[
|
||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
|
||||
...(!desktop
|
||||
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
|
||||
: []),
|
||||
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -311,13 +311,17 @@ a.source:hover {
|
||||
.controls { gap: 2px; }
|
||||
.play, .icon { width: 44px; height: 44px; }
|
||||
.right { display: flex; gap: 0; }
|
||||
.right > :not(.queueButton):not(.shareButton) { display: none; }
|
||||
.right > :not(.queueButton):not(.shareButton):not(.playlistButton) { display: none; }
|
||||
.error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px;
|
||||
padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; }
|
||||
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
.now > :first-child { display: none; }
|
||||
/* share + очередь + три кнопки управления не оставляют месту название трека (e2e 320px). */
|
||||
.shareButton { display: none; }
|
||||
}
|
||||
|
||||
.shareStatus {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { shareTrack } from '../shared/lib/share';
|
||||
@@ -178,6 +179,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
</div>
|
||||
|
||||
<div className={s.right}>
|
||||
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
|
||||
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||
<Icon src={shareIcon} size={20} />
|
||||
</button>
|
||||
|
||||
@@ -44,6 +44,10 @@ LOG_LEVEL=info
|
||||
SUPPORTED_LOCALES=ru,en
|
||||
MIN_ANDROID_VERSION_CODE=0
|
||||
|
||||
# Genius: empty token disables enrichment. Queue and controls: Admin → Sources.
|
||||
GENIUS_ACCESS_TOKEN=
|
||||
GENIUS_REQUEST_INTERVAL=2s
|
||||
|
||||
# --- Самообновление Android (M5) ---
|
||||
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
|
||||
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
|
||||
|
||||
@@ -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) | Веб-клиент |
|
||||
|
||||
@@ -102,8 +102,10 @@
|
||||
прочитайте заметки к релизу на странице релизов Gitea.
|
||||
|
||||
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
||||
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
|
||||
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
|
||||
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
|
||||
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
|
||||
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа
|
||||
и в меню профиля.
|
||||
|
||||
## 7. Наблюдение
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@
|
||||
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url}]}` — Android, Windows, Linux, macOS |
|
||||
| GET | `/app/download/{client}/{version}` | файл клиента (прокси / кэш релиза Gitea) |
|
||||
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
|
||||
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
|
||||
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
|
||||
| `TRANSCODE_CONCURRENCY` | `2` | |
|
||||
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
|
||||
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/download` |
|
||||
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
|
||||
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
|
||||
| `LOG_LEVEL` | `info` | |
|
||||
|
||||
@@ -43,7 +43,13 @@
|
||||
| `overlay` | `rgba(3,5,10,.66)` | `rgba(20,30,60,.32)` | Подложка диалогов |
|
||||
| `glass` | `rgba(12,16,28,.78)` | `rgba(255,255,255,.84)` | Стеклянные карточки (вход, диалоги) + `backdrop-filter: blur(28px) saturate(1.3)` |
|
||||
|
||||
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — радиальный градиент `#9CC6FF → #2F80FF → #3A1FA8`.
|
||||
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
|
||||
|
||||
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
|
||||
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
|
||||
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
|
||||
и монохромный слои с отступами для масок лаунчера; фон — `#05070D`.
|
||||
После изменения значков установленные приложения нужно пересобрать и обновить.
|
||||
|
||||
### Типографика
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Genius: поиск метаданных и получение текста
|
||||
|
||||
Реализован Go-провайдер `services/api/internal/genius` и отдельная команда
|
||||
`services/api/cmd/genius`. Поиск через API возвращает кандидатов, ссылки на изображения
|
||||
и исполнителя. Текст выбранной песни извлекается из HTML страницы: из контейнеров
|
||||
`data-lyrics-container` либо из `songPage.lyricsData.body.html` во встроенном состоянии.
|
||||
JavaScript не исполняется. Используется HTML-парсер `golang.org/x/net/html`.
|
||||
|
||||
Это проверяемый первый этап: команда работает отдельно от сервера. Автоматическое
|
||||
дополнение каталога, хранение текстов в БД, аннотации и отображение в плеере пока
|
||||
не подключены. Поэтому переменная ниже пока нужна именно процессу команды,
|
||||
а не Docker Compose или веб-клиенту.
|
||||
|
||||
## Почему Go
|
||||
|
||||
Основной API Pipu написан на Go. Небольшой провайдер можно подключить к нему без
|
||||
отдельного Python-сервиса. LyricsGenius удобен для Python, но собственный адаптер
|
||||
позволяет явно контролировать выбор версии песни, сетевые ограничения и ошибки.
|
||||
HTML Genius не является стабильным API: при изменении разметки парсер потребует обновления.
|
||||
|
||||
## Получение токена
|
||||
|
||||
1. Войти или зарегистрироваться на https://genius.com.
|
||||
2. Открыть https://genius.com/api-clients и создать новый API Client.
|
||||
3. Указать название, например `Pipu Music`, и URL своего приложения. Если форма
|
||||
требует Redirect URI, указать URL под своим контролем; для чтения с Client Access
|
||||
Token OAuth-переходы и обработчик callback в Pipu не нужны.
|
||||
4. В созданном клиенте нажать **Generate Access Token**.
|
||||
5. Сохранить **Client Access Token** в переменной `GENIUS_ACCESS_TOKEN` процесса.
|
||||
Client ID и Client Secret вместо него не подходят.
|
||||
|
||||
Токен нужен для поиска через `api.genius.com`. Получение HTML по уже известному
|
||||
URL не требует токена и не отправляет заголовок Authorization. Не помещать токен
|
||||
в git, клиентские переменные `VITE_*`, логи или переписку.
|
||||
|
||||
Источник: https://lyricsgenius.readthedocs.io/en/master/setup.html.
|
||||
|
||||
## Проверка в PowerShell 7
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
```powershell
|
||||
Set-Location services/api
|
||||
$env:GENIUS_ACCESS_TOKEN = Read-Host 'Genius Client Access Token' -MaskInput
|
||||
go run ./cmd/genius -search 'Исполнитель Название песни'
|
||||
```
|
||||
|
||||
Выбрать нужного исполнителя и версию песни из результатов. Команда намеренно
|
||||
не считает первый результат правильным. Затем подставить возвращённый `url`:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/genius -url 'https://genius.com/Artist-song-lyrics'
|
||||
Remove-Item Env:GENIUS_ACCESS_TOKEN
|
||||
```
|
||||
|
||||
URL во втором примере — шаблон. Результат — JSON с `text`, `source_url` и временем
|
||||
получения `fetched_at`. Это обычный текст без временной синхронизации.
|
||||
|
||||
## Ограничения и дальнейшее подключение
|
||||
|
||||
- HTTP 401, 403, 404, 429 и другие ошибки возвращаются явно; `HTTPError` сохраняет
|
||||
`Retry-After`. Автоматических повторов и обхода блокировок нет.
|
||||
- Таймаут запроса — 15 секунд, предел ответа — 8 MiB. Разрешён только HTTPS
|
||||
на Genius; перенаправления на другой хост запрещены.
|
||||
- Отсутствие распознаваемого текста возвращает `ErrLyricsUnavailable`; HTML
|
||||
блокировки или навигация не становятся текстом песни.
|
||||
- Перед интеграцией в каталог нужны кэш результатов, лимит частоты запросов,
|
||||
сохранение выбранного Genius ID и обработка временных ошибок без потери старого текста.
|
||||
- Токен API не предоставляет лицензию на тексты. Документация LyricsGenius
|
||||
предупреждает, что парсинг противоречит условиям Genius; отображление и хранение
|
||||
в публичном сервисе требуют отдельной проверки разрешений:
|
||||
https://lyricsgenius.readthedocs.io/en/latest/how_it_works.html.
|
||||
|
||||
## Тесты
|
||||
|
||||
```powershell
|
||||
go test ./internal/genius ./cmd/genius
|
||||
```
|
||||
|
||||
Тесты используют синтетические тексты и подмену HTTP: переносы, HTML-сущности,
|
||||
служебные элементы, встроенное состояние, ошибки HTTP, проверка URL и
|
||||
отсутствие токена при запросе страницы. Реальный доступ к Genius проверяется
|
||||
отдельно из сети сервера с собственным токеном.
|
||||
@@ -246,7 +246,7 @@ curl -s https://music.example.com/readyz
|
||||
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
|
||||
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
|
||||
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
|
||||
ссылка `https://<ваш-домен>/android`.
|
||||
ссылка `https://<ваш-домен>/download`.
|
||||
|
||||
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
|
||||
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
## Самообновление
|
||||
|
||||
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
|
||||
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция.
|
||||
|
||||
Приложение распространяется только через Gitea Releases (не Google Play).
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
```
|
||||
apps/web/src/
|
||||
├── app/ # провайдеры, роутер, глобальные стили, гарды
|
||||
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
|
||||
├── pages/ # login, setup-admin, setup-password, download, home, search, artist, album,
|
||||
│ # collection/{playlists,history,likes,dislikes}, playlist, track, settings,
|
||||
│ # admin/{users,sources,library,upload,upload-queue,settings}
|
||||
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"icons": "node scripts/generate-icons.mjs",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
|
||||
@@ -4,6 +4,40 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/v1/admin/genius": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getGeniusStats"];
|
||||
put?: never;
|
||||
post: operations["controlGenius"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/tracks/{id}/genius": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getTrackGenius"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/healthz": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1293,6 +1327,44 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/app/clients": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Клиенты последнего релиза (Android и десктоп)
|
||||
* @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
|
||||
* Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||
*/
|
||||
get: operations["getAppClients"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/app/download/{client}/{version}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Файл клиента (кэшируется на сервере, поддерживает Range) */
|
||||
get: operations["downloadAppClient"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/app/android/latest": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2030,7 +2102,40 @@ export interface components {
|
||||
/** @description Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости */
|
||||
features: components["schemas"]["TrackFeatures"] | null;
|
||||
};
|
||||
GeniusStats: {
|
||||
configured: boolean;
|
||||
paused: boolean;
|
||||
running: boolean;
|
||||
total: number;
|
||||
pending: number;
|
||||
matched: number;
|
||||
lyrics: number;
|
||||
annotations: number;
|
||||
images: number;
|
||||
unmatched: number;
|
||||
errors: number;
|
||||
last_error: string;
|
||||
/** Format: date-time */
|
||||
cooldown_until: string;
|
||||
};
|
||||
GeniusContent: {
|
||||
status: string;
|
||||
lyrics: string;
|
||||
source_url: string;
|
||||
song_image_url: string;
|
||||
artist_image_url: string;
|
||||
description: string;
|
||||
annotations: components["schemas"]["GeniusAnnotation"][];
|
||||
};
|
||||
GeniusAnnotation: {
|
||||
fragment: string;
|
||||
text: string;
|
||||
url: string;
|
||||
verified: boolean;
|
||||
authors: string;
|
||||
};
|
||||
Artist: {
|
||||
image_url?: string;
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -2249,6 +2354,22 @@ export interface components {
|
||||
early_skip_rate: number | null;
|
||||
like_rate: number | null;
|
||||
};
|
||||
/** @enum {string} */
|
||||
AppClient: "android" | "windows" | "linux" | "macos";
|
||||
AppClientFile: {
|
||||
client: components["schemas"]["AppClient"];
|
||||
filename: string;
|
||||
/** Format: int64 */
|
||||
size: number;
|
||||
sha256?: string;
|
||||
download_url: string;
|
||||
};
|
||||
AppClients: {
|
||||
version: string;
|
||||
version_code: number;
|
||||
notes_md: string;
|
||||
clients: components["schemas"]["AppClientFile"][];
|
||||
};
|
||||
AndroidRelease: {
|
||||
/** @example 0.6.1 */
|
||||
version: string;
|
||||
@@ -2326,6 +2447,75 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
getGeniusStats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Persistent enrichment statistics (admin only) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["GeniusStats"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
controlGenius: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @enum {string} */
|
||||
action: "start" | "pause" | "retry";
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Updated statistics */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["GeniusStats"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getTrackGenius: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Cached lyrics and annotations; never makes an upstream request */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["GeniusContent"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getHealthz: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4189,6 +4379,51 @@ export interface operations {
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getAppClients: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Доступные сборки */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AppClients"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
downloadAppClient: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
client: components["schemas"]["AppClient"];
|
||||
version: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Установщик или APK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/octet-stream": string;
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getAndroidLatest: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -22,17 +22,41 @@
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"app": {
|
||||
"link": "Android app",
|
||||
"title": "pipu for Android",
|
||||
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
|
||||
"download": "Download APK",
|
||||
"link": "Download apps",
|
||||
"badge": "Apps",
|
||||
"title": "pipu on your devices",
|
||||
"subtitle": "Clients for this server: phone, desktop and the web. The build for your system comes first.",
|
||||
"platform": {
|
||||
"android": "Android",
|
||||
"windows": "Windows",
|
||||
"linux": "Linux",
|
||||
"macos": "macOS"
|
||||
},
|
||||
"download": {
|
||||
"android": "Download APK",
|
||||
"windows": "Download for Windows",
|
||||
"linux": "Download .deb",
|
||||
"macos": "Download for macOS"
|
||||
},
|
||||
"version": "version {{version}}",
|
||||
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.",
|
||||
"other": "Other platforms",
|
||||
"step_install": {
|
||||
"android": "Open the downloaded file. If Android asks, allow installs from this source.",
|
||||
"windows": "Run the installer and follow the steps. pipu will appear in the Start menu.",
|
||||
"linux": "Install the package: sudo apt install ./pipu-music-….deb. Needs WebKitGTK 4.1 and GStreamer.",
|
||||
"macos": "Open the disk image and drag pipu into Applications."
|
||||
},
|
||||
"step_server": "Launch pipu and enter the server address:",
|
||||
"copy_server": "Copy the server address",
|
||||
"step_updates": "Sign in with your account. The app offers new versions by itself.",
|
||||
"step_updates": {
|
||||
"android": "Sign in with your account. The app offers new versions by itself.",
|
||||
"windows": "Sign in with your account. Get a new version from this page.",
|
||||
"linux": "Sign in with your account. Get a new version from this page.",
|
||||
"macos": "Sign in with your account. Get a new version from this page."
|
||||
},
|
||||
"checksum": "SHA-256 checksum",
|
||||
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).",
|
||||
"unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).",
|
||||
"unavailable_platform": "The latest release does not include a build for {{platform}} yet.",
|
||||
"to_login": "Sign in to the web app"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"genius": {
|
||||
"description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.",
|
||||
"loading": "Loading…", "disabled": "Not configured", "paused": "Paused", "cooldown": "Waiting after Genius rate limit", "running": "Processing", "waiting": "Waiting for new tracks",
|
||||
"configure": "Add GENIUS_ACCESS_TOKEN to deploy/.env and recreate the API container.",
|
||||
"total": "Total tracks", "pending": "Queued", "matched": "Matched on Genius", "lyrics": "Lyrics", "annotations": "With annotations", "images": "With artwork", "unmatched": "No exact match", "errors": "Errors / incomplete data",
|
||||
"last_error": "Last error", "token_error": "Genius rejected the token. Check it and restart the API.", "access_error": "Genius restricted access. Processing is paused.", "no_lyrics": "Lyrics are currently unavailable.", "request_error": "Some data could not be retrieved. A retry is scheduled automatically.", "resume_at": "Retry no earlier than",
|
||||
"start": "Resume", "pause": "Pause", "retry": "Retry errors and unmatched", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius"
|
||||
},
|
||||
"app": {
|
||||
"name": "pipu",
|
||||
"tagline": "Your music from your own server"
|
||||
@@ -41,7 +49,7 @@
|
||||
"menu": "Profile menu",
|
||||
"settings": "Settings",
|
||||
"logout": "Sign out",
|
||||
"android_app": "Android app"
|
||||
"download_apps": "Download apps"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Administrator",
|
||||
|
||||
@@ -22,17 +22,41 @@
|
||||
"logout": "Выйти"
|
||||
},
|
||||
"app": {
|
||||
"link": "Приложение для Android",
|
||||
"title": "pipu для Android",
|
||||
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
|
||||
"download": "Скачать APK",
|
||||
"link": "Скачать приложения",
|
||||
"badge": "Приложения",
|
||||
"title": "pipu на ваших устройствах",
|
||||
"subtitle": "Клиенты для этого сервера: телефон, компьютер и веб. Сначала предлагается сборка под вашу систему.",
|
||||
"platform": {
|
||||
"android": "Android",
|
||||
"windows": "Windows",
|
||||
"linux": "Linux",
|
||||
"macos": "macOS"
|
||||
},
|
||||
"download": {
|
||||
"android": "Скачать APK",
|
||||
"windows": "Скачать для Windows",
|
||||
"linux": "Скачать .deb",
|
||||
"macos": "Скачать для macOS"
|
||||
},
|
||||
"version": "версия {{version}}",
|
||||
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
||||
"other": "Другие платформы",
|
||||
"step_install": {
|
||||
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
||||
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
|
||||
"linux": "Установите пакет: sudo apt install ./pipu-music-….deb. Нужны WebKitGTK 4.1 и GStreamer.",
|
||||
"macos": "Откройте образ и перетащите pipu в Программы."
|
||||
},
|
||||
"step_server": "Запустите pipu и введите адрес сервера:",
|
||||
"copy_server": "Скопировать адрес сервера",
|
||||
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
|
||||
"step_updates": {
|
||||
"android": "Войдите своим логином. Новые версии приложение предложит установить само.",
|
||||
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.",
|
||||
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.",
|
||||
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы."
|
||||
},
|
||||
"checksum": "Контрольная сумма SHA-256",
|
||||
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
|
||||
"unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
|
||||
"unavailable_platform": "В последнем релизе пока нет сборки для {{platform}}.",
|
||||
"to_login": "Войти в веб-версию"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"genius": {
|
||||
"description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.",
|
||||
"loading": "Загрузка…", "disabled": "Не подключён", "paused": "Приостановлено", "cooldown": "Ожидание после ограничения Genius", "running": "Обработка", "waiting": "Ожидание новых треков",
|
||||
"configure": "Добавьте GENIUS_ACCESS_TOKEN в deploy/.env и пересоздайте контейнер API.",
|
||||
"total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные",
|
||||
"last_error": "Последняя ошибка", "token_error": "Genius отклонил токен. Проверьте его и перезапустите API.", "access_error": "Genius ограничил доступ. Обработка приостановлена.", "no_lyrics": "Текст пока недоступен.", "request_error": "Не удалось получить все данные. Повтор запланирован автоматически.", "resume_at": "Повтор не раньше",
|
||||
"start": "Продолжить", "pause": "Приостановить", "retry": "Повторить ошибки и ненайденные", "pending_text": "Трек ожидает обработки Genius.", "about": "О песне", "explanations": "Пояснения к песне", "verified": "Подтверждено в Genius"
|
||||
},
|
||||
"app": {
|
||||
"name": "pipu",
|
||||
"tagline": "Ваша музыка с вашего сервера"
|
||||
@@ -41,7 +49,7 @@
|
||||
"menu": "Меню профиля",
|
||||
"settings": "Настройки",
|
||||
"logout": "Выйти",
|
||||
"android_app": "Приложение для Android"
|
||||
"download_apps": "Скачать приложения"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Администратор",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
// All platform assets derive from the editable web SVG. Requires workspace dependencies.
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const desktop = join(root, 'apps/desktop');
|
||||
const require = createRequire(join(desktop, 'package.json'));
|
||||
const cli = require.resolve('@tauri-apps/cli/tauri.js');
|
||||
const scratch = join(root, 'artifacts/icon-build');
|
||||
const source = join(root, 'apps/web/public/favicon.svg');
|
||||
const svg = readFileSync(source, 'utf8');
|
||||
const body = svg.replace(/<svg[^>]*>/, '').replace('</svg>', '');
|
||||
mkdirSync(scratch, { recursive: true });
|
||||
|
||||
function render(input, output, sizes = []) {
|
||||
mkdirSync(output, { recursive: true });
|
||||
const result = spawnSync(process.execPath, [cli, 'icon', input, '--output', output,
|
||||
...sizes.flatMap(size => ['--png', String(size)])], { cwd: desktop, stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Icon generation failed: ${input}`);
|
||||
}
|
||||
|
||||
function variant(name, contents, size, destination) {
|
||||
const input = join(scratch, `${name}.svg`);
|
||||
const output = join(scratch, name);
|
||||
writeFileSync(input, `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">${contents}</svg>\n`);
|
||||
render(input, output, [size]);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
copyFileSync(join(output, `${size}x${size}.png`), destination);
|
||||
}
|
||||
|
||||
const native = join(scratch, 'desktop');
|
||||
render(source, native);
|
||||
for (const name of ['32x32.png', '64x64.png', '128x128.png', '128x128@2x.png', 'icon.png', 'icon.ico', 'icon.icns']) {
|
||||
copyFileSync(join(native, name), join(desktop, 'src-tauri/icons', name));
|
||||
}
|
||||
copyFileSync(join(native, '32x32.png'), join(root, 'apps/web/public/favicon-32.png'));
|
||||
const background = '<rect width="512" height="512" fill="#05070D"/>';
|
||||
variant('touch', background + body, 180, join(root, 'apps/web/public/apple-touch-icon.png'));
|
||||
variant('mobile', background + body, 1024, join(root, 'apps/mobile/assets/icon.png'));
|
||||
// The complete orb fits inside Android's central 66/108 safe-zone circle.
|
||||
variant('adaptive', `<g transform="translate(92.16 92.16) scale(.64)">${body}</g>`, 1024,
|
||||
join(root, 'apps/mobile/assets/adaptive-icon.png'));
|
||||
const mono = body.replace(/<defs>[\s\S]*?<\/defs>/, '').replace(/<circle[^>]*\/>/, '')
|
||||
.replaceAll('url(#note)', '#FFFFFF');
|
||||
variant('monochrome', `<g transform="translate(51.2 51.2) scale(.8)">${mono}</g>`, 1024,
|
||||
join(root, 'apps/mobile/assets/monochrome-icon.png'));
|
||||
console.log('Updated desktop, Android, and browser icons.');
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -29,6 +29,12 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
|
||||
|
||||
export const STATUS_START = '<!-- android-status -->';
|
||||
export const STATUS_END = '<!-- /android-status -->';
|
||||
export const DESKTOP_PLATFORMS = ['linux', 'windows'];
|
||||
|
||||
export function desktopStatusMarks(platform) {
|
||||
if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`);
|
||||
return { start: `<!-- desktop-${platform}-status -->`, end: `<!-- /desktop-${platform}-status -->` };
|
||||
}
|
||||
|
||||
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
|
||||
export function groupCommits(commits) {
|
||||
@@ -76,16 +82,62 @@ export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing
|
||||
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
|
||||
}
|
||||
|
||||
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
|
||||
export function replaceAndroidStatus(body, block) {
|
||||
const start = body.indexOf(STATUS_START);
|
||||
const end = body.indexOf(STATUS_END);
|
||||
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
|
||||
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
|
||||
/** Блок статуса сборки десктопа в тексте релиза (отдельный для linux и windows). */
|
||||
export function renderDesktopStatus({ platform, status, runUrl, files }) {
|
||||
const label = platform === 'windows' ? 'Windows' : 'Linux';
|
||||
const pending = platform === 'windows' ? 'NSIS-установщик' : '.deb';
|
||||
const lines =
|
||||
status === 'success'
|
||||
? [
|
||||
`✅ **Десктоп (${label}):** собрано — ${files.map((f) => `\`${f.name}\` (${formatSize(f.size)})`).join(', ')}.`,
|
||||
]
|
||||
: status === 'cancelled'
|
||||
? [
|
||||
`⏹ **Десктоп (${label}):** сборка отменена — [лог сборки](${runUrl}).`,
|
||||
'Файлы появятся здесь после перезапуска сборки.',
|
||||
]
|
||||
: status === 'failure'
|
||||
? [
|
||||
`❌ **Десктоп (${label}):** сборка не удалась — [лог сборки](${runUrl}).`,
|
||||
'Остальные клиенты от этого не зависят; установщик появится после перезапуска сборки.',
|
||||
]
|
||||
: [
|
||||
`⏳ **Десктоп (${label}):** собирается — [ход сборки](${runUrl}).`,
|
||||
`${pending} появится в этом релизе автоматически.`,
|
||||
];
|
||||
const { start, end } = desktopStatusMarks(platform);
|
||||
return [start, ...lines.map((l) => `> ${l}`), end].join('\n');
|
||||
}
|
||||
|
||||
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */
|
||||
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
|
||||
function replaceBlock(body, startMark, endMark, block) {
|
||||
const start = body.indexOf(startMark);
|
||||
const end = body.indexOf(endMark);
|
||||
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
|
||||
return body.slice(0, start) + block + body.slice(end + endMark.length);
|
||||
}
|
||||
|
||||
/** Заменяет блок статуса Android в тексте релиза (или дописывает его в начало, если блока нет). */
|
||||
export function replaceAndroidStatus(body, block) {
|
||||
return replaceBlock(body, STATUS_START, STATUS_END, block);
|
||||
}
|
||||
|
||||
export function replaceDesktopStatus(body, platform, block) {
|
||||
const { start, end } = desktopStatusMarks(platform);
|
||||
return replaceBlock(body, start, end, block);
|
||||
}
|
||||
|
||||
/** Текст релиза: статусы сборок, изменения, обновление сервера, установка клиентов. */
|
||||
export function renderNotes({
|
||||
version,
|
||||
tag,
|
||||
prevTag,
|
||||
prerelease,
|
||||
repoUrl,
|
||||
groups,
|
||||
androidStatus,
|
||||
desktopLinuxStatus,
|
||||
desktopWindowsStatus,
|
||||
}) {
|
||||
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
|
||||
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
|
||||
const out = [];
|
||||
@@ -93,7 +145,10 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
|
||||
if (prerelease) {
|
||||
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
|
||||
}
|
||||
out.push(androidStatus, '', '## Что изменилось', '');
|
||||
out.push(androidStatus, '');
|
||||
if (desktopLinuxStatus) out.push(desktopLinuxStatus, '');
|
||||
if (desktopWindowsStatus) out.push(desktopWindowsStatus, '');
|
||||
out.push('## Что изменилось', '');
|
||||
if (groups.breaking.length) {
|
||||
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
|
||||
}
|
||||
@@ -124,11 +179,14 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
|
||||
'',
|
||||
`Ожидается \`"version":"${version}"\`.`,
|
||||
'',
|
||||
'## Android',
|
||||
'## Клиенты',
|
||||
'',
|
||||
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
|
||||
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
|
||||
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
|
||||
'Скачайте с этой страницы или с `/download` на сервере (подскажет сборку под вашу систему).',
|
||||
'',
|
||||
`- **Android:** \`pipu-music-${version}.apk\` — откройте на телефоне; Android попросит разрешить установку из этого источника.`,
|
||||
`- **Windows:** \`pipu-music-${version}-windows-setup.exe\`.`,
|
||||
`- **Linux:** \`pipu-music-${version}-linux.deb\` — \`sudo apt install ./pipu-music-${version}-linux.deb\` (нужны WebKitGTK 4.1 и GStreamer).`,
|
||||
`- **Проверка APK:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
|
||||
'',
|
||||
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
|
||||
'',
|
||||
@@ -202,6 +260,8 @@ async function create() {
|
||||
repoUrl,
|
||||
groups: groupCommits(commitsBetween(prevTag, tag)),
|
||||
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
|
||||
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl }),
|
||||
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl }),
|
||||
});
|
||||
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
|
||||
const saved = release
|
||||
@@ -242,13 +302,38 @@ async function android(status, apkPath) {
|
||||
console.log(`статус Android: ${status}`);
|
||||
}
|
||||
|
||||
async function desktop(platform, status, paths) {
|
||||
const { release, runUrl } = await context();
|
||||
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
|
||||
const files = [];
|
||||
if (status === 'success') {
|
||||
if (!paths.length) throw new Error('desktop success: укажите файлы');
|
||||
for (const file of paths) {
|
||||
const data = readFileSync(file);
|
||||
files.push({ name: basename(file), size: data.length });
|
||||
const sum = `${file}.sha256`;
|
||||
if (!existsSync(sum)) writeFileSync(sum, `${createHash('sha256').update(data).digest('hex')}\n`);
|
||||
await upload(release, file);
|
||||
if (existsSync(sum)) await upload(release, sum);
|
||||
}
|
||||
}
|
||||
const block = renderDesktopStatus({ platform, status, runUrl, files });
|
||||
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceDesktopStatus(release.body ?? '', platform, block) } });
|
||||
console.log(`статус десктопа (${platform}): ${status}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
if (cmd === 'create') return create();
|
||||
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
|
||||
return android(args[0], args[1]);
|
||||
}
|
||||
throw new Error('использование: release.mjs create | android building|failure|cancelled | android success <file.apk>');
|
||||
if (cmd === 'desktop' && DESKTOP_PLATFORMS.includes(args[0]) && ['building', 'success', 'failure', 'cancelled'].includes(args[1])) {
|
||||
return desktop(args[0], args[1], args.slice(2));
|
||||
}
|
||||
throw new Error(
|
||||
'использование: release.mjs create | android building|failure|cancelled | android success <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...>',
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||
|
||||
@@ -4,8 +4,11 @@ import { test } from 'node:test';
|
||||
import {
|
||||
groupCommits,
|
||||
renderAndroidStatus,
|
||||
renderDesktopStatus,
|
||||
renderNotes,
|
||||
replaceAndroidStatus,
|
||||
replaceDesktopStatus,
|
||||
desktopStatusMarks,
|
||||
STATUS_END,
|
||||
STATUS_START,
|
||||
} from './release.mjs';
|
||||
@@ -77,6 +80,34 @@ test('adds a status block when the body has none', () => {
|
||||
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
|
||||
});
|
||||
|
||||
test('desktop status blocks', () => {
|
||||
const linuxMarks = desktopStatusMarks('linux');
|
||||
const winMarks = desktopStatusMarks('windows');
|
||||
const building = renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' });
|
||||
assert.ok(building.startsWith(linuxMarks.start) && building.endsWith(linuxMarks.end));
|
||||
assert.match(building, /Десктоп \(Linux\):.*собирается — \[ход сборки\]\(https:\/\/run\)/);
|
||||
const ok = renderDesktopStatus({
|
||||
platform: 'linux',
|
||||
status: 'success',
|
||||
files: [{ name: 'pipu-music-1.4.0-linux.deb', size: 4 * 1024 * 1024 }],
|
||||
});
|
||||
assert.match(ok, /`pipu-music-1\.4\.0-linux\.deb` \(4,0 МБ\)/);
|
||||
assert.equal(replaceDesktopStatus(`a\n\n${building}\n\nb`, 'linux', ok).includes(building), false);
|
||||
|
||||
const winBuilding = renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' });
|
||||
assert.ok(winBuilding.startsWith(winMarks.start) && winBuilding.endsWith(winMarks.end));
|
||||
const winOk = renderDesktopStatus({
|
||||
platform: 'windows',
|
||||
status: 'success',
|
||||
files: [{ name: 'pipu-music-1.4.0-windows-setup.exe', size: 12 * 1024 * 1024 }],
|
||||
});
|
||||
const both = `${building}\n\n${winBuilding}`;
|
||||
const patched = replaceDesktopStatus(both, 'windows', winOk);
|
||||
assert.match(patched, /Linux/);
|
||||
assert.match(patched, /windows-setup\.exe/);
|
||||
assert.doesNotMatch(patched, /Десктоп \(Windows\):.*собирается/);
|
||||
});
|
||||
|
||||
test('release notes', () => {
|
||||
const notes = renderNotes({
|
||||
version: '0.2.0',
|
||||
@@ -86,12 +117,16 @@ test('release notes', () => {
|
||||
repoUrl,
|
||||
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
|
||||
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
|
||||
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' }),
|
||||
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' }),
|
||||
});
|
||||
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
|
||||
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
|
||||
assert.match(notes, /\.\/update\.sh v0\.2\.0/);
|
||||
assert.match(notes, /"version":"0\.2\.0"/);
|
||||
assert.match(notes, /pipu-music-0\.2\.0\.apk/);
|
||||
assert.match(notes, /pipu-music-0\.2\.0-windows-setup\.exe/);
|
||||
assert.match(notes, /pipu-music-0\.2\.0-linux\.deb/);
|
||||
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
|
||||
assert.doesNotMatch(notes, /Предварительная версия/);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"pipu/api/internal/appupdate"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/buildinfo"
|
||||
"pipu/api/internal/genius"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/platform/config"
|
||||
"pipu/api/internal/platform/db"
|
||||
@@ -105,6 +106,8 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
})
|
||||
|
||||
libStore := library.NewStore(pool)
|
||||
geniusSvc := genius.NewService(pool, cfg.GeniusAccessToken, cfg.GeniusRequestInterval, log)
|
||||
go geniusSvc.Run(ctx)
|
||||
board := library.NewProgressBoard()
|
||||
scanner := library.NewScanner(libStore, prober, board, log)
|
||||
agent := library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken)
|
||||
@@ -132,6 +135,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
covers.Run(ctx)
|
||||
indexer.Trigger()
|
||||
fingerprints.Trigger(ctx)
|
||||
geniusSvc.Wake()
|
||||
}
|
||||
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
|
||||
fingerprints.Trigger(ctx)
|
||||
@@ -168,6 +172,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log)
|
||||
wh.Warm = pb.Prewarm
|
||||
return server.NewRouter(server.Deps{
|
||||
Genius: genius.NewHandler(geniusSvc),
|
||||
Log: log,
|
||||
System: sys,
|
||||
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Command genius searches metadata or retrieves lyrics from an explicitly selected Genius URL.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"pipu/api/internal/genius"
|
||||
)
|
||||
|
||||
func main() {
|
||||
query := flag.String("search", "", "search candidates by artist and title (requires GENIUS_ACCESS_TOKEN)")
|
||||
page := flag.String("url", "", "retrieve lyrics from a selected https://genius.com/...-lyrics URL")
|
||||
flag.Parse()
|
||||
if (*query == "") == (*page == "") || flag.NArg() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "provide exactly one of -search or -url")
|
||||
os.Exit(2)
|
||||
}
|
||||
client := genius.New(os.Getenv("GENIUS_ACCESS_TOKEN"))
|
||||
var result any
|
||||
var err error
|
||||
if *query != "" {
|
||||
result, err = client.Search(context.Background(), *query)
|
||||
} else {
|
||||
result, err = client.FetchLyrics(context.Background(), *page)
|
||||
}
|
||||
if err == nil {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
err = encoder.Encode(result)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.10.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/text v0.25.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package appupdate — самообновление Android-приложения через сервер (docs/mobile.md#самообновление):
|
||||
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK.
|
||||
// Package appupdate — раздача клиентов через сервер (docs/mobile.md#самообновление):
|
||||
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и файлы.
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
"pipu/api/internal/platform/httpx"
|
||||
)
|
||||
|
||||
// CodeUnavailable — обновления не настроены или релиза с APK нет.
|
||||
// CodeUnavailable — обновления не настроены или релиза с клиентами нет.
|
||||
const CodeUnavailable = "app.update_unavailable"
|
||||
|
||||
const cacheTTL = 10 * time.Minute
|
||||
@@ -73,14 +73,22 @@ type release struct {
|
||||
Assets []asset `json:"assets"`
|
||||
}
|
||||
|
||||
// Latest — сведения о последней версии.
|
||||
type clientFile struct {
|
||||
Kind, Name, URL, ChecksumURL, SHA256 string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Latest — сведения о последней версии и файлах клиентов.
|
||||
type Latest struct {
|
||||
Version string
|
||||
VersionCode int
|
||||
Notes string
|
||||
Size int64
|
||||
SHA256 string
|
||||
apkURL string
|
||||
Files map[string]clientFile
|
||||
}
|
||||
|
||||
func (l *Latest) android() (clientFile, bool) {
|
||||
f, ok := l.Files["android"]
|
||||
return f, ok && f.SHA256 != ""
|
||||
}
|
||||
|
||||
// Handler реализует app-часть gen.StrictServerInterface.
|
||||
@@ -92,7 +100,7 @@ type Handler struct {
|
||||
mu sync.Mutex
|
||||
cached *Latest
|
||||
cachedAt time.Time
|
||||
dl sync.Mutex // одна загрузка APK за раз
|
||||
dl sync.Mutex
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик.
|
||||
@@ -100,7 +108,6 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler {
|
||||
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
|
||||
}
|
||||
|
||||
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
|
||||
func (h *Handler) configured() bool {
|
||||
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
|
||||
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
|
||||
@@ -125,7 +132,70 @@ func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// latest — последний релиз с APK (кэш на 10 минут).
|
||||
func classifyAsset(name string) string {
|
||||
n := strings.ToLower(name)
|
||||
if strings.HasSuffix(n, ".sha256") {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(n, ".apk"):
|
||||
return "android"
|
||||
case strings.HasSuffix(n, ".exe"), strings.HasSuffix(n, ".msi"):
|
||||
return "windows"
|
||||
case strings.HasSuffix(n, ".appimage"), strings.HasSuffix(n, ".deb"), strings.HasSuffix(n, ".rpm"):
|
||||
return "linux"
|
||||
case strings.HasSuffix(n, ".dmg"), strings.Contains(n, ".app.tar"):
|
||||
return "macos"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func assetRank(kind, name string) int {
|
||||
n := strings.ToLower(name)
|
||||
switch kind {
|
||||
case "windows":
|
||||
if strings.Contains(n, "setup") {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
case "linux":
|
||||
switch {
|
||||
case strings.HasSuffix(n, ".deb"):
|
||||
return 3
|
||||
case strings.HasSuffix(n, ".rpm"):
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func pickClientFiles(assets []asset) map[string]clientFile {
|
||||
sums := map[string]string{}
|
||||
for _, a := range assets {
|
||||
if strings.HasSuffix(strings.ToLower(a.Name), ".sha256") {
|
||||
sums[strings.TrimSuffix(a.Name, ".sha256")] = a.URL
|
||||
sums[strings.TrimSuffix(a.Name, ".SHA256")] = a.URL
|
||||
}
|
||||
}
|
||||
out := map[string]clientFile{}
|
||||
for _, a := range assets {
|
||||
kind := classifyAsset(a.Name)
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
cur, ok := out[kind]
|
||||
if ok && assetRank(kind, a.Name) <= assetRank(kind, cur.Name) {
|
||||
continue
|
||||
}
|
||||
out[kind] = clientFile{Kind: kind, Name: a.Name, URL: a.URL, Size: a.Size, ChecksumURL: sums[a.Name]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
if !h.configured() {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
|
||||
@@ -142,7 +212,7 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
if err != nil {
|
||||
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
|
||||
if h.cached != nil {
|
||||
return h.cached, nil // устаревший ответ лучше, чем никакого
|
||||
return h.cached, nil
|
||||
}
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
|
||||
}
|
||||
@@ -156,35 +226,77 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
if err != nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, err.Error())
|
||||
}
|
||||
name := "pipu-music-" + version + ".apk"
|
||||
var apk, sum *asset
|
||||
for i := range rel.Assets {
|
||||
switch rel.Assets[i].Name {
|
||||
case name:
|
||||
apk = &rel.Assets[i]
|
||||
case name + ".sha256":
|
||||
sum = &rel.Assets[i]
|
||||
files := pickClientFiles(rel.Assets)
|
||||
if len(files) == 0 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
|
||||
}
|
||||
if apk, ok := files["android"]; ok {
|
||||
if apk.ChecksumURL == "" {
|
||||
delete(files, "android")
|
||||
} else if digest, err := h.readDigest(ctx, apk.ChecksumURL); err != nil {
|
||||
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err)
|
||||
delete(files, "android")
|
||||
} else {
|
||||
apk.SHA256 = digest
|
||||
files["android"] = apk
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||
}
|
||||
}
|
||||
if apk == nil || sum == nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||
}
|
||||
sres, err := h.get(ctx, sum.URL)
|
||||
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
|
||||
h.cachedAt = time.Now()
|
||||
return h.cached, nil
|
||||
}
|
||||
|
||||
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
|
||||
sres, err := h.get(ctx, url)
|
||||
if err != nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable")
|
||||
return "", err
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
|
||||
_ = sres.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
|
||||
if len(digest) != 64 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "malformed checksum")
|
||||
return "", fmt.Errorf("malformed checksum")
|
||||
}
|
||||
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Size: apk.Size, SHA256: digest, apkURL: apk.URL}
|
||||
h.cachedAt = time.Now()
|
||||
return h.cached, nil
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
func (h *Handler) fileURL(kind, version string) string {
|
||||
return "/api/v1/app/download/" + kind + "/" + version
|
||||
}
|
||||
|
||||
// GetAppClients реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetAppClients(ctx context.Context, _ gen.GetAppClientsRequestObject) (gen.GetAppClientsResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
order := []string{"windows", "macos", "linux", "android"}
|
||||
clients := make([]gen.AppClientFile, 0, len(l.Files))
|
||||
for _, kind := range order {
|
||||
f, ok := l.Files[kind]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := gen.AppClientFile{
|
||||
Client: gen.AppClient(kind),
|
||||
Filename: f.Name,
|
||||
Size: f.Size,
|
||||
DownloadUrl: h.fileURL(kind, l.Version),
|
||||
}
|
||||
if f.SHA256 != "" {
|
||||
item.Sha256 = &f.SHA256
|
||||
}
|
||||
clients = append(clients, item)
|
||||
}
|
||||
return gen.GetAppClients200JSONResponse{
|
||||
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAndroidLatest реализует gen.StrictServerInterface.
|
||||
@@ -193,15 +305,18 @@ func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRe
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, ok := l.android()
|
||||
if !ok {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||
}
|
||||
return gen.GetAndroidLatest200JSONResponse{
|
||||
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: l.Size, Sha256: l.SHA256,
|
||||
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: f.Size, Sha256: f.SHA256,
|
||||
DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensure скачивает APK в кэш (если его там нет) и сверяет SHA-256 из релиза.
|
||||
func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
|
||||
dst := filepath.Join(h.cfg.Dir, "pipu-music-"+l.Version+".apk")
|
||||
func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) {
|
||||
dst := filepath.Join(h.cfg.Dir, f.Name)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return dst, nil
|
||||
}
|
||||
@@ -213,12 +328,12 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
|
||||
if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := h.get(ctx, l.apkURL)
|
||||
res, err := h.get(ctx, f.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
tmp, err := os.CreateTemp(h.cfg.Dir, ".apk-*")
|
||||
tmp, err := os.CreateTemp(h.cfg.Dir, ".dl-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -231,26 +346,53 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if got := hex.EncodeToString(hash.Sum(nil)); got != l.SHA256 {
|
||||
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, l.SHA256)
|
||||
if f.SHA256 != "" {
|
||||
if got := hex.EncodeToString(hash.Sum(nil)); got != f.SHA256 {
|
||||
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, f.SHA256)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
h.cleanup(dst)
|
||||
h.cleanup(f.Name)
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// cleanup оставляет в кэше только текущий APK.
|
||||
func (h *Handler) cleanup(keep string) {
|
||||
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*.apk"))
|
||||
func (h *Handler) cleanup(keepName string) {
|
||||
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
|
||||
ver := versionFromName(keepName)
|
||||
for _, m := range matches {
|
||||
if m != keep {
|
||||
base := filepath.Base(m)
|
||||
if ver != "" && strings.Contains(base, ver) {
|
||||
continue
|
||||
}
|
||||
if base != keepName {
|
||||
_ = os.Remove(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func versionFromName(name string) string {
|
||||
m := regexp.MustCompile(`(\d+\.\d+\.\d+(?:-rc\.\d+)?)`).FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileResponse, error) {
|
||||
f, ok := l.Files[kind]
|
||||
if !ok || (kind == "android" && f.SHA256 == "") {
|
||||
return fileResponse{}, apperr.NotFound(CodeUnavailable, "client build is unavailable")
|
||||
}
|
||||
path, err := h.ensure(ctx, f)
|
||||
if err != nil {
|
||||
h.log.ErrorContext(ctx, "app update: download failed", "err", err, "client", kind)
|
||||
return fileResponse{}, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the file")
|
||||
}
|
||||
return fileResponse{path: path, name: f.Name, req: httpx.RequestFrom(ctx)}, nil
|
||||
}
|
||||
|
||||
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
|
||||
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
@@ -260,22 +402,36 @@ func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroi
|
||||
if req.Version != l.Version {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
|
||||
}
|
||||
path, err := h.ensure(ctx, l)
|
||||
if err != nil {
|
||||
h.log.ErrorContext(ctx, "app update: apk download failed", "err", err)
|
||||
return nil, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the APK")
|
||||
}
|
||||
return apkResponse{path: path, name: filepath.Base(path), req: httpx.RequestFrom(ctx)}, nil
|
||||
return h.serveFile(ctx, l, "android")
|
||||
}
|
||||
|
||||
type apkResponse struct {
|
||||
// DownloadAppClient реализует gen.StrictServerInterface.
|
||||
func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Version != l.Version {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
|
||||
}
|
||||
return h.serveFile(ctx, l, string(req.Client))
|
||||
}
|
||||
|
||||
type fileResponse struct {
|
||||
path string
|
||||
name string
|
||||
req *http.Request
|
||||
}
|
||||
|
||||
// VisitDownloadAndroidApkResponse реализует gen.DownloadAndroidApkResponseObject (Range — для докачки).
|
||||
func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
|
||||
func (a fileResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
|
||||
return a.write(w)
|
||||
}
|
||||
|
||||
func (a fileResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error {
|
||||
return a.write(w)
|
||||
}
|
||||
|
||||
func (a fileResponse) write(w http.ResponseWriter) error {
|
||||
f, err := os.Open(a.path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -288,7 +444,11 @@ func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) erro
|
||||
if a.req == nil {
|
||||
return errors.New("appupdate: request missing in context")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||
ctype := "application/octet-stream"
|
||||
if strings.HasSuffix(strings.ToLower(a.name), ".apk") {
|
||||
ctype = "application/vnd.android.package-archive"
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`)
|
||||
http.ServeContent(w, a.req, a.name, info.ModTime(), f)
|
||||
return nil
|
||||
|
||||
@@ -16,6 +16,22 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPickClientFiles(t *testing.T) {
|
||||
files := pickClientFiles([]asset{
|
||||
{Name: "pipu-music-1.3.0.apk", Size: 10},
|
||||
{Name: "pipu-music-1.3.0.apk.sha256", Size: 65, URL: "https://gitea/sum"},
|
||||
{Name: "pipu-music_1.3.0_x64-setup.exe", Size: 20},
|
||||
{Name: "pipu-music_1.3.0_amd64.deb", Size: 11},
|
||||
{Name: "pipu-music_1.3.0_amd64.AppImage", Size: 30},
|
||||
{Name: "pipu-music_1.3.0_aarch64.dmg", Size: 40},
|
||||
})
|
||||
assert.Equal(t, "pipu-music-1.3.0.apk", files["android"].Name)
|
||||
assert.Equal(t, "https://gitea/sum", files["android"].ChecksumURL)
|
||||
assert.Equal(t, "pipu-music_1.3.0_x64-setup.exe", files["windows"].Name)
|
||||
assert.Equal(t, "pipu-music_1.3.0_amd64.deb", files["linux"].Name)
|
||||
assert.Equal(t, "pipu-music_1.3.0_aarch64.dmg", files["macos"].Name)
|
||||
}
|
||||
|
||||
func TestVersionCode(t *testing.T) {
|
||||
cases := map[string]int{"v0.6.1": 60_199, "1.2.0-rc.3": 1_020_003, "v10.0.0": 10_000_099}
|
||||
for tag, want := range cases {
|
||||
@@ -60,17 +76,18 @@ func TestLatestAndDownload(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0.6.1", l.Version)
|
||||
assert.Equal(t, 60_199, l.VersionCode)
|
||||
assert.Equal(t, hex.EncodeToString(sum[:]), l.SHA256)
|
||||
android := l.Files["android"]
|
||||
assert.Equal(t, hex.EncodeToString(sum[:]), android.SHA256)
|
||||
|
||||
path, err := h.ensure(context.Background(), l)
|
||||
path, err := h.ensure(context.Background(), android)
|
||||
require.NoError(t, err)
|
||||
got, _ := os.ReadFile(path)
|
||||
assert.Equal(t, apk, got)
|
||||
|
||||
// Подменённый файл не пройдёт сверку.
|
||||
require.NoError(t, os.Remove(path))
|
||||
l.SHA256 = "00" + l.SHA256[2:]
|
||||
_, err = h.ensure(context.Background(), l)
|
||||
android.SHA256 = "00" + android.SHA256[2:]
|
||||
_, err = h.ensure(context.Background(), android)
|
||||
assert.ErrorContains(t, err, "checksum mismatch")
|
||||
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
|
||||
@@ -566,5 +566,6 @@ func TestPublicRoutes(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
|
||||
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
|
||||
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/clients", nil).Code, "download page is public")
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
|
||||
}
|
||||
@@ -55,8 +55,9 @@ var publicRoutes = map[string]bool{
|
||||
"GET /api/v1/server-info": true,
|
||||
"POST /api/v1/auth/login": true,
|
||||
"POST /api/v1/auth/refresh": true,
|
||||
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
|
||||
// Клиенты раздаются по ссылке /download без входа: в файлах нет ничего, кроме приложения.
|
||||
"GET /api/v1/app/android/latest": true,
|
||||
"GET /api/v1/app/clients": true,
|
||||
}
|
||||
|
||||
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
|
||||
@@ -93,7 +94,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
|
||||
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
|
||||
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/"))
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Package genius retrieves song metadata from the API and plain lyrics from public HTML.
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrLyricsUnavailable = errors.New("genius: lyrics absent or page markup unsupported")
|
||||
|
||||
// HTTPError preserves throttling information without exposing response bodies or credentials.
|
||||
type HTTPError struct {
|
||||
StatusCode int
|
||||
RetryAfter string
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string { return fmt.Sprintf("genius: HTTP %d", e.StatusCode) }
|
||||
|
||||
type Artist struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"image_url"`
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
ImageURL string `json:"song_art_image_url"`
|
||||
ThumbURL string `json:"song_art_image_thumbnail_url"`
|
||||
HeaderURL string `json:"header_image_url"`
|
||||
Artist Artist `json:"primary_artist"`
|
||||
Description struct {
|
||||
Plain string `json:"plain"`
|
||||
} `json:"description"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"cover_art_url"`
|
||||
ThumbURL string `json:"cover_art_thumbnail_url"`
|
||||
} `json:"album"`
|
||||
}
|
||||
|
||||
type Lyrics struct {
|
||||
Text string `json:"text"`
|
||||
SourceURL string `json:"source_url"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
token string
|
||||
http *http.Client
|
||||
mu sync.Mutex
|
||||
next time.Time
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// SetInterval must be called before sharing the client with workers.
|
||||
func (c *Client) SetInterval(d time.Duration) { c.interval = max(d, time.Second) }
|
||||
|
||||
func New(token string) *Client {
|
||||
return &Client{token: strings.TrimSpace(token), http: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("genius: too many redirects")
|
||||
}
|
||||
// Do not forward credentials or fetch content outside the initial host.
|
||||
if req.URL.Scheme != "https" || req.URL.Host != via[0].URL.Host || req.URL.User != nil {
|
||||
return errors.New("genius: refused redirect")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// Search returns candidates; callers must select a version rather than assume the first hit matches.
|
||||
func (c *Client) Search(ctx context.Context, query string) ([]Song, error) {
|
||||
if c.token == "" {
|
||||
return nil, errors.New("genius: GENIUS_ACCESS_TOKEN is required for search")
|
||||
}
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, errors.New("genius: empty search query")
|
||||
}
|
||||
body, err := c.get(ctx, "https://api.genius.com/search?q="+url.QueryEscape(query), true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Response struct {
|
||||
Hits []struct {
|
||||
Type string `json:"type"`
|
||||
Result Song `json:"result"`
|
||||
} `json:"hits"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("genius: decode search: %w", err)
|
||||
}
|
||||
songs := make([]Song, 0, len(result.Response.Hits))
|
||||
for _, hit := range result.Response.Hits {
|
||||
if hit.Type == "song" {
|
||||
songs = append(songs, hit.Result)
|
||||
}
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
// FetchLyrics accepts a selected public song URL. The token is never sent to the HTML site.
|
||||
func (c *Client) FetchLyrics(ctx context.Context, rawURL string) (Lyrics, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Scheme != "https" || u.Host != "genius.com" || u.User != nil || !strings.HasSuffix(u.Path, "-lyrics") {
|
||||
return Lyrics{}, errors.New("genius: expected https://genius.com/<song>-lyrics")
|
||||
}
|
||||
u.RawQuery, u.Fragment = "", ""
|
||||
body, err := c.get(ctx, u.String(), false)
|
||||
if err != nil {
|
||||
return Lyrics{}, err
|
||||
}
|
||||
text, err := parseLyrics(body)
|
||||
if err != nil {
|
||||
return Lyrics{}, err
|
||||
}
|
||||
return Lyrics{Text: text, SourceURL: u.String(), FetchedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, endpoint string, api bool) ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
wait := time.Until(c.next)
|
||||
c.next = time.Now().Add(max(wait, 0) + c.interval)
|
||||
c.mu.Unlock()
|
||||
if wait > 0 {
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "PipuMusic/1.0")
|
||||
if api {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
} else {
|
||||
req.Header.Set("Accept", "text/html")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genius: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &HTTPError{StatusCode: resp.StatusCode, RetryAfter: resp.Header.Get("Retry-After")}
|
||||
}
|
||||
const maxBody = 8 << 20
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxBody {
|
||||
return nil, errors.New("genius: response exceeds 8 MiB")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type transportFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func TestLyricsFormatting(t *testing.T) {
|
||||
text, err := parseLyrics([]byte(`<header>Not lyrics</header><div data-lyrics-container="true"><div class="LyricsHeader-x">Title</div>[Куплет 1]<br>Привет & <a href="/annotation">мир</a><br><br>Next <b>line</b><span data-exclude-from-selection="true">Embed</span><script>bad()</script></div><div data-lyrics-container="true">[Припев]<br>Again<button>Share</button></div>`))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "[Куплет 1]\nПривет & мир\n\nNext line\n\n[Припев]\nAgain", text)
|
||||
}
|
||||
|
||||
func TestUnavailable(t *testing.T) {
|
||||
for _, page := range []string{`<h1>Just a moment</h1>`, `<div data-lyrics-container="true"> </div>`, `<script>window.__PRELOADED_STATE__ = JSON.parse('broken')</script>`} {
|
||||
_, err := parseLyrics([]byte(page))
|
||||
require.ErrorIs(t, err, ErrLyricsUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedLyrics(t *testing.T) {
|
||||
text, err := parseLyrics([]byte(`<script>window.__PRELOADED_STATE__ = JSON.parse('{"songPage":{"lyricsData":{"body":{"html":"<p>[Verse]<br>It\'s \\"fine\\" & good<br>Привет</p>"}}}}')</script>`))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "[Verse]\nIt's \"fine\" & good\nПривет", text)
|
||||
}
|
||||
|
||||
func TestURLValidation(t *testing.T) {
|
||||
c := New("secret")
|
||||
c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network call"); return nil, nil })
|
||||
for _, u := range []string{"https://evil.test/song-lyrics", "http://genius.com/song-lyrics", "https://genius.com:443/song-lyrics", "https://secret@genius.com/song-lyrics", "https://genius.com/artists/test"} {
|
||||
_, err := c.FetchLyrics(context.Background(), u)
|
||||
require.Error(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequests(t *testing.T) {
|
||||
c := New("secret")
|
||||
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
|
||||
body := `<div data-lyrics-container="true">Synthetic test text</div>`
|
||||
if r.URL.Host == "api.genius.com" {
|
||||
require.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "artist & title", r.URL.Query().Get("q"))
|
||||
body = `{"response":{"hits":[{"type":"song","result":{"id":12,"title":"Example","url":"https://genius.com/example-lyrics","primary_artist":{"name":"Artist"}}}]}}`
|
||||
} else {
|
||||
require.Empty(t, r.Header.Get("Authorization"))
|
||||
require.Empty(t, r.URL.RawQuery)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})
|
||||
songs, err := c.Search(context.Background(), "artist & title")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, songs, 1)
|
||||
lyrics, err := c.FetchLyrics(context.Background(), songs[0].URL+"?tracking=1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Synthetic test text", lyrics.Text)
|
||||
require.Equal(t, songs[0].URL, lyrics.SourceURL)
|
||||
require.False(t, lyrics.FetchedAt.IsZero())
|
||||
}
|
||||
|
||||
func TestHTTPFailures(t *testing.T) {
|
||||
for _, status := range []int{401, 403, 404, 429, 500} {
|
||||
c := New("secret")
|
||||
c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: status, Header: http.Header{"Retry-After": {"60"}}, Body: io.NopCloser(strings.NewReader("private upstream body"))}, nil
|
||||
})
|
||||
_, err := c.Search(context.Background(), "test")
|
||||
var httpErr *HTTPError
|
||||
require.True(t, errors.As(err, &httpErr))
|
||||
require.Equal(t, status, httpErr.StatusCode)
|
||||
require.Equal(t, "60", httpErr.RetryAfter)
|
||||
require.NotContains(t, err.Error(), "private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirects(t *testing.T) {
|
||||
c := New("secret")
|
||||
first, _ := http.NewRequest("GET", "https://api.genius.com/search", nil)
|
||||
for _, endpoint := range []string{"http://api.genius.com/search", "https://genius.com/song-lyrics", "https://evil.test/"} {
|
||||
req, _ := http.NewRequest("GET", endpoint, nil)
|
||||
require.Error(t, c.http.CheckRedirect(req, []*http.Request{first}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type Handler struct{ svc *Service }
|
||||
|
||||
func NewHandler(s *Service) *Handler { return &Handler{svc: s} }
|
||||
|
||||
func requireAdmin(ctx context.Context) error {
|
||||
p, ok := auth.PrincipalFrom(ctx)
|
||||
if !ok {
|
||||
return apperr.Unauthorized("auth.unauthorized", "authentication required")
|
||||
}
|
||||
if p.Role != auth.RoleAdmin {
|
||||
return apperr.Forbidden("auth.forbidden", "administrator required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetGeniusStats(ctx context.Context, _ gen.GetGeniusStatsRequestObject) (gen.GetGeniusStatsResponseObject, error) {
|
||||
if err := requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats, err := h.svc.Stats(ctx)
|
||||
return gen.GetGeniusStats200JSONResponse(stats), err
|
||||
}
|
||||
|
||||
func (h *Handler) ControlGenius(ctx context.Context, req gen.ControlGeniusRequestObject) (gen.ControlGeniusResponseObject, error) {
|
||||
if err := requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !h.svc.configured {
|
||||
return nil, apperr.Conflict("genius.disabled", "configure GENIUS_ACCESS_TOKEN in deploy/.env")
|
||||
}
|
||||
if req.Body == nil {
|
||||
return nil, apperr.Validation("action", "validation.required", "action required", nil)
|
||||
}
|
||||
tx, err := h.svc.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
switch string(req.Body.Action) {
|
||||
case "pause":
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=true`)
|
||||
case "start":
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=false,last_error=''`)
|
||||
case "retry":
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_tracks SET status='pending',next_attempt=now(),last_error='' WHERE status IN ('error','partial','unmatched') AND locked_until<=now()`)
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=false,last_error=''`)
|
||||
}
|
||||
default:
|
||||
return nil, apperr.Validation("action", "validation.invalid", "unknown action", nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.svc.Wake()
|
||||
stats, err := h.svc.Stats(ctx)
|
||||
return gen.ControlGenius200JSONResponse(stats), err
|
||||
}
|
||||
|
||||
func (h *Handler) GetTrackGenius(ctx context.Context, req gen.GetTrackGeniusRequestObject) (gen.GetTrackGeniusResponseObject, error) {
|
||||
if _, ok := auth.PrincipalFrom(ctx); !ok {
|
||||
return nil, apperr.Unauthorized("auth.unauthorized", "authentication required")
|
||||
}
|
||||
content := emptyContent()
|
||||
var body []byte
|
||||
err := h.svc.pool.QueryRow(ctx, `SELECT COALESCE(g.content,'{}'),COALESCE(g.status,'pending') FROM tracks t LEFT JOIN genius_tracks g ON g.track_id=t.id WHERE t.id=$1`, req.Id).Scan(&body, &content.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, apperr.NotFound("catalog.not_found", "track not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := content.Status
|
||||
if err = json.Unmarshal(body, &content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content.Status = status
|
||||
if !h.svc.configured && status == "pending" {
|
||||
content.Status = "disabled"
|
||||
}
|
||||
return gen.GetTrackGenius200JSONResponse(content), nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// Parse only designated lyrics containers, never the entire page (navigation, ads, annotations).
|
||||
func parseLyrics(body []byte) (string, error) {
|
||||
doc, err := html.Parse(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var blocks []string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if excluded(n) {
|
||||
return
|
||||
}
|
||||
if attr(n, "data-lyrics-container") == "true" {
|
||||
if text := nodeText(n); text != "" {
|
||||
blocks = append(blocks, text)
|
||||
}
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
if len(blocks) == 0 {
|
||||
if text := embeddedLyrics(doc); text != "" {
|
||||
return text, nil
|
||||
}
|
||||
return "", ErrLyricsUnavailable
|
||||
}
|
||||
return strings.Join(blocks, "\n\n"), nil
|
||||
}
|
||||
|
||||
func attr(n *html.Node, name string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == name {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func excluded(n *html.Node) bool {
|
||||
return n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style" || n.Data == "button" ||
|
||||
attr(n, "data-exclude-from-selection") == "true" || attr(n, "aria-hidden") == "true" ||
|
||||
strings.Contains(attr(n, "class"), "LyricsHeader"))
|
||||
}
|
||||
|
||||
var statePattern = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*JSON\.parse\('((?:[^'\\]|\\.)*)'\)`)
|
||||
|
||||
// Read a JSON string literal only; never evaluate JavaScript from a remote page.
|
||||
func embeddedLyrics(doc *html.Node) string {
|
||||
var result string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if result != "" {
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "script" && n.FirstChild != nil {
|
||||
match := statePattern.FindStringSubmatch(n.FirstChild.Data)
|
||||
if len(match) == 2 {
|
||||
decoded, ok := decodeJSString(match[1])
|
||||
if ok {
|
||||
var state struct {
|
||||
SongPage struct {
|
||||
LyricsData struct {
|
||||
Body struct {
|
||||
HTML string `json:"html"`
|
||||
} `json:"body"`
|
||||
} `json:"lyricsData"`
|
||||
} `json:"songPage"`
|
||||
}
|
||||
if json.Unmarshal([]byte(decoded), &state) == nil {
|
||||
fragment, err := html.Parse(strings.NewReader(strings.ReplaceAll(state.SongPage.LyricsData.Body.HTML, "\n", "")))
|
||||
if err == nil {
|
||||
result = nodeText(fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return result
|
||||
}
|
||||
|
||||
func decodeJSString(raw string) (string, bool) {
|
||||
var quoted strings.Builder
|
||||
quoted.WriteByte('"')
|
||||
for i := 0; i < len(raw); i++ {
|
||||
switch raw[i] {
|
||||
case '"':
|
||||
quoted.WriteString(`\"`)
|
||||
case '\\':
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return "", false
|
||||
}
|
||||
switch raw[i] {
|
||||
case '\'':
|
||||
quoted.WriteByte('\'')
|
||||
case 'x':
|
||||
if i+2 >= len(raw) {
|
||||
return "", false
|
||||
}
|
||||
quoted.WriteString(`\u00`)
|
||||
quoted.WriteString(raw[i+1 : i+3])
|
||||
i += 2
|
||||
default:
|
||||
quoted.WriteByte('\\')
|
||||
quoted.WriteByte(raw[i])
|
||||
}
|
||||
default:
|
||||
quoted.WriteByte(raw[i])
|
||||
}
|
||||
}
|
||||
quoted.WriteByte('"')
|
||||
var decoded string
|
||||
err := json.Unmarshal([]byte(quoted.String()), &decoded)
|
||||
return decoded, err == nil
|
||||
}
|
||||
|
||||
func nodeText(root *html.Node) string {
|
||||
var out strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if excluded(n) {
|
||||
return
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
out.WriteString(n.Data)
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "br" {
|
||||
out.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
block := n.Type == html.ElementNode && (n.Data == "p" || n.Data == "div") && n != root
|
||||
if block {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
if block {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
lines := strings.Split(strings.ReplaceAll(out.String(), "\r", ""), "\n")
|
||||
clean := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.Join(strings.Fields(line), " ")
|
||||
if line == "" && (len(clean) == 0 || clean[len(clean)-1] == "") {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(clean, "\n"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||