Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab49e22357 | ||
|
|
75ae2092ad |
No files matched your search
@@ -2687,6 +2687,8 @@ components:
|
||||
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]
|
||||
properties:
|
||||
excluded_dirs:
|
||||
$ref: "#/components/schemas/ExcludedDirs"
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -2747,6 +2749,8 @@ components:
|
||||
type: object
|
||||
required: [name, type]
|
||||
properties:
|
||||
excluded_dirs:
|
||||
$ref: "#/components/schemas/ExcludedDirs"
|
||||
name:
|
||||
type: string
|
||||
maxLength: 100
|
||||
@@ -2774,6 +2778,8 @@ components:
|
||||
SourcePatch:
|
||||
type: object
|
||||
properties:
|
||||
excluded_dirs:
|
||||
$ref: "#/components/schemas/ExcludedDirs"
|
||||
name:
|
||||
type: string
|
||||
maxLength: 100
|
||||
@@ -2802,6 +2808,14 @@ components:
|
||||
description: |
|
||||
Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid`
|
||||
с `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:
|
||||
type: object
|
||||
required: [ok, message, audio_files]
|
||||
|
||||
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||
axum = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod media;
|
||||
mod proxy;
|
||||
mod store;
|
||||
mod update;
|
||||
|
||||
use media::Media;
|
||||
use proxy::Proxy;
|
||||
@@ -110,6 +111,7 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(update::Updater::default())
|
||||
.setup(|app| {
|
||||
let saved = store::load_server(app.handle())?;
|
||||
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||
@@ -150,7 +152,8 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
||||
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!())
|
||||
.expect("desktop app failed to start");
|
||||
|
||||
@@ -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-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 {
|
||||
@@ -110,7 +110,11 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
} else {
|
||||
match transfer(&proxy, request, tail).await {
|
||||
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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { releaseNotesText } from '@pipu/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
@@ -10,7 +11,6 @@ import { Button } from './kit';
|
||||
export function useUpdateState() {
|
||||
return useSyncExternalStore(updater.subscribe, () => updater.state);
|
||||
}
|
||||
|
||||
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
|
||||
export function UpdateDialog() {
|
||||
const colors = useColors();
|
||||
@@ -32,6 +32,7 @@ export function UpdateDialog() {
|
||||
|
||||
const { release, required } = st;
|
||||
const busy = st.status === 'downloading' || st.status === 'verifying';
|
||||
const notes = releaseNotesText(release.notes_md);
|
||||
const action = () => void updater.downloadAndInstall(session, server ?? '');
|
||||
|
||||
return (
|
||||
@@ -57,9 +58,9 @@ export function UpdateDialog() {
|
||||
{t('mobile.update_title', { version: release.version })}
|
||||
</Text>
|
||||
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
|
||||
{release.notes_md.trim() !== '' && (
|
||||
<ScrollView style={{ maxHeight: 240 }}>
|
||||
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text>
|
||||
{notes !== '' && (
|
||||
<ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
|
||||
<Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
|
||||
</ScrollView>
|
||||
)}
|
||||
<Text style={{ color: colors.text3, fontSize: 12 }}>
|
||||
@@ -115,13 +116,3 @@ export function UpdateDialog() {
|
||||
</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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { SettingsPage } from '../pages/SettingsPage';
|
||||
import { SetupPage } from '../pages/SetupPage';
|
||||
import { AppShell } from '../widgets/AppShell';
|
||||
import { RouteError } from '../widgets/RouteError';
|
||||
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
|
||||
|
||||
export interface RouterContext {
|
||||
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 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 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 settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||
@@ -281,6 +285,7 @@ const routeTree = rootRoute.addChildren([
|
||||
homeRoute,
|
||||
trackRoute,
|
||||
playlistsRoute,
|
||||
downloadsRoute,
|
||||
sharedPlaylistRoute,
|
||||
playlistRoute,
|
||||
settingsRoute,
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
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(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(isDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -14,11 +14,19 @@ export function isDesktop(): boolean {
|
||||
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();
|
||||
if (!tauri) throw new Error('Tauri API unavailable');
|
||||
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||
}
|
||||
const invoke = desktopInvoke;
|
||||
|
||||
export type DesktopHandle = {
|
||||
proxyBase: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
|
||||
import { AddToPlaylist } from './playlists';
|
||||
import { SaveOfflineButton } from '../features/desktop/DesktopFeatures';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
@@ -218,6 +219,7 @@ export function TrackList({
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
||||
{!actions && <SaveOfflineButton track={tr} compact />}
|
||||
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
||||
{disliked ? (
|
||||
<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([]);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
||||
import { App } from './App';
|
||||
import { createAppRouter } from './app/router';
|
||||
import { applyTheme } from './app/session';
|
||||
import { createDesktop, DesktopProvider } from './desktop';
|
||||
import { createDesktop, DesktopProvider, desktopFetch } from './desktop';
|
||||
|
||||
const LOCALE_KEY = 'pipu.locale';
|
||||
|
||||
@@ -70,6 +70,7 @@ const session = new AuthSession({
|
||||
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
||||
baseUrl: desktop?.proxyBase,
|
||||
refreshStore: desktop?.refreshStore,
|
||||
fetch: desktop ? desktopFetch : undefined,
|
||||
});
|
||||
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||
await session.restore();
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||
import s from './page.module.css';
|
||||
import { Onboarding } from './Onboarding';
|
||||
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
|
||||
|
||||
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
|
||||
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
|
||||
@@ -27,6 +28,8 @@ export function SettingsPage() {
|
||||
<ProfileSection />
|
||||
<RecommendationSettings />
|
||||
<AppearanceSection />
|
||||
<DesktopOfflineSection />
|
||||
<DesktopUpdateButton />
|
||||
<PasswordSection />
|
||||
<SessionsSection />
|
||||
</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();
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useId, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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>}
|
||||
</div>
|
||||
<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 && (
|
||||
<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];
|
||||
|
||||
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 session = useSession();
|
||||
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 [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
|
||||
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
|
||||
const [excludedDirs, setExcludedDirs] = useState((source?.excluded_dirs ?? []).join('\n'));
|
||||
const excludedDirsId = useId();
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const common = {
|
||||
name: name.trim(),
|
||||
tag_priority: priority,
|
||||
excluded_dirs: excludedDirs.split(/\r?\n/).map((dir) => dir.trim()).filter(Boolean),
|
||||
...(type === 'local'
|
||||
? { local_path: localPath }
|
||||
: { 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 () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||
for (const key of ['library-status', 'library-errors', 'analysis-errors']) {
|
||||
void queryClient.invalidateQueries({ queryKey: [key] });
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
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 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}>
|
||||
<span className={l.label}>{t('sources.tag_priority')}</span>
|
||||
<Segmented
|
||||
|
||||
@@ -238,6 +238,20 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core';
|
||||
import type { PlayerState } from './types';
|
||||
import { offlineOwner } from '../features/desktop/offline';
|
||||
|
||||
const LOCAL_KEY = 'pipu.player';
|
||||
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
|
||||
@@ -30,6 +31,7 @@ function deviceId(): string {
|
||||
}
|
||||
|
||||
export class PlaybackPersistence {
|
||||
private get localKey() { const owner = offlineOwner(this.session); return owner ? `${LOCAL_KEY}.${owner}` : LOCAL_KEY; }
|
||||
private readonly device = deviceId();
|
||||
lastServerSave = 0;
|
||||
constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {}
|
||||
@@ -50,7 +52,7 @@ export class PlaybackPersistence {
|
||||
saveLocal() {
|
||||
if (!this.getState().track) return;
|
||||
try {
|
||||
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState())));
|
||||
localStorage.setItem(this.localKey, JSON.stringify(this.snapshot(this.getState())));
|
||||
} catch {
|
||||
// недоступное хранилище не мешает воспроизведению
|
||||
}
|
||||
@@ -91,7 +93,7 @@ export class PlaybackPersistence {
|
||||
async restore(): Promise<Saved | null> {
|
||||
let local: Saved | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_KEY);
|
||||
const raw = localStorage.getItem(this.localKey);
|
||||
local = raw ? (JSON.parse(raw) as Saved) : null;
|
||||
} catch {
|
||||
local = null;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MediaControls } from './media';
|
||||
import type { PlayerState } from './types';
|
||||
import { AudioEngine } from './engine';
|
||||
import { PlaybackPersistence } from './persistence';
|
||||
import { DesktopOffline, desktopEventSink, offlineOwner } from '../features/desktop/offline';
|
||||
|
||||
export type { PlayContext, Track };
|
||||
|
||||
@@ -34,6 +35,8 @@ const PRELOAD_BEFORE_MS = 20_000;
|
||||
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
||||
*/
|
||||
export class Player {
|
||||
readonly offline: DesktopOffline | null;
|
||||
private retryEvents: (() => Promise<void>) | null = null;
|
||||
private readonly engine: AudioEngine;
|
||||
private get audio() { return this.engine.audio; }
|
||||
private get overlap() { return this.engine.overlap; }
|
||||
@@ -65,8 +68,16 @@ export class Player {
|
||||
audio?: HTMLAudioElement,
|
||||
) {
|
||||
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({
|
||||
send: async (events) => {
|
||||
if (this.sendEvents) return this.sendEvents(events);
|
||||
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||
},
|
||||
});
|
||||
@@ -82,9 +93,16 @@ export class Player {
|
||||
|
||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||
start() {
|
||||
this.offline?.activate();
|
||||
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) ---
|
||||
|
||||
subscribe = (fn: () => void) => {
|
||||
@@ -137,11 +155,20 @@ export class Player {
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
const version = this.wavePolicy.version;
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/wave/sessions', {
|
||||
let res;
|
||||
try {
|
||||
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', {
|
||||
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;
|
||||
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
|
||||
if (!tracks.length) {
|
||||
@@ -339,12 +366,20 @@ export class Player {
|
||||
private async load(autoplay: boolean, startMs = 0) {
|
||||
const track = this.queue.current;
|
||||
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 });
|
||||
// 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;
|
||||
this.media.updateMediaMetadata(track);
|
||||
if (!autoplay) return;
|
||||
void this.offline?.save(track).catch(() => undefined);
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(track, startMs);
|
||||
this.preloaded = null;
|
||||
@@ -425,7 +460,8 @@ export class Player {
|
||||
const next = this.state.queue[this.state.index + 1];
|
||||
if (!next?.stream_url || !next.available) return;
|
||||
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;
|
||||
void this.overlap.play().catch(() => {
|
||||
this.fading = false;
|
||||
@@ -458,7 +494,8 @@ export class Player {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
@@ -471,6 +508,7 @@ export class Player {
|
||||
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.media.updateMediaMetadata(next);
|
||||
void this.offline?.save(next).catch(() => undefined);
|
||||
void this.audio.play().finally(() => {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
@@ -560,7 +598,7 @@ export class Player {
|
||||
if (!cur || !next?.stream_url || this.preloaded === next.id) return;
|
||||
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
|
||||
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 saved = await this.persistence.restore();
|
||||
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;
|
||||
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;
|
||||
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
|
||||
this.context = saved.context;
|
||||
@@ -608,6 +654,7 @@ export class Player {
|
||||
this.fading = false;
|
||||
this.finishAttempt('skip');
|
||||
this.engine.dispose();
|
||||
this.offline?.dispose();
|
||||
this.persistence.dispose();
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
this.timers = [];
|
||||
|
||||
@@ -37,6 +37,7 @@ import { Ambient } from './Ambient';
|
||||
import { PlayerBar } from './PlayerBar';
|
||||
import { SearchBox } from './SearchBox';
|
||||
import s from './AppShell.module.css';
|
||||
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
|
||||
|
||||
type NavPath =
|
||||
| '/'
|
||||
@@ -49,6 +50,7 @@ type NavPath =
|
||||
| '/uploads/mine'
|
||||
| '/admin/settings'
|
||||
| '/collection/playlists'
|
||||
| '/collection/downloads'
|
||||
| '/collection/history'
|
||||
| '/collection/likes'
|
||||
| '/collection/dislikes';
|
||||
@@ -147,6 +149,7 @@ function NavSection({
|
||||
export function AppShell() {
|
||||
return useAuthState().status === 'signed_in' ? (
|
||||
<PlayerProvider>
|
||||
<DesktopUpdateDialog />
|
||||
<Shell />
|
||||
</PlayerProvider>
|
||||
) : null;
|
||||
@@ -174,6 +177,7 @@ function Shell() {
|
||||
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
|
||||
<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/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
||||
<NavLink
|
||||
@@ -298,6 +302,7 @@ function ShellMain() {
|
||||
const key = useRouterState({ select: (st) => st.location.pathname });
|
||||
return (
|
||||
<main id="content" className={s.content} tabIndex={-1}>
|
||||
<DesktopOfflineBanner />
|
||||
<div key={key} className={s.routeIn}>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Outlet />
|
||||
|
||||
@@ -15,3 +15,32 @@ pnpm --filter @pipu/desktop bundle
|
||||
На Windows получается NSIS-установщик (~3 МБ). На Linux в релиз идёт только `.deb` (~4 МБ): AppImage тащит WebKitGTK и раздувается до ~80 МБ. Зависимости пакета: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
|
||||
|
||||
Релиз по тегу собирает Linux `.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea.
|
||||
|
||||
## Офлайн и кеш
|
||||
|
||||
Коллекция → **Скачанное и кеш** доступна без сервера, в том числе после перезапуска.
|
||||
Полностью загруженные прослушанные треки автоматически сохраняются в 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` этим потоком не поддерживаются.
|
||||
@@ -139,6 +139,20 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
|
||||
|
||||
Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов).
|
||||
|
||||
В редакторе хранилища поле **«Не сканировать папки»** задаёт до 100 исключений:
|
||||
по одному пути относительно корня хранилища в строке, например `Downloads_PumpEars`
|
||||
или `Artist/Samples`. Исключается вся ветка; соседняя `Downloads_PumpEars-copy`
|
||||
не затрагивается. Пути чувствительны к регистру, шаблоны и абсолютные пути не используются.
|
||||
Настройка хранится в `library_sources.excluded_dirs`; пустой список снимает исключения.
|
||||
Во время активного скана менять список нельзя.
|
||||
|
||||
Исключённые ветки не обходятся; точечный скан такого пути отклоняется. Уже записанные
|
||||
файлы остаются в каталоге и не помечаются пропавшими. Сразу после сохранения исключений
|
||||
ошибки чтения и задачи аудиоанализа этих файлов перестают попадать в диагностику и повтор.
|
||||
Анализатор не забирает их в работу; если у трека есть другая разрешённая копия, использует её.
|
||||
После снятия исключений сохранённые задачи снова учитываются; неудавшиеся можно повторить.
|
||||
Файлы на диске не изменяются.
|
||||
|
||||
Во время обхода админка показывает число посещённых папок (включая корень), найденных
|
||||
аудиофайлов и текущую папку относительно корня хранилища. До завершения обхода общее
|
||||
число файлов неизвестно, поэтому индикатор показывает активность без процента.
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@
|
||||
кэширует ответ на 10 мин и APK на диске. Так:
|
||||
- мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
|
||||
- версия приложения согласована с версией сервера (`min_supported` из `server-info`).
|
||||
2. Если `version_code` новее — диалог с changelog (Markdown из релиза), кнопки «Обновить» / «Позже».
|
||||
2. Если `version_code` новее — диалог с changelog (текст из Markdown релиза без служебных CI-блоков сборок), кнопки «Обновить» / «Позже».
|
||||
Если текущая версия ниже `min_supported` — обновление обязательно.
|
||||
3. Загрузка APK через `expo-file-system` в `cacheDirectory` с прогрессом; проверка **SHA-256** (значение из релиза).
|
||||
Хэш считается кусками по 1 МБ (`js-sha256`), APK целиком в память JS не читается. Загрузка идёт с `Bearer`,
|
||||
|
||||
@@ -1960,6 +1960,7 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
TagPriority: "tags_first" | "path_first";
|
||||
Source: {
|
||||
excluded_dirs?: components["schemas"]["ExcludedDirs"];
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1988,6 +1989,7 @@ export interface components {
|
||||
items: components["schemas"]["Source"][];
|
||||
};
|
||||
SourceInput: {
|
||||
excluded_dirs?: components["schemas"]["ExcludedDirs"];
|
||||
name: string;
|
||||
type: components["schemas"]["SourceType"];
|
||||
nfs_server?: string;
|
||||
@@ -2000,6 +2002,7 @@ export interface components {
|
||||
write_template?: string;
|
||||
};
|
||||
SourcePatch: {
|
||||
excluded_dirs?: components["schemas"]["ExcludedDirs"];
|
||||
name?: string;
|
||||
nfs_server?: string;
|
||||
nfs_export?: string;
|
||||
@@ -2018,6 +2021,8 @@ export interface components {
|
||||
*/
|
||||
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: {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
|
||||
@@ -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 { AGE_GROUPS, GENDERS } from './onboarding';
|
||||
export { releaseNotesText } from './releases';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -98,6 +98,10 @@
|
||||
"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}}",
|
||||
"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_current_dir": "Current folder: {{path}}",
|
||||
"scan_preparing": "Matching files with the library…",
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
"copy_server": "Copy the server address",
|
||||
"step_updates": {
|
||||
"android": "Sign in with your account. The app offers new versions by itself.",
|
||||
"windows": "Sign in with your account. Get a new version from this page.",
|
||||
"linux": "Sign in with your account. Get a new version from this page.",
|
||||
"macos": "Sign in with your account. Get a new version from this page."
|
||||
"windows": "Sign in with your account. The app will offer new versions.",
|
||||
"linux": "Sign in with your account. The app will offer new versions through your system package installer.",
|
||||
"macos": "Sign in with your account. The app will offer new versions."
|
||||
},
|
||||
"checksum": "SHA-256 checksum",
|
||||
"unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).",
|
||||
|
||||
@@ -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": {
|
||||
"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",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"pattern_invalid": "Pattern error: {{reason}}",
|
||||
"nfs_invalid": "Invalid value",
|
||||
"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",
|
||||
"scan_interval_invalid": "The interval must be at least {{min}} minutes",
|
||||
"source_type_invalid": "Unknown storage type",
|
||||
|
||||
@@ -98,6 +98,10 @@
|
||||
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
|
||||
"scanning": "Сканирование: {{processed}} из {{total}}",
|
||||
"scan_walking": "Обход папок…",
|
||||
"excluded_dirs": "Не сканировать папки",
|
||||
"excluded_hint": "По одной папке в строке, путь от корня хранилища. Например: Downloads_PumpEars. Все подпапки тоже исключаются. Учитывается регистр; шаблоны со звёздочкой не поддерживаются. Анализ и его ошибки отключаются сразу после сохранения. Уже добавленные записи сохраняются. Пустой список возвращает папки в сканирование и анализ.",
|
||||
"excluded_running": "Дождитесь завершения сканирования, чтобы изменить исключения.",
|
||||
"excluded_count": "Исключено папок из сканирования: {{count}}",
|
||||
"scan_walk_progress": "Обход папок… Проверено папок: {{dirs}} · найдено аудиофайлов: {{found}}",
|
||||
"scan_current_dir": "Текущая папка: {{path}}",
|
||||
"scan_preparing": "Сопоставление файлов с библиотекой…",
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
"copy_server": "Скопировать адрес сервера",
|
||||
"step_updates": {
|
||||
"android": "Войдите своим логином. Новые версии приложение предложит установить само.",
|
||||
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.",
|
||||
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.",
|
||||
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы."
|
||||
"windows": "Войдите своим логином. Приложение предложит установить новые версии.",
|
||||
"linux": "Войдите своим логином. Приложение предложит установить новые версии через системный установщик пакетов.",
|
||||
"macos": "Войдите своим логином. Приложение предложит установить новые версии."
|
||||
},
|
||||
"checksum": "Контрольная сумма SHA-256",
|
||||
"unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
|
||||
|
||||
@@ -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": {
|
||||
"description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.",
|
||||
"loading": "Загрузка…", "disabled": "Не подключён", "paused": "Обработка приостановлена", "cooldown": "Ожидание после ограничения Genius", "running": "Получение данных из Genius…", "queued": "Очередь ожидает обработки", "waiting": "Ожидание новых треков",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"pattern_invalid": "Ошибка в шаблоне: {{reason}}",
|
||||
"nfs_invalid": "Недопустимое значение",
|
||||
"local_path_invalid": "Папка не найдена",
|
||||
"excluded_dirs_invalid": "Укажите до 100 папок: относительные пути без .., по одной папке в строке (до 1000 символов).",
|
||||
"name_invalid": "Название — от 1 до {{max}} символов",
|
||||
"scan_interval_invalid": "Интервал — не меньше {{min}} минут",
|
||||
"source_type_invalid": "Неизвестный тип хранилища",
|
||||
|
||||
@@ -511,6 +511,9 @@ type EventBatchResult struct {
|
||||
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.
|
||||
type FieldError struct {
|
||||
Code string `json:"code"`
|
||||
@@ -972,10 +975,13 @@ type SessionList struct {
|
||||
// Source defines model for Source.
|
||||
type Source struct {
|
||||
// AcceptsUploads Принимает импорт из очереди на добавление (NFS монтируется агентом ещё и на запись)
|
||||
AcceptsUploads bool `json:"accepts_uploads"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
LastScanAt *time.Time `json:"last_scan_at"`
|
||||
AcceptsUploads bool `json:"accepts_uploads"`
|
||||
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"`
|
||||
LastScanAt *time.Time `json:"last_scan_at"`
|
||||
|
||||
// LocalPath Подпапка относительно LIBRARY_HOST, пусто — весь каталог
|
||||
LocalPath *string `json:"local_path"`
|
||||
@@ -998,16 +1004,19 @@ type Source struct {
|
||||
|
||||
// SourceInput defines model for SourceInput.
|
||||
type SourceInput struct {
|
||||
AcceptsUploads *bool `json:"accepts_uploads,omitempty"`
|
||||
LocalPath *string `json:"local_path,omitempty"`
|
||||
Name string `json:"name"`
|
||||
NfsExport *string `json:"nfs_export,omitempty"`
|
||||
NfsOptions *string `json:"nfs_options,omitempty"`
|
||||
NfsServer *string `json:"nfs_server,omitempty"`
|
||||
ScanIntervalMinutes *int `json:"scan_interval_minutes"`
|
||||
TagPriority *TagPriority `json:"tag_priority,omitempty"`
|
||||
Type SourceType `json:"type"`
|
||||
WriteTemplate *string `json:"write_template,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"`
|
||||
Name string `json:"name"`
|
||||
NfsExport *string `json:"nfs_export,omitempty"`
|
||||
NfsOptions *string `json:"nfs_options,omitempty"`
|
||||
NfsServer *string `json:"nfs_server,omitempty"`
|
||||
ScanIntervalMinutes *int `json:"scan_interval_minutes"`
|
||||
TagPriority *TagPriority `json:"tag_priority,omitempty"`
|
||||
Type SourceType `json:"type"`
|
||||
WriteTemplate *string `json:"write_template,omitempty"`
|
||||
}
|
||||
|
||||
// SourceList defines model for SourceList.
|
||||
@@ -1021,12 +1030,15 @@ type SourcePatch struct {
|
||||
AcceptsUploads *bool `json:"accepts_uploads,omitempty"`
|
||||
|
||||
// Enabled false — отключить хранилище (треки становятся недоступны), true — подключить снова
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
LocalPath *string `json:"local_path,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
NfsExport *string `json:"nfs_export,omitempty"`
|
||||
NfsOptions *string `json:"nfs_options,omitempty"`
|
||||
NfsServer *string `json:"nfs_server,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"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
NfsExport *string `json:"nfs_export,omitempty"`
|
||||
NfsOptions *string `json:"nfs_options,omitempty"`
|
||||
NfsServer *string `json:"nfs_server,omitempty"`
|
||||
|
||||
// ScanIntervalMinutes 0 — отключить автоматический скан
|
||||
ScanIntervalMinutes *int `json:"scan_interval_minutes,omitempty"`
|
||||
@@ -11377,303 +11389,306 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"H4sIAAAAAAAC/+y9fW8cx5U3+lUas88FyDwjkpItJ5GwuJDl110rZkT5+l5EusPmTJHsqKd73N1DijEE",
|
||||
"iGRs2ZeKuPZmd4Nkbcebu9gL5J8RxbFGFF8Af4Lur5BPclHnVFVXdVd195A9lP1ggSAWZ3q6qk6dOnVe",
|
||||
"f+fjRtvv9nyPeFHYuPJxo2cHdpdEJIC/rveD0A/ovzokbAdOL3J8r3GlEX8VD+OT5EF8HD9LHsSD5GF8",
|
||||
"lOzEz634INlOHiSb8XHywIpH8TNr0SP3olYbXrRowY+G8X6yE+8n28nn8TB+biWbyRZ9S3wUj5JPk51G",
|
||||
"s+HQUT7qk2Cj0Wx4dpc0rjTwHY1mI2yvkq5NZxVt9Og3YRQ43krj/v1m400vcqKNdzv0W3hJz45W03c4",
|
||||
"nUazEZCP+k5AOo0rUdAn8vuW/aBrR40rjX4fnsy//z2n60Ti5ZkZuvCl/MIOWbb7btS4cnmu2eja95xu",
|
||||
"v9u4cmmO/uV4+NdFMY7jRWSFBDDQgt8P2mRyC/mg5/p2ZyGyo374luNGRLfN3yZb8SDZSraTzWTHSh7G",
|
||||
"Q9i+Z1b8LB7EJ8kufPe40WyQez3X75DGlWXbDYl+A0MYTKGPE5EucNr/CMhy40rj72ZTdpzFx8JZeaqN",
|
||||
"+2IxdhDYG/TvMNpw6Qd00fTvD0ISTIpw9+mPw57vhQTmPR/4Sy7p0n+2fS8iHjCH3eu5TtumZJzt4RP/",
|
||||
"89chpenH0lhFa+bvhREzu/J18lk8ip/EB/HAmrr51nXr569e/uk07Cr7OX37tU7X8Sgp4FgHfo8EkYOT",
|
||||
"bgfEjkinZUfKkjt2RC5ETpfk191sdJyw59obLSTjxw2v77r2EqU7kjD3A6dTgZ7NhhO2lly/fZd0pAO9",
|
||||
"5PsusT36vWuHUcv1VxyvaLqls3H9tu0SjcxoNrr9MGq1V21vhbTaAekQL3JsN9RPR364Z4fhuh8YJh6Q",
|
||||
"NYesh60+cG+oOV3/Hh/H38XDZMsCGbqZbMaHcNwexKN4D/71yIqPxbHbp38exQMr3o+P4yfxIN6LX8RD",
|
||||
"Kjbj4YxFhbKVbMJH+8k2vBhEMMjpF/HIov87FIPGT5IdGCLZpq8dJpvxAX0weRQfxKPkEyt+kTyO97mI",
|
||||
"pgL6WbKd7qG8Vh9pW8TRN+kz95uNfkgCzkR5sZSezF/hURWPZ1iQDZqns9hrhbcMO2fe/aZ8SrJseEcQ",
|
||||
"wV/6NWlHdF3ivM3bKyR/5oSkqyTy0sOrkXfSjVrhJGaJCsOrL9Eux13qd/PLsIPICaPS6cNTN8kyfVPb",
|
||||
"XyNBq+27fqASIHcSswvFX/YDt5LA6fQDkLgt5e3iTq0skSInMkiKKLDbd6tv4i36uG5dG8Qu2jlZDcid",
|
||||
"B5wde4dMogyhm3yrVMKIRRj3nO5a/sY4pz088xadB2mNpFvod7t2sPGjOjX1HIpW2++j6pM/dufM7fJ8",
|
||||
"ynbqPbYrZxLV8s7ntkMrfbXT8mx3I3TCN4PA16hshH+co39A3BaouDkFY96OVq2AuHbkrBEr8q1olVgh",
|
||||
"2BVW4PvRVasXOHTa1rLjEstZtuw124E9alp+tEqCdScklm3d9fx1Dx/yl61o1QktoPJMFc0LB2zp+azq",
|
||||
"rytrnWV8WondM5smfpnyY7oodYrSdjTZnpVudi36gsI9L01nYLNgplpeDNLvf0OC1hoJql3pvkf0QmXZ",
|
||||
"dlzFZJC++6hP+qbvgr7n0Zdrv4z8XksctKwpHA9AO9+lZjBV1rfgj/hYMsZASx7G+1TPpgr1drxPlXT6",
|
||||
"RPJJspnsWn978HsrPomP433QtgfxM/r/TSt5mGzFx/TVI/CCbCWPyk9HZpfYstM1MvoJYjXVDZDXq9/N",
|
||||
"TuA7nZvEJXao4dCOv+5RlZtfNXnDyvFaYb/X84MItyND0i+pXZNsxiOk14jaJRa1bJJd8C8cJTvwZ3wU",
|
||||
"H8d7SBVKxUZTs3eeH5Gw1dWN819AW/aW4/hpfGxN3bCDu3QBwkIaUguJDjutu/7CVfvS5de0qwyd3xBF",
|
||||
"pjhe9Nqr2jmukSB00AtA7tndHngt5mZem7moG5M93WqDX+XjsvuSvzzzQ4kybK5iNU11C7MbpuWJXu+6",
|
||||
"6zAvB/GohfCrho2M0mg21h2v46+DDeZ4/Xv0lXbbly+8dHniVa/3HbdzCvai95HBkJzAhhWfvZT4YlaC",
|
||||
"2spCCon6luNqzllbELzwBhA7c795Rtr5bgflc7WrR91HnX/ufPeC0etsWxGa9iEcnzCwrbo7WRJZptNf",
|
||||
"9F2dkoEvTksUYb1krnOq+RZp/lVdgF17hRhZ1cimJYaHzpxg3CBPvILBIMyyvJJWbYFjuLvgUe0s+h0n",
|
||||
"utaOdMZBxWmc1vNmnM6bXqS1dttR7pajL5tBj5puajZfmO267y83rvyq5GilxLh/J+sfp1oTKFpUtQBF",
|
||||
"bUg1N3rTv4hHFtPKXiRfxEc8anXC/J7PQEUYwE9eaFSw+81T+c4r7k/P3qDCCcjQ6Th0ObY7L5EWtUCD",
|
||||
"VpjuTWQHKyQ6rcXFfo2fj20pgJYJG9PkfKC+Up5eumSFrkZ+q8VSShn3pZlJ/Wj1ln+XeDoTqd0mYdiK",
|
||||
"6NdaqUfu9ZyAhC3H08brHsTH8YEVfwe67FE8svCFF6gKHB9AsICaG5vxMD5ItuOjeB/irRpDiSwHJFxN",
|
||||
"Z5IZ6vfxi2TXWsR7A7b2isUUwUUrHmW+6ZDwbuT3FrWuJTqC4DeuVL5O7IAEWs2xz4JaRdt8g+S2RyGt",
|
||||
"MqxCVfZ+3ca97vtRGAV273oaKLhJPuoT3e3okXVdhEi6Fch6q7pIVp5uqm/XzfU6hDTm2SPGWbb7QUC3",
|
||||
"qXSmBQ9oZlo8NeCMW5kNXydLYKFyW4KxjJYDroOw+CAkgXFh2Xhl1773HvFWotXGlddebercaGXhum95",
|
||||
"CMyK95IdeolggG4AN8oo2cKo+NPkQbINN8nBjCXF+OBL/iM4jhhpO7bg80Nq+Z9jcE3iJRhAu1Eok/VB",
|
||||
"5Ih0e35gBxsKa2Ro9g1IHUq1HXqxUkKAHyQ+piSLjyykaeOUp1wKk2mWR9+an6RuoW+uUfvFjtqrGs/r",
|
||||
"2lha/7xrb8DrwANi33sXf3R5bq7ENczGKZ7eTRJCOonu2uhFJqdXp4/pCMQQGAsIHUnro/mW6kzJZxAZ",
|
||||
"PrSY2+tBshMPranb/bm5V4j1ypwV78dH8TB+Pi2UrE3wgMEttBcP4XdU1zq0ILw9xPc1SoMAYl3KIqQZ",
|
||||
"68j1lkPcjsGPnjGZZIuYuBWEGz7WxPfoBn+beE4/vOZ5fmRzNTizV/1olQV18rMI7JUuUQwa6aIk9/Rf",
|
||||
"mEynNRI4y44+syK7Lj4wGwZfKr2iKeZtXvX1NAMm6/jl5Kh+kHKE1KhrGPRpFRuQCkdrvnc3Aqet347Q",
|
||||
"91ZK3s68/8avhSu8mK9EbhSbjfLi3EQ0K1fX2VRIbt6xhciOwtL9ykuMtu8tOyv9wJS10/Z9t+Ove62+",
|
||||
"FzludUsJvNGm8D1dq+E7SM8wx8hyWyz9tEvlqkly9ux+aFpjj3gdYyghH2eQfhn5ke3qf9f3CuaTdXSl",
|
||||
"myBmKnv/cZh0oulaJTaTt1rQWJ6H2BSFyrkt1vHYO8R2o1VTNCg9GlwF9O9qtD39QdEO54SRH2zQG/fU",
|
||||
"PhKqM45p3YP/qGIuiM5cTofk7ypYXB0msEynl2UDv+OH0RtOoA/Ad5xxcx54ALx4ciwuC6/XTeo9Z4nq",
|
||||
"imOH4Jcdl7QqMpgcrh8vVl6mmLhqPJolulaIRsvLroO/FDK+LAZjkzCGot2lvilPzGZh7KpB9zQzGW9l",
|
||||
"w2thh4ouOPqA6ZJywtDxVloFj+C2V98lTDDHuZtEQZrwlh9v3V4rNUs/tNfIDRLBVZNT7RmtmnwvxHCc",
|
||||
"EtllqyRMVyztGJuWliH8Fccz+0FSb1XZoiT3BWiXa46UoJKrkxhAycMoPoyHyQPr+79eXw38LrFmrQ8x",
|
||||
"Vvr9i6b1/V/nnXvEtX72/Qu4pIWn4qJkNcp+4gJfzak8AFI+rEwIHRlvkB92Pvl/J3r/YBK9nVYvIMvl",
|
||||
"5R3OPDx2vrnhYnZnywzXHZH3vSXfDrhtoMsEVaX0RFJtszUj8ZP4BXAEJipBNhPmwsRPkBmSz/gHA+AN",
|
||||
"+jHGzZJNjJBhahLExnarhJLOFh49S4RX/uWYmbw5qq76TruU6dM9v47P3282VogXkKKtrmnhmjVXWVj6",
|
||||
"ilLvkPh3U7q22fKKT8B1Qb3MOVghrZXA7/c0vPofGJxKPscMO7w/n7D8LM6Aj6iApF/viSJEcDLih+iE",
|
||||
"P4lP4sGMFX8LcnUPbuDkE1FquGVRdo/30Z//JD6m31gQIk4eQrqZcFrjLI6Tz0DkfkblMPtxsjNDlWtm",
|
||||
"wfa9DglaF19pNBsXX2ld/Hmj2bg017pE//vKXOsV+t9X51qv0v9enmtdpv99ba7Vc9GiLT1QgmotnlSd",
|
||||
"Id0/43pO4mN+P4AbFk61ssgs1Y7j5xLVcFEBsTvve+6GKWE79UJ1VC4vPfGyXzzPmCvEYwlInKxdlNnL",
|
||||
"hP0DkpMrUUxzBkv1q3R2r5Q57SUCFB6IeTtanbejiAQah7ATtohHV2FUMFbIvfzM58pMJfxdU36/YW50",
|
||||
"XnpTvIdfjhH/kFZastN56xyHKpjkPN7oZ7VQ2WtMZs84Tjhuj6a/KZ++0QqZJLWbjZAl45ylLLn6hok4",
|
||||
"mEbD8SJqvaN6UHoc+ePZDAGw9Jj1KBeAhMQO2qtw1bsu4Tkoq+j0arD0cG1YeTnwuyXVY6q8/eCDd99Y",
|
||||
"+ylV3bes+ABuqGF8lGzFA8z0PqCadfKQauf7kIVEBfMWe+aICulkKx5RyVYmMf02ROrHs7H85WXXUTLo",
|
||||
"Zfc1uh1Ny+35oVNYTTdGPQX/IN070NXDyA4i5gBtEQ98V3edHmwhuct92nARhX1YoevcBd3dY//oOKH4",
|
||||
"iP9bt7GUVVohCSH58TTetfSRplwOIu+K6RAs2e2715GFTV7pvBFrLxG3QOf/uKYlGie8ENkRMZ7c6ol6",
|
||||
"WQLcv6NJqWMuFJx06Y1eyph4vsfSR7KSEl7RcrwO3ru6iHmP2FGry6LJIoCxvEy5wkO55Gp5MVztLy+7",
|
||||
"hkNZdKhKKdPvdcrcMKXvWPPdvmKUeP3ukkb2S4dA3hBOfZWEQoY30uWrNBQDy9ygrOhOGbe+6/X6USHL",
|
||||
"jsWoGbYsd8qpbCmu0blmPSxamEqS49ji4X+Q3JtynlBILjZ1C6mDI03c5DIluLp0o79Iqz9zMc5VO+As",
|
||||
"lEugHMbPQQHYS7aTx8zaTTaTzWQnfhEfxIOrzEyL98BQ+1zKn1Lzx/biF5BDBnZx8mmyLUO1zHbJbI9N",
|
||||
"NNSoGtmNMVh9dVfB63ZPp0nekXbGcMQzCR7SUb00pz2rmpzAi5dQ9xV/N80VpKc2dpXpIB2o5uJ81Cfs",
|
||||
"CbgR9e4dNb0jnUsRK9dR35xn8tOWOIs30UOhyQeQzsp42pn4ZeGopuL8U0UvSnKKMlgUFcqbzuorpZZl",
|
||||
"pmAop6wXlOpU0B2qVq9kWVXMLOuWVXEpFLiVChc/3dMP4LHxBTYKkry4lok4hiEsfmYQX5K3wZxVUK74",
|
||||
"2tFqC/IPQ3OJiDlMkFKP2e2ptpALWh6zcGWyCQUxJ6yKGR2JyWfxgEUToJhA1NtAvQHP7DVf+YoiUpAM",
|
||||
"EYhc19Lr5QaJbNBO7jq9nsmNFtkrZyJcbtNFaoVK0HQa6p6xGYiFFaVkSLBi+vzVXIRxAF5pUdF0gI7g",
|
||||
"tDp9ZMX7ULLhXPyZ14TwohKZXrT70eqM463ZrtOR412LevkX2Y6rT/hOdpNN8DofoZschwV+eUrZyZqi",
|
||||
"34rPT3iKeDwwFWI9ni7OFTTio43ANcM1I0qb5BM2CEQ7GYzdA3CXDxrNajeilF+sTUUK7G5YVsSV2/Fc",
|
||||
"oqgsvZnRL9x2DXvJ70dXllzbu9toVk0tNeYs3yR2OyKdWzyR7ez34xly4pR7wJwPJ8+5jqQlhQZnTloq",
|
||||
"yPE0+I95smZZXhPMk5dXMsMt4w3T2W38ZwYVOpDeWkolSMbOSUP2hWHOHWMmVnuVMHPCJJjV473o313k",
|
||||
"ZQaLy7bjLlpTKMkYegb6XjMWEpc/kDiB0cARPfPTpdpdI/4ztaOSbTCuAA+TRQMxoohihkqw7yBdg0kV",
|
||||
"fD+VrqmXt+DQSymwdPyVwO4ooYTSs41U1FMfKvmMQYeSSj8GA2LFI17FB3bpMH5CJSlLWIkHyRfJlpVW",
|
||||
"GVKSt33/rkOqOR5vssQSgSnR6UItXtfvkMDGetJMXV66Xwtt25sP/JWAhLpUv07HlN7Nq986jiaYeh2/",
|
||||
"tDpOQNqRH2wUYSk1rX7oeCvWsh+s20HHCl07XCXhjPHiqiREirMFPSdcPaO7b9nvewbaLPdd15AahVl5",
|
||||
"hkxFf82YTL/KcGT0dA7btmeFkb1Crlo9tptWjwRt4tEPLSdM8aks33M3rE6froM+3CYwJTkUv267dzHr",
|
||||
"vReQnh3wf/NnIZ3Qs13nN/QPHVuxZ6tDCUk0gtjGuJem32JDFtpopnRM9y7ptHjqtEriNxgLOyS0qKkS",
|
||||
"kY4V+tayHTQtx2u7/Q6lY4alZ8rLs9JaA+AWZdkqh3JWU1Ypk7jJzmm6TM5LKcNJpQhm1ZkKA6Os40yd",
|
||||
"01pBjmHWB8/H26M3hZX8Nh7Ez+MXyU7TohoqVV3VuwVwhaAiDjLv4mdUo46PBL7AUJNJpxOBCxC3xAq/",
|
||||
"wozlGmDpmtpEtMrghPnMsyCAerwWQj7nKfxvqaINt/E+pJo8hfsS01AgfjqI9+n90bQgSxFSGU8ojUfJ",
|
||||
"ZvIJmJaU2oeYyEgvfJbbAgbFERhBL+DCfVRF+p1Kp9NbHJ+CvXNAOeQ4fgLA5k8ZoBcsZCDb0vvMOHqO",
|
||||
"J6BqWnpRcrk5U7ty2rU2Z2wSvt6izG9G4jxLlWvGCyRYI8G73rKvOTw9p6VF31rTQm8t8UL/lpwkpxUa",
|
||||
"e2hv7yF7SrXciD0CvL3Fs66GyefJF4xbUUkcJg+Sz6ioEAkDPIdAyeCVjGQUThkMOUO6btfxWqyivpUF",
|
||||
"Dcp5EGDO8SHL/kQ/AvvRdb9DLKYIXmBuA8wmBUZOdpvgcWALPabLgwlSTk8eABoFT6QT6vcweQDAC2wn",
|
||||
"Ls7N/fznWmA35vFM96zn9PoXuv3QaWuh2jiAWQtzfUPlx/TOAiWBslD11FqJd0wgdhC8SdcWD6ypBdL9",
|
||||
"P0gwLS+zMTdzcWau1GZnjtQUuUlmYN0aC/Zay876AxSG2orlUxdHqK6DLOXAdDiEHE4m3AdyfKpQV2Ka",
|
||||
"u7ZifQgZ9siVo/iZyprP6S6BQygeUDNF8gBpj1CmvqO2MgU7jFr98Mwed3l6avGGDsCcD5jSr4AP6gge",
|
||||
"cZY6fdAIy5RMiAdFVRffgJjiMo0VSWAK1gNeMSEXXSAKpb7owpr6xVsLKJGPqDEP2osAsxjET1lO13F8",
|
||||
"KKQ8fx1wGNwGj6a1LDZBSCnYdWpYnb15ggFkGCA+9kGtOWH6D89tU3K1j6333n395rWb/1frnfcXbjWt",
|
||||
"+AS6imzFx3jzIUrEI/CkUB0c/TWVtDhTSMxbDlvkHhWUlU4ufdzvZYve1e9D0DEqvY5SvXpkSnFhaLOk",
|
||||
"YBfpvRis2W6r63j9SOcei/8p3kNGBDqCjgx6wghFH1UYjqSgTcZPRhkbuvlgW5fyEE7qwqpabJj+qtUl",
|
||||
"Ych8txVwyVZavcDxAyfaKNVD7ZV5/qjkPy+fIb+41gMnIq2IdHsuCzBmqPz/pTEwZOUtPO9gCiafCLMR",
|
||||
"Ax4dvx3OMkk10+383U+n1bDZUKADFQXOKkRemeSXWFU5BiqTKwc7Q2ATvzVzgjdHrKbU6Efd5ow8ytxQ",
|
||||
"cGDMV4DBc625BzRNZBQBVikfRJ84okiUM0oQrcTQnnARir5c5US+5IMiEfFyeY2CzLfmva9FFUFF4qya",
|
||||
"yLweJapcHfkW3UIY5USVA20kliTOdJRfvLVwIa1RAvSlz6n+EawDhl/yEC/ZVOUY6JUKqaJEnQg06ELB",
|
||||
"Q29qPvoIK0Q1IwvEphHDf2JibperP0egPUGYA8qHjpKd6aZF2VPCO88OtMnwuAfa2f/ID6xK8TkTtSGt",
|
||||
"ouyubtR8xmu92q5m9bhkO36CPo/kEVYqM3MY/QkQEgOujQ8QnhV+tfjqpUsWZB5AQtAMn12L5SMs3vaS",
|
||||
"TWsRQ+szAbFD31ucsUDFT8NwzPeGHhcp8sZuWAEsL1cQ8jQEaFnImZ85NXDbhlSXP8nj2FmLQMkLfK6z",
|
||||
"EIJbnLntqTX8BiFoEC8LuYighN7j970oReTBiG+2rkuKimUBHk6LiVOPEWGGaq9bUT4fjbRAC6us/5So",
|
||||
"PLdIGBkR//odx0/xQHKXzYECMIkQlfGxopZCDtBz5o0+ZlF09TgJgY/W8EgFcaTn5+Lc3Ny0VkZJpMxX",
|
||||
"Jt3VK2oonMYxC/AX+L4MEf6CQM8YD0D/+7MUtjNFgmbSeHEGHIv8VIfRIr37ZDKiqMhc9VctzDqgwhuU",
|
||||
"+mnzaTDVV0PUP+USeW8LuCNTU+UtC5VeLw+igNjdX/Ztl10aItbej/xGs+EHzorjQRLKqrOyCjPqOFBb",
|
||||
"5/rr2lfKV4v0wsheCVvLTgDleCwFjv6hfQVH6JyXQDaqII2W9OypBvxpSHmyefO70ggbC4jVGk0TUW4N",
|
||||
"T/8LaFmPrKzCRe9WdratKY0Wl1HC4MQ305/gVXjIkDqHepWy44TtludX6eVVoRvfMrGjfsDpXUX0w2a9",
|
||||
"xX+WB36P/4mLORaieAaCiyquU2lJvMXkGW/3grc8S0YccH9h8im2pdlD3ItkB/x5VAF6ALLwiMlCVBTi",
|
||||
"pyA5D8HTzLN9cheTpha9NPBQGVUszd+qRsw0k0t/h1JZYcAS4R4/SKCKjxjmgly5wvhJXDvW1MVLVvJw",
|
||||
"+qrsd0KrIm8+DKv4QMo7fVVj1KKmc2p+eiZqmdb7pwdDGlo+xQo5pb2SzoBRNr0lnRJVRi31ugUL5EVS",
|
||||
"1BAkwcpGpUddv9/xSBi23D7C9pT8IkM8OiMxXvZtxhW+2wl1mmlYY3lcbpMLplOHq6FaGNzsaUhz2s03",
|
||||
"Ux4bBJqapJ0m8w+Yv6IMXE2sn0aGlZzUagOfsoukQAVQyJOeYX7WxVlmnSZxZowyuj3iqFX5HeouOSqC",
|
||||
"sxLq8cNw2e6QVqhUOr7WLCnZXPW7pEXlxpqjT4vNQq2jlgBi+CnEOvc4ygvkbgIGDAeEsVi7tV1wQuzi",
|
||||
"bUnV40PQfh+ye+4x+HT0ugHMr+v7ndqndoQYNZhXwO7bU0yw6y85LmkB6oGCLQOAUHngQMlwpbLHZY2q",
|
||||
"NJl+kLbVavuua/d0+Y3xt1SLALfEQxnvjWpk9Ao9gISoA6DBSXyQbAN6+gu4C4EqT6COM/ktpWKyTS9d",
|
||||
"MwHAu6enAJsoL47VogftU/1oD4NCJ3Qr6CUt7Us6Z+MM2sSLWNIDpzB8Qm8EskzPXuCsrOqNgWiVdBWb",
|
||||
"pmMHdwH3gf6i2Qg3woh0zRAPa3bg2GpzOj9Yghd81Hf0bpJ1sqThiiWQAk7o2p4hDTsvESBv8Qap3JIi",
|
||||
"F6/eFm0lBccf8HT2TfBssagq2K/xIaCwlTW2KABEHBuhz7zssXpxnM/Cpe7vp8Rt/FJu84EVByAphYez",
|
||||
"evMPTAndLWr+0RQn7zDNGxxaIA/o+ZMbX9bQHkRT9Mdy2bBSYhQfjb2EbM7bOEsqPlp0i6ADButJotGH",
|
||||
"6bdV8WiE38wEwoiJNAZ4loJ+GX/kdXdboqiNcjGP/SIGwXLfdZviM7oprY/6fmRnvoh8v+XawQpJPxJN",
|
||||
"MKSfe2k2GFt3FevpLFANUT+s3F8cp9kqgMgez8+a7kzu3enEpC2SlloOipg1aDhLcYa5U8ybIjaoF3CY",
|
||||
"I44ljrssKMEP1eNkS0o1n7EQpnLJDslrr6LRDBEzKZfpOEX0TLbpawcQGxzN6JOKK4RmZROh9FF5gvkl",
|
||||
"/8P8m283rflfvM3l5odkab7J1pzijQ6tn1ER84Ulu2f2WXsYKlbRH83cLFVYcwwzoWgnzTjRfpf3ZylD",
|
||||
"4xtTypTHMvNBz0uXLzfH6Qgff63LzWI9EJNtVvcgAtRwJ38OWjdT0Hma1zCHUz03oa7JBcdf7e3KBtMd",
|
||||
"0YxzoOt47M+LJQKg7NTrG/Zio5xSpQo0JnAAo5yRWKuUzce67JacqNUhPWSIckt7yYkCFhkuf5jth8xy",
|
||||
"pZNv+x3SrvbkaaA5+BXZcskaoqhxpV70ve6SyO7YkY1FSisk6AWOFzWadEJVUEXTMfzl1pmu0oybvJzg",
|
||||
"fIT6UpEZbmULPSRarKKv0kAhM1alHM14CCrO93+Nn4AyDLdTNof22IoPqHK/TeWJgno0xHIeCXM62QYc",
|
||||
"/LFpKRbCQVlPuZI0KpKuYsRjnsWr0EFlPz7VWjiHVnboo0RCJAydS5+/sUU6TmSyiXgMsGWM1vf8MHSW",
|
||||
"XNISZ0D/po8wztjq96CWWQvzDLCZ30FBHQYDQEM4BudH8lAAjsAmPeQE59ia38X7WHNML6rPpEIUUTm2",
|
||||
"x8NdUF9ju66/3spOS2tRkTZx1kintbTB8nkq4PegNXnGclxEaG1VF7/07htrmtWSI5CXcskRmHhTLfv5",
|
||||
"rAEkjspBWeEzNdg2ip9BnbDjrWBASdp0keAwsJJt4C3oeLBVrfPxyhmRdVhr4wIF7JtUyzJEaS01Qcua",
|
||||
"yiQKn7BQGVWNR/ELKpWaVgYAId6zlC2bHqPxc2EPonFMw/xxlz0Ge+ghAKEp8qsk0bvHm01ugctzyOxd",
|
||||
"zISqAwqzQjqPZP+pKhpvhijn+qiiM68ZKYc1J2TEFcHVo1QPU8WCrM5lQ5QMX0jSbrJCX3dJajQAk4aT",
|
||||
"1660F0Je+mu4K39asnJGFapmhDDZ7pfkTrHaXgdmjGQEvKw2V9Ktb44Vltv99MkxjP8xHh0ztFjJEhYB",
|
||||
"xdLRzzvAWBxfHCe8CPtqSDaXlcOqKmE1+V7ZFzsfEMh3NSMtlPg+TuOUytuHYzlQSlOS6vBXmMPdBZ6M",
|
||||
"sV0YZ/JapHunTy09m3NcbyDkQqIcCwGUNiWacIiOJ6yGQgSNjFnGcOuoKtWUog/cFBiBuqfT6/l6Reaj",
|
||||
"pr00gBAwx7s+SCnZ+9llIRDUAZ2d0ulaRn94YU1B264ndMkirkTVuk/AnTtifuEsEabHtyMLWE6AEKq4",
|
||||
"ydmtG9tnXsZ8v+z7urvKdbpONJYl07Xvoed/nB/1SNCCeEvZeOX3BdRSjzM2jDvmrzLElWetzMCwsvyg",
|
||||
"ObqZd+pmca+SDmkLrFRxnnq9wF8jIv6i77KB4ZqzJXgV+HAhwy7UJjh8o1pJrCIHgEzzNR970LUDQrvC",
|
||||
"XKng9tb3ZYU0QU6xcprrBXNeOzVgyVZEw2Ugo1p3UUXnoT6RX2dMQY67GbbplKlyzFNBosjxVrRqhs7r",
|
||||
"U8kZhaiBwhN1mPVDDaGtC/iggJ2k3o7H7B2AHZDiRiHw0Tb2MjOgTQDC5xmN8JctGjHKPa5Iz+x5/iVG",
|
||||
"IZdZr46MTQMnlPOUKaj7JwjGjvjuG6Brd6HQxODp0WRPKZCKj7nTGaDI4qf0Q9kftJfsZCoKZ7PV5PHx",
|
||||
"tC4gbDgUFRlSpUQ6H5aoll3twLTa0zn5S3m7CLnbzOemReWJDKUDFbZeXl7dh6coWzTbpkt/aPI7W3AW",
|
||||
"clWJsjdOwVRk5Yot1qityXUCurlOt+cHET6H/5Y+bi3bjptTxVkBqJrRse5Eq53AXvcAWamXATRKeYWH",
|
||||
"WE9b+HjadrQ6Qn5or5HXTbXjkNa7Ucmxvmy77hJzamhwag8Quz0+wsQ1DPFrayx4bfaAlZSDhfKEHV3w",
|
||||
"nqML/nMEMoQCwRdggR3Eoyb2DH4CZhhCH+YjX/HIlIrrV2v1NFY7qwmB1UlzaKY7xRYhtyzn+2LafGMP",
|
||||
"sLG2vyLt7hsm8RYhnSVtyZvfj9q+mnxLaeYS9ECzvmx3nV6L2IG7kTZjK0KdHqNHnLmbD5+ZibC8wbwO",
|
||||
"027ImmwfZGq5nsUD66cW646ah2Fc2oDmSPowxhDVRDTR07efYCcehgbHO3Aj0thIhbmfWrtozVrX3luw",
|
||||
"Zi124L7jyU7J5/SF0yz3VVSbCTR9aqvgMTyqilcPRKLLEa348yYTbGoL9rck+iiVCzl301hlTjtCScHr",
|
||||
"jK2Lc3OyR2TPfBfKA4QR8VrRauD3V1arTwwgGww4uew8V4HKZK/RzyNPMpkg0jjNlJuMHCxvTt6qO+vW",
|
||||
"TIzOsKqWER3DuAsZOqevaZ6F5ibq/kIrdZclSVj5FAnxWd7ZVjuV90XHoIw+0ta6K/6A6IgjODFHIGZu",
|
||||
"2BEJHNu1Fja6Sz4gpeYIf5dsqICZKUxzsdClP2ziZO4UriA03mJZT0UZRRlFNBJJFBOd+V3ZiE06Uz6K",
|
||||
"abUL0KH0XG5saHraObWqmm2wygNMme64pQ0FGICY1hrQcnXgROQWy1W4vkp0qkXXCTHCPMZ2yq+9wX6v",
|
||||
"nrqLmi6IWhiILyF9QCDZwJX6LNV1qTqQwsZA+hHasSmUN4drprb83z7/T+vnc/+b4tAHZCSIPkjguYcG",
|
||||
"F0/g971OKwqcnuGCsrski3wkR5cq9hUBdxtvKiKNKQ/QlLfmTqXdNbqDTw8BJn5ZOgPBCGfoopU20NIk",
|
||||
"Jb1QkYzBsoFtfZr8DgCLTpIdnX+DIZifAMIIAxcQUPGD+OCqtch6RC2mmTwKBx1L3bOOWLd+GOY7rvo1",
|
||||
"xsErLshA+rPCpVLid5o6pUcY8DrEAITNc5pOMt3Bku0czKEme2nsMJaUMCJmJTa2yMesMNNCf2WFhPr7",
|
||||
"WGbmUzMviPR2P3CijQUq1phpQeyABNf6uDf411tcuv/Dh7cAFQjLINm36W6sRlGvcZ++2GEo5/mdQOcy",
|
||||
"QHjS3b355sIt69r8u9a80+tbN/qh056x4j8gYhd9imoXiK+/BdBcOxIilgJm7ds950Lb75AV4k3f9rDp",
|
||||
"TdrxHHAu/R7x6GN0vjix6RkLgr1Q6JumaQJHACqm3XNmuh1EzWKR80Y6Vzp1CQebw2dTUY9DNa40XpmZ",
|
||||
"m3mFAc0Aiek7Z9cuzkIPmVm733FAXq0Q+A/dZ0gDeLfTuNJ4zwmja/AE69xFIhKExtBJ+sjse07XwR6C",
|
||||
"JQ9ex+yd+3cg6NrzvRA54dLcnGhQjGn/dg8dX47vzf6aRbDwRizFk6FLgJQmYI9sWBpr8oD0n8YDK/4u",
|
||||
"2U4eMEE1kNy8pmHEvNOYESCts46ajfhf0xdiAQ/rbAu35hRsxHRTbpWRbDJb+ROErofUS9725w59u7qL",
|
||||
"K8Rz0AOp3ca3SfQ2PLEQ2RHrsTcZSsvDaGg9TzmVWi6RRbzAaa92obVNZFP1y2mHjBjQv2YaVarM0iEM",
|
||||
"r1nidd+LAt/F8RsohUgYve53NsZaXF6VVWO4oiu/aL4fBRvliqOx/Zf6IIsuvqztwWLljrQh2j3IsZ/r",
|
||||
"LAV2sDFre7a7ETrhbNqZySxW2LNv8p41P2b5Iq+lupzh7SeP6R0j0IBUAKamhUgHyYNkF3zUD1nB2YB1",
|
||||
"h7TefSP1HNUirb6Khwz7bQ8mOMQoGwuaJQ/p3WaYLWAzoMa0S1X8DNAzSjpJoDHGqcRTeNLoEdUKgJv0",
|
||||
"a74RZxVx2RZwEHWq4qwRj+pPejEOoaCwhP8KUXEFhr+OLf6SgY6mNY3SEFDAzkFtcwo/KufbCnggg0aU",
|
||||
"WUEUp8IIp9r5CkLkPXz0fwUZIi/lVCKkDr74f6VuXlW3XWkLdqqNXvXD6AJvzWbc63f8MHrD0W2zyeAy",
|
||||
"tVqApVH23803WsCvqNL+COKrjSsN0WEJYp0NnowvdjV780+SSxgRAIZLxyBqu4laZEXmldmiwIHSuSJN",
|
||||
"CmD4RNLv0HACwJULGi/BqVgnLdgyKb7sXC3wQpRJH2BeFqY9vQ+TL5ItyLADiowEjHWyU8dWfcvwFY+T",
|
||||
"XR4QewLi4QWg224xnHKkdNMS3UMrEh0hkmYDIlqjm65ieAA79uVJfsncYhBSDwHhINlElEmueZxgVSm9",
|
||||
"Yephaizh30ydmwrolNS0MJ1RsqNhUiSKnlyYIjUrQeiYuDSTqDdBNs2MpOPTryRMsed1iZHcS7PdhfKk",
|
||||
"5XS7g135tXlmf2EYIY+494TumchAwtTCEUakUxS3Y94WMdMjkd9vyS6UaTO/nGAFGcIIFvBdvM+y7HmF",
|
||||
"XkaPgstnD8qkEbEDMtZmGs0MBzCUqDwTnM6Irb7/mL53zlboy2LBP4iIxUiA7J2eJ/PnHVLFipWYBfbM",
|
||||
"BKkr9QTR2xxYWUyNzuwtXAeR/3Osiz1152TamIOasCh3yJB6aGmQCRQlRHXOXrUWveWQvSvXo8wKIz+w",
|
||||
"V8gFe4V40cxtL7eCodL+AGUDqyvexxCE2o81281DtAHJ6M9XlBpepg0ciwapKMtQn0Jddg+qXoboDc64",
|
||||
"v6D4kzVxmYzgkPsMVRIYF2seWsvO+s1CJHsqu+vTgMtbwYyjwDJhMfux07mP7A+5ZJqMqtQSTxGwFOss",
|
||||
"+cSKnwA0wCjX1z/5HceXy823KV13aS416wipaPj5++oNmK3EbsrGv1rQbWBY16Z8ne8Xk+tZMOYmNY3q",
|
||||
"mWmlcy+FxSchpssoM55nhcmKDrhMTKrbHzIJBIA9v8/U8oEFLSVF+Z+wIou6Q+X326RqnYOkfCmq1Xmz",
|
||||
"UValqlMszrKkgELDSdAan5wgadkYRsVKagxFBTQkvKaO+4Nkuw56ZweBrJ1hGkWcwOntR1qkPPQ7gjqG",
|
||||
"WfUnkLGwy1pD6PpWsf0UbassqWsVeAwWqQzP9rHKneD5vm7X6z/GuQ0/v2NcxmvfSvroF6IMXEkuqcWh",
|
||||
"9G/xQD3en52G/6qd8tkeK4q58vGZ+FXrkprHd58n37AheUrYy+EgNgktE/0ZMv2Yn1a4bLGToRS2q0WD",
|
||||
"ZhY1h61iNaA82xAHHsj8xKvldI3nTs9rvJlaDfylCeVBx3RR9LqHmPRH8fAqD5A9ReWGtfWL96zFt9+8",
|
||||
"ZWld2oszVvwXtC35G1Gkzv3cYk/OQKc0lr2skZMLbdubrJ7Ttj2Jve/rPb21DZX2tTOEUqWO9cxZXJcM",
|
||||
"5EEi0Z0TBzs9I0Y8S7RuQXeLhOdmrUgN+PTyZRg/S7YBzBjcHFamhd6oLtHCXzlKvfhqM6/RmfRQXSfL",
|
||||
"yUiRTHNRwHFBCOy0reCBdVmqFRrobPtBM5fQ/QQi8lLRNHN7ZxJpWdqGmvKdFy2Q7Kzki05IxJhTrM/5",
|
||||
"PtVk8ut4/g8YXTgnTpfVMVZsrWnxOm5Yvoj3Q0wLngz3f5lN6tZytpQYvgdBnyFkHR3FA5YJgeDpI7mp",
|
||||
"wnF8qCgXirbD8CGH8R5Q7nO5h+eeQuOZ2178VT4PYgpBNNVkJ+66yB0wkeqNfow9Kqx4t+jp1G7il7xo",
|
||||
"9tv3RM+0RZ0LmGVs50/leZwIKV1cm5bA9D9E5B8yi+W5Qtz6PLVySFc9IqJfM+Ti65GKn7MuK8l2fACu",
|
||||
"+QfjXh5l8V5q1n3AnhnXNpcRB95y3AjKXj7WJqqkIJxiP0urjPVvAnjPOl6kRcPMZdJIQDk/rhyuDH5m",
|
||||
"xQyumnP7vlYj0KyLt4o3rteHCmKb7KvZJSyHEmGL8Zj3TS9yoo0yt7AGJPxRU4FAB1Dcp9hLRtGSUvXm",
|
||||
"QCrYoT/Ys1ToDfoKBVljxoq/TnuGYJc11q0n29gZhoROS8zBt2V2NEtdMyYa0Jc6rExAM5oUDGxFICvN",
|
||||
"OaJ7hSmyL2QPmBQhEy1f6tK9BgCZPtCgDyafWExpFvwzYiePMtFjMZlk+3THDmADJ3noSoG3WboUC7bt",
|
||||
"yAeCuTeEdMGd2AILg7UEg5QD1t7wsaRbqZiVYx2piZ6ml5gWgwdFw/EsN7dObmb2sg5SVI/LdUbmlXL4",
|
||||
"z8DC5vR/pOC7INYbL32nlCz+VC8d1qfqMthFqfUfPz1yyyjlmjvzFiKEuKTb5hQc1qYg2Yb8GHBdLqbA",
|
||||
"44tyEwJV+5mxbtreCuEOnH345jv19tW4OeHVQiiclrMMGiu512tkhYBWETbj9ulfHDorhS8+RVZ5v+P4",
|
||||
"sz9R2VjMb8nxbBg/++L75kbxSjOJ0zMtK/NtXPnVnbxDQzSxGChl1srQ2E6VN8UAbRCAhLFVIoMZOowH",
|
||||
"kltsTAaXQk8G8UK/T422yV09N19m1EgDs1rs5WL9Z+LvQLwd0v3gKlgt8e6vhdgUCI4yLO1I4RltfnbR",
|
||||
"5odEqTnRBFkfYKogSPEDDtWYtgBNtmesxY8WLbwemdhVvre4WjNEBAMW1trGUgAERwBFiRsbeQEH7gKY",
|
||||
"ak666QTLR4pJXdwG1iScoGtqsyLTYPvUH5utfo2yACVrdVNdC2QJCGk13ef5l5cUa+SronN1FQPgXoA5",
|
||||
"fIrJVnCYeParnCprcRi4Z6or1PrJTzB9HUHkB/Gzn/zkqtDZT9gLwC7fA5R26JPN8+qh0dAR+NvkXIKZ",
|
||||
"2x60z8VbnnXElZI3sXuVnM64SM/rogJpD2FUgFrkYCTHlGjmDFm64RMS3+kAYwnvizVPoANLNNWG5XFY",
|
||||
"HykJszVVIbEtFNE4PfxraV05CGhNiqwuGVXsbNVU1OSLepb7FyXl1Ah0nL+ZpMM7ngs4JEGJOf9nfuSl",
|
||||
"6KGpKzVAkODBHIgTZOFd9QLKWpQL8DB+yqwa1vgUb+RnqXWPAx2gK8BIEHr+v+E2CoOafIoTO4Do1R4M",
|
||||
"81TtoI1Q4fg+VgAPBuqz1AZK+5Bnpo/8KLIogLlmXDuMWrAT2qBK2r59Ykpf5zRSYwL3YGU323PTnj6a",
|
||||
"SJEONLRvWvwaa2r4shpISipN6CxIdKFnh+G6H3TGd0jIR1B/8UoN4/dAxTuUwl7gDhTXsubGRQeZeude",
|
||||
"ZUDneGVvchhVI7D6M3DxDJMHYFs9NpnPNykpUA9i1Jggs90i3Z4f2MGGGEzHdF8WEqyeK+oJsz2F1E7f",
|
||||
"fxpWCgg2v8NM13ASHJXHr8ZMW6hiY007hHMVIuKQe7PLyowy6R+YifdbyNkFHjyARoIDawokKgbHgf3i",
|
||||
"p03r+lsXQOAfoJoGZ3AAsxglW9PN2x6vZBJQWUVdKhEqMMX8pbeEQB++auUnkD77nTgdqXON3iR/kqC3",
|
||||
"9+E+EGDcaIGxuugHGIE/ZvPlp0Ij+cW5uJnZ2ko6Bmewz+oreMnybNo+SWWIEv51l/rdVKcypdVfY8CY",
|
||||
"k7t2YACt90mK8yWbSqZTPbGc4gFS6rXtyHb9lVNoaJK/WiZ9rzdre53AdzqzHX/dc327M/sxA28z78Yb",
|
||||
"7NFr+NNrvbsGPwADpmAmPAeFK/IxstTrxpXG/337dud/3r49I/1n6kLQZv+c/t//h8ZxOJ51vuZ1Ztjq",
|
||||
"Z3p2+y6UXAbtVWeNnNljeW3+H2v3Tl6b/0cVsHwqPkh+B/3TmO3M6zoHar3psGl0YHP/NiJhgGvrgEcp",
|
||||
"Fcug18ucWol1XJsnj5rcVqlBDx2ED6lEhQidEMJ0Lm87EbEtVp+OHdm42i5i7JkVS2WoHLbcQsU8LYPn",
|
||||
"FYfJNo/Np0k/o+TxbQ/Tn9AtAH3Gpc4LVnyA85qxAIZqS63RHvKvuZ4PJ1fCnqKbxhT8Vy2715vBPqil",
|
||||
"uVNU4iF530PqThQsDAa6SVxih8RkqUt2Eb0v04VOwA0vj5bsQmiW832ya7H5XtDs5q41BW56aRuO4z3O",
|
||||
"WTyy80w5H6V8jm34CvyycjMqMTAO2rSSh4Dlk+LNsOlfobzRtFge94DBOkA3AetDx+v462HTmumQpab1",
|
||||
"xo23qUrxF9mzDSroCzAhfwtqxGGyYy36bocEvEidpdzsACbE55i+uCnVVEzRF1k/yxCraTG4EWGYD1nO",
|
||||
"Jr2apBSWeDTN0g/LT4WSh5iFIT3t+ej1rrOdmeThSEfRHYzfS01ajjhSpiBx/UfjjyntmCtDPpfJJ1nO",
|
||||
"n2LcxvhvSPcWyH5SyvapRoAH4P44qgGnWiXNoM0fNSsGlbZICh/8sFUPvx2R6EIauD5bdPQvGgnCDt4k",
|
||||
"9BCRTaAc5EF96kgxZwIwfgV7gXd2npxkwBH04UhNxmAtvijtiydoIUjUZpZaMYYqPH8NH5y0sbaAdDHW",
|
||||
"pEpmFZWVmo5XVF/g+iaDO0HZfcQA0ZJNHjZSjbLpms2++ud3TjwR+b0LaR+vEr645fdu8f5bFeLG0P9O",
|
||||
"CfoKil+cg9YS2FLv8lxxD8GJRm1hQUYWlLFSwPLhKgLLMImHvB8bC1qyRhQ1hW9P0OEG0JI7UqtU5oDS",
|
||||
"sRuDCdD7yZJdDKlScf5JQfh5ouzXj1ZnXX/F8eTkmKzDNh7GT6ypRdZBm3LEFWudLC1O81lvC4yOgCwH",
|
||||
"JFy9kFqbVrxntX3/rkOsxZ7T67eCaPG2N7X4ThT13vfcjavWAr0byVVrwe6SBScif78QBU47umrN29Hq",
|
||||
"38tTXZxuco1fQCch2YfWIhu6Ffl3CYR7qDbNrknmZxaZGzL0iBR6GLCWFViZq3j2OYJvLuwke7HRIkKp",
|
||||
"IgfMOW6maGR621t8de6iRdc0w/ASWu2AdIgXObYL5cHfsKKlHfQyKo03T4Add5DIILdkaBVWZPRzfH3k",
|
||||
"+62u7W20qErW7UWh1gR4D3hgMkEwePfLin/1o9VblCFCk7YH2/YZ30O2YfXreV8yTtCnEKVs+FjW1/pR",
|
||||
"FnqSnVcfYTv02Wzv4fcVPdks2JSNKUHAuJk7uRZDBK4RrPPLZEc+JSLcjEnmB6zY+7kSGKtKpAu265YR",
|
||||
"6prrVqPVl6eNzw3rAuxISSXKD+DyQL8HAh2y3hOD5JNyIjGRaZb9NzXi/Ams6gsZUizHJVMQT3oyLeQs",
|
||||
"+JHwShsIOxr6o0Ck5gHWC4pReMiJBYFGYjB+z6YkZ/L38KrISTqRcqfp74ep++b/SXaVKik5uQFdL1jR",
|
||||
"AZL6MHlE/yn7Tff0sSQgEhMzem0MKSSVyiGlypGm6xfHbLqF0A7nJoC/Yudml8vAQYbg9UviP8Omc/jh",
|
||||
"vMKCLaCMB6ftr8lpS+Vp8smnzIl0gDqByJqHN2HSvFz+Bta/lBCNwPvNFDMFzvlBPLii1iRhe4a0yuPQ",
|
||||
"WlxbxISatJnbUTyYvnrbk70NSv0gSyZaDJ3fEKqFCPcEpRmfubfiePdkD/D/eeFau03cCzdJxwlIOzI4",
|
||||
"Ga/72Oey5iT+tbEy7WtK2De+5jdEb2W9MjfX5H1mXnu1Sf+8eGlu7s6pTCyna6+Q2XWy1Du7x+uPIH+h",
|
||||
"AdbZeoyYDlym8DLD6h+SpXm9iSMdOrLGgwbGTFhF5rPgFLhz06onhge+A4kMu9YUPM9iDU5nmqO4ylVw",
|
||||
"aUrFPtwR38X7yY4GtMwPozfXhPu8fqENL3/9ZdStpSMXlAx8A8EjBr9dj8Gdac0Om4Yd+wzWNAvBXJ6b",
|
||||
"k9mp59ob2Jpc5ifs9W1yd96YKLLCDWIkoUjZqYOE/yG051FRUl9Kqi4pxGJnPSKtxb7T6gVkOVy0EBZZ",
|
||||
"OSfJpoqnzNMKD+PR1Qw2hlKQeMLejo9kCr5NtaI3yETzNm+Ql2S1GljElK5ZN+cYkzR3QVc+aGqx1UcQ",
|
||||
"uqC2wW+xB56iSAFzKSdwdsn3ozAK7N4FyfVRIOP/Q46jYnnCYrcfRq32qu2tkJwDRc7C3AcvEUtJoLoM",
|
||||
"9okcpAjex5janFqcT+Pjmdte2kGXNcPJmYCZ/GiGHb6X7HB9iYduSjRdLeoLiV7nVLouEWkyXK8b6ofq",
|
||||
"t8ECKyY8FEnyTKBagt3drED6+nqOsKRgDlO3GR/k+kzITshMrnCaCS8KxphOU3KUOk7oOndJIZbuG/yZ",
|
||||
"H3UjrZvEbkekA5GCqrVddWzv79W00yr9PNu+6xLWGTKzX6tOGPlYK2+qe7nuEjt4hz1XyT/1teSZO64L",
|
||||
"a2YkISMqebaPIZom69PZvNu0mW4ue1iNqcl0MqO0p7T4EbMvW0T1qkSJ5DW1SVFzpY1J3Gfk8FJx9N5/",
|
||||
"y6LTbuGfapJCvrfk20HH8VZA6+prNmrBXiPvp49NRu9IB7i+6jsc4L5M2XjVUNQt4wkf19V3TYClMByd",
|
||||
"tAVerpwB/Hc8vAh3u6IJMLBZvmUnrCOZIhHX7bX8Ha/UM+nb6rT7QUC8qMUfXUTEyF2qj4iA8lHTig8w",
|
||||
"QJmGzx+CeqSo0+lLpkQcE6NXmcAoywZOQ1jTqd5cqipfVZVuwBjIRTPo++tRqK/D2pRiqAnUCiuD/DA1",
|
||||
"aMU6Ev5halael8L8rWpj6mu0NLou9+hcCCM7KvTjzLMnF6IJg2WqA5kg/ZXejVdzvUlP4HRCof4w+Tz5",
|
||||
"grlCJFMCXKebyW5NGS1KcS5rg5XpLynmRIUaT3hhKYfP4EIbgbWr9MUzuN+a+htmvq/ZpwnA9stjjNHm",
|
||||
"6ryZRPGbQfAyvy+TudRK99+1w+gCIgavO15YxcsKh9V1wqg4o21ePDVh+rvQVVqfX7bQb7cBf/6UxDWY",
|
||||
"M/rUA0Rz4FOaIM/T17+Urm5ibedFagPjVQSYULaiTM/En3TqZpWie8ydcAL2uW9XlVoGp1MNnc0AU3zH",
|
||||
"cOWgd/+cDh8O9jIumx/M6ZsNV+2AFJ3Bm2TNvyt2ZAEer2Tw/V6uLhTZtkPAJGJg3ywTg2f7sMYRdeHD",
|
||||
"fA2K8TMBQJIOmGxjhFlq2Aj+w320w5JPp69mc5VQ4062WU/vA5GlkgFOYlb/NmQqpeM1Xu5R0150sJfn",
|
||||
"KcCQebSFJEB6bA/7W0ZejksluERUFiLTdgQrz34MH7zbuX9VlPgCP0GTTvSAMg35iVyyc+aMUiP0Em9B",
|
||||
"wDKct/P8p7If88Y+BIP7KE2BMDNoNTdSQGz4IpzF4oXSGxdc2+A1u8l+WjVZtcYMA/YyDrUwiA/grD2u",
|
||||
"fIjGQrXV+tZIlCfCJPL98PVjaIHn5lpjnkzBywq4h1SGU40RQxKGHJ/FaGvc2Fjgj02yhRKOUa1Pd+oj",
|
||||
"K/C611IjJbC3+PtT59yULol4utgVwwleeuLxfhekb5zPjXTm/Pe6GozxlwpDmw/6mJV05kk/LKC86rfX",
|
||||
"ZqAucsJJTdZ16IAsIweCQJ8wSKoXFuIODTP+63hAvxpAmiC93raxJuWJiuGWiVxDmR1HtolfGHFtZm57",
|
||||
"i23w/i+itiPqWRVgimQHQV2UygCGXfCElWqLWk44Ss8ZBBdMaRNbXtEPn0Jl7QlQ5vlVDLQhBZgelj4w",
|
||||
"kDpSpI3izTW5hvTXTBxlQpJHGkWLvgVu1hEsAareRxNyMP0T5xOOgpvf9OesLkqdUlOfQDZUuYCxKMQ3",
|
||||
"OOOmHbFYWwIsyGPKTmF8JSR2gFlv+uNEpcvf266bHicYPoPugOnQclM8DtLAkSawK1s8ojTgGd3Q7Oop",
|
||||
"g/iCw4AZ2seUglDVtcUbKlHpfQJTEN1LIK6Ix2LRI/eiVhsin/omUrjKykDDZukrAQ9fmpurjDwMj2nT",
|
||||
"oxs2lOGwBGn2F6uOpf/iVdCsPvaOdsgfU/AY9wJzasNqHRbrKSLSAh9jEFLWs9iJ0JwSuU2cvvzhXzAx",
|
||||
"lJ0TjGDAFUDvjCPIvOP4wYAWBPrePqqVzQxosWQhs+IE0dzQivdBRj8FiY4CHiobBtAgcmixP+h5G2Cz",
|
||||
"r2Tntjf1/V9XVpc6kff9C+tvn35pff9XdoGAbPn+xbQRKRnrj+RuKDwHU1ojdH5t+0FA2hHptOAEFJxG",
|
||||
"1lvtPA7ly2Vn6N7G9IhndXXu+TLZAfG5DxFlTHUWUXWqXMvcjbJ7Pz62XtGWbFNlokm/k0sUAMXncmqL",
|
||||
"HMd7packWCPBBcdb9s1HhE78mdppR9wHA9bKUHVVwM1wxcLKOsqiAo1G6kaJOdMyxNNjVTkZAJwjPH7I",
|
||||
"muMpF6cKQ8JLHHPwIrz4nmUEg/3wO7itoE2vpkdrsjtt0IsWgF7vUnJNlEHFKHqDbA9ioaKN43FGqau/",
|
||||
"LqZ0SOFnyoA8yfy3EUakm+E/1XEVpp6romA5+M06LzvUEH+DxxDyszcBwV71z7AOtiPZZzfAz1AR5/1C",
|
||||
"hL8rk9NtyS1JGFh5Xe1bsXIohUZXl4K34T4mx+ebUqZ+6qkCP+L0WZy8jA/OZlfLnAagS3XUQiodhITb",
|
||||
"J9sZpqDeeiQKI62Ll6zk4fRVSy1yPERkO7XeEQ3yxY8ALuJ0tY5XLJadyTR43twm39xWALgx/YsZD/tU",
|
||||
"YXi/1w/xdwz4aaDVGIBK4DSsv4yyfya2+KE3WGpWaadSeH8A7X/Zt10qy19Ox6b676C0GZTm1FWAFNMn",
|
||||
"frA4wBKvJNOHht5m/u9wkv0zYYR3O+F5B2ArQhlxCBswbpjRItkS8eCqaEcte0JSv9zn9aFLSJPCBso8",
|
||||
"OTXZtpwOYDocwz2VAjMoSApSr7fSgt5MpMiklHBRN9ltKtqiGgk7QRwniZyzK8Rz+mEpVd/GxyZIWxzh",
|
||||
"OnuVhsbX7fYq6VjuRuC0Q8v2OpbteX6ESOxXLY+skcDq2ncJ/dLq91BJsAKRUDs5gkpNvg3ViL9HveMk",
|
||||
"rXyGTrEKOisgMUX9EPWaqagfWhdn5mbmmmpk9p03r71hzVrz125dfweSs9HdPkJb9bVXrfjf4y+mETQZ",
|
||||
"VaDUdNuDqo0h6Ih/e/D7294im/rMR33SJ63lvusuWrOW+LgfkqD1Ud+PbPyuacX7POMAJ8yfFF28FwX4",
|
||||
"hBh4YKFfcw+UpWFBS6bz7JD8klozSTNgXZqMKddSTTvryVRPlCnXlYk3JGbtclB5Zcal2jJH7OmxNSXa",
|
||||
"kDWtaq329C2M0zCgSgKZ0dklI+GJA+iaADJLtlPk5VHyRXyodBalv6XHaQoaCYGn7xjCBw9Yh6GB9X6P",
|
||||
"eNfm353OF49/6ESrncBel1oNVyiuE3k+6KWUyAY39R4YFcfg56+xAi+bXQQn7guBaCQDdqPDAKMQLIKp",
|
||||
"hj3SHaxVSM52HY+UxNzT3ppjNk6B3y1EdtQP33LciJoEPzJHf9pOeIxehJrdraUP4b8j+qnm/cnuGY5/",
|
||||
"LyDtVdK+W3BffpV1+2dFEjo7ZqyFd65duHT5NTDpRZtx2ZTnfcqZTcK6IEx1/HbIJzTT7fzdq5qTP8/m",
|
||||
"eR6tXvlYL7XZazqJH0C7128Y10n+aoQE4goIBPP3ecwIA/DP0Lt8atYEVadIG0ZC/RIem/h+4DC6bfgT",
|
||||
"h/dkwe1nPMsAEezEbVPHRvyR6W3ZFt0MVlDCAxM3y+k3ICTEMzsBbtgBP40L9MHKlzElyMN6b1rslD+S",
|
||||
"blqzrBQW+iGlJDZ+50A3VSnT9zhttLz5AXyNxHmDtJ1aktZ6AR0kcvDXbb+PPxKQz3M6F5wktn7FfpMG",
|
||||
"4P2lX5O2XrT8F4PgqilhM5tqwZUcBm8kd7vR7xq2hGJb9xlD4mAJGZg3NDgFo6/ba2TW70WFqYdvk+hD",
|
||||
"e42834smnXooD1MhA6iuHDeUVSP0xWc6mHB8Lh4QFEnWzsWfeayHNst3KSuEBmLLiZ6GtPPIDoDgE7ri",
|
||||
"6athDD1+5sVaB5Jw34rSF6VuzaJ09+HZIP3SLf4KXoZWyPd/pXpk8jjt67f9/Yvqu1aaLfqm1xFbV3oX",
|
||||
"/NvLSNoU/Qzzqz5bnjj3mOsKlibMzuCou3fuemoxg2fwYWtj50zxeapsUV1nViO/WJ6SBZURW8I5IVw6",
|
||||
"HNUOMXGwHycP3o55NGY9ug1j9xhVGUkrFX9B7k1aKP6CsdDLY5lv6uUTKJFnkXqVDyE3YZshkUG7SSlR",
|
||||
"geokDKN7R8EnS9tG6NlildhutPqbIkXiHfbIJHGKYAj0uxTAVX6KQtHCIGFxqPFfIY4oEo1O5BeomYdy",
|
||||
"TktA7M5GITVu4hOTxfnpbBTQgkEEPmMpBZtpRhXz4ctdzShXXp575dwm9zVmtIsSm3zWsnbiQ2TgYX76",
|
||||
"RXv8z2BWHmN3d77Tx/ETzLnhoWQpRZulhe5YUxXJN61lFHVOHzeWiB2Q4Fo/WqVTpAIRk/JQkPYDt3Gl",
|
||||
"MQuCkr3rY5FXgO+83xSfAAS49HeXKN9CE17pA9dZCig1pI94cEr6iOUNSp+ISLr8wzTBR/oUhIU8hV6v",
|
||||
"cf/O/f8/AAD//+0IjFXOgAEA",
|
||||
"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",
|
||||
}
|
||||
|
||||
// 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, 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")
|
||||
}
|
||||
@@ -61,11 +61,15 @@ func (d DTO) Tracks(ctx context.Context, rows []TrackRow) ([]gen.Track, error) {
|
||||
}
|
||||
|
||||
func (h *Handler) sourceDTO(s Source) gen.Source {
|
||||
if s.ExcludedDirs == nil {
|
||||
s.ExcludedDirs = []string{}
|
||||
}
|
||||
out := gen.Source{
|
||||
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,
|
||||
Status: gen.SourceStatus(s.Status), StatusMessage: s.StatusMessage, LastScanAt: s.LastScanAt,
|
||||
CreatedAt: s.CreatedAt, AcceptsUploads: s.AcceptsUploads, WriteTemplate: s.WriteTemplate,
|
||||
ExcludedDirs: &s.ExcludedDirs,
|
||||
}
|
||||
if !s.TagsFirst {
|
||||
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{
|
||||
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,
|
||||
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate,
|
||||
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs,
|
||||
})
|
||||
if err != nil {
|
||||
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{
|
||||
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,
|
||||
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate,
|
||||
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -205,7 +205,13 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
|
||||
if err != nil {
|
||||
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) {
|
||||
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): после импорта из очереди трек сразу появляется в каталоге.
|
||||
// Возвращает id записи media_files. Хуки AfterScan (обложки, поиск, отпечатки) вызывает сам.
|
||||
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)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
@@ -470,7 +479,7 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
|
||||
|
||||
// walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются;
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if pathExcluded(rel, excludedDirs) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
dirs++
|
||||
if report != nil {
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestWalkAndHash(t *testing.T) {
|
||||
write("_incoming/new.mp3", 10)
|
||||
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)
|
||||
assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found))
|
||||
for _, w := range found {
|
||||
@@ -69,7 +69,7 @@ func TestWalkAndHash(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestWalkReportsIncrementalProgress(t *testing.T) {
|
||||
}
|
||||
var counts, folders []int
|
||||
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)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -105,9 +105,17 @@ type SourceInput struct {
|
||||
Enabled *bool
|
||||
AcceptsUploads *bool
|
||||
WriteTemplate *string // "" — убрать
|
||||
ExcludedDirs *[]string
|
||||
}
|
||||
|
||||
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 {
|
||||
name := strings.TrimSpace(*in.Name)
|
||||
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 {
|
||||
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 {
|
||||
return Source{}, err
|
||||
}
|
||||
@@ -387,6 +400,16 @@ func (s *SourceService) testRead(ctx context.Context, src Source) (TestResult, e
|
||||
if err != nil {
|
||||
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()) {
|
||||
n++
|
||||
if n >= 1000 {
|
||||
|
||||
@@ -45,6 +45,7 @@ type Source struct {
|
||||
CreatedAt time.Time
|
||||
AcceptsUploads bool
|
||||
WriteTemplate *string
|
||||
ExcludedDirs []string
|
||||
}
|
||||
|
||||
// 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',
|
||||
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) {
|
||||
var (
|
||||
@@ -95,7 +96,7 @@ func scanSource(row pgx.Row) (Source, error) {
|
||||
interval *int64
|
||||
)
|
||||
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) {
|
||||
return Source{}, ErrNotFound
|
||||
}
|
||||
@@ -150,17 +151,20 @@ func tagPriority(tagsFirst bool) string {
|
||||
|
||||
// SaveSource создаёт или полностью обновляет хранилище (кроме статуса и времени скана).
|
||||
func (st *Store) SaveSource(ctx context.Context, s Source) (Source, error) {
|
||||
if s.ExcludedDirs == nil {
|
||||
s.ExcludedDirs = []string{}
|
||||
}
|
||||
return scanSource(st.pool.QueryRow(ctx, `
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, now())
|
||||
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, $14, now())
|
||||
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,
|
||||
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,
|
||||
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 обновляет состояние подключения.
|
||||
@@ -263,7 +267,9 @@ func (st *Store) FileByPath(ctx context.Context, sourceID uuid.UUID, rel string)
|
||||
// SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов).
|
||||
func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
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.
|
||||
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)
|
||||
if err != nil {
|
||||
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, `
|
||||
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 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)
|
||||
if err != nil {
|
||||
return Counts{}, fmt.Errorf("library: counts: %w", err)
|
||||
@@ -530,8 +539,9 @@ type FileError struct {
|
||||
// FileErrors — страница файлов с ошибками чтения (по пути, после after).
|
||||
func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
|
||||
rows, err := st.pool.Query(ctx, `
|
||||
SELECT id, source_id, rel_path, scan_error FROM media_files
|
||||
WHERE scan_error IS NOT NULL AND missing_since IS NULL AND rel_path > $1 ORDER BY rel_path LIMIT $2`, after, limit)
|
||||
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 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 {
|
||||
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
|
||||
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'
|
||||
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
|
||||
LIMIT 1) f
|
||||
WHERE al.cover_checked_at IS NULL
|
||||
@@ -612,12 +622,13 @@ type AnalysisCounts struct {
|
||||
func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
|
||||
var c AnalysisCounts
|
||||
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'),
|
||||
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
|
||||
(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)
|
||||
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 {
|
||||
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, '')
|
||||
FROM analysis_jobs a JOIN tracks t ON t.id = a.track_id
|
||||
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
|
||||
LIMIT 1
|
||||
) f ON true
|
||||
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 {
|
||||
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) {
|
||||
tag, err := st.pool.Exec(ctx, `
|
||||
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 {
|
||||
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
|
||||
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
|
||||
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
|
||||
ORDER BY mf.id
|
||||
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 albums al ON al.id = t.album_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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("library: template samples: %w", err)
|
||||
|
||||
@@ -116,7 +116,7 @@ func (s *TemplateService) Preview(ctx context.Context, id uuid.UUID, patterns []
|
||||
return nil, 0, err
|
||||
}
|
||||
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 {
|
||||
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 (
|
||||
SELECT a.track_id FROM analysis_jobs a
|
||||
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'
|
||||
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
|
||||
LIMIT %s
|
||||
FOR UPDATE OF a SKIP LOCKED)
|
||||
@@ -108,10 +112,17 @@ class Worker:
|
||||
return []
|
||||
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
|
||||
JOIN media_files f ON f.id = t.primary_file_id
|
||||
JOIN library_sources s ON s.id = f.source_id
|
||||
CROSS JOIN LATERAL (
|
||||
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[])
|
||||
""",
|
||||
([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)
|
||||
|
||||
|
||||
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:
|
||||
with psycopg.connect(DSN) as conn:
|
||||
assert len(load_interactions(conn)) >= 60
|
||||
|
||||
Reference in new issue
Block a user