Compare commits

...
2 Commits
Author SHA1 Message Date
mrleo1nid ab49e22357 feat(desktop): add offline cache and verified app updates
ci / module boundaries / boundaries (push) Successful in 7s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 3m8s
ci / desktop / rust (desktop proxy) (push) Successful in 4m21s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m0s
release / desktop (Linux и Windows в релиз) (push) Successful in 11m49s
release / prune (старые файлы и прогоны) (push) Successful in 5s
Clean release notes in Android and desktop update dialogs. Persist downloaded audio and offline playback events per server and user; add desktop update checks, checksum verification and installer launch.
2026-10-22 22:37:35 +03:00
mrleo1nid 75ae2092ad feat(library): exclude configured folders from scanning and analysis
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 1m8s
ci / recommender / recommender (push) Failing after 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / ts / ts (packages + web) (push) Successful in 7m20s
2026-10-22 15:51:15 +03:00
50 changed files with 1602 additions and 370 deletions

No files matched your search

+14
View File
@@ -2687,6 +2687,8 @@ components:
type: object type: object
required: [id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority, scan_interval_minutes, accepts_uploads, write_template, status, status_message, last_scan_at, created_at, scan] required: [id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority, scan_interval_minutes, accepts_uploads, write_template, status, status_message, last_scan_at, created_at, scan]
properties: properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
id: id:
type: string type: string
format: uuid format: uuid
@@ -2747,6 +2749,8 @@ components:
type: object type: object
required: [name, type] required: [name, type]
properties: properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name: name:
type: string type: string
maxLength: 100 maxLength: 100
@@ -2774,6 +2778,8 @@ components:
SourcePatch: SourcePatch:
type: object type: object
properties: properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name: name:
type: string type: string
maxLength: 100 maxLength: 100
@@ -2802,6 +2808,14 @@ components:
description: | description: |
Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid` Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid`
с `params.reason`. Проверка обратимости не обязательна для сохранения — её показывает `write-template/check`. с `params.reason`. Проверка обратимости не обязательна для сохранения — её показывает `write-template/check`.
ExcludedDirs:
type: array
maxItems: 100
description: Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
items:
type: string
minLength: 1
maxLength: 1000
SourceTestResult: SourceTestResult:
type: object type: object
required: [ok, message, audio_files] required: [ok, message, audio_files]
+1
View File
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["net", "sync", "time"] } tokio = { version = "1", features = ["net", "sync", "time"] }
axum = "0.8" axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
+4 -1
View File
@@ -1,6 +1,7 @@
mod media; mod media;
mod proxy; mod proxy;
mod store; mod store;
mod update;
use media::Media; use media::Media;
use proxy::Proxy; use proxy::Proxy;
@@ -110,6 +111,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app))) .plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
.plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_window_state::Builder::default().build())
.manage(update::Updater::default())
.setup(|app| { .setup(|app| {
let saved = store::load_server(app.handle())?; let saved = store::load_server(app.handle())?;
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?; let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
@@ -150,7 +152,8 @@ pub fn run() {
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set,
update::update_check, update::update_install
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("desktop app failed to start"); .expect("desktop app failed to start");
+6 -2
View File
@@ -86,7 +86,7 @@ fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
} }
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS")); headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with")); headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after")); headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after, x-pipu-transport-error"));
} }
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response { async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
@@ -110,7 +110,11 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
} else { } else {
match transfer(&proxy, request, tail).await { match transfer(&proxy, request, tail).await {
Ok(response) => response, Ok(response) => response,
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(), Err(_) => {
let mut response = (StatusCode::BAD_GATEWAY, "Server unavailable").into_response();
response.headers_mut().insert("x-pipu-transport-error", HeaderValue::from_static("1"));
response
},
} }
}; };
cors(response.headers_mut(), origin.as_deref()); cors(response.headers_mut(), origin.as_deref());
+152
View File
@@ -0,0 +1,152 @@
//! Server-proxied releases: never accept installer paths or commands from the UI.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{io::Write, path::Path, time::Duration};
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::sync::Mutex;
#[derive(Clone, Deserialize, Serialize)]
pub struct Build {
client: String,
version: String,
filename: String,
size: u64,
sha256: Option<String>,
download_url: String,
}
#[derive(Deserialize)]
struct Catalog { version: String, notes_md: String, clients: Vec<Build> }
#[derive(Clone, Serialize)]
pub struct Release { build: Build, notes: String }
#[derive(Default)]
pub struct Updater { release: Mutex<Option<(String, Release)>> }
fn client() -> &'static str {
if cfg!(target_os = "windows") { "windows" }
else if cfg!(target_os = "macos") { "macos" } else { "linux" }
}
fn version_code(raw: &str) -> Option<u64> {
let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v);
let parts: Vec<u64> = base.split('.').map(str::parse).collect::<Result<_, _>>().ok()?;
let rc: u64 = rc.parse().ok()?;
if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; }
Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc)
}
fn http() -> Result<reqwest::Client, String> {
reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10)).read_timeout(Duration::from_secs(60))
.build().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result<Option<Release>, String> {
let mut saved = updater.release.lock().await;
*saved = None;
let server = crate::store::load_server(&app)?.ok_or("No server selected")?;
let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?
.json().await.map_err(|e| e.to_string())?;
let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); };
let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?;
if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); }
let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() };
let release = Release { build, notes };
validate_build(&release.build)?;
*saved = Some((server, release.clone()));
Ok(Some(release))
}
fn validate_build(build: &Build) -> Result<(), String> {
let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or("");
let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" };
let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()));
if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); }
// Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release.
if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) {
return Err("Invalid installer URL".into());
}
Ok(())
}
#[tauri::command]
pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> {
let saved = updater.release.lock().await;
let (server, release) = saved.as_ref().ok_or("Check for an update first")?;
if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); }
let build = &release.build;
validate_build(build)?;
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(&build.filename);
let part = dir.join(format!("{}.part", uuid::Uuid::new_v4()));
let result = async {
let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?;
let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?;
let mut hash = Sha256::new();
let mut bytes = 0u64;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
bytes += chunk.len() as u64;
if bytes > build.size { return Err("Installer exceeds expected size".to_string()); }
hash.update(&chunk);
file.write_all(&chunk).map_err(|e| e.to_string())?;
let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string());
}
if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); }
file.sync_all().map_err(|e| e.to_string())?;
drop(file);
if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; }
std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
launch(&path)?;
Ok(())
}.await;
let _ = std::fs::remove_file(&part);
result
}
fn launch(path: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") {
let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd
} else { std::process::Command::new(path) };
command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{ std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; }
#[cfg(target_os = "macos")]
{ std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; }
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_order() {
assert!(version_code("1.7.2") > version_code("1.7.2-rc.3"));
assert!(version_code("1.8.0-rc.1") > version_code("1.7.99"));
assert_eq!(version_code("1.7.1"), Some(1070199));
assert_eq!(version_code("1.7.100"), None);
assert_eq!(version_code("1.7.1;cmd"), None);
}
#[test]
fn installer_is_restricted_to_verified_server_asset() {
let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" };
let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42,
sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) };
assert!(validate_build(&build).is_ok());
build.download_url = "https://other.example/installer.exe".into();
assert!(validate_build(&build).is_err());
build.download_url = format!("/api/v1/app/download/{}/1.8.0", client());
build.filename = "../installer.exe".into();
assert!(validate_build(&build).is_err());
build.filename = format!("pipu.{extension}");
build.sha256 = None;
assert!(validate_build(&build).is_err());
}
}
+5 -14
View File
@@ -1,4 +1,5 @@
import { useEffect, useSyncExternalStore } from 'react'; import { useEffect, useSyncExternalStore } from 'react';
import { releaseNotesText } from '@pipu/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native'; import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
@@ -10,7 +11,6 @@ import { Button } from './kit';
export function useUpdateState() { export function useUpdateState() {
return useSyncExternalStore(updater.subscribe, () => updater.state); return useSyncExternalStore(updater.subscribe, () => updater.state);
} }
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */ /** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
export function UpdateDialog() { export function UpdateDialog() {
const colors = useColors(); const colors = useColors();
@@ -32,6 +32,7 @@ export function UpdateDialog() {
const { release, required } = st; const { release, required } = st;
const busy = st.status === 'downloading' || st.status === 'verifying'; const busy = st.status === 'downloading' || st.status === 'verifying';
const notes = releaseNotesText(release.notes_md);
const action = () => void updater.downloadAndInstall(session, server ?? ''); const action = () => void updater.downloadAndInstall(session, server ?? '');
return ( return (
@@ -57,9 +58,9 @@ export function UpdateDialog() {
{t('mobile.update_title', { version: release.version })} {t('mobile.update_title', { version: release.version })}
</Text> </Text>
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>} {required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
{release.notes_md.trim() !== '' && ( {notes !== '' && (
<ScrollView style={{ maxHeight: 240 }}> <ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text> <Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
</ScrollView> </ScrollView>
)} )}
<Text style={{ color: colors.text3, fontSize: 12 }}> <Text style={{ color: colors.text3, fontSize: 12 }}>
@@ -115,13 +116,3 @@ export function UpdateDialog() {
</Modal> </Modal>
); );
} }
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
function plainNotes(md: string): string {
return md
.replace(/^#{1,6}\s*/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/^\s*[-*]\s+/gm, '• ')
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
.trim();
}
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test';
test('desktop files survive restart, isolate owners and clear only recent cache', async ({ page }) => {
await page.goto('/login');
const saved = await page.evaluate(async () => {
const source = '/src/features/desktop/offline.ts';
const { DesktopOffline } = await import(source);
const track = { id: 'cached-track', title: 'Offline song', available: true, stream_url: 'data:audio/wav;base64,UklGRg==', duration_ms: 1000, artists: [], album: { id: 'album', title: 'Album', cover_url: null }, genres: [], reaction: null };
const session = { client: { POST: async () => ({ data: { items: [track] }, response: new Response('{}') }) } };
const offline = new DesktopOffline('test-server/alice', session);
await offline.ready;
await offline.save(track, true);
await offline.save({ ...track, id: 'recent-track' });
const tracks = offline.tracks();
const result = { count: tracks.length, blob: tracks[0].stream_url.startsWith('blob:'), pinned: offline.pinned(track.id) };
offline.dispose();
return result;
});
expect(saved).toEqual({ count: 2, blob: true, pinned: true });
await page.reload();
const restored = await page.evaluate(async () => {
const source = '/src/features/desktop/offline.ts';
const { DesktopOffline } = await import(source);
const session = { client: { POST: async () => { throw new TypeError('offline'); } } };
const alice = new DesktopOffline('test-server/alice', session);
const bob = new DesktopOffline('test-server/bob', session);
await Promise.all([alice.ready, bob.ready]);
const before = alice.tracks().length;
const body = await (await fetch(alice.tracks()[0].stream_url)).blob();
await alice.clearRecent();
const result = { before, bytes: body.size, after: alice.tracks().map((t: { id: string }) => t.id), other: bob.tracks().length };
alice.dispose(); bob.dispose();
return result;
});
expect(restored).toEqual({ before: 2, bytes: 4, after: ['cached-track'], other: 0 });
});
test('desktop update dialog hides build reports and launches installer', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('pipu.locale', 'ru');
(window as Window & { __TAURI__?: unknown }).__TAURI__ = {
core: { invoke: async (command: string) => {
if (command === 'bootstrap') return { proxyBase: location.origin, server: 'https://music.example' };
if (command === 'update_check') return { build: { version: '1.8.0', size: 1048576 }, notes: '<!-- android-status -->\n> APK SHA-256 abc\n<!-- /android-status -->\n## Изменения\n- **Офлайн-музыка**' };
if (command === 'update_install') document.documentElement.dataset.installer = 'opened';
return null;
} },
event: { listen: async () => () => undefined },
};
});
await page.goto('/login');
await page.getByLabel('Логин').fill('alice');
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
await page.getByRole('button', { name: 'Войти' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText('Доступна версия 1.8.0');
await expect(dialog).toContainText('• Офлайн-музыка');
await expect(dialog).not.toContainText('android-status');
await expect(dialog).not.toContainText('SHA-256');
await dialog.getByRole('button', { name: 'Скачать и установить' }).click();
await expect(dialog).toContainText('Установщик открыт');
await expect(page.locator('html')).toHaveAttribute('data-installer', 'opened');
});
+5
View File
@@ -33,6 +33,7 @@ import { SettingsPage } from '../pages/SettingsPage';
import { SetupPage } from '../pages/SetupPage'; import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell'; import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError'; import { RouteError } from '../widgets/RouteError';
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
export interface RouterContext { export interface RouterContext {
session: AuthSession; session: AuthSession;
@@ -147,6 +148,9 @@ const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', compo
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> }); const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> }); const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage }); const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
const downloadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/downloads', component: DesktopDownloadsPage,
beforeLoad: ({ context }) => { if (!context.desktop) throw redirect({ to: '/settings' }); },
});
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> }); const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage }); const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
@@ -281,6 +285,7 @@ const routeTree = rootRoute.addChildren([
homeRoute, homeRoute,
trackRoute, trackRoute,
playlistsRoute, playlistsRoute,
downloadsRoute,
sharedPlaylistRoute, sharedPlaylistRoute,
playlistRoute, playlistRoute,
settingsRoute, settingsRoute,
+18 -1
View File
@@ -1,12 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthSession, type AuthUser } from '@pipu/core';
import { createDesktop, isDesktop } from './desktop'; import { createDesktop, desktopFetch, isDesktop } from './desktop';
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
describe('desktop bridge', () => { describe('desktop bridge', () => {
it('restores the last desktop user when the proxy cannot reach the server', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
const user = { id: 'alice', username: 'alice' } as AuthUser;
const clear = vi.fn();
const session = new AuthSession({ clientType: 'desktop', fetch: desktopFetch, baseUrl: 'http://local', refreshStore: {
get: async () => 'refresh', set: vi.fn(), clear, getUser: async () => user,
} });
expect(await session.restore()).toEqual({ status: 'signed_in', offline: true, user });
expect(clear).not.toHaveBeenCalled();
});
it('reports a failed proxy hop as a network failure, preserving real HTTP errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
await expect(desktopFetch('http://local/api')).rejects.toThrow('Server unavailable');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('denied', { status: 401 })));
expect((await desktopFetch('http://local/api')).status).toBe(401);
});
it('is inactive in the browser', () => { it('is inactive in the browser', () => {
expect(isDesktop()).toBe(false); expect(isDesktop()).toBe(false);
}); });
+9 -1
View File
@@ -14,11 +14,19 @@ export function isDesktop(): boolean {
return api() !== undefined; return api() !== undefined;
} }
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> { /** A failed loopback hop must behave like a network failure to offline auth restore. */
export const desktopFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (response.headers.get('x-pipu-transport-error') === '1') throw new TypeError('Server unavailable');
return response;
};
export function desktopInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const tauri = api(); const tauri = api();
if (!tauri) throw new Error('Tauri API unavailable'); if (!tauri) throw new Error('Tauri API unavailable');
return tauri.core.invoke(cmd, args) as Promise<T>; return tauri.core.invoke(cmd, args) as Promise<T>;
} }
const invoke = desktopInvoke;
export type DesktopHandle = { export type DesktopHandle = {
proxyBase: string; proxyBase: string;
+2
View File
@@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react'; import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists'; import { AddToPlaylist } from './playlists';
import { SaveOfflineButton } from '../features/desktop/DesktopFeatures';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
@@ -218,6 +219,7 @@ export function TrackList({
</Link> </Link>
)} )}
<span className={s.end} data-playlist-actions={actions ? true : undefined}> <span className={s.end} data-playlist-actions={actions ? true : undefined}>
{!actions && <SaveOfflineButton track={tr} compact />}
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />} {actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
{disliked ? ( {disliked ? (
<button <button
@@ -0,0 +1,147 @@
import { releaseNotesText, type Track } from '@pipu/core';
import { useEffect, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession } from '../../app/session';
import { desktopInvoke, useDesktop } from '../../desktop';
import { TrackList } from '../../entities/catalog';
import { usePlayer, usePlayerState } from '../../player/context';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import { formatBytes } from '../../shared/lib/format';
import { checkIcon, downloadIcon } from '../../shared/ui/icons';
import s from '../../pages/page.module.css';
export function useOffline() {
const offline = usePlayer().offline;
useSyncExternalStore(offline?.subscribe ?? (() => () => undefined), offline?.snapshot ?? (() => 0));
return offline;
}
export function SaveOfflineButton({ track, compact = false }: { track: Track; compact?: boolean }) {
const offline = useOffline();
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
if (!offline) return null;
return <span>
<Button busy={busy} disabled={!track.available} icon={offline.pinned(track.id) ? checkIcon : downloadIcon}
aria-label={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}
title={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')} onClick={() => {
setBusy(true); setError(false);
void (offline.pinned(track.id) ? offline.remove(track.id) : offline.save(track, true))
.catch(() => setError(true)).finally(() => setBusy(false));
}}>{compact ? null : t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}</Button>
{error && <span role="alert">{t('desktop.download_failed')}</span>}
</span>;
}
export function DesktopOfflineSection() {
const offline = useOffline();
const { track } = usePlayerState();
const { t, i18n } = useTranslation();
const [error, setError] = useState(false);
useEffect(() => { void offline?.ready.catch(() => setError(true)); }, [offline]);
if (!offline) return null;
const tracks = offline.tracks();
return <section className={s.section}>
<h2>{t('desktop.offline')}</h2>
<p className={s.note}>{t('desktop.cache_hint')}</p>
<div className={s.toolbar}>
<span>{formatBytes(offline.bytes, i18n.language)}</span>
<Button onClick={() => void offline.clearRecent().catch(() => setError(true))}>{t('desktop.clear_recent')}</Button>
{track && <SaveOfflineButton track={track} />}
</div>
<FormError>{error ? t('desktop.storage_failed') : null}</FormError>
{tracks.length > 0 ? <TrackList tracks={tracks} context={{ type: 'collection', id: 'offline' }} actions={(track) => <SaveOfflineButton track={track} compact />} /> : <p>{t('desktop.offline_empty')}</p>}
</section>;
}
export function DesktopDownloadsPage() {
const { t } = useTranslation();
return <div className={s.page}><h1>{t('desktop.offline')}</h1><DesktopOfflineSection /></div>;
}
export function DesktopOfflineBanner() {
const state = useAuthState();
const desktop = useDesktop();
const { t } = useTranslation();
return desktop && state.status === 'signed_in' && state.offline ? <p role="status">{t('mobile.offline')}</p> : null;
}
type Release = { build: { version: string; size: number }; notes: string };
type Update = { status: 'idle' | 'checking' | 'latest' | 'error' | 'available' | 'installing' | 'ready'; release?: Release; dismissed?: boolean; progress?: number };
let update: Update = { status: 'idle' };
let generation = 0;
const listeners = new Set<() => void>();
function setUpdate(value: Update) { update = value; for (const fn of listeners) fn(); }
const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; };
function useUpdate() { return useSyncExternalStore(subscribe, () => update); }
async function checkUpdate(server: string, manual = false) {
if (update.status === 'checking' || update.status === 'installing') return;
const key = `pipu.update.checked.${server}`;
if (!manual && Date.now() - Number(localStorage.getItem(key) ?? 0) < 6 * 60 * 60 * 1000) return;
setUpdate({ status: 'checking' });
const requestGeneration = generation;
try {
const release = await desktopInvoke<Release | null>('update_check');
if (requestGeneration !== generation) return;
localStorage.setItem(key, String(Date.now()));
setUpdate(release ? { status: 'available', release } : { status: manual ? 'latest' : 'idle' });
} catch { if (requestGeneration === generation) setUpdate({ status: manual ? 'error' : 'idle' }); }
}
export function DesktopUpdateButton() {
const desktop = useDesktop();
const state = useUpdate();
const { t } = useTranslation();
if (!desktop?.server) return null;
return <section className={s.section}>
<h2>{t('desktop.updates')}</h2>
<Button busy={state.status === 'checking'} disabled={state.status === 'installing'} onClick={() => void checkUpdate(desktop.server!, true)}>{t('mobile.update_check')}</Button>
{state.status === 'latest' && <p role="status">{t('mobile.update_latest')}</p>}
{state.status === 'error' && !state.release && <FormError>{t('desktop.check_failed')}</FormError>}
</section>;
}
export function DesktopUpdateDialog() {
const desktop = useDesktop();
const session = useSession();
const state = useUpdate();
const { t } = useTranslation();
useEffect(() => {
if (!desktop?.server) return;
const check = () => void checkUpdate(desktop.server!);
check();
window.addEventListener('focus', check);
return () => { window.removeEventListener('focus', check); generation++; setUpdate({ status: 'idle' }); };
}, [desktop, session]);
useEffect(() => {
if (!desktop) return;
let stop: (() => void) | undefined;
let disposed = false;
// The native updater emits progress through the same Tauri event surface as media controls.
const api = (window as Window & { __TAURI__?: { event: { listen: (name: string, fn: (e: { payload: string }) => void) => Promise<() => void> } } }).__TAURI__;
void api?.event.listen('update-progress', (e) => {
const progress = Number(e.payload);
if (update.status === 'installing' && Number.isFinite(progress)) setUpdate({ ...update, progress: Math.min(1, Math.max(0, progress)) });
}).then((fn) => { if (disposed) fn(); else stop = fn; });
return () => { disposed = true; stop?.(); };
}, [desktop]);
if (!desktop || !state.release || state.dismissed) return null;
const release = state.release;
const busy = state.status === 'installing';
const close = () => { if (!busy) setUpdate({ ...state, dismissed: true }); };
const notes = releaseNotesText(release.notes);
return <Dialog title={t('mobile.update_title', { version: release.build.version })} onClose={close} footer={<>
<Button disabled={busy} onClick={close}>{t('mobile.update_later')}</Button>
<Button busy={busy} onClick={() => {
setUpdate({ status: 'installing', release, progress: 0 });
void desktopInvoke('update_install').then(() => setUpdate({ status: 'ready', release })).catch(() => setUpdate({ status: 'error', release }));
}}>{t('mobile.update_install')}</Button>
</>}>
{notes && <p style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '35vh', overflowY: 'auto' }}>{notes}</p>}
<p>{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}</p>
{busy && <p role="status">{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}</p>}
{state.status === 'ready' && <p role="status">{t('desktop.installer_open')}</p>}
{state.status === 'error' && <FormError>{t('mobile.update_failed')}</FormError>}
</Dialog>;
}
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AuthSession, PlayEvent } from '@pipu/core';
import { desktopEventSink, evictRecent, offlineOwner } from './offline';
import { createDesktop } from '../../desktop';
import { albumTracks } from '../../test/fakeApi';
afterEach(() => {
localStorage.clear();
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
});
describe('desktop cache policy', () => {
it('evicts oldest recent files while keeping manual downloads outside the limit', () => {
const entry = (id: string, used: number, pinned = false) => ({ id, used, pinned, track: albumTracks[0]!, audio: { size: 60 } as Blob });
expect(evictRecent([entry('new', 3), entry('manual', 0, true), entry('old', 1), entry('middle', 2)], 100)).toEqual(['old', 'middle']);
});
});
it('persists offline events across sink restarts and deduplicates retries', async () => {
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke: async () => ({ proxyBase: 'http://local', server: 'https://music.example' }) } };
await createDesktop();
const post = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue({ data: {}, response: new Response('{}') });
const session = { state: { status: 'signed_in', user: { id: 'alice' } }, client: { POST: post } } as unknown as AuthSession;
const owner = offlineOwner(session)!;
const event: PlayEvent = { id: 'one', type: 'play_start', occurred_at: new Date().toISOString(), track_id: 'track' };
await expect(desktopEventSink(session, owner)([event])).rejects.toThrow('offline');
await desktopEventSink(session, owner)([event]);
expect(post.mock.calls[1]?.[1].body.events).toEqual([event]);
expect(JSON.parse(localStorage.getItem(`pipu.events.${owner}`)!)).toEqual([]);
});
+181
View File
@@ -0,0 +1,181 @@
import { unwrap, type AuthSession, type PlayEvent, type Track } from '@pipu/core';
import { desktopHandle } from '../../desktop';
export const RECENT_LIMIT = 500 * 1024 * 1024;
type Entry = { id: string; track: Track; audio: Blob; cover?: Blob; pinned: boolean; used: number };
const size = (e: Entry) => e.audio.size + (e.cover?.size ?? 0);
export function evictRecent(entries: Entry[], limit = RECENT_LIMIT): string[] {
let total = entries.filter((e) => !e.pinned).reduce((n, e) => n + size(e), 0);
const removed: string[] = [];
for (const e of entries.filter((e) => !e.pinned).sort((a, b) => a.used - b.used)) {
if (total <= limit) break;
removed.push(e.id);
total -= size(e);
}
return removed;
}
export function offlineOwner(session: AuthSession): string | null {
const desktop = desktopHandle();
return desktop?.server && session.state.status === 'signed_in'
? JSON.stringify([desktop.server, session.state.user.id]) : null;
}
/** WebView IndexedDB is persistent across desktop restarts; signed URLs are never stored. */
export class DesktopOffline {
private db: Promise<IDBDatabase> | null = null;
private loaded: Promise<void> | null = null;
private entries: Entry[] = [];
private urls = new Map<string, Track>();
private objects = new Map<string, string[]>();
private inUse = new Set<string>();
private listeners = new Set<() => void>();
private revision = 0;
private writes: Promise<unknown> = Promise.resolve();
private disposed = false;
get ready(): Promise<void> {
this.loaded ??= this.reload();
return this.loaded;
}
activate() { this.disposed = false; }
constructor(readonly owner: string, private session: AuthSession) {}
private database(): Promise<IDBDatabase> {
return this.db ??= new Promise((resolve, reject) => {
const request = indexedDB.open(`pipu.offline.${this.owner}`, 1);
request.onupgradeneeded = () => request.result.createObjectStore('tracks', { keyPath: 'id' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
snapshot = () => this.revision;
private async reload() {
const db = await this.database();
const entries = await new Promise<Entry[]>((resolve, reject) => {
const req = db.transaction('tracks').objectStore('tracks').getAll();
req.onsuccess = () => resolve(req.result as Entry[]);
req.onerror = () => reject(req.error);
});
if (this.disposed) return;
this.entries = entries;
for (const e of entries) {
if (this.urls.has(e.id)) continue;
const audio = URL.createObjectURL(e.audio);
const cover = e.cover ? URL.createObjectURL(e.cover) : null;
this.objects.set(e.id, [...(this.objects.get(e.id) ?? []), audio, ...(cover ? [cover] : [])]);
this.urls.set(e.id, { ...e.track, stream_url: audio, available: true, album: { ...e.track.album, cover_url: cover } });
}
for (const id of this.urls.keys()) if (!entries.some((e) => e.id === id)) {
this.urls.delete(id);
if (!this.inUse.has(id)) this.release(id);
}
this.revision++;
for (const fn of this.listeners) fn();
}
tracks() { return this.entries.map((e) => this.local(e.track)); }
private release(id: string) {
for (const url of this.objects.get(id) ?? []) URL.revokeObjectURL(url);
this.objects.delete(id);
}
/** Keep only files currently playing/crossfading alive after cache deletion. */
keep(ids: string[]) {
this.inUse = new Set(ids);
for (const id of this.objects.keys()) if (!this.inUse.has(id) && !this.urls.has(id)) this.release(id);
}
local(track: Track): Track { return this.urls.get(track.id) ?? track; }
has(id: string) { return this.entries.some((e) => e.id === id); }
pinned(id: string) { return this.entries.some((e) => e.id === id && e.pinned); }
get bytes() { return this.entries.reduce((n, e) => n + size(e), 0); }
private mutate(fn: (store: IDBObjectStore) => void): Promise<void> {
const work = this.writes.catch(() => undefined).then(async () => {
const db = await this.database();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('tracks', 'readwrite');
fn(tx.objectStore('tracks'));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
await this.reload();
});
this.writes = work;
return work;
}
private downloads = new Map<string, Promise<void>>();
private downloadTail: Promise<unknown> = Promise.resolve();
async save(track: Track, pinned = false): Promise<void> {
await this.ready;
const pending = this.downloads.get(track.id);
if (pending) { await pending; return this.save(track, pinned); }
const existing = this.entries.find((e) => e.id === track.id);
if (existing) {
await this.mutate((store) => {
const latest = this.entries.find((e) => e.id === track.id);
if (latest) store.put({ ...latest, pinned: latest.pinned || pinned, used: Date.now() });
});
return;
}
const job = this.downloadTail.catch(() => undefined).then(() => this.download(track, pinned));
this.downloadTail = job;
this.downloads.set(track.id, job);
try { await job; } finally { this.downloads.delete(track.id); }
}
private async download(track: Track, pinned: boolean) {
if (this.disposed) return;
const fresh = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })).items[0];
if (!fresh?.available || !fresh.stream_url) throw new Error('Track unavailable');
const response = await fetch(fresh.stream_url);
if (!response.ok) throw new Error('Download failed');
const audio = await response.blob();
if (!audio.size) throw new Error('Empty download');
let cover: Blob | undefined;
if (fresh.album.cover_url) {
try {
const response = await fetch(`${fresh.album.cover_url}&size=300`);
if (response.ok) cover = await response.blob();
} catch { /* Artwork is optional. */ }
}
if (this.disposed) return;
const entry: Entry = { id: track.id, track: { ...fresh, stream_url: null, album: { ...fresh.album, cover_url: null } }, audio, cover, pinned, used: Date.now() };
await this.mutate((store) => {
const removed = evictRecent([...this.entries.filter((e) => e.id !== entry.id), entry]);
for (const id of removed) store.delete(id);
if (!removed.includes(entry.id)) store.put(entry);
});
}
async remove(id: string) { await this.mutate((store) => store.delete(id)); }
async clearRecent() { await this.mutate((store) => { for (const e of this.entries) if (!e.pinned) store.delete(e.id); }); }
dispose() {
this.disposed = true;
queueMicrotask(() => {
if (!this.disposed) return;
for (const id of this.objects.keys()) this.release(id);
void this.downloadTail.catch(() => undefined).then(() => this.writes)
.finally(() => this.db?.then((db) => db.close())).catch(() => undefined);
});
}
}
/** Persist before sending; retries keep event IDs for server-side deduplication. */
export function desktopEventSink(session: AuthSession, owner: string) {
const key = `pipu.events.${owner}`;
let sending = Promise.resolve();
return async (events: PlayEvent[]) => {
const work = sending.catch(() => undefined).then(async () => {
const old = JSON.parse(localStorage.getItem(key) ?? '[]') as PlayEvent[];
const merged = [...new Map([...old, ...events].map((e) => [e.id, e])).values()];
localStorage.setItem(key, JSON.stringify(merged));
if (offlineOwner(session) !== owner) return;
while (merged.length) {
if (offlineOwner(session) !== owner) return;
const batch = merged.slice(0, 100);
unwrap(await session.client.POST('/api/v1/events', { body: { events: batch } }));
merged.splice(0, batch.length);
localStorage.setItem(key, JSON.stringify(merged));
}
});
sending = work;
await work;
};
}
+2 -1
View File
@@ -9,7 +9,7 @@ import { I18nextProvider } from 'react-i18next';
import { App } from './App'; import { App } from './App';
import { createAppRouter } from './app/router'; import { createAppRouter } from './app/router';
import { applyTheme } from './app/session'; import { applyTheme } from './app/session';
import { createDesktop, DesktopProvider } from './desktop'; import { createDesktop, DesktopProvider, desktopFetch } from './desktop';
const LOCALE_KEY = 'pipu.locale'; const LOCALE_KEY = 'pipu.locale';
@@ -70,6 +70,7 @@ const session = new AuthSession({
deviceName: desktop ? 'Pipu / Desktop' : deviceName(), deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
baseUrl: desktop?.proxyBase, baseUrl: desktop?.proxyBase,
refreshStore: desktop?.refreshStore, refreshStore: desktop?.refreshStore,
fetch: desktop ? desktopFetch : undefined,
}); });
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу. // До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
await session.restore(); await session.restore();
+3
View File
@@ -13,6 +13,7 @@ import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch'; import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css'; import s from './page.module.css';
import { Onboarding } from './Onboarding'; import { Onboarding } from './Onboarding';
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>; type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>; type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
@@ -27,6 +28,8 @@ export function SettingsPage() {
<ProfileSection /> <ProfileSection />
<RecommendationSettings /> <RecommendationSettings />
<AppearanceSection /> <AppearanceSection />
<DesktopOfflineSection />
<DesktopUpdateButton />
<PasswordSection /> <PasswordSection />
<SessionsSection /> <SessionsSection />
</div> </div>
@@ -0,0 +1,63 @@
import type { components } from '@pipu/api-client';
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { SessionProvider } from '../../app/session';
import { mobileFixtures } from '../../test/mobileFixtures';
import { SourceDialog } from './SourcesPage';
afterEach(cleanup);
type Source = components['schemas']['Source'];
const base = (mobileFixtures['/api/v1/admin/sources'] as { items: Source[] }).items[0]!;
async function fixture(source: Source | null = { ...base, excluded_dirs: ['Downloads_PumpEars'] }, reject = false) {
const bodies: Record<string, unknown>[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => {
const req = new Request(input, init);
if (req.method === 'PATCH' || req.method === 'POST') {
const body = await req.json() as Record<string, unknown>;
bodies.push(body);
return new Response(JSON.stringify(reject
? { code: 'validation.excluded_dirs_invalid', errors: [{ field: 'excluded_dirs', code: 'validation.excluded_dirs_invalid' }] }
: { ...base, ...body }), { status: reject ? 422 : 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ path: '', dirs: [] }), { headers: { 'Content-Type': 'application/json' } });
} });
const i18n = await createI18n('ru');
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const close = vi.fn();
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
<SourceDialog source={source} onClose={close} />
</QueryClientProvider></SessionProvider></I18nextProvider>);
return { bodies, close, user: userEvent.setup() };
}
it('loads and saves one excluded path per line', async () => {
const { bodies, close, user } = await fixture();
const input = screen.getByLabelText('Не сканировать папки');
expect(input).toHaveValue('Downloads_PumpEars');
await user.type(input, '\nArtist/Samples\n');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(bodies[0]?.excluded_dirs).toEqual(['Downloads_PumpEars', 'Artist/Samples']);
});
it('clears the list explicitly so excluded folders can be scanned again', async () => {
const { bodies, close, user } = await fixture();
await user.clear(screen.getByLabelText('Не сканировать папки'));
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(bodies[0]?.excluded_dirs).toEqual([]);
});
it('keeps invalid paths in the editor and displays the server validation', async () => {
const { user, close } = await fixture({ ...base, excluded_dirs: ['../outside'] }, true);
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(screen.getByLabelText('Не сканировать папки')).toHaveAttribute('aria-invalid', 'true'));
expect(screen.getByLabelText('Не сканировать папки')).toHaveValue('../outside');
expect(close).not.toHaveBeenCalled();
});
+22 -3
View File
@@ -2,7 +2,7 @@ import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react'; import { useId, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session'; import { useSession, useUser } from '../../app/session';
@@ -120,6 +120,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
{source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>} {source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>}
</div> </div>
<span className={l.location}>{location(source)}</span> <span className={l.location}>{location(source)}</span>
{!!source.excluded_dirs?.length && <span className={s.note}>{t('sources.excluded_count', { count: source.excluded_dirs.length })}</span>}
{source.status === 'error' && source.status_message && ( {source.status === 'error' && source.status_message && (
<span className={l.message}>{source.status_message}</span> <span className={l.message}>{source.status_message}</span>
)} )}
@@ -185,7 +186,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
const INTERVALS = [0, 60, 360, 1440]; const INTERVALS = [0, 60, 360, 1440];
function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) { export function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']); const { t } = useTranslation(['admin', 'common']);
const session = useSession(); const session = useSession();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -199,12 +200,15 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3'); const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3');
const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first'); const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0); const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
const [excludedDirs, setExcludedDirs] = useState((source?.excluded_dirs ?? []).join('\n'));
const excludedDirsId = useId();
const save = useMutation({ const save = useMutation({
mutationFn: async () => { mutationFn: async () => {
const common = { const common = {
name: name.trim(), name: name.trim(),
tag_priority: priority, tag_priority: priority,
excluded_dirs: excludedDirs.split(/\r?\n/).map((dir) => dir.trim()).filter(Boolean),
...(type === 'local' ...(type === 'local'
? { local_path: localPath } ? { local_path: localPath }
: { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }), : { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }),
@@ -224,11 +228,14 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
}, },
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['sources'] }); await queryClient.invalidateQueries({ queryKey: ['sources'] });
for (const key of ['library-status', 'library-errors', 'analysis-errors']) {
void queryClient.invalidateQueries({ queryKey: [key] });
}
onClose(); onClose();
}, },
}); });
const err = save.error; const err = save.error;
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options']; const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options', 'excluded_dirs'];
const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null; const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null;
const submit = (e: FormEvent) => { const submit = (e: FormEvent) => {
@@ -297,6 +304,18 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
/> />
</> </>
)} )}
<div className={l.field}>
<label htmlFor={excludedDirsId} className={l.label}>{t('sources.excluded_dirs')}</label>
<textarea id={excludedDirsId} className={l.excludedDirs} rows={5}
value={excludedDirs} onChange={(event) => setExcludedDirs(event.target.value)}
disabled={source?.scan?.running ?? false}
placeholder="Downloads_PumpEars"
aria-invalid={!!fieldError(err, 'excluded_dirs')}
aria-describedby={`${excludedDirsId}-hint`} />
<span id={`${excludedDirsId}-hint`} className={s.note}>
{fieldError(err, 'excluded_dirs') ?? t(source?.scan?.running ? 'sources.excluded_running' : 'sources.excluded_hint')}
</span>
</div>
<div className={l.field}> <div className={l.field}>
<span className={l.label}>{t('sources.tag_priority')}</span> <span className={l.label}>{t('sources.tag_priority')}</span>
<Segmented <Segmented
@@ -238,6 +238,20 @@
gap: 6px; gap: 6px;
} }
.excludedDirs {
width: 100%;
box-sizing: border-box;
resize: vertical;
border: 1px solid var(--line2);
border-radius: var(--radius-md);
padding: 10px 12px;
background: var(--surface2);
color: var(--text);
font: 13px var(--font-mono);
}
.excludedDirs:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.excludedDirs:disabled { opacity: 0.6; }
.tiles { .tiles {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+4 -2
View File
@@ -1,5 +1,6 @@
import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core'; import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core';
import type { PlayerState } from './types'; import type { PlayerState } from './types';
import { offlineOwner } from '../features/desktop/offline';
const LOCAL_KEY = 'pipu.player'; const LOCAL_KEY = 'pipu.player';
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */ /** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
@@ -30,6 +31,7 @@ function deviceId(): string {
} }
export class PlaybackPersistence { export class PlaybackPersistence {
private get localKey() { const owner = offlineOwner(this.session); return owner ? `${LOCAL_KEY}.${owner}` : LOCAL_KEY; }
private readonly device = deviceId(); private readonly device = deviceId();
lastServerSave = 0; lastServerSave = 0;
constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {} constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {}
@@ -50,7 +52,7 @@ export class PlaybackPersistence {
saveLocal() { saveLocal() {
if (!this.getState().track) return; if (!this.getState().track) return;
try { try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState()))); localStorage.setItem(this.localKey, JSON.stringify(this.snapshot(this.getState())));
} catch { } catch {
// недоступное хранилище не мешает воспроизведению // недоступное хранилище не мешает воспроизведению
} }
@@ -91,7 +93,7 @@ export class PlaybackPersistence {
async restore(): Promise<Saved | null> { async restore(): Promise<Saved | null> {
let local: Saved | null = null; let local: Saved | null = null;
try { try {
const raw = localStorage.getItem(LOCAL_KEY); const raw = localStorage.getItem(this.localKey);
local = raw ? (JSON.parse(raw) as Saved) : null; local = raw ? (JSON.parse(raw) as Saved) : null;
} catch { } catch {
local = null; local = null;
+57 -10
View File
@@ -18,6 +18,7 @@ import { MediaControls } from './media';
import type { PlayerState } from './types'; import type { PlayerState } from './types';
import { AudioEngine } from './engine'; import { AudioEngine } from './engine';
import { PlaybackPersistence } from './persistence'; import { PlaybackPersistence } from './persistence';
import { DesktopOffline, desktopEventSink, offlineOwner } from '../features/desktop/offline';
export type { PlayContext, Track }; export type { PlayContext, Track };
@@ -34,6 +35,8 @@ const PRELOAD_BEFORE_MS = 20_000;
* Media Session и сохранение «трек и момент» (локально и на сервере). * Media Session и сохранение «трек и момент» (локально и на сервере).
*/ */
export class Player { export class Player {
readonly offline: DesktopOffline | null;
private retryEvents: (() => Promise<void>) | null = null;
private readonly engine: AudioEngine; private readonly engine: AudioEngine;
private get audio() { return this.engine.audio; } private get audio() { return this.engine.audio; }
private get overlap() { return this.engine.overlap; } private get overlap() { return this.engine.overlap; }
@@ -65,8 +68,16 @@ export class Player {
audio?: HTMLAudioElement, audio?: HTMLAudioElement,
) { ) {
this.engine = new AudioEngine(audio); this.engine = new AudioEngine(audio);
const owner = offlineOwner(session);
this.offline = owner ? new DesktopOffline(owner, session) : null;
if (owner) {
const send = desktopEventSink(session, owner);
this.retryEvents = () => send([]);
this.sendEvents = send;
}
this.events = new EventBuffer({ this.events = new EventBuffer({
send: async (events) => { send: async (events) => {
if (this.sendEvents) return this.sendEvents(events);
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } })); unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
}, },
}); });
@@ -82,8 +93,15 @@ export class Player {
/** Запуск фоновой отправки событий (парный вызов — dispose). */ /** Запуск фоновой отправки событий (парный вызов — dispose). */
start() { start() {
this.offline?.activate();
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS)); this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
if (this.retryEvents) {
void this.retryEvents().catch(() => undefined);
this.timers.push(setInterval(() => void this.retryEvents?.().catch(() => undefined), FLUSH_EVERY_MS));
} }
}
private sendEvents: ((events: import('@pipu/core').PlayEvent[]) => Promise<void>) | null = null;
// --- подписка (useSyncExternalStore) --- // --- подписка (useSyncExternalStore) ---
@@ -137,11 +155,20 @@ export class Player {
this.finishAttempt('skip'); this.finishAttempt('skip');
this.leaveWave(); this.leaveWave();
const version = this.wavePolicy.version; const version = this.wavePolicy.version;
const res = unwrap( let res;
await this.session.client.POST('/api/v1/wave/sessions', { try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', {
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) }, body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
}), }));
); } catch (error) {
if (!this.offline) throw error;
await this.offline.ready;
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = this.offline.tracks().filter((t) => t.reaction !== 'dislike');
if (!tracks.length) throw error;
this.playList(tracks.sort(() => Math.random() - 0.5), 0, { type: 'collection', id: 'offline' });
return;
}
if (!this.wavePolicy.isCurrent(version)) return; if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter((t) => t.available && t.stream_url); const tracks = res.tracks.filter((t) => t.available && t.stream_url);
if (!tracks.length) { if (!tracks.length) {
@@ -339,12 +366,20 @@ export class Player {
private async load(autoplay: boolean, startMs = 0) { private async load(autoplay: boolean, startMs = 0) {
const track = this.queue.current; const track = this.queue.current;
if (!track?.stream_url) return; if (!track?.stream_url) return;
const version = this.restoreVersion;
if (this.offline) {
await this.offline.ready.catch(() => undefined);
if (this.queue.current !== track || version !== this.restoreVersion) return;
}
this.offline?.keep([track.id]);
const source = this.offline?.local(track).stream_url ?? track.stream_url;
this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms }); this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
// Keep the restored element's buffer when the user presses Play. // Keep the restored element's buffer when the user presses Play.
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url; if (this.audio.getAttribute('src') !== source) this.audio.src = source;
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000; if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
this.media.updateMediaMetadata(track); this.media.updateMediaMetadata(track);
if (!autoplay) return; if (!autoplay) return;
void this.offline?.save(track).catch(() => undefined);
this.applyLoudness(); this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs); this.attempt = new PlayAttempt(track, startMs);
this.preloaded = null; this.preloaded = null;
@@ -425,7 +460,8 @@ export class Player {
const next = this.state.queue[this.state.index + 1]; const next = this.state.queue[this.state.index + 1];
if (!next?.stream_url || !next.available) return; if (!next?.stream_url || !next.available) return;
this.fading = true; this.fading = true;
this.overlap.src = next.stream_url; this.offline?.keep([this.queue.current!.id, next.id]);
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
this.overlap.volume = 0; this.overlap.volume = 0;
void this.overlap.play().catch(() => { void this.overlap.play().catch(() => {
this.fading = false; this.fading = false;
@@ -458,7 +494,8 @@ export class Player {
return; return;
} }
this.syncQueue(); this.syncQueue();
const src = next.stream_url; const src = this.offline?.local(next).stream_url ?? next.stream_url;
this.offline?.keep([next.id]);
if (!src) { if (!src) {
this.fading = false; this.fading = false;
this.ignoreEnded = false; this.ignoreEnded = false;
@@ -471,6 +508,7 @@ export class Player {
this.attempt = new PlayAttempt(next, Math.round(at * 1000)); this.attempt = new PlayAttempt(next, Math.round(at * 1000));
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms }); this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
this.media.updateMediaMetadata(next); this.media.updateMediaMetadata(next);
void this.offline?.save(next).catch(() => undefined);
void this.audio.play().finally(() => { void this.audio.play().finally(() => {
this.fading = false; this.fading = false;
this.ignoreEnded = false; this.ignoreEnded = false;
@@ -560,7 +598,7 @@ export class Player {
if (!cur || !next?.stream_url || this.preloaded === next.id) return; if (!cur || !next?.stream_url || this.preloaded === next.id) return;
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return; if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
this.preloaded = next.id; this.preloaded = next.id;
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined); void fetch(this.offline?.local(next).stream_url ?? next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
} }
// --- сохранение состояния --- // --- сохранение состояния ---
@@ -583,10 +621,18 @@ export class Player {
const version = ++this.restoreVersion; const version = ++this.restoreVersion;
const saved = await this.persistence.restore(); const saved = await this.persistence.restore();
if (version !== this.restoreVersion || !saved?.queue.length) return; if (version !== this.restoreVersion || !saved?.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })); let items: Track[];
try {
items = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })).items;
} catch (error) {
if (!this.offline) throw error;
await this.offline.ready;
const cached = new Map(this.offline.tracks().map((t) => [t.id, t]));
items = saved.queue.flatMap((id) => cached.get(id) ?? []);
}
if (version !== this.restoreVersion) return; if (version !== this.restoreVersion) return;
const wanted = saved.queue[saved.index]; const wanted = saved.queue[saved.index];
const tracks = res.items.filter((t) => t.available && t.stream_url); const tracks = items.filter((t) => t.available && t.stream_url);
if (!tracks.length) return; if (!tracks.length) return;
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0); const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
this.context = saved.context; this.context = saved.context;
@@ -608,6 +654,7 @@ export class Player {
this.fading = false; this.fading = false;
this.finishAttempt('skip'); this.finishAttempt('skip');
this.engine.dispose(); this.engine.dispose();
this.offline?.dispose();
this.persistence.dispose(); this.persistence.dispose();
for (const t of this.timers) clearInterval(t); for (const t of this.timers) clearInterval(t);
this.timers = []; this.timers = [];
+5
View File
@@ -37,6 +37,7 @@ import { Ambient } from './Ambient';
import { PlayerBar } from './PlayerBar'; import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox'; import { SearchBox } from './SearchBox';
import s from './AppShell.module.css'; import s from './AppShell.module.css';
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
type NavPath = type NavPath =
| '/' | '/'
@@ -49,6 +50,7 @@ type NavPath =
| '/uploads/mine' | '/uploads/mine'
| '/admin/settings' | '/admin/settings'
| '/collection/playlists' | '/collection/playlists'
| '/collection/downloads'
| '/collection/history' | '/collection/history'
| '/collection/likes' | '/collection/likes'
| '/collection/dislikes'; | '/collection/dislikes';
@@ -147,6 +149,7 @@ function NavSection({
export function AppShell() { export function AppShell() {
return useAuthState().status === 'signed_in' ? ( return useAuthState().status === 'signed_in' ? (
<PlayerProvider> <PlayerProvider>
<DesktopUpdateDialog />
<Shell /> <Shell />
</PlayerProvider> </PlayerProvider>
) : null; ) : null;
@@ -174,6 +177,7 @@ function Shell() {
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} /> <NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}> <NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} /> <NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
{desktop && <NavLink to="/collection/downloads" label={t('desktop.offline')} icon={downloadIcon} activeIcon={downloadIcon} />}
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} /> <NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} /> <NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink <NavLink
@@ -298,6 +302,7 @@ function ShellMain() {
const key = useRouterState({ select: (st) => st.location.pathname }); const key = useRouterState({ select: (st) => st.location.pathname });
return ( return (
<main id="content" className={s.content} tabIndex={-1}> <main id="content" className={s.content} tabIndex={-1}>
<DesktopOfflineBanner />
<div key={key} className={s.routeIn}> <div key={key} className={s.routeIn}>
<Suspense fallback={<PageSkeleton />}> <Suspense fallback={<PageSkeleton />}>
<Outlet /> <Outlet />
+29
View File
@@ -15,3 +15,32 @@ pnpm --filter @pipu/desktop bundle
На Windows получается NSIS-установщик (~3 МБ). На Linux в релиз идёт только `.deb` (~4 МБ): AppImage тащит WebKitGTK и раздувается до ~80 МБ. Зависимости пакета: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer. На Windows получается NSIS-установщик (~3 МБ). На Linux в релиз идёт только `.deb` (~4 МБ): AppImage тащит WebKitGTK и раздувается до ~80 МБ. Зависимости пакета: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
Релиз по тегу собирает Linux `.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea. Релиз по тегу собирает Linux `.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea.
## Офлайн и кеш
Коллекция → **Скачанное и кеш** доступна без сервера, в том числе после перезапуска.
Полностью загруженные прослушанные треки автоматически сохраняются в IndexedDB WebView;
кеш недавних ограничен 500 МБ, при заполнении удаляются самые старые записи.
Кнопка «Скачать» закрепляет трек: такие загрузки не вытесняются кешем и удаляются вручную.
Обложки сохраняются рядом с аудио; подписанные ссылки не сохраняются.
Хранилище и локальная очередь воспроизведения разделены по адресу сервера и пользователю.
Настройки показывают размер хранилища и позволяют очистить недавние треки, сохранив ручные загрузки.
Плеер предпочитает локальное аудио (включая crossfade); при запуске без сети восстанавливает
сессию и сохранённую очередь без автозапуска. Если сервер волны недоступен, можно запустить
перемешанные локальные треки без дизлайков. События прослушивания сохраняются локально
и досылаются с исходными идентификаторами при восстановлении связи.
## Обновления
При запуске и возврате в окно (не чаще раза в 6 часов), а также вручную в настройках,
нативный клиент читает `/api/v1/app/clients` через настроенный сервер Pipu.
Выбирается сборка текущей ОС, версия сравнивается с установленной с учётом `-rc.N`.
Диалог показывает пользовательские заметки без служебных блоков CI.
«Скачать и установить» загружает сборку в каталог кеша приложения, показывает прогресс,
проверяет размер и SHA-256 и только затем открывает установщик. Без контрольной суммы
обновление не предлагается. Windows запускает EXE/MSI; Linux открывает DEB/RPM через
системный обработчик пакетов; macOS открывает DMG. Установку подтверждает пользователь
в системном интерфейсе, после неё требуется перезапуск приложения. На Linux необходим
установленный обработчик пакетов для `xdg-open`; AppImage и `.app.tar` этим потоком не поддерживаются.
+14
View File
@@ -139,6 +139,20 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов). Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов).
В редакторе хранилища поле **«Не сканировать папки»** задаёт до 100 исключений:
по одному пути относительно корня хранилища в строке, например `Downloads_PumpEars`
или `Artist/Samples`. Исключается вся ветка; соседняя `Downloads_PumpEars-copy`
не затрагивается. Пути чувствительны к регистру, шаблоны и абсолютные пути не используются.
Настройка хранится в `library_sources.excluded_dirs`; пустой список снимает исключения.
Во время активного скана менять список нельзя.
Исключённые ветки не обходятся; точечный скан такого пути отклоняется. Уже записанные
файлы остаются в каталоге и не помечаются пропавшими. Сразу после сохранения исключений
ошибки чтения и задачи аудиоанализа этих файлов перестают попадать в диагностику и повтор.
Анализатор не забирает их в работу; если у трека есть другая разрешённая копия, использует её.
После снятия исключений сохранённые задачи снова учитываются; неудавшиеся можно повторить.
Файлы на диске не изменяются.
Во время обхода админка показывает число посещённых папок (включая корень), найденных Во время обхода админка показывает число посещённых папок (включая корень), найденных
аудиофайлов и текущую папку относительно корня хранилища. До завершения обхода общее аудиофайлов и текущую папку относительно корня хранилища. До завершения обхода общее
число файлов неизвестно, поэтому индикатор показывает активность без процента. число файлов неизвестно, поэтому индикатор показывает активность без процента.
+1 -1
View File
@@ -109,7 +109,7 @@
кэширует ответ на 10 мин и APK на диске. Так: кэширует ответ на 10 мин и APK на диске. Так:
- мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным); - мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
- версия приложения согласована с версией сервера (`min_supported` из `server-info`). - версия приложения согласована с версией сервера (`min_supported` из `server-info`).
2. Если `version_code` новее — диалог с changelog (Markdown из релиза), кнопки «Обновить» / «Позже». 2. Если `version_code` новее — диалог с changelog (текст из Markdown релиза без служебных CI-блоков сборок), кнопки «Обновить» / «Позже».
Если текущая версия ниже `min_supported` — обновление обязательно. Если текущая версия ниже `min_supported` — обновление обязательно.
3. Загрузка APK через `expo-file-system` в `cacheDirectory` с прогрессом; проверка **SHA-256** (значение из релиза). 3. Загрузка APK через `expo-file-system` в `cacheDirectory` с прогрессом; проверка **SHA-256** (значение из релиза).
Хэш считается кусками по 1 МБ (`js-sha256`), APK целиком в память JS не читается. Загрузка идёт с `Bearer`, Хэш считается кусками по 1 МБ (`js-sha256`), APK целиком в память JS не читается. Загрузка идёт с `Bearer`,
@@ -1960,6 +1960,7 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
TagPriority: "tags_first" | "path_first"; TagPriority: "tags_first" | "path_first";
Source: { Source: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
name: string; name: string;
@@ -1988,6 +1989,7 @@ export interface components {
items: components["schemas"]["Source"][]; items: components["schemas"]["Source"][];
}; };
SourceInput: { SourceInput: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name: string; name: string;
type: components["schemas"]["SourceType"]; type: components["schemas"]["SourceType"];
nfs_server?: string; nfs_server?: string;
@@ -2000,6 +2002,7 @@ export interface components {
write_template?: string; write_template?: string;
}; };
SourcePatch: { SourcePatch: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name?: string; name?: string;
nfs_server?: string; nfs_server?: string;
nfs_export?: string; nfs_export?: string;
@@ -2018,6 +2021,8 @@ export interface components {
*/ */
write_template?: string; write_template?: string;
}; };
/** @description Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan. */
ExcludedDirs: string[];
SourceTestResult: { SourceTestResult: {
ok: boolean; ok: boolean;
message: string; message: string;
+1
View File
@@ -29,3 +29,4 @@ export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveS
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave'; export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
export { AGE_GROUPS, GENDERS } from './onboarding'; export { AGE_GROUPS, GENDERS } from './onboarding';
export { releaseNotesText } from './releases';
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { releaseNotesText } from './releases';
describe('release notes', () => {
it('removes build reports from all platforms, preserving real changes', () => {
expect(releaseNotesText('<!-- android-status -->\n> APK `file.apk` SHA-256: abc\n<!-- /android-status -->\n\n<!-- desktop-linux-status -->\n> package.deb\n<!-- /desktop-linux-status -->\n\n## Changes\n- **Offline** [playback](https://example.com)\n- Fix `player`')).toBe('Changes\n• Offline playback\n• Fix player');
});
it('hides comments and handles releases containing only build reports', () => {
expect(releaseNotesText('<!-- android-status -->\n> build\n<!-- /android-status -->')).toBe('');
expect(releaseNotesText('<!-- internal -->\n> Hello\n\n\nWorld')).toBe('Hello\n\nWorld');
});
});
+16
View File
@@ -0,0 +1,16 @@
/** Release bodies also contain CI build reports; these are not user-facing notes. */
export function releaseNotesText(markdown: string): string {
return markdown
.replace(/<!--\s*([\w-]+-status)\s*-->[\s\S]*?<!--\s*\/\1\s*-->/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/^\s*```[^\n]*\n[\s\S]*?^\s*```\s*$/gm, '')
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
.replace(/^\s*>\s?/gm, '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/(`+)(.*?)\1/g, '$2')
.replace(/\*\*(.*?)\*\*|__(.*?)__/g, '$1$2')
.replace(/^\s*[-*+]\s+/gm, '• ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
+4
View File
@@ -98,6 +98,10 @@
"delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.", "delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.",
"scanning": "Scanning: {{processed}} of {{total}}", "scanning": "Scanning: {{processed}} of {{total}}",
"scan_walking": "Walking folders…", "scan_walking": "Walking folders…",
"excluded_dirs": "Folders to exclude from scanning",
"excluded_hint": "One folder per line, relative to the storage root. Example: Downloads_PumpEars. All subfolders are excluded too. Paths are case sensitive; wildcards are not supported. Analysis and its errors are excluded as soon as you save. Existing catalog records are retained. An empty list enables scanning and analysis again.",
"excluded_running": "Wait for the current scan to finish before editing exclusions.",
"excluded_count": "Folders excluded from scanning: {{count}}",
"scan_walk_progress": "Walking folders… Directories checked: {{dirs}} · audio files found: {{found}}", "scan_walk_progress": "Walking folders… Directories checked: {{dirs}} · audio files found: {{found}}",
"scan_current_dir": "Current folder: {{path}}", "scan_current_dir": "Current folder: {{path}}",
"scan_preparing": "Matching files with the library…", "scan_preparing": "Matching files with the library…",
+3 -3
View File
@@ -51,9 +51,9 @@
"copy_server": "Copy the server address", "copy_server": "Copy the server address",
"step_updates": { "step_updates": {
"android": "Sign in with your account. The app offers new versions by itself.", "android": "Sign in with your account. The app offers new versions by itself.",
"windows": "Sign in with your account. Get a new version from this page.", "windows": "Sign in with your account. The app will offer new versions.",
"linux": "Sign in with your account. Get a new version from this page.", "linux": "Sign in with your account. The app will offer new versions through your system package installer.",
"macos": "Sign in with your account. Get a new version from this page." "macos": "Sign in with your account. The app will offer new versions."
}, },
"checksum": "SHA-256 checksum", "checksum": "SHA-256 checksum",
"unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).", "unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).",
+13
View File
@@ -1,4 +1,17 @@
{ {
"desktop": {
"offline": "Downloads and cache",
"save_track": "Download",
"remove_download": "Remove download",
"download_failed": "Could not download the track",
"storage_failed": "Could not open or change the cache. Check available disk space.",
"cache_hint": "Recent tracks are cached automatically (up to 500 MB). Manual downloads remain until removed. Available offline, including after restarting the app.",
"clear_recent": "Clear recent cache",
"offline_empty": "Played and downloaded tracks will appear here.",
"updates": "App updates",
"check_failed": "Could not check for updates. Try again later.",
"installer_open": "The installer is open. Complete installation and restart the app."
},
"genius": { "genius": {
"description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.", "description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.",
"loading": "Loading…", "disabled": "Not configured", "paused": "Processing paused", "cooldown": "Waiting after Genius rate limit", "running": "Fetching data from Genius…", "queued": "Queue awaiting processing", "waiting": "Waiting for new tracks", "loading": "Loading…", "disabled": "Not configured", "paused": "Processing paused", "cooldown": "Waiting after Genius rate limit", "running": "Fetching data from Genius…", "queued": "Queue awaiting processing", "waiting": "Waiting for new tracks",
+1
View File
@@ -42,6 +42,7 @@
"pattern_invalid": "Pattern error: {{reason}}", "pattern_invalid": "Pattern error: {{reason}}",
"nfs_invalid": "Invalid value", "nfs_invalid": "Invalid value",
"local_path_invalid": "Folder not found", "local_path_invalid": "Folder not found",
"excluded_dirs_invalid": "Enter up to 100 folders: relative paths without .., one per line (up to 1000 characters).",
"name_invalid": "The name must be 1 to {{max}} characters", "name_invalid": "The name must be 1 to {{max}} characters",
"scan_interval_invalid": "The interval must be at least {{min}} minutes", "scan_interval_invalid": "The interval must be at least {{min}} minutes",
"source_type_invalid": "Unknown storage type", "source_type_invalid": "Unknown storage type",
+4
View File
@@ -98,6 +98,10 @@
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.", "delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
"scanning": "Сканирование: {{processed}} из {{total}}", "scanning": "Сканирование: {{processed}} из {{total}}",
"scan_walking": "Обход папок…", "scan_walking": "Обход папок…",
"excluded_dirs": "Не сканировать папки",
"excluded_hint": "По одной папке в строке, путь от корня хранилища. Например: Downloads_PumpEars. Все подпапки тоже исключаются. Учитывается регистр; шаблоны со звёздочкой не поддерживаются. Анализ и его ошибки отключаются сразу после сохранения. Уже добавленные записи сохраняются. Пустой список возвращает папки в сканирование и анализ.",
"excluded_running": "Дождитесь завершения сканирования, чтобы изменить исключения.",
"excluded_count": "Исключено папок из сканирования: {{count}}",
"scan_walk_progress": "Обход папок… Проверено папок: {{dirs}} · найдено аудиофайлов: {{found}}", "scan_walk_progress": "Обход папок… Проверено папок: {{dirs}} · найдено аудиофайлов: {{found}}",
"scan_current_dir": "Текущая папка: {{path}}", "scan_current_dir": "Текущая папка: {{path}}",
"scan_preparing": "Сопоставление файлов с библиотекой…", "scan_preparing": "Сопоставление файлов с библиотекой…",
+3 -3
View File
@@ -51,9 +51,9 @@
"copy_server": "Скопировать адрес сервера", "copy_server": "Скопировать адрес сервера",
"step_updates": { "step_updates": {
"android": "Войдите своим логином. Новые версии приложение предложит установить само.", "android": "Войдите своим логином. Новые версии приложение предложит установить само.",
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.", "windows": "Войдите своим логином. Приложение предложит установить новые версии.",
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.", "linux": "Войдите своим логином. Приложение предложит установить новые версии через системный установщик пакетов.",
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы." "macos": "Войдите своим логином. Приложение предложит установить новые версии."
}, },
"checksum": "Контрольная сумма SHA-256", "checksum": "Контрольная сумма SHA-256",
"unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).", "unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
+13
View File
@@ -1,4 +1,17 @@
{ {
"desktop": {
"offline": "Скачанное и кеш",
"save_track": "Скачать",
"remove_download": "Удалить загрузку",
"download_failed": "Не удалось скачать трек",
"storage_failed": "Не удалось открыть или изменить кеш. Проверьте свободное место.",
"cache_hint": "Недавние треки сохраняются автоматически (до 500 МБ). Скачанные вручную остаются до удаления. Доступны без сети, в том числе после перезапуска.",
"clear_recent": "Очистить кеш недавних",
"offline_empty": "Здесь появятся прослушанные и скачанные треки.",
"updates": "Обновления приложения",
"check_failed": "Не удалось проверить обновления. Повторите позже.",
"installer_open": "Установщик открыт. Завершите установку и перезапустите приложение."
},
"genius": { "genius": {
"description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.", "description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.",
"loading": "Загрузка…", "disabled": "Не подключён", "paused": "Обработка приостановлена", "cooldown": "Ожидание после ограничения Genius", "running": "Получение данных из Genius…", "queued": "Очередь ожидает обработки", "waiting": "Ожидание новых треков", "loading": "Загрузка…", "disabled": "Не подключён", "paused": "Обработка приостановлена", "cooldown": "Ожидание после ограничения Genius", "running": "Получение данных из Genius…", "queued": "Очередь ожидает обработки", "waiting": "Ожидание новых треков",
+1
View File
@@ -42,6 +42,7 @@
"pattern_invalid": "Ошибка в шаблоне: {{reason}}", "pattern_invalid": "Ошибка в шаблоне: {{reason}}",
"nfs_invalid": "Недопустимое значение", "nfs_invalid": "Недопустимое значение",
"local_path_invalid": "Папка не найдена", "local_path_invalid": "Папка не найдена",
"excluded_dirs_invalid": "Укажите до 100 папок: относительные пути без .., по одной папке в строке (до 1000 символов).",
"name_invalid": "Название — от 1 до {{max}} символов", "name_invalid": "Название — от 1 до {{max}} символов",
"scan_interval_invalid": "Интервал — не меньше {{min}} минут", "scan_interval_invalid": "Интервал — не меньше {{min}} минут",
"source_type_invalid": "Неизвестный тип хранилища", "source_type_invalid": "Неизвестный тип хранилища",
+312 -297
View File
@@ -511,6 +511,9 @@ type EventBatchResult struct {
Rejected int `json:"rejected"` Rejected int `json:"rejected"`
} }
// ExcludedDirs Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
type ExcludedDirs = []string
// FieldError defines model for FieldError. // FieldError defines model for FieldError.
type FieldError struct { type FieldError struct {
Code string `json:"code"` Code string `json:"code"`
@@ -974,6 +977,9 @@ type Source struct {
// AcceptsUploads Принимает импорт из очереди на добавление (NFS монтируется агентом ещё и на запись) // AcceptsUploads Принимает импорт из очереди на добавление (NFS монтируется агентом ещё и на запись)
AcceptsUploads bool `json:"accepts_uploads"` AcceptsUploads bool `json:"accepts_uploads"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// ExcludedDirs Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
ExcludedDirs *ExcludedDirs `json:"excluded_dirs,omitempty"`
Id openapi_types.UUID `json:"id"` Id openapi_types.UUID `json:"id"`
LastScanAt *time.Time `json:"last_scan_at"` LastScanAt *time.Time `json:"last_scan_at"`
@@ -999,6 +1005,9 @@ type Source struct {
// SourceInput defines model for SourceInput. // SourceInput defines model for SourceInput.
type SourceInput struct { type SourceInput struct {
AcceptsUploads *bool `json:"accepts_uploads,omitempty"` AcceptsUploads *bool `json:"accepts_uploads,omitempty"`
// ExcludedDirs Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
ExcludedDirs *ExcludedDirs `json:"excluded_dirs,omitempty"`
LocalPath *string `json:"local_path,omitempty"` LocalPath *string `json:"local_path,omitempty"`
Name string `json:"name"` Name string `json:"name"`
NfsExport *string `json:"nfs_export,omitempty"` NfsExport *string `json:"nfs_export,omitempty"`
@@ -1022,6 +1031,9 @@ type SourcePatch struct {
// Enabled false — отключить хранилище (треки становятся недоступны), true — подключить снова // Enabled false — отключить хранилище (треки становятся недоступны), true — подключить снова
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitempty"`
// ExcludedDirs Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
ExcludedDirs *ExcludedDirs `json:"excluded_dirs,omitempty"`
LocalPath *string `json:"local_path,omitempty"` LocalPath *string `json:"local_path,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
NfsExport *string `json:"nfs_export,omitempty"` NfsExport *string `json:"nfs_export,omitempty"`
@@ -11377,303 +11389,306 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"cf/O/f8/AAD//+0IjFXOgAEA", "loca/xniiDLR6ER9gZ55qOa0BNSyN0qpcROfmCzOj71RQgsOEfiUpxRsphlV3IevdjVjXHl57qVzm9xX",
"mNEuS2zyWcvGiQ+QgQf56Zft8T+CWXmM3d3FTh/HjzHnRoSSlRRtnha6Q6Zqkm/ayCj6nD5qLFEroMG1",
"XrTKpsgEIibloSDtBW7jSmMWBCV/10cyrwDfea8pPwEIcOXvDtW+hSa8ygeusxQwaigfieCU8hHPG1Q+",
"kZF09Ydpgo/yKQgLdQrdbuPeB/f+bwAAAP//4XfHrPyCAQA=",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file
@@ -88,3 +88,73 @@ func TestLibraryDiagnostics(t *testing.T) {
require.Equal(t, 1, paths) require.Equal(t, 1, paths)
require.Equal(t, 1, missing) require.Equal(t, 1, missing)
} }
func TestScanExcludedFolders(t *testing.T) {
pool := connect(t)
ctx := context.Background()
require.NoError(t, db.Migrate(ctx, pool))
store := library.NewStore(pool)
src, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "exclusions", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, store.DeleteSource(ctx, src.ID))
require.NoError(t, store.CleanupOrphans(ctx))
})
root := t.TempDir()
for _, rel := range []string{"Downloads_PumpEars/Artist - Loop.wav", "Downloads_PumpEars/nested/Artist - Hit.wav", "Downloads_PumpEars-copy/Artist - Song.flac"} {
p := filepath.Join(root, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
require.NoError(t, os.WriteFile(p, []byte(rel), 0o644))
}
board := library.NewProgressBoard()
scanner := library.NewScanner(store, fakeProber{}, board, slog.New(slog.DiscardHandler))
scan := func() library.Progress {
require.NoError(t, scanner.Start(src, root, true))
require.Eventually(t, func() bool { p, ok := board.Get(src.ID); return ok && !p.Running }, 10*time.Second, 10*time.Millisecond)
p, _ := board.Get(src.ID)
require.Empty(t, p.Error)
return p
}
require.Equal(t, 3, scan().Found)
_, err = pool.Exec(ctx, `UPDATE analysis_jobs SET status = 'failed', error = 'too short' WHERE track_id IN (SELECT track_id FROM media_files WHERE source_id = $1)`, src.ID)
require.NoError(t, err)
before, err := store.AnalysisCounts(ctx)
require.NoError(t, err)
src.ExcludedDirs = []string{"Downloads_PumpEars"}
src, err = store.SaveSource(ctx, src)
require.NoError(t, err)
saved, err := store.GetSource(ctx, src.ID)
require.NoError(t, err)
require.Equal(t, src.ExcludedDirs, saved.ExcludedDirs)
counts, err := store.AnalysisCounts(ctx)
require.NoError(t, err)
require.Equal(t, before.Failed-2, counts.Failed, "old failed jobs from excluded folders disappear immediately")
_, err = store.RetryFailedAnalysis(ctx)
require.NoError(t, err)
var failed int
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM analysis_jobs WHERE status = 'failed' AND track_id IN (SELECT track_id FROM media_files WHERE source_id = $1)`, src.ID).Scan(&failed))
require.Equal(t, 2, failed, "retry must not requeue excluded jobs")
p := scan()
require.Equal(t, 1, p.Found)
require.Equal(t, 1, p.ToProcess)
require.Zero(t, p.Missing)
files, err := store.Files(ctx, src.ID)
require.NoError(t, err)
require.Len(t, files, 3, "excluded records must be preserved")
for _, f := range files {
require.False(t, f.Missing, "excluded files must not be treated as absent")
}
// Literal matching must not treat % and _ as SQL wildcards.
for _, test := range []struct {
path string
excluded bool
}{{"100%_loops/a.wav", true}, {"100xxloops/a.wav", false}, {"100%_loops-copy/a.wav", false}} {
var excluded bool
require.NoError(t, pool.QueryRow(ctx, `SELECT library_path_excluded($1, ARRAY['100%_loops'])`, test.path).Scan(&excluded))
require.Equal(t, test.excluded, excluded)
}
src.ExcludedDirs = []string{}
src, err = store.SaveSource(ctx, src)
require.NoError(t, err)
require.Equal(t, 3, scan().Found, "clearing exclusions makes the subtree eligible again")
}
@@ -0,0 +1,50 @@
package library
import (
"path"
"strings"
"unicode/utf8"
"pipu/api/internal/platform/apperr"
)
// normalizeExcludedDirs accepts literal paths relative to the storage root, not globs.
func normalizeExcludedDirs(dirs []string) ([]string, error) {
invalid := func() ([]string, error) {
return nil, apperr.Validation("excluded_dirs", "validation.excluded_dirs_invalid", "use up to 100 relative folder paths without .., one per entry", nil)
}
if len(dirs) > 100 {
return invalid()
}
out := []string{}
seen := map[string]bool{}
for _, dir := range dirs {
dir = strings.ReplaceAll(strings.TrimSpace(dir), "\\", "/")
if dir == "" || strings.HasPrefix(dir, "/") || strings.ContainsAny(dir, "\x00\r\n") || utf8.RuneCountInString(dir) > 1000 || (len(dir) > 1 && dir[1] == ':') {
return invalid()
}
for _, part := range strings.Split(dir, "/") {
if part == ".." {
return invalid()
}
}
dir = path.Clean(dir)
if dir == "." {
return invalid()
}
if !seen[dir] {
out = append(out, dir)
seen[dir] = true
}
}
return out, nil
}
func pathExcluded(rel string, dirs []string) bool {
for _, dir := range dirs {
if rel == dir || strings.HasPrefix(rel, dir+"/") {
return true
}
}
return false
}
@@ -0,0 +1,85 @@
package library
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
)
func TestNormalizeExcludedDirs(t *testing.T) {
dirs, err := normalizeExcludedDirs([]string{" Downloads_PumpEars/ ", `Artist\Samples`, "Downloads_PumpEars", "Artist/./Samples", "100%_loops"})
require.NoError(t, err)
require.Equal(t, []string{"Downloads_PumpEars", "Artist/Samples", "100%_loops"}, dirs)
for _, invalid := range []string{"", ".", "./", "/etc", "../outside", "album/../samples", `C:\Downloads`, `\\server\share`, "folder\nother", strings.Repeat("a", 1001)} {
t.Run(invalid, func(t *testing.T) {
_, err := normalizeExcludedDirs([]string{invalid})
require.Error(t, err)
})
}
_, err = normalizeExcludedDirs(make([]string, 101))
require.Error(t, err)
dirs, err = normalizeExcludedDirs(nil)
require.NoError(t, err)
require.Empty(t, dirs)
}
func TestWalkPrunesExcludedSubtrees(t *testing.T) {
root := t.TempDir()
for _, rel := range []string{"Downloads_PumpEars/sample.wav", "Downloads_PumpEars/nested/sample.wav", "Downloads_PumpEars-copy/song.flac", "Artist/Downloads_PumpEars/song.flac", "Artist/Samples/loop.wav", "Artist/Album/song.flac", "100%_loops/loop.wav", "100xxloops/song.flac"} {
p := filepath.Join(root, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
require.NoError(t, os.WriteFile(p, []byte("audio"), 0o644))
}
dirs := []string{"Downloads_PumpEars", "Artist/Samples", "100%_loops"}
var visited []string
found, err := walk(root, dirs, func(string) bool { return false }, func(_, _ int, current string) { visited = append(visited, current) })
require.NoError(t, err)
require.ElementsMatch(t, []string{"Downloads_PumpEars-copy/song.flac", "Artist/Downloads_PumpEars/song.flac", "Artist/Album/song.flac", "100xxloops/song.flac"}, rels(found))
for _, dir := range visited {
require.False(t, pathExcluded(dir, dirs), "excluded directories must not be visited")
}
// A point scan must reject an excluded path before touching storage or the filesystem.
_, err = (&Scanner{}).ScanFile(context.Background(), Source{ExcludedDirs: dirs}, root, "Downloads_PumpEars/sample.wav")
require.ErrorContains(t, err, "excluded scan folder")
}
type exclusionsSourceStore struct {
SourceStore
source Source
}
func (s *exclusionsSourceStore) GetSource(context.Context, uuid.UUID) (Source, error) {
return s.source, nil
}
func (s *exclusionsSourceStore) SaveSource(_ context.Context, src Source) (Source, error) {
s.source = src
return src, nil
}
func TestExcludedDirsPatchPreservesOmittedAndAllowsClearing(t *testing.T) {
id := uuid.New()
store := &exclusionsSourceStore{source: Source{ID: id, Name: "Base", Type: SourceLocal, Status: "mounted", ExcludedDirs: []string{"Downloads"}}}
board := NewProgressBoard()
svc := &SourceService{store: store, board: board}
ctx := auth.WithPrincipal(context.Background(), auth.Principal{Role: auth.RoleAdmin})
_, err := svc.UpdateSource(ctx, id, SourceInput{})
require.NoError(t, err)
require.Equal(t, []string{"Downloads"}, store.source.ExcludedDirs)
empty := []string{}
_, err = svc.UpdateSource(ctx, id, SourceInput{ExcludedDirs: &empty})
require.NoError(t, err)
require.Empty(t, store.source.ExcludedDirs)
board.start(id, false, time.Now())
dirs := []string{"Downloads_PumpEars"}
_, err = svc.UpdateSource(ctx, id, SourceInput{ExcludedDirs: &dirs})
require.Error(t, err)
require.Empty(t, store.source.ExcludedDirs, "a running scan must not race with exclusions changes")
}
+6 -2
View File
@@ -61,11 +61,15 @@ func (d DTO) Tracks(ctx context.Context, rows []TrackRow) ([]gen.Track, error) {
} }
func (h *Handler) sourceDTO(s Source) gen.Source { func (h *Handler) sourceDTO(s Source) gen.Source {
if s.ExcludedDirs == nil {
s.ExcludedDirs = []string{}
}
out := gen.Source{ out := gen.Source{
Id: s.ID, Name: s.Name, Type: gen.SourceType(s.Type), NfsServer: s.NFSServer, NfsExport: s.NFSExport, Id: s.ID, Name: s.Name, Type: gen.SourceType(s.Type), NfsServer: s.NFSServer, NfsExport: s.NFSExport,
NfsOptions: s.NFSOptions, LocalPath: s.LocalPath, TagPriority: gen.TagsFirst, NfsOptions: s.NFSOptions, LocalPath: s.LocalPath, TagPriority: gen.TagsFirst,
Status: gen.SourceStatus(s.Status), StatusMessage: s.StatusMessage, LastScanAt: s.LastScanAt, Status: gen.SourceStatus(s.Status), StatusMessage: s.StatusMessage, LastScanAt: s.LastScanAt,
CreatedAt: s.CreatedAt, AcceptsUploads: s.AcceptsUploads, WriteTemplate: s.WriteTemplate, CreatedAt: s.CreatedAt, AcceptsUploads: s.AcceptsUploads, WriteTemplate: s.WriteTemplate,
ExcludedDirs: &s.ExcludedDirs,
} }
if !s.TagsFirst { if !s.TagsFirst {
out.TagPriority = gen.PathFirst out.TagPriority = gen.PathFirst
@@ -122,7 +126,7 @@ func (h *Handler) CreateSource(ctx context.Context, req gen.CreateSourceRequestO
src, err := h.svc.CreateSource(ctx, SourceInput{ src, err := h.svc.CreateSource(ctx, SourceInput{
Name: &b.Name, Type: SourceType(b.Type), NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions, Name: &b.Name, Type: SourceType(b.Type), NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions,
LocalPath: b.LocalPath, TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes, LocalPath: b.LocalPath, TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -145,7 +149,7 @@ func (h *Handler) UpdateSource(ctx context.Context, req gen.UpdateSourceRequestO
src, err := h.svc.UpdateSource(ctx, req.Id, SourceInput{ src, err := h.svc.UpdateSource(ctx, req.Id, SourceInput{
Name: b.Name, NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions, LocalPath: b.LocalPath, Name: b.Name, NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions, LocalPath: b.LocalPath,
TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes, Enabled: b.Enabled, TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes, Enabled: b.Enabled,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
+17 -2
View File
@@ -205,7 +205,13 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
if err != nil { if err != nil {
return err return err
} }
found, err := walk(root, func(rel string) bool { return meta.MatchPath(patterns, rel).Skip }, func(found, dirs int, currentDir string) { // Excluded files keep their catalog rows and are not treated as missing or rename candidates.
for rel := range existing {
if pathExcluded(rel, src.ExcludedDirs) {
delete(existing, rel)
}
}
found, err := walk(root, src.ExcludedDirs, func(rel string) bool { return meta.MatchPath(patterns, rel).Skip }, func(found, dirs int, currentDir string) {
sc.board.update(src.ID, func(p *Progress) { sc.board.update(src.ID, func(p *Progress) {
p.Found, p.WalkedDirs, p.CurrentDir = found, dirs, currentDir p.Found, p.WalkedDirs, p.CurrentDir = found, dirs, currentDir
}) })
@@ -299,6 +305,9 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
// ScanFile — точечный скан одного файла (scan_path): после импорта из очереди трек сразу появляется в каталоге. // ScanFile — точечный скан одного файла (scan_path): после импорта из очереди трек сразу появляется в каталоге.
// Возвращает id записи media_files. Хуки AfterScan (обложки, поиск, отпечатки) вызывает сам. // Возвращает id записи media_files. Хуки AfterScan (обложки, поиск, отпечатки) вызывает сам.
func (sc *Scanner) ScanFile(ctx context.Context, src Source, root, rel string) (uuid.UUID, error) { func (sc *Scanner) ScanFile(ctx context.Context, src Source, root, rel string) (uuid.UUID, error) {
if pathExcluded(filepath.ToSlash(rel), src.ExcludedDirs) {
return uuid.Nil, fmt.Errorf("library: file is in an excluded scan folder: %s", rel)
}
rows, err := sc.store.Patterns(ctx, src.ID) rows, err := sc.store.Patterns(ctx, src.ID)
if err != nil { if err != nil {
return uuid.Nil, err return uuid.Nil, err
@@ -470,7 +479,7 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
// walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются; // walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются;
// skip — пути, которые шаблоны велят игнорировать. Ошибки отдельных каталогов не прерывают обход. // skip — пути, которые шаблоны велят игнорировать. Ошибки отдельных каталогов не прерывают обход.
func walk(root string, skip func(rel string) bool, report func(found, dirs int, currentDir string)) ([]walked, error) { func walk(root string, excludedDirs []string, skip func(rel string) bool, report func(found, dirs int, currentDir string)) ([]walked, error) {
if _, err := os.Stat(root); err != nil { if _, err := os.Stat(root); err != nil {
return nil, fmt.Errorf("library: source root: %w", err) return nil, fmt.Errorf("library: source root: %w", err)
} }
@@ -495,6 +504,12 @@ func walk(root string, skip func(rel string) bool, report func(found, dirs int,
return nil return nil
} }
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
if pathExcluded(rel, excludedDirs) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() { if d.IsDir() {
dirs++ dirs++
if report != nil { if report != nil {
@@ -55,7 +55,7 @@ func TestWalkAndHash(t *testing.T) {
write("_incoming/new.mp3", 10) write("_incoming/new.mp3", 10)
write("big.mp3", 3<<20) write("big.mp3", 3<<20)
found, err := walk(root, func(rel string) bool { return rel == "_incoming/new.mp3" }, nil) found, err := walk(root, nil, func(rel string) bool { return rel == "_incoming/new.mp3" }, nil)
require.NoError(t, err) require.NoError(t, err)
assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found)) assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found))
for _, w := range found { for _, w := range found {
@@ -69,7 +69,7 @@ func TestWalkAndHash(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, h1, h2, "hash survives a rename") assert.Equal(t, h1, h2, "hash survives a rename")
_, err = walk(filepath.Join(root, "nope"), func(string) bool { return false }, nil) _, err = walk(filepath.Join(root, "nope"), nil, func(string) bool { return false }, nil)
assert.Error(t, err) assert.Error(t, err)
} }
@@ -83,7 +83,7 @@ func TestWalkReportsIncrementalProgress(t *testing.T) {
} }
var counts, folders []int var counts, folders []int
var paths []string var paths []string
found, err := walk(root, func(rel string) bool { return rel == "b-album/skip.mp3" }, func(n, dirs int, path string) { found, err := walk(root, nil, func(rel string) bool { return rel == "b-album/skip.mp3" }, func(n, dirs int, path string) {
counts, folders, paths = append(counts, n), append(folders, dirs), append(paths, path) counts, folders, paths = append(counts, n), append(folders, dirs), append(paths, path)
}) })
require.NoError(t, err) require.NoError(t, err)
+23
View File
@@ -105,9 +105,17 @@ type SourceInput struct {
Enabled *bool Enabled *bool
AcceptsUploads *bool AcceptsUploads *bool
WriteTemplate *string // "" — убрать WriteTemplate *string // "" — убрать
ExcludedDirs *[]string
} }
func (s *SourceService) apply(src *Source, in SourceInput) error { func (s *SourceService) apply(src *Source, in SourceInput) error {
if in.ExcludedDirs != nil {
dirs, err := normalizeExcludedDirs(*in.ExcludedDirs)
if err != nil {
return err
}
src.ExcludedDirs = dirs
}
if in.Name != nil { if in.Name != nil {
name := strings.TrimSpace(*in.Name) name := strings.TrimSpace(*in.Name)
if name == "" || utf8.RuneCountInString(name) > 100 { if name == "" || utf8.RuneCountInString(name) > 100 {
@@ -228,6 +236,11 @@ func (s *SourceService) UpdateSource(ctx context.Context, id uuid.UUID, in Sourc
if err := s.apply(&src, in); err != nil { if err := s.apply(&src, in); err != nil {
return Source{}, err return Source{}, err
} }
if !slices.Equal(before.ExcludedDirs, src.ExcludedDirs) && s.board != nil {
if progress, ok := s.board.Get(id); ok && progress.Running {
return Source{}, apperr.Conflict(CodeScanRunning, "wait for the running scan before changing excluded folders")
}
}
if src, err = s.store.SaveSource(ctx, src); err != nil { if src, err = s.store.SaveSource(ctx, src); err != nil {
return Source{}, err return Source{}, err
} }
@@ -387,6 +400,16 @@ func (s *SourceService) testRead(ctx context.Context, src Source) (TestResult, e
if err != nil { if err != nil {
return err return err
} }
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
if pathExcluded(filepath.ToSlash(rel), src.ExcludedDirs) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if !d.IsDir() && IsAudio(d.Name()) { if !d.IsDir() && IsAudio(d.Name()) {
n++ n++
if n >= 1000 { if n >= 1000 {
+32 -18
View File
@@ -45,6 +45,7 @@ type Source struct {
CreatedAt time.Time CreatedAt time.Time
AcceptsUploads bool AcceptsUploads bool
WriteTemplate *string WriteTemplate *string
ExcludedDirs []string
} }
// PatternRow — шаблон пути источника. // PatternRow — шаблон пути источника.
@@ -86,7 +87,7 @@ func newID() uuid.UUID { return uuid.Must(uuid.NewV7()) }
const sourceColumns = `id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority = 'tags_first', const sourceColumns = `id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority = 'tags_first',
extract(epoch from scan_interval)::bigint, status, status_message, last_scan_at, created_at, accepts_uploads, extract(epoch from scan_interval)::bigint, status, status_message, last_scan_at, created_at, accepts_uploads,
write_template` write_template, excluded_dirs`
func scanSource(row pgx.Row) (Source, error) { func scanSource(row pgx.Row) (Source, error) {
var ( var (
@@ -95,7 +96,7 @@ func scanSource(row pgx.Row) (Source, error) {
interval *int64 interval *int64
) )
err := row.Scan(&s.ID, &s.Name, &typ, &s.NFSServer, &s.NFSExport, &s.NFSOptions, &s.LocalPath, &s.TagsFirst, err := row.Scan(&s.ID, &s.Name, &typ, &s.NFSServer, &s.NFSExport, &s.NFSOptions, &s.LocalPath, &s.TagsFirst,
&interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads, &s.WriteTemplate) &interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads, &s.WriteTemplate, &s.ExcludedDirs)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return Source{}, ErrNotFound return Source{}, ErrNotFound
} }
@@ -150,17 +151,20 @@ func tagPriority(tagsFirst bool) string {
// SaveSource создаёт или полностью обновляет хранилище (кроме статуса и времени скана). // SaveSource создаёт или полностью обновляет хранилище (кроме статуса и времени скана).
func (st *Store) SaveSource(ctx context.Context, s Source) (Source, error) { func (st *Store) SaveSource(ctx context.Context, s Source) (Source, error) {
if s.ExcludedDirs == nil {
s.ExcludedDirs = []string{}
}
return scanSource(st.pool.QueryRow(ctx, ` return scanSource(st.pool.QueryRow(ctx, `
INSERT INTO library_sources (id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority, INSERT INTO library_sources (id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority,
scan_interval, status, status_message, accepts_uploads, write_template, created_at) scan_interval, status, status_message, accepts_uploads, write_template, excluded_dirs, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, now()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, $14, now())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, nfs_server = EXCLUDED.nfs_server, ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, nfs_server = EXCLUDED.nfs_server,
nfs_export = EXCLUDED.nfs_export, nfs_options = EXCLUDED.nfs_options, local_path = EXCLUDED.local_path, nfs_export = EXCLUDED.nfs_export, nfs_options = EXCLUDED.nfs_options, local_path = EXCLUDED.local_path,
tag_priority = EXCLUDED.tag_priority, scan_interval = EXCLUDED.scan_interval, tag_priority = EXCLUDED.tag_priority, scan_interval = EXCLUDED.scan_interval,
accepts_uploads = EXCLUDED.accepts_uploads, write_template = EXCLUDED.write_template accepts_uploads = EXCLUDED.accepts_uploads, write_template = EXCLUDED.write_template, excluded_dirs = EXCLUDED.excluded_dirs
RETURNING `+sourceColumns, RETURNING `+sourceColumns,
s.ID, s.Name, string(s.Type), s.NFSServer, s.NFSExport, s.NFSOptions, s.LocalPath, tagPriority(s.TagsFirst), s.ID, s.Name, string(s.Type), s.NFSServer, s.NFSExport, s.NFSOptions, s.LocalPath, tagPriority(s.TagsFirst),
intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage, s.AcceptsUploads, s.WriteTemplate)) intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage, s.AcceptsUploads, s.WriteTemplate, s.ExcludedDirs))
} }
// SetSourceStatus обновляет состояние подключения. // SetSourceStatus обновляет состояние подключения.
@@ -263,7 +267,9 @@ func (st *Store) FileByPath(ctx context.Context, sourceID uuid.UUID, rel string)
// SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов). // SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов).
func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) { func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) {
rows, err := st.pool.Query(ctx, ` rows, err := st.pool.Query(ctx, `
SELECT rel_path FROM media_files WHERE source_id = $1 AND missing_since IS NULL ORDER BY random() LIMIT $2`, SELECT f.rel_path FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.source_id = $1 AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
ORDER BY random() LIMIT $2`,
sourceID, n) sourceID, n)
if err != nil { if err != nil {
return nil, fmt.Errorf("library: sample files: %w", err) return nil, fmt.Errorf("library: sample files: %w", err)
@@ -342,7 +348,9 @@ func (st *Store) MarkMissing(ctx context.Context, ids []uuid.UUID, at time.Time)
// PurgeMissing удаляет файлы, пропавшие раньше before. // PurgeMissing удаляет файлы, пропавшие раньше before.
func (st *Store) PurgeMissing(ctx context.Context, sourceID uuid.UUID, before time.Time) (int64, error) { func (st *Store) PurgeMissing(ctx context.Context, sourceID uuid.UUID, before time.Time) (int64, error) {
tag, err := st.pool.Exec(ctx, `DELETE FROM media_files WHERE source_id = $1 AND missing_since < $2`, tag, err := st.pool.Exec(ctx, `DELETE FROM media_files f USING library_sources s
WHERE f.source_id = s.id AND f.source_id = $1 AND f.missing_since < $2
AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)`,
sourceID, before) sourceID, before)
if err != nil { if err != nil {
return 0, fmt.Errorf("library: purge missing: %w", err) return 0, fmt.Errorf("library: purge missing: %w", err)
@@ -511,7 +519,8 @@ func (st *Store) Counts(ctx context.Context) (Counts, error) {
err := st.pool.QueryRow(ctx, ` err := st.pool.QueryRow(ctx, `
SELECT (SELECT count(*) FROM artists), (SELECT count(*) FROM albums), (SELECT count(*) FROM tracks), SELECT (SELECT count(*) FROM artists), (SELECT count(*) FROM albums), (SELECT count(*) FROM tracks),
(SELECT count(*) FROM media_files), (SELECT count(*) FROM media_files WHERE missing_since IS NOT NULL), (SELECT count(*) FROM media_files), (SELECT count(*) FROM media_files WHERE missing_since IS NOT NULL),
(SELECT count(*) FROM media_files WHERE scan_error IS NOT NULL AND missing_since IS NULL)`). (SELECT count(*) FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.scan_error IS NOT NULL AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs))`).
Scan(&c.Artists, &c.Albums, &c.Tracks, &c.Files, &c.Missing, &c.Errors) Scan(&c.Artists, &c.Albums, &c.Tracks, &c.Files, &c.Missing, &c.Errors)
if err != nil { if err != nil {
return Counts{}, fmt.Errorf("library: counts: %w", err) return Counts{}, fmt.Errorf("library: counts: %w", err)
@@ -530,8 +539,9 @@ type FileError struct {
// FileErrors — страница файлов с ошибками чтения (по пути, после after). // FileErrors — страница файлов с ошибками чтения (по пути, после after).
func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) { func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
rows, err := st.pool.Query(ctx, ` rows, err := st.pool.Query(ctx, `
SELECT id, source_id, rel_path, scan_error FROM media_files SELECT f.id, f.source_id, f.rel_path, f.scan_error FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE scan_error IS NOT NULL AND missing_since IS NULL AND rel_path > $1 ORDER BY rel_path LIMIT $2`, after, limit) WHERE f.scan_error IS NOT NULL AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
AND f.rel_path > $1 ORDER BY f.rel_path LIMIT $2`, after, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("library: file errors: %w", err) return nil, fmt.Errorf("library: file errors: %w", err)
} }
@@ -551,7 +561,7 @@ func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int) ([]CoverCand
SELECT mf.source_id, mf.rel_path FROM tracks t SELECT mf.source_id, mf.rel_path FROM tracks t
JOIN media_files mf ON mf.id = t.primary_file_id JOIN media_files mf ON mf.id = t.primary_file_id
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted' JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE t.album_id = al.id WHERE t.album_id = al.id AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)
ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST
LIMIT 1) f LIMIT 1) f
WHERE al.cover_checked_at IS NULL WHERE al.cover_checked_at IS NULL
@@ -612,12 +622,13 @@ type AnalysisCounts struct {
func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) { func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
var c AnalysisCounts var c AnalysisCounts
err := st.pool.QueryRow(ctx, ` err := st.pool.QueryRow(ctx, `
WITH jobs AS (SELECT * FROM analysis_jobs WHERE NOT library_track_scan_excluded(track_id))
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'), SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'), count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1), (SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1),
(SELECT error FROM analysis_jobs WHERE status = 'failed' AND error IS NOT NULL (SELECT error FROM jobs WHERE status = 'failed' AND error IS NOT NULL
GROUP BY error ORDER BY count(*) DESC LIMIT 1) GROUP BY error ORDER BY count(*) DESC LIMIT 1)
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError) FROM jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError)
if err != nil { if err != nil {
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err) return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
} }
@@ -640,12 +651,13 @@ func (st *Store) AnalysisErrors(ctx context.Context, after uuid.UUID, limit int)
SELECT t.id, t.title, f.source_id, s.name, f.rel_path, COALESCE(a.error, '') SELECT t.id, t.title, f.source_id, s.name, f.rel_path, COALESCE(a.error, '')
FROM analysis_jobs a JOIN tracks t ON t.id = a.track_id FROM analysis_jobs a JOIN tracks t ON t.id = a.track_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT mf.source_id, mf.rel_path FROM media_files mf WHERE mf.track_id = t.id SELECT mf.source_id, mf.rel_path FROM media_files mf JOIN library_sources src ON src.id = mf.source_id
WHERE mf.track_id = t.id AND NOT library_path_excluded(mf.rel_path, src.excluded_dirs)
ORDER BY (mf.id = t.primary_file_id) DESC NULLS LAST, (mf.missing_since IS NULL) DESC, mf.id ORDER BY (mf.id = t.primary_file_id) DESC NULLS LAST, (mf.missing_since IS NULL) DESC, mf.id
LIMIT 1 LIMIT 1
) f ON true ) f ON true
LEFT JOIN library_sources s ON s.id = f.source_id LEFT JOIN library_sources s ON s.id = f.source_id
WHERE a.status = 'failed' AND t.id > $1 ORDER BY t.id LIMIT $2`, after, limit) WHERE a.status = 'failed' AND NOT library_track_scan_excluded(t.id) AND t.id > $1 ORDER BY t.id LIMIT $2`, after, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("library: analysis errors: %w", err) return nil, fmt.Errorf("library: analysis errors: %w", err)
} }
@@ -659,7 +671,7 @@ func (st *Store) AnalysisErrors(ctx context.Context, after uuid.UUID, limit int)
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) { func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
tag, err := st.pool.Exec(ctx, ` tag, err := st.pool.Exec(ctx, `
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now() UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
WHERE status = 'failed'`) WHERE status = 'failed' AND NOT library_track_scan_excluded(track_id)`)
if err != nil { if err != nil {
return 0, fmt.Errorf("library: retry analysis: %w", err) return 0, fmt.Errorf("library: retry analysis: %w", err)
} }
@@ -748,6 +760,7 @@ func (st *Store) FilesToFingerprint(ctx context.Context, withFP bool, after uuid
FROM media_files mf FROM media_files mf
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted' JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE mf.missing_since IS NULL AND mf.scan_error IS NULL WHERE mf.missing_since IS NULL AND mf.scan_error IS NULL
AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)
AND (mf.sha256 IS NULL OR ($1 AND mf.fingerprinted_at IS NULL)) AND mf.id > $2 AND (mf.sha256 IS NULL OR ($1 AND mf.fingerprinted_at IS NULL)) AND mf.id > $2
ORDER BY mf.id ORDER BY mf.id
LIMIT $3`, withFP, after, limit) LIMIT $3`, withFP, after, limit)
@@ -801,7 +814,8 @@ func (st *Store) TemplateSamples(ctx context.Context, sourceID uuid.UUID, n int)
JOIN tracks t ON t.id = mf.track_id JOIN tracks t ON t.id = mf.track_id
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
JOIN artists aa ON aa.id = al.album_artist_id JOIN artists aa ON aa.id = al.album_artist_id
WHERE mf.source_id = $1 AND mf.missing_since IS NULL JOIN library_sources src ON src.id = mf.source_id
WHERE mf.source_id = $1 AND mf.missing_since IS NULL AND NOT library_path_excluded(mf.rel_path, src.excluded_dirs)
ORDER BY random() LIMIT $2`, sourceID, n) ORDER BY random() LIMIT $2`, sourceID, n)
if err != nil { if err != nil {
return nil, fmt.Errorf("library: template samples: %w", err) return nil, fmt.Errorf("library: template samples: %w", err)
+1 -1
View File
@@ -116,7 +116,7 @@ func (s *TemplateService) Preview(ctx context.Context, id uuid.UUID, patterns []
return nil, 0, err return nil, 0, err
} }
if len(paths) == 0 { if len(paths) == 0 {
found, err := walk(root, func(string) bool { return false }, nil) found, err := walk(root, src.ExcludedDirs, func(string) bool { return false }, nil)
if err != nil { if err != nil {
return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error()) return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error())
} }
@@ -0,0 +1,29 @@
-- +goose Up
ALTER TABLE library_sources ADD COLUMN excluded_dirs text[] NOT NULL DEFAULT '{}';
-- +goose StatementBegin
CREATE FUNCTION library_path_excluded(rel_path text, excluded_dirs text[])
RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT EXISTS (
SELECT 1 FROM unnest(excluded_dirs) AS d(dir)
WHERE rel_path = dir OR starts_with(rel_path, dir || '/')
);
$$;
-- +goose StatementEnd
-- Keep historical jobs and files; only tracks whose every known file is excluded are suppressed.
-- +goose StatementBegin
CREATE FUNCTION library_track_scan_excluded(track uuid)
RETURNS boolean LANGUAGE sql STABLE AS $$
SELECT EXISTS (SELECT 1 FROM media_files WHERE track_id = track)
AND NOT EXISTS (
SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.track_id = track AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
);
$$;
-- +goose StatementEnd
-- +goose Down
DROP FUNCTION library_track_scan_excluded(uuid);
DROP FUNCTION library_path_excluded(text, text[]);
ALTER TABLE library_sources DROP COLUMN excluded_dirs;
@@ -93,9 +93,13 @@ class Worker:
WHERE j.track_id IN ( WHERE j.track_id IN (
SELECT a.track_id FROM analysis_jobs a SELECT a.track_id FROM analysis_jobs a
JOIN tracks t ON t.id = a.track_id JOIN tracks t ON t.id = a.track_id
JOIN media_files f ON f.id = t.primary_file_id
JOIN library_sources s ON s.id = f.source_id AND s.status = 'mounted'
WHERE a.status = 'queued' WHERE a.status = 'queued'
AND EXISTS (
SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.track_id = t.id AND s.status = 'mounted'
AND f.missing_since IS NULL AND f.scan_error IS NULL
AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
)
ORDER BY a.updated_at ORDER BY a.updated_at
LIMIT %s LIMIT %s
FOR UPDATE OF a SKIP LOCKED) FOR UPDATE OF a SKIP LOCKED)
@@ -108,10 +112,17 @@ class Worker:
return [] return []
files = conn.execute( files = conn.execute(
""" """
SELECT t.id::text, s.id::text, s.type::text, s.local_path, f.rel_path SELECT t.id::text, f.source_id::text, f.source_type, f.local_path, f.rel_path
FROM tracks t FROM tracks t
JOIN media_files f ON f.id = t.primary_file_id CROSS JOIN LATERAL (
JOIN library_sources s ON s.id = f.source_id SELECT s.id AS source_id, s.type::text AS source_type, s.local_path, mf.rel_path
FROM media_files mf JOIN library_sources s ON s.id = mf.source_id
WHERE mf.track_id = t.id AND s.status = 'mounted'
AND mf.missing_since IS NULL AND mf.scan_error IS NULL
AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)
ORDER BY (mf.id = t.primary_file_id) DESC NULLS LAST, mf.id
LIMIT 1
) f
WHERE t.id = ANY(%s::uuid[]) WHERE t.id = ANY(%s::uuid[])
""", """,
([r[0] for r in rows],), ([r[0] for r in rows],),
@@ -122,6 +122,37 @@ def test_analysis_worker_saves_features(world: dict[str, Any]) -> None:
assert row is not None and row[0] == pytest.approx(120.0) assert row is not None and row[0] == pytest.approx(120.0)
def test_analysis_worker_skips_excluded_folders_and_uses_allowed_copy(world: dict[str, Any]) -> None:
worker = Worker(DSN, "/library", "/library-host", 1)
blocked, alternative, allowed = world["tracks"][:3]
with psycopg.connect(DSN, autocommit=True) as conn:
source = conn.execute(
"SELECT source_id FROM media_files WHERE track_id = %s", (blocked,)
).fetchone()
assert source is not None
conn.execute(
"UPDATE library_sources SET excluded_dirs = ARRAY['Downloads_PumpEars'] WHERE id = %s",
(source[0],),
)
for track, rel in [(blocked, "Downloads_PumpEars/nested/loop.wav"), (alternative, "Downloads_PumpEars/loop.wav"), (allowed, "Downloads_PumpEars-copy/song.flac")]:
conn.execute("UPDATE media_files SET rel_path = %s WHERE track_id = %s", (rel, track))
conn.execute(
"INSERT INTO media_files (id, source_id, rel_path, size_bytes, mtime, format, duration_ms, track_id) "
"VALUES (%s, %s, 'Music/allowed-copy.flac', 1, now(), 'flac', 200000, %s)",
(uid(), source[0], alternative),
)
conn.execute(
"UPDATE analysis_jobs SET status = 'queued' WHERE track_id = ANY(%s::uuid[])",
([blocked, alternative, allowed],),
)
jobs = {job.track_id: job.path for job in worker.claim(conn, 10000)}
assert blocked not in jobs
assert jobs[alternative].replace("\\", "/").endswith("/Music/allowed-copy.flac")
assert jobs[allowed].replace("\\", "/").endswith("/Downloads_PumpEars-copy/song.flac")
row = conn.execute("SELECT attempts, status FROM analysis_jobs WHERE track_id = %s", (blocked,)).fetchone()
assert row == (0, 'queued'), "excluded files must not consume retry attempts"
def test_training_and_wave_queries(world: dict[str, Any]) -> None: def test_training_and_wave_queries(world: dict[str, Any]) -> None:
with psycopg.connect(DSN) as conn: with psycopg.connect(DSN) as conn:
assert len(load_interactions(conn)) >= 60 assert len(load_interactions(conn)) >= 60