Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb6b36888 | ||
|
|
83b604437c | ||
|
|
73ad61e7a5 | ||
|
|
fb20258489 | ||
|
|
78c006184b | ||
|
|
d3116aba96 | ||
|
|
b7518aa450 | ||
|
|
eb8031b126 | ||
|
|
e4b841a8d8 |
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:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
|
||||
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
|
||||
# и переписывает статус: собран / не удалась (scripts/release.mjs).
|
||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
|
||||
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
|
||||
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
||||
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
|
||||
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
|
||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||
#
|
||||
# Секреты (Settings → Actions → Secrets):
|
||||
@@ -197,3 +199,142 @@ jobs:
|
||||
- name: Статус в релизе — сборка отменена
|
||||
if: cancelled()
|
||||
run: node scripts/release.mjs android cancelled
|
||||
|
||||
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
|
||||
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
|
||||
desktop:
|
||||
name: desktop (Linux и Windows в релиз)
|
||||
needs: [release, android]
|
||||
if: always() && needs.release.result == 'success'
|
||||
timeout-minutes: 90
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
PIPU_VERSION: ${{ needs.release.outputs.version }}
|
||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
VERSION: ${{ needs.release.outputs.version }}
|
||||
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
XWIN_CACHE_DIR: /opt/ci-cache/xwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Статус в релизе — собирается
|
||||
run: |
|
||||
node scripts/release.mjs desktop linux building
|
||||
node scripts/release.mjs desktop windows building
|
||||
- name: Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||
libdbus-1-dev libsecret-1-dev patchelf \
|
||||
nsis lld llvm clang
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
~/.cargo/bin/cargo-xwin
|
||||
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
|
||||
restore-keys: cargo-desktop-
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Сборка deb
|
||||
id: linux
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm bundle:linux
|
||||
bundle=src-tauri/target/release/bundle
|
||||
shopt -s nullglob
|
||||
for f in "$bundle"/deb/*.deb; do
|
||||
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||
done
|
||||
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||
rm -rf src-tauri/target/release
|
||||
- name: Linux в релиз
|
||||
id: linux-upload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
f="pipu-music-${PIPU_VERSION}-linux.deb"
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "::error::нет $f после сборки"
|
||||
exit 1
|
||||
fi
|
||||
node scripts/release.mjs desktop linux success "$f"
|
||||
- name: Windows toolchain
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
if ! command -v cargo-xwin >/dev/null; then
|
||||
cargo install --locked cargo-xwin
|
||||
fi
|
||||
if [ ! -d /opt/ci-cache ]; then
|
||||
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
|
||||
mkdir -p "$HOME/.cache/xwin"
|
||||
else
|
||||
mkdir -p /opt/ci-cache/xwin
|
||||
fi
|
||||
- name: Сборка NSIS
|
||||
id: win
|
||||
if: ${{ !cancelled() }}
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm bundle:windows:cross
|
||||
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
|
||||
shopt -s nullglob
|
||||
copied=
|
||||
for f in "$bundle"/*-setup.exe; do
|
||||
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||
copied=1
|
||||
done
|
||||
if [ -z "$copied" ]; then
|
||||
echo "::error::нет NSIS setup.exe после сборки в $bundle"
|
||||
ls -la "$bundle" || true
|
||||
exit 1
|
||||
fi
|
||||
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||
- name: Windows в релиз
|
||||
id: win-upload
|
||||
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
|
||||
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||
- name: Статус Linux — сборка не удалась
|
||||
if: failure() && steps.linux-upload.outcome != 'success'
|
||||
run: node scripts/release.mjs desktop linux failure
|
||||
- name: Статус Windows — сборка не удалась
|
||||
if: failure() && steps.win-upload.outcome != 'success'
|
||||
run: node scripts/release.mjs desktop windows failure
|
||||
- name: Статус в релизе — сборка отменена
|
||||
if: cancelled()
|
||||
run: |
|
||||
node scripts/release.mjs desktop linux cancelled
|
||||
node scripts/release.mjs desktop windows cancelled
|
||||
|
||||
prune:
|
||||
name: prune (старые файлы и прогоны)
|
||||
needs: [release, android, desktop]
|
||||
if: always() && needs.release.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "${{ env.NODE_VERSION }}" }
|
||||
- name: Оставить 8 релизов и 8 прогонов
|
||||
env:
|
||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: node scripts/release.mjs prune
|
||||
@@ -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
|
||||
@@ -1504,7 +1549,7 @@ paths:
|
||||
security: [] # публично: страница /download без входа
|
||||
summary: Клиенты последнего релиза (Android и десктоп)
|
||||
description: |
|
||||
Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG.
|
||||
Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
|
||||
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||
responses:
|
||||
"200":
|
||||
@@ -3016,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
|
||||
|
||||
@@ -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 |
@@ -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 });
|
||||
|
||||
@@ -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} />}
|
||||
</>;
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function ArtistPage({ id }: { id: string }) {
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
<Cover album={{ id }} size={168} round />
|
||||
<Cover album={{ id, cover_url: a?.image_url }} size={168} round />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('catalog.artist')}</span>
|
||||
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
||||
|
||||
@@ -0,0 +1,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) => (
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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 {
|
||||
|
||||
@@ -123,7 +123,7 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
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.AppImage', size: 18_000_000, download_url: '/api/v1/app/download/linux/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' },
|
||||
],
|
||||
@@ -302,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') {
|
||||
|
||||
@@ -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) | Веб-клиент |
|
||||
|
||||
@@ -103,8 +103,11 @@
|
||||
|
||||
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
||||
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
|
||||
сборка под систему посетителя (APK, Windows, Linux, macOS), остальные платформы ниже, адрес сервера
|
||||
для копирования и короткая инструкция. Та же страница — внизу экрана входа и в меню профиля.
|
||||
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
|
||||
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. После каждого релиза CI
|
||||
оставляет файлы только у последних 8 релизов и последние 8 прогонов Actions (теги и заметки не удаляет).
|
||||
Та же страница — внизу экрана входа
|
||||
и в меню профиля.
|
||||
|
||||
## 7. Наблюдение
|
||||
|
||||
|
||||
@@ -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,6 +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.
|
||||
|
||||
Готовые файлы с последнего релиза Gitea сервер отдаёт на публичной странице `/download`: посетителю сразу предлагается сборка его ОС, остальные платформы и Android — ниже.
|
||||
Релиз по тегу собирает 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 проверяется
|
||||
отдельно из сети сервера с собственным токеном.
|
||||
@@ -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;
|
||||
@@ -1302,7 +1336,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Клиенты последнего релиза (Android и десктоп)
|
||||
* @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG.
|
||||
* @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
|
||||
* Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||
*/
|
||||
get: operations["getAppClients"];
|
||||
@@ -2068,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;
|
||||
@@ -2380,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;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"download": {
|
||||
"android": "Download APK",
|
||||
"windows": "Download for Windows",
|
||||
"linux": "Download AppImage",
|
||||
"linux": "Download .deb",
|
||||
"macos": "Download for macOS"
|
||||
},
|
||||
"version": "version {{version}}",
|
||||
@@ -43,7 +43,7 @@
|
||||
"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": "Make the file executable and open it. For a .deb, install the package.",
|
||||
"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:",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"download": {
|
||||
"android": "Скачать APK",
|
||||
"windows": "Скачать для Windows",
|
||||
"linux": "Скачать AppImage",
|
||||
"linux": "Скачать .deb",
|
||||
"macos": "Скачать для macOS"
|
||||
},
|
||||
"version": "версия {{version}}",
|
||||
@@ -43,7 +43,7 @@
|
||||
"step_install": {
|
||||
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
||||
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
|
||||
"linux": "Сделайте файл исполняемым и откройте его. Для .deb — установите пакет.",
|
||||
"linux": "Установите пакет: sudo apt install ./pipu-music-….deb. Нужны WebKitGTK 4.1 и GStreamer.",
|
||||
"macos": "Откройте образ и перетащите pipu в Программы."
|
||||
},
|
||||
"step_server": "Запустите pipu и введите адрес сервера:",
|
||||
|
||||
@@ -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": "Ваша музыка с вашего сервера"
|
||||
|
||||
@@ -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.');
|
||||
@@ -4,6 +4,7 @@
|
||||
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
|
||||
// node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза
|
||||
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
|
||||
// node scripts/release.mjs prune — файлы только у последних 8 релизов, старые прогоны Actions
|
||||
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
|
||||
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
|
||||
// PUBLIC_URL — внешний адрес Gitea для ссылок в тексте (job ходит в API по внутреннему адресу, и html_url
|
||||
@@ -11,7 +12,7 @@
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -29,6 +30,16 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
|
||||
|
||||
export const STATUS_START = '<!-- android-status -->';
|
||||
export const STATUS_END = '<!-- /android-status -->';
|
||||
export const DESKTOP_PLATFORMS = ['linux', 'windows'];
|
||||
/** Сколько последних опубликованных релизов оставляем с файлами (APK, .deb, NSIS). */
|
||||
export const KEEP_RELEASE_FILES = 8;
|
||||
/** Сколько последних прогонов Actions оставляем (логи/статусы). */
|
||||
export const KEEP_ACTION_RUNS = 8;
|
||||
|
||||
export function desktopStatusMarks(platform) {
|
||||
if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`);
|
||||
return { start: `<!-- desktop-${platform}-status -->`, end: `<!-- /desktop-${platform}-status -->` };
|
||||
}
|
||||
|
||||
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
|
||||
export function groupCommits(commits) {
|
||||
@@ -76,16 +87,62 @@ export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing
|
||||
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
|
||||
}
|
||||
|
||||
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
|
||||
export function replaceAndroidStatus(body, block) {
|
||||
const start = body.indexOf(STATUS_START);
|
||||
const end = body.indexOf(STATUS_END);
|
||||
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
|
||||
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
|
||||
/** Блок статуса сборки десктопа в тексте релиза (отдельный для linux и windows). */
|
||||
export function renderDesktopStatus({ platform, status, runUrl, files }) {
|
||||
const label = platform === 'windows' ? 'Windows' : 'Linux';
|
||||
const pending = platform === 'windows' ? 'NSIS-установщик' : '.deb';
|
||||
const lines =
|
||||
status === 'success'
|
||||
? [
|
||||
`✅ **Десктоп (${label}):** собрано — ${files.map((f) => `\`${f.name}\` (${formatSize(f.size)})`).join(', ')}.`,
|
||||
]
|
||||
: status === 'cancelled'
|
||||
? [
|
||||
`⏹ **Десктоп (${label}):** сборка отменена — [лог сборки](${runUrl}).`,
|
||||
'Файлы появятся здесь после перезапуска сборки.',
|
||||
]
|
||||
: status === 'failure'
|
||||
? [
|
||||
`❌ **Десктоп (${label}):** сборка не удалась — [лог сборки](${runUrl}).`,
|
||||
'Остальные клиенты от этого не зависят; установщик появится после перезапуска сборки.',
|
||||
]
|
||||
: [
|
||||
`⏳ **Десктоп (${label}):** собирается — [ход сборки](${runUrl}).`,
|
||||
`${pending} появится в этом релизе автоматически.`,
|
||||
];
|
||||
const { start, end } = desktopStatusMarks(platform);
|
||||
return [start, ...lines.map((l) => `> ${l}`), end].join('\n');
|
||||
}
|
||||
|
||||
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */
|
||||
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
|
||||
function replaceBlock(body, startMark, endMark, block) {
|
||||
const start = body.indexOf(startMark);
|
||||
const end = body.indexOf(endMark);
|
||||
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
|
||||
return body.slice(0, start) + block + body.slice(end + endMark.length);
|
||||
}
|
||||
|
||||
/** Заменяет блок статуса Android в тексте релиза (или дописывает его в начало, если блока нет). */
|
||||
export function replaceAndroidStatus(body, block) {
|
||||
return replaceBlock(body, STATUS_START, STATUS_END, block);
|
||||
}
|
||||
|
||||
export function replaceDesktopStatus(body, platform, block) {
|
||||
const { start, end } = desktopStatusMarks(platform);
|
||||
return replaceBlock(body, start, end, block);
|
||||
}
|
||||
|
||||
/** Текст релиза: статусы сборок, изменения, обновление сервера, установка клиентов. */
|
||||
export function renderNotes({
|
||||
version,
|
||||
tag,
|
||||
prevTag,
|
||||
prerelease,
|
||||
repoUrl,
|
||||
groups,
|
||||
androidStatus,
|
||||
desktopLinuxStatus,
|
||||
desktopWindowsStatus,
|
||||
}) {
|
||||
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
|
||||
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
|
||||
const out = [];
|
||||
@@ -93,7 +150,10 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
|
||||
if (prerelease) {
|
||||
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
|
||||
}
|
||||
out.push(androidStatus, '', '## Что изменилось', '');
|
||||
out.push(androidStatus, '');
|
||||
if (desktopLinuxStatus) out.push(desktopLinuxStatus, '');
|
||||
if (desktopWindowsStatus) out.push(desktopWindowsStatus, '');
|
||||
out.push('## Что изменилось', '');
|
||||
if (groups.breaking.length) {
|
||||
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
|
||||
}
|
||||
@@ -124,11 +184,14 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
|
||||
'',
|
||||
`Ожидается \`"version":"${version}"\`.`,
|
||||
'',
|
||||
'## Android',
|
||||
'## Клиенты',
|
||||
'',
|
||||
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
|
||||
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
|
||||
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
|
||||
'Скачайте с этой страницы или с `/download` на сервере (подскажет сборку под вашу систему).',
|
||||
'',
|
||||
`- **Android:** \`pipu-music-${version}.apk\` — откройте на телефоне; Android попросит разрешить установку из этого источника.`,
|
||||
`- **Windows:** \`pipu-music-${version}-windows-setup.exe\`.`,
|
||||
`- **Linux:** \`pipu-music-${version}-linux.deb\` — \`sudo apt install ./pipu-music-${version}-linux.deb\` (нужны WebKitGTK 4.1 и GStreamer).`,
|
||||
`- **Проверка APK:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
|
||||
'',
|
||||
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
|
||||
'',
|
||||
@@ -136,6 +199,35 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function publishedAt(item) {
|
||||
return Date.parse(item.published_at || item.created_at || item.started_at || 0);
|
||||
}
|
||||
|
||||
/** Релизы, с которых снимаем вложения: черновики не трогаем, текущий тег всегда оставляем. */
|
||||
export function staleReleases(releases, { keep = KEEP_RELEASE_FILES, currentTag } = {}) {
|
||||
const published = releases.filter((r) => !r.draft);
|
||||
published.sort((a, b) => publishedAt(b) - publishedAt(a));
|
||||
const keepIds = new Set(published.slice(0, keep).map((r) => r.id));
|
||||
const current = published.find((r) => r.tag_name === currentTag);
|
||||
if (current) keepIds.add(current.id);
|
||||
return published.filter((r) => !keepIds.has(r.id) && (r.assets?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
/** Завершённые прогоны Actions, которые можно удалить (текущий прогон не трогаем). */
|
||||
export function staleActionRuns(runs, { keep = KEEP_ACTION_RUNS, currentRunId } = {}) {
|
||||
const done = runs.filter((r) => r.status === 'completed' && String(r.id) !== String(currentRunId ?? ''));
|
||||
done.sort((a, b) => publishedAt(b) - publishedAt(a));
|
||||
const keepIds = new Set(done.slice(0, keep).map((r) => r.id));
|
||||
return done.filter((r) => !keepIds.has(r.id));
|
||||
}
|
||||
|
||||
function actionRunsFrom(body) {
|
||||
if (Array.isArray(body)) return body;
|
||||
if (Array.isArray(body?.workflow_runs)) return body.workflow_runs;
|
||||
if (Array.isArray(body?.runs)) return body.runs;
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// CLI
|
||||
|
||||
@@ -202,6 +294,8 @@ async function create() {
|
||||
repoUrl,
|
||||
groups: groupCommits(commitsBetween(prevTag, tag)),
|
||||
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
|
||||
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl }),
|
||||
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl }),
|
||||
});
|
||||
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
|
||||
const saved = release
|
||||
@@ -210,6 +304,41 @@ async function create() {
|
||||
console.log(`${release ? 'обновлён' : 'создан'} релиз ${saved.html_url}`);
|
||||
}
|
||||
|
||||
async function listPages(path, itemsOf) {
|
||||
const all = [];
|
||||
for (let page = 1; page <= 40; page += 1) {
|
||||
const sep = path.includes('?') ? '&' : '?';
|
||||
const body = await api('GET', `${path}${sep}limit=50&page=${page}`);
|
||||
const batch = itemsOf(body);
|
||||
if (!batch.length) break;
|
||||
all.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function prune() {
|
||||
const tag = process.env.TAG;
|
||||
const currentRunId = process.env.GITHUB_RUN_ID;
|
||||
const releases = await listPages('/releases', (body) => (Array.isArray(body) ? body : []));
|
||||
let files = 0;
|
||||
for (const rel of staleReleases(releases, { currentTag: tag })) {
|
||||
for (const a of rel.assets ?? []) {
|
||||
await api('DELETE', `/releases/${rel.id}/assets/${a.id}`);
|
||||
files += 1;
|
||||
console.log(`удалён файл ${rel.tag_name}/${a.name}`);
|
||||
}
|
||||
}
|
||||
const runs = await listPages('/actions/runs', actionRunsFrom);
|
||||
let statuses = 0;
|
||||
for (const run of staleActionRuns(runs, { currentRunId })) {
|
||||
await api('DELETE', `/actions/runs/${run.id}`);
|
||||
statuses += 1;
|
||||
console.log(`удалён прогон Actions ${run.id} (${run.path ?? run.name ?? run.event ?? ''})`);
|
||||
}
|
||||
console.log(`очистка: файлов ${files}, прогонов ${statuses}`);
|
||||
}
|
||||
|
||||
async function upload(release, file) {
|
||||
const name = basename(file);
|
||||
for (const old of release.assets ?? []) {
|
||||
@@ -242,13 +371,39 @@ async function android(status, apkPath) {
|
||||
console.log(`статус Android: ${status}`);
|
||||
}
|
||||
|
||||
async function desktop(platform, status, paths) {
|
||||
const { release, runUrl } = await context();
|
||||
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
|
||||
const files = [];
|
||||
if (status === 'success') {
|
||||
if (!paths.length) throw new Error('desktop success: укажите файлы');
|
||||
for (const file of paths) {
|
||||
const data = readFileSync(file);
|
||||
files.push({ name: basename(file), size: data.length });
|
||||
const sum = `${file}.sha256`;
|
||||
if (!existsSync(sum)) writeFileSync(sum, `${createHash('sha256').update(data).digest('hex')}\n`);
|
||||
await upload(release, file);
|
||||
if (existsSync(sum)) await upload(release, sum);
|
||||
}
|
||||
}
|
||||
const block = renderDesktopStatus({ platform, status, runUrl, files });
|
||||
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceDesktopStatus(release.body ?? '', platform, block) } });
|
||||
console.log(`статус десктопа (${platform}): ${status}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
if (cmd === 'create') return create();
|
||||
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
|
||||
return android(args[0], args[1]);
|
||||
}
|
||||
throw new Error('использование: release.mjs create | android building|failure|cancelled | android success <file.apk>');
|
||||
if (cmd === 'desktop' && DESKTOP_PLATFORMS.includes(args[0]) && ['building', 'success', 'failure', 'cancelled'].includes(args[1])) {
|
||||
return desktop(args[0], args[1], args.slice(2));
|
||||
}
|
||||
if (cmd === 'prune') return prune();
|
||||
throw new Error(
|
||||
'использование: release.mjs create | android building|failure|cancelled | android success <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...> | prune',
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||
|
||||
@@ -4,8 +4,13 @@ import { test } from 'node:test';
|
||||
import {
|
||||
groupCommits,
|
||||
renderAndroidStatus,
|
||||
renderDesktopStatus,
|
||||
renderNotes,
|
||||
replaceAndroidStatus,
|
||||
replaceDesktopStatus,
|
||||
desktopStatusMarks,
|
||||
staleActionRuns,
|
||||
staleReleases,
|
||||
STATUS_END,
|
||||
STATUS_START,
|
||||
} from './release.mjs';
|
||||
@@ -77,6 +82,34 @@ test('adds a status block when the body has none', () => {
|
||||
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
|
||||
});
|
||||
|
||||
test('desktop status blocks', () => {
|
||||
const linuxMarks = desktopStatusMarks('linux');
|
||||
const winMarks = desktopStatusMarks('windows');
|
||||
const building = renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' });
|
||||
assert.ok(building.startsWith(linuxMarks.start) && building.endsWith(linuxMarks.end));
|
||||
assert.match(building, /Десктоп \(Linux\):.*собирается — \[ход сборки\]\(https:\/\/run\)/);
|
||||
const ok = renderDesktopStatus({
|
||||
platform: 'linux',
|
||||
status: 'success',
|
||||
files: [{ name: 'pipu-music-1.4.0-linux.deb', size: 4 * 1024 * 1024 }],
|
||||
});
|
||||
assert.match(ok, /`pipu-music-1\.4\.0-linux\.deb` \(4,0 МБ\)/);
|
||||
assert.equal(replaceDesktopStatus(`a\n\n${building}\n\nb`, 'linux', ok).includes(building), false);
|
||||
|
||||
const winBuilding = renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' });
|
||||
assert.ok(winBuilding.startsWith(winMarks.start) && winBuilding.endsWith(winMarks.end));
|
||||
const winOk = renderDesktopStatus({
|
||||
platform: 'windows',
|
||||
status: 'success',
|
||||
files: [{ name: 'pipu-music-1.4.0-windows-setup.exe', size: 12 * 1024 * 1024 }],
|
||||
});
|
||||
const both = `${building}\n\n${winBuilding}`;
|
||||
const patched = replaceDesktopStatus(both, 'windows', winOk);
|
||||
assert.match(patched, /Linux/);
|
||||
assert.match(patched, /windows-setup\.exe/);
|
||||
assert.doesNotMatch(patched, /Десктоп \(Windows\):.*собирается/);
|
||||
});
|
||||
|
||||
test('release notes', () => {
|
||||
const notes = renderNotes({
|
||||
version: '0.2.0',
|
||||
@@ -86,16 +119,53 @@ test('release notes', () => {
|
||||
repoUrl,
|
||||
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
|
||||
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
|
||||
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' }),
|
||||
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' }),
|
||||
});
|
||||
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
|
||||
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
|
||||
assert.match(notes, /\.\/update\.sh v0\.2\.0/);
|
||||
assert.match(notes, /"version":"0\.2\.0"/);
|
||||
assert.match(notes, /pipu-music-0\.2\.0\.apk/);
|
||||
assert.match(notes, /pipu-music-0\.2\.0-windows-setup\.exe/);
|
||||
assert.match(notes, /pipu-music-0\.2\.0-linux\.deb/);
|
||||
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
|
||||
assert.doesNotMatch(notes, /Предварительная версия/);
|
||||
});
|
||||
|
||||
test('keeps files on the newest eight releases', () => {
|
||||
const releases = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
tag_name: `v1.0.${i}`,
|
||||
draft: false,
|
||||
published_at: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
|
||||
assets: i === 0 ? [] : [{ id: 100 + i, name: `a${i}.apk` }],
|
||||
}));
|
||||
const stale = staleReleases(releases, { keep: 8, currentTag: 'v1.0.9' });
|
||||
assert.deepEqual(
|
||||
stale.map((r) => r.tag_name).sort(),
|
||||
['v1.0.1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('does not delete the current Actions run or unfinished jobs', () => {
|
||||
const runs = [
|
||||
{ id: 20, status: 'completed', created_at: '2026-01-20T00:00:00Z' },
|
||||
{ id: 19, status: 'in_progress', created_at: '2026-01-19T00:00:00Z' },
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
status: 'completed',
|
||||
created_at: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
|
||||
})),
|
||||
];
|
||||
const stale = staleActionRuns(runs, { keep: 8, currentRunId: 20 });
|
||||
assert.equal(stale.some((r) => r.id === 20 || r.id === 19), false);
|
||||
assert.deepEqual(
|
||||
stale.map((r) => r.id).sort((a, b) => a - b),
|
||||
[1, 2, 3, 4],
|
||||
);
|
||||
});
|
||||
|
||||
test('release notes for a first prerelease without changes', () => {
|
||||
const notes = renderNotes({
|
||||
version: '0.1.0-rc.1',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -225,6 +225,13 @@ const (
|
||||
WaveStartSeedTypeTrack WaveStartSeedType = "track"
|
||||
)
|
||||
|
||||
// Defines values for ControlGeniusJSONBodyAction.
|
||||
const (
|
||||
Pause ControlGeniusJSONBodyAction = "pause"
|
||||
Retry ControlGeniusJSONBodyAction = "retry"
|
||||
Start ControlGeniusJSONBodyAction = "start"
|
||||
)
|
||||
|
||||
// Defines values for GetCoverParamsSize.
|
||||
const (
|
||||
N1200 GetCoverParamsSize = 1200
|
||||
@@ -351,6 +358,7 @@ type AppClients struct {
|
||||
type Artist struct {
|
||||
AlbumCount int `json:"album_count"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
ImageUrl *string `json:"image_url,omitempty"`
|
||||
Name string `json:"name"`
|
||||
TrackCount int `json:"track_count"`
|
||||
}
|
||||
@@ -454,6 +462,43 @@ type FieldError struct {
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
// GeniusAnnotation defines model for GeniusAnnotation.
|
||||
type GeniusAnnotation struct {
|
||||
Authors string `json:"authors"`
|
||||
Fragment string `json:"fragment"`
|
||||
Text string `json:"text"`
|
||||
Url string `json:"url"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// GeniusContent defines model for GeniusContent.
|
||||
type GeniusContent struct {
|
||||
Annotations []GeniusAnnotation `json:"annotations"`
|
||||
ArtistImageUrl string `json:"artist_image_url"`
|
||||
Description string `json:"description"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
SongImageUrl string `json:"song_image_url"`
|
||||
SourceUrl string `json:"source_url"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// GeniusStats defines model for GeniusStats.
|
||||
type GeniusStats struct {
|
||||
Annotations int `json:"annotations"`
|
||||
Configured bool `json:"configured"`
|
||||
CooldownUntil time.Time `json:"cooldown_until"`
|
||||
Errors int `json:"errors"`
|
||||
Images int `json:"images"`
|
||||
LastError string `json:"last_error"`
|
||||
Lyrics int `json:"lyrics"`
|
||||
Matched int `json:"matched"`
|
||||
Paused bool `json:"paused"`
|
||||
Pending int `json:"pending"`
|
||||
Running bool `json:"running"`
|
||||
Total int `json:"total"`
|
||||
Unmatched int `json:"unmatched"`
|
||||
}
|
||||
|
||||
// HealthStatus defines model for HealthStatus.
|
||||
type HealthStatus struct {
|
||||
Status HealthStatusStatus `json:"status"`
|
||||
@@ -1399,6 +1444,14 @@ type ListAuditParams struct {
|
||||
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
// ControlGeniusJSONBody defines parameters for ControlGenius.
|
||||
type ControlGeniusJSONBody struct {
|
||||
Action ControlGeniusJSONBodyAction `json:"action"`
|
||||
}
|
||||
|
||||
// ControlGeniusJSONBodyAction defines parameters for ControlGenius.
|
||||
type ControlGeniusJSONBodyAction string
|
||||
|
||||
// ListLibraryErrorsParams defines parameters for ListLibraryErrors.
|
||||
type ListLibraryErrorsParams struct {
|
||||
Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"`
|
||||
@@ -1522,6 +1575,9 @@ type ListMyUploadsParams struct {
|
||||
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
// ControlGeniusJSONRequestBody defines body for ControlGenius for application/json ContentType.
|
||||
type ControlGeniusJSONRequestBody ControlGeniusJSONBody
|
||||
|
||||
// UpdateUploadSettingsJSONRequestBody defines body for UpdateUploadSettings for application/json ContentType.
|
||||
type UpdateUploadSettingsJSONRequestBody = UploadSettingsPatch
|
||||
|
||||
@@ -1614,6 +1670,12 @@ type ServerInterface interface {
|
||||
// Журнал действий (admin), новые сверху
|
||||
// (GET /api/v1/admin/audit)
|
||||
ListAudit(w http.ResponseWriter, r *http.Request, params ListAuditParams)
|
||||
|
||||
// (GET /api/v1/admin/genius)
|
||||
GetGeniusStats(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// (POST /api/v1/admin/genius)
|
||||
ControlGenius(w http.ResponseWriter, r *http.Request)
|
||||
// Вернуть в очередь треки, которые не удалось проанализировать (admin)
|
||||
// (POST /api/v1/admin/library/analysis/retry)
|
||||
RetryAnalysis(w http.ResponseWriter, r *http.Request)
|
||||
@@ -1830,6 +1892,9 @@ type ServerInterface interface {
|
||||
// Трек
|
||||
// (GET /api/v1/tracks/{id})
|
||||
GetTrack(w http.ResponseWriter, r *http.Request, id EntityId)
|
||||
|
||||
// (GET /api/v1/tracks/{id}/genius)
|
||||
GetTrackGenius(w http.ResponseWriter, r *http.Request, id EntityId)
|
||||
// Создать пачку и зарезервировать место (moderator, admin)
|
||||
// (POST /api/v1/uploads)
|
||||
CreateUploadBatch(w http.ResponseWriter, r *http.Request)
|
||||
@@ -1884,6 +1949,16 @@ func (_ Unimplemented) ListAudit(w http.ResponseWriter, r *http.Request, params
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// (GET /api/v1/admin/genius)
|
||||
func (_ Unimplemented) GetGeniusStats(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// (POST /api/v1/admin/genius)
|
||||
func (_ Unimplemented) ControlGenius(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Вернуть в очередь треки, которые не удалось проанализировать (admin)
|
||||
// (POST /api/v1/admin/library/analysis/retry)
|
||||
func (_ Unimplemented) RetryAnalysis(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2311,6 +2386,11 @@ func (_ Unimplemented) GetTrack(w http.ResponseWriter, r *http.Request, id Entit
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// (GET /api/v1/tracks/{id}/genius)
|
||||
func (_ Unimplemented) GetTrackGenius(w http.ResponseWriter, r *http.Request, id EntityId) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Создать пачку и зарезервировать место (moderator, admin)
|
||||
// (POST /api/v1/uploads)
|
||||
func (_ Unimplemented) CreateUploadBatch(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2445,6 +2525,46 @@ func (siw *ServerInterfaceWrapper) ListAudit(w http.ResponseWriter, r *http.Requ
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetGeniusStats operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetGeniusStats(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetGeniusStats(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ControlGenius operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ControlGenius(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ControlGenius(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// RetryAnalysis operation middleware
|
||||
func (siw *ServerInterfaceWrapper) RetryAnalysis(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -4609,6 +4729,37 @@ func (siw *ServerInterfaceWrapper) GetTrack(w http.ResponseWriter, r *http.Reque
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetTrackGenius operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetTrackGenius(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
|
||||
// ------------- Path parameter "id" -------------
|
||||
var id EntityId
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetTrackGenius(w, r, id)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// CreateUploadBatch operation middleware
|
||||
func (siw *ServerInterfaceWrapper) CreateUploadBatch(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -5066,6 +5217,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/admin/audit", wrapper.ListAudit)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/admin/genius", wrapper.GetGeniusStats)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Post(options.BaseURL+"/api/v1/admin/genius", wrapper.ControlGenius)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Post(options.BaseURL+"/api/v1/admin/library/analysis/retry", wrapper.RetryAnalysis)
|
||||
})
|
||||
@@ -5282,6 +5439,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/tracks/{id}", wrapper.GetTrack)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/tracks/{id}/genius", wrapper.GetTrackGenius)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Post(options.BaseURL+"/api/v1/uploads", wrapper.CreateUploadBatch)
|
||||
})
|
||||
@@ -5359,6 +5519,39 @@ func (response ListAuditdefaultApplicationProblemPlusJSONResponse) VisitListAudi
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type GetGeniusStatsRequestObject struct {
|
||||
}
|
||||
|
||||
type GetGeniusStatsResponseObject interface {
|
||||
VisitGetGeniusStatsResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type GetGeniusStats200JSONResponse GeniusStats
|
||||
|
||||
func (response GetGeniusStats200JSONResponse) VisitGetGeniusStatsResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type ControlGeniusRequestObject struct {
|
||||
Body *ControlGeniusJSONRequestBody
|
||||
}
|
||||
|
||||
type ControlGeniusResponseObject interface {
|
||||
VisitControlGeniusResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type ControlGenius200JSONResponse GeniusStats
|
||||
|
||||
func (response ControlGenius200JSONResponse) VisitControlGeniusResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type RetryAnalysisRequestObject struct {
|
||||
}
|
||||
|
||||
@@ -7491,6 +7684,23 @@ func (response GetTrackdefaultApplicationProblemPlusJSONResponse) VisitGetTrackR
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type GetTrackGeniusRequestObject struct {
|
||||
Id EntityId `json:"id"`
|
||||
}
|
||||
|
||||
type GetTrackGeniusResponseObject interface {
|
||||
VisitGetTrackGeniusResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type GetTrackGenius200JSONResponse GeniusContent
|
||||
|
||||
func (response GetTrackGenius200JSONResponse) VisitGetTrackGeniusResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type CreateUploadBatchRequestObject struct {
|
||||
Body *CreateUploadBatchJSONRequestBody
|
||||
}
|
||||
@@ -7882,6 +8092,12 @@ type StrictServerInterface interface {
|
||||
// Журнал действий (admin), новые сверху
|
||||
// (GET /api/v1/admin/audit)
|
||||
ListAudit(ctx context.Context, request ListAuditRequestObject) (ListAuditResponseObject, error)
|
||||
|
||||
// (GET /api/v1/admin/genius)
|
||||
GetGeniusStats(ctx context.Context, request GetGeniusStatsRequestObject) (GetGeniusStatsResponseObject, error)
|
||||
|
||||
// (POST /api/v1/admin/genius)
|
||||
ControlGenius(ctx context.Context, request ControlGeniusRequestObject) (ControlGeniusResponseObject, error)
|
||||
// Вернуть в очередь треки, которые не удалось проанализировать (admin)
|
||||
// (POST /api/v1/admin/library/analysis/retry)
|
||||
RetryAnalysis(ctx context.Context, request RetryAnalysisRequestObject) (RetryAnalysisResponseObject, error)
|
||||
@@ -8098,6 +8314,9 @@ type StrictServerInterface interface {
|
||||
// Трек
|
||||
// (GET /api/v1/tracks/{id})
|
||||
GetTrack(ctx context.Context, request GetTrackRequestObject) (GetTrackResponseObject, error)
|
||||
|
||||
// (GET /api/v1/tracks/{id}/genius)
|
||||
GetTrackGenius(ctx context.Context, request GetTrackGeniusRequestObject) (GetTrackGeniusResponseObject, error)
|
||||
// Создать пачку и зарезервировать место (moderator, admin)
|
||||
// (POST /api/v1/uploads)
|
||||
CreateUploadBatch(ctx context.Context, request CreateUploadBatchRequestObject) (CreateUploadBatchResponseObject, error)
|
||||
@@ -8197,6 +8416,61 @@ func (sh *strictHandler) ListAudit(w http.ResponseWriter, r *http.Request, param
|
||||
}
|
||||
}
|
||||
|
||||
// GetGeniusStats operation middleware
|
||||
func (sh *strictHandler) GetGeniusStats(w http.ResponseWriter, r *http.Request) {
|
||||
var request GetGeniusStatsRequestObject
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.GetGeniusStats(ctx, request.(GetGeniusStatsRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "GetGeniusStats")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(GetGeniusStatsResponseObject); ok {
|
||||
if err := validResponse.VisitGetGeniusStatsResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// ControlGenius operation middleware
|
||||
func (sh *strictHandler) ControlGenius(w http.ResponseWriter, r *http.Request) {
|
||||
var request ControlGeniusRequestObject
|
||||
|
||||
var body ControlGeniusJSONRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
|
||||
return
|
||||
}
|
||||
request.Body = &body
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.ControlGenius(ctx, request.(ControlGeniusRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "ControlGenius")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(ControlGeniusResponseObject); ok {
|
||||
if err := validResponse.VisitControlGeniusResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// RetryAnalysis operation middleware
|
||||
func (sh *strictHandler) RetryAnalysis(w http.ResponseWriter, r *http.Request) {
|
||||
var request RetryAnalysisRequestObject
|
||||
@@ -10184,6 +10458,32 @@ func (sh *strictHandler) GetTrack(w http.ResponseWriter, r *http.Request, id Ent
|
||||
}
|
||||
}
|
||||
|
||||
// GetTrackGenius operation middleware
|
||||
func (sh *strictHandler) GetTrackGenius(w http.ResponseWriter, r *http.Request, id EntityId) {
|
||||
var request GetTrackGeniusRequestObject
|
||||
|
||||
request.Id = id
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.GetTrackGenius(ctx, request.(GetTrackGeniusRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "GetTrackGenius")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(GetTrackGeniusResponseObject); ok {
|
||||
if err := validResponse.VisitGetTrackGeniusResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUploadBatch operation middleware
|
||||
func (sh *strictHandler) CreateUploadBatch(w http.ResponseWriter, r *http.Request) {
|
||||
var request CreateUploadBatchRequestObject
|
||||
@@ -10568,278 +10868,284 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
"AQA=",
|
||||
"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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -161,9 +161,9 @@ func assetRank(kind, name string) int {
|
||||
return 1
|
||||
case "linux":
|
||||
switch {
|
||||
case strings.HasSuffix(n, ".appimage"):
|
||||
return 3
|
||||
case strings.HasSuffix(n, ".deb"):
|
||||
return 3
|
||||
case strings.HasSuffix(n, ".rpm"):
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestPickClientFiles(t *testing.T) {
|
||||
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.AppImage", files["linux"].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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||