mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(playback): ReplayGain prefetch, gapless UI sync, library peak column (#1231)
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Read Psysonic app config from WebKit localStorage + XDG dirs.
|
||||
* Emits key=value lines (passwords/secrets redacted; header values hashed).
|
||||
*
|
||||
* Usage: node scripts/lib/extract-app-config.mjs [--app-id ID] [--repo-root PATH]
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { appId: process.env.PSYSONIC_APP_ID || '', repoRoot: '' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--app-id' && argv[i + 1]) {
|
||||
out.appId = argv[++i];
|
||||
} else if (argv[i] === '--repo-root' && argv[i + 1]) {
|
||||
out.repoRoot = argv[++i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sha256(text) {
|
||||
return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function emitSection(name) {
|
||||
process.stdout.write(`\n[${name}]\n`);
|
||||
}
|
||||
|
||||
function emit(key, value) {
|
||||
const v = String(value ?? '').replace(/\n/g, '\\n');
|
||||
process.stdout.write(`${key}=${v}\n`);
|
||||
}
|
||||
|
||||
function readAppIdFromRepo(repoRoot) {
|
||||
const conf = path.join(repoRoot, 'src-tauri', 'tauri.conf.json');
|
||||
if (!fs.existsSync(conf)) return '';
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(conf, 'utf8'));
|
||||
return typeof j.identifier === 'string' ? j.identifier : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function xdgDataHome() {
|
||||
return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
}
|
||||
|
||||
function xdgConfigHome() {
|
||||
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||
}
|
||||
|
||||
function decodeWebKitValue(raw) {
|
||||
if (raw == null) return null;
|
||||
const buf = Buffer.isBuffer(raw) ? raw : raw instanceof Uint8Array ? Buffer.from(raw) : null;
|
||||
if (buf) {
|
||||
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
||||
return new TextDecoder('utf-16le').decode(buf.subarray(2));
|
||||
}
|
||||
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
||||
return new TextDecoder('utf-16be').decode(buf.subarray(2));
|
||||
}
|
||||
// WebKit often stores UTF-16LE without BOM
|
||||
if (buf.length >= 4 && buf[1] === 0 && buf[3] === 0) {
|
||||
return new TextDecoder('utf-16le').decode(buf);
|
||||
}
|
||||
return buf.toString('utf8');
|
||||
}
|
||||
if (typeof raw === 'string') return raw;
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function readLocalStorageRaw(dbPath, storageKey) {
|
||||
try {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(storageKey);
|
||||
db.close();
|
||||
if (!row?.value) return null;
|
||||
return decodeWebKitValue(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readLocalStorageKey(dbPath, storageKey) {
|
||||
const text = readLocalStorageRaw(dbPath, storageKey);
|
||||
if (text == null) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function pickLocalStorageFile(dataDir) {
|
||||
const dir = path.join(dataDir, 'localstorage');
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.localstorage') && !f.includes('-wal') && !f.includes('-shm'))
|
||||
.map(f => path.join(dir, f));
|
||||
if (files.length === 0) return null;
|
||||
|
||||
// Prefer packaged app origin over vite dev (1420) when both exist.
|
||||
const ranked = files.sort((a, b) => {
|
||||
const score = p => {
|
||||
const base = path.basename(p);
|
||||
if (base.includes('tauri_localhost')) return 0;
|
||||
if (base.includes('1420')) return 2;
|
||||
return 1;
|
||||
};
|
||||
return score(a) - score(b);
|
||||
});
|
||||
|
||||
for (const file of ranked) {
|
||||
const auth = readLocalStorageKey(file, 'psysonic-auth');
|
||||
if (auth?.state?.servers?.length) return file;
|
||||
}
|
||||
return ranked[0];
|
||||
}
|
||||
|
||||
function probeHttpReachability(rawUrl) {
|
||||
if (!rawUrl) return 'empty';
|
||||
const url = rawUrl.startsWith('http') ? rawUrl : `http://${rawUrl}`;
|
||||
const r = spawnSync(
|
||||
'curl',
|
||||
['-sS', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5', '--max-time', '10', url],
|
||||
{ encoding: 'utf8', timeout: 15000 },
|
||||
);
|
||||
if (r.error) return `error:${r.error.code ?? 'unknown'}`;
|
||||
if (r.status !== 0) return `curl_exit_${r.status}`;
|
||||
return `http_${r.stdout.trim() || '000'}`;
|
||||
}
|
||||
|
||||
function dumpNetworkProbes(servers) {
|
||||
emitSection('app_network_probe');
|
||||
const seen = new Set();
|
||||
servers.forEach((srv, i) => {
|
||||
for (const [kind, raw] of [
|
||||
['url', srv.url],
|
||||
['alternateUrl', srv.alternateUrl],
|
||||
]) {
|
||||
if (!raw || seen.has(raw)) continue;
|
||||
seen.add(raw);
|
||||
emit(`probe.${i}.${kind}`, probeHttpReachability(raw));
|
||||
emit(`probe.${i}.${kind}_target`, raw);
|
||||
}
|
||||
});
|
||||
if (seen.size === 0) emit('probe_status', 'no server URLs configured');
|
||||
}
|
||||
|
||||
function dumpServerProfiles(state) {
|
||||
const servers = state?.servers ?? [];
|
||||
emit('active_server_id', state?.activeServerId ?? '');
|
||||
emit('server_count', servers.length);
|
||||
servers.forEach((srv, i) => {
|
||||
const p = `server.${i}`;
|
||||
emit(`${p}.id`, srv.id ?? '');
|
||||
emit(`${p}.name`, srv.name ?? '');
|
||||
emit(`${p}.url`, srv.url ?? '');
|
||||
emit(`${p}.alternateUrl`, srv.alternateUrl ?? '');
|
||||
emit(`${p}.shareUsesLocalUrl`, srv.shareUsesLocalUrl === true ? 'true' : 'false');
|
||||
emit(`${p}.username`, srv.username ?? '');
|
||||
emit(`${p}.password_set`, srv.password ? 'yes' : 'no');
|
||||
emit(`${p}.password_sha256`, srv.password ? sha256(srv.password) : 'none');
|
||||
emit(`${p}.customHeadersApplyTo`, srv.customHeadersApplyTo ?? 'public');
|
||||
const headers = srv.customHeaders ?? [];
|
||||
emit(`${p}.customHeaders_count`, headers.length);
|
||||
headers.forEach((h, hi) => {
|
||||
emit(`${p}.customHeaders.${hi}.name`, h.name ?? '');
|
||||
emit(`${p}.customHeaders.${hi}.value_sha256`, h.value ? sha256(h.value) : 'empty');
|
||||
});
|
||||
});
|
||||
dumpNetworkProbes(servers);
|
||||
}
|
||||
|
||||
function fileMeta(label, filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
emit(`${label}_exists`, 'no');
|
||||
return;
|
||||
}
|
||||
const st = fs.statSync(filePath);
|
||||
emit(`${label}_exists`, 'yes');
|
||||
emit(`${label}_path`, filePath);
|
||||
emit(`${label}_size`, st.size);
|
||||
emit(`${label}_mtime`, st.mtime.toISOString());
|
||||
}
|
||||
|
||||
function listDir(label, dirPath, max = 12) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
emit(`${label}_exists`, 'no');
|
||||
return;
|
||||
}
|
||||
emit(`${label}_exists`, 'yes');
|
||||
emit(`${label}_path`, dirPath);
|
||||
const entries = fs.readdirSync(dirPath).sort();
|
||||
emit(`${label}_entry_count`, entries.length);
|
||||
entries.slice(0, max).forEach((name, i) => emit(`${label}.entry.${i}`, name));
|
||||
}
|
||||
|
||||
const { appId: appIdArg, repoRoot } = parseArgs(process.argv);
|
||||
let appId = appIdArg || (repoRoot ? readAppIdFromRepo(repoRoot) : '');
|
||||
if (!appId) appId = readAppIdFromRepo(process.cwd()) || 'dev.psysonic.player';
|
||||
|
||||
const dataDir = path.join(xdgDataHome(), appId);
|
||||
const configDir = path.join(xdgConfigHome(), appId);
|
||||
|
||||
emitSection('app_config_paths');
|
||||
emit('app_id', appId);
|
||||
emit('data_dir', dataDir);
|
||||
emit('config_dir', configDir);
|
||||
emit('data_dir_exists', fs.existsSync(dataDir) ? 'yes' : 'no');
|
||||
emit('config_dir_exists', fs.existsSync(configDir) ? 'yes' : 'no');
|
||||
|
||||
const lsFile = pickLocalStorageFile(dataDir);
|
||||
fileMeta('localstorage_db', lsFile);
|
||||
|
||||
emitSection('app_preferences');
|
||||
if (lsFile) {
|
||||
const lang = readLocalStorageRaw(lsFile, 'psysonic_language');
|
||||
emit('language', lang ?? 'unknown');
|
||||
|
||||
const authWrap = readLocalStorageKey(lsFile, 'psysonic-auth');
|
||||
if (authWrap?.state) {
|
||||
emitSection('app_servers');
|
||||
dumpServerProfiles(authWrap.state);
|
||||
} else {
|
||||
emit('app_servers_status', 'psysonic-auth not found or empty');
|
||||
}
|
||||
} else {
|
||||
emit('app_servers_status', 'no localstorage database found');
|
||||
}
|
||||
|
||||
emitSection('app_config_files');
|
||||
for (const rel of ['linux_wayland_text_profile', 'mini_player_pos.json', '.window-state.json']) {
|
||||
fileMeta(rel.replace(/\./g, '_'), path.join(configDir, rel));
|
||||
}
|
||||
|
||||
emitSection('app_data_artifacts');
|
||||
fileMeta('hsts_storage', path.join(dataDir, 'hsts-storage.sqlite'));
|
||||
fileMeta('library_db', path.join(dataDir, 'databases', 'library', 'library.sqlite'));
|
||||
listDir('localstorage_dir', path.join(dataDir, 'localstorage'));
|
||||
Reference in New Issue
Block a user