mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ae64cbe78 | |||
| f281078275 | |||
| ea63b35396 | |||
| 2a88ca3248 |
@@ -339,6 +339,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Channel publish now refreshes `nix/upstream-sources.json` and `flake.lock` on the channel branch **before** cutting `app-v*` tags, so Nix builds from release tags no longer fail with stale `npmDepsHash` (e.g. after promote finalizes `package-lock.json` version).
|
||||
|
||||
### Queue — Infinite Queue and Smart Radio top-ups no longer show `…` / `0:00`
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#930](https://github.com/Psychotoxical/psysonic/pull/930)**
|
||||
|
||||
* Tracks added automatically by **Infinite Queue** or by **Smart Radio** could render as `…` / `0:00` instead of their real title and duration when the queue was filled without a queue-replacing playback (single-track enqueue from a song row, search result, etc).
|
||||
* Same root cause as PR #892 — just on the auto-add paths the earlier fix did not cover. The owning server is now pinned before each auto-top-up so the resolver cache sees the fresh tracks.
|
||||
|
||||
### Advanced Search — centered button label
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#925](https://github.com/Psychotoxical/psysonic/pull/925)**
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-Udo9/56uWmuSw/HbIm057lomG6h0rA+1GhY3k9lb/1A="
|
||||
"npmDepsHash": "sha256-emDRungiioA+m4+nz9IwVd38nXA2dUtUCYA11FW1SXo="
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.47.0-dev",
|
||||
"version": "1.47.0-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.47.0-dev",
|
||||
"version": "1.47.0-rc.1",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.47.0-dev",
|
||||
"version": "1.47.0-rc.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
|
||||
Generated
+7
-7
@@ -4053,7 +4053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.47.0-dev"
|
||||
version = "1.47.0-rc.1"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4105,7 +4105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.47.0-dev"
|
||||
version = "1.47.0-rc.1"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4124,7 +4124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.47.0-dev"
|
||||
version = "1.47.0-rc.1"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4154,7 +4154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.47.0-dev"
|
||||
version = "1.47.0-rc.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
@@ -4163,7 +4163,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.47.0-dev"
|
||||
version = "1.47.0-rc.1"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4180,7 +4180,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.47.0-dev"
|
||||
version = "1.47.0-rc.1"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4195,7 +4195,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.47.0-dev"
|
||||
version = "1.47.0-rc.1"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.47.0-dev"
|
||||
version = "1.47.0-rc.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde::Serialize;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::collections::HashMap;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::Mutex;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
@@ -369,24 +369,33 @@ mod macos {
|
||||
}
|
||||
|
||||
fn read_host_total_cpu_ticks() -> u64 {
|
||||
let mut mib = [libc::CTL_KERN, libc::KERN_CP_TIME];
|
||||
let mut cp_time = [0_u64; libc::CPUSTATES as usize];
|
||||
let mut size = mem::size_of_val(&cp_time);
|
||||
let mut num_cpus: u32 = 0;
|
||||
let mut cpu_info: *mut i32 = std::ptr::null_mut();
|
||||
let mut num_cpu_info: u32 = 0;
|
||||
let ok = unsafe {
|
||||
libc::sysctl(
|
||||
mib.as_mut_ptr(),
|
||||
mib.len() as _,
|
||||
cp_time.as_mut_ptr() as *mut _,
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
) == 0
|
||||
libc::host_processor_info(
|
||||
libc::mach_host_self(),
|
||||
libc::PROCESSOR_CPU_LOAD_INFO,
|
||||
&mut num_cpus,
|
||||
&mut cpu_info,
|
||||
&mut num_cpu_info,
|
||||
) == libc::KERN_SUCCESS
|
||||
};
|
||||
if ok {
|
||||
cp_time.iter().sum()
|
||||
} else {
|
||||
0
|
||||
if !ok || cpu_info.is_null() {
|
||||
return 0;
|
||||
}
|
||||
let total: u64 = unsafe {
|
||||
std::slice::from_raw_parts(cpu_info, num_cpu_info as usize)
|
||||
.iter()
|
||||
.map(|&ticks| ticks as u64)
|
||||
.sum()
|
||||
};
|
||||
unsafe {
|
||||
let size = num_cpu_info as usize * mem::size_of::<i32>();
|
||||
#[allow(deprecated)]
|
||||
libc::vm_deallocate(libc::mach_task_self(), cpu_info as _, size);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
fn refresh_target_processes(sys: &mut System, self_pid: Pid) -> Vec<Pid> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.47.0-dev",
|
||||
"version": "1.47.0-rc.1",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -30,7 +30,11 @@
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' ipc: http://ipc.localhost tauri:; connect-src 'self' ipc: http://ipc.localhost tauri: https: http: ws: wss:; img-src 'self' asset: http://asset.localhost https: http: data: blob:; media-src 'self' asset: http://asset.localhost https: http: data: blob:;",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA/**", "$APPLOCALDATA/**", "$DATA/**"]
|
||||
"scope": [
|
||||
"$APPDATA/**",
|
||||
"$APPLOCALDATA/**",
|
||||
"$DATA/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+12
-3
@@ -2,6 +2,7 @@ import { getSimilarSongs2, getTopSongs } from '../api/subsonicArtists';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildInfiniteQueueCandidates } from '../utils/playback/buildInfiniteQueueCandidates';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { ensureQueueServerPinned } from '../utils/playback/playbackServer';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { setIsAudioPaused } from './engineState';
|
||||
import {
|
||||
@@ -35,8 +36,11 @@ type GetState = () => PlayerState;
|
||||
*/
|
||||
function appendTracksAndPlayFirst(set: SetState, get: GetState, fresh: Track[]): void {
|
||||
if (fresh.length === 0) return;
|
||||
// Pin the server *before* reading state so the appended refs (and the
|
||||
// resolver seed) carry the canonical server key — otherwise queue rows for
|
||||
// the appended tracks render as the resolver placeholder. See PR #892.
|
||||
const serverId = ensureQueueServerPinned();
|
||||
const state = get();
|
||||
const serverId = state.queueServerId ?? '';
|
||||
if (serverId) seedQueueResolver(serverId, fresh);
|
||||
const incoming: QueueItemRef[] = toQueueItemRefs(serverId, fresh);
|
||||
const playAt = state.queueItems.length;
|
||||
@@ -93,8 +97,12 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||
// an Orbit session between scheduling and resolving.
|
||||
if (isInOrbitSession()) return;
|
||||
if (newTracks.length > 0) {
|
||||
// Pin before set so the appended refs carry the canonical server
|
||||
// key; without this the auto-added rows render as '…' / 0:00
|
||||
// when the queue was populated without a queue-replacing playTrack
|
||||
// (see PR #892).
|
||||
const serverId = ensureQueueServerPinned();
|
||||
set(state => {
|
||||
const serverId = state.queueServerId ?? '';
|
||||
if (serverId) seedQueueResolver(serverId, newTracks);
|
||||
const newItems = [...state.queueItems, ...toQueueItemRefs(serverId, newTracks)];
|
||||
return { queueItems: newItems };
|
||||
@@ -146,8 +154,9 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||
// Keep the last HISTORY_KEEP played tracks so the user can still
|
||||
// navigate backwards a few songs. Trimmed ids stay in the seen-set.
|
||||
const HISTORY_KEEP = 5;
|
||||
// Pin before set; same reasoning as the infinite top-up above.
|
||||
const serverId = ensureQueueServerPinned();
|
||||
set(state => {
|
||||
const serverId = state.queueServerId ?? '';
|
||||
if (serverId) seedQueueResolver(serverId, fresh);
|
||||
const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP);
|
||||
const newItems = [
|
||||
|
||||
@@ -22,8 +22,8 @@ import { clearSeekTarget } from './seekTargetState';
|
||||
import i18n from '../i18n';
|
||||
import { playListenSessionFinalize } from './playListenSession';
|
||||
import {
|
||||
bindQueueServerForPlayback,
|
||||
clearQueueServerForPlayback,
|
||||
ensureQueueServerPinned,
|
||||
playbackServerDiffersFromActive,
|
||||
} from '../utils/playback/playbackServer';
|
||||
import { useLuckyMixStore } from './luckyMixStore';
|
||||
@@ -57,17 +57,6 @@ function seedIncoming(state: PlayerState, tracks: Track[]): void {
|
||||
if (serverId) seedQueueResolver(serverId, tracks);
|
||||
}
|
||||
|
||||
/** Pin the active server before a mutation adds new tracks. Without this an
|
||||
* enqueue against a still-null `queueServerId` (e.g. the user's first action
|
||||
* is a single-track enqueue rather than a queue-replacing playTrack) leaves
|
||||
* `seedIncoming` as a no-op — the refs land with the empty server key, the
|
||||
* cache stays cold, and every new row renders as the resolver placeholder
|
||||
* in the queue panel. No-op when already pinned, or when no active server
|
||||
* is available (e.g. unit tests without an authed auth store). */
|
||||
function ensureQueueServerPinned(state: PlayerState): void {
|
||||
if (state.queueServerId == null) bindQueueServerForPlayback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Eleven queue-mutation actions. Shared invariant: every action except
|
||||
* `setRadioArtistId` pushes a queue-undo snapshot and calls
|
||||
@@ -99,7 +88,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
return;
|
||||
}
|
||||
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
|
||||
ensureQueueServerPinned(get());
|
||||
ensureQueueServerPinned();
|
||||
set(state => {
|
||||
seedIncoming(state, tracks);
|
||||
const items = itemsOf(state);
|
||||
@@ -131,7 +120,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
setCurrentRadioArtistId(artistId);
|
||||
}
|
||||
pushQueueUndoFromGetter(get);
|
||||
ensureQueueServerPinned(get());
|
||||
ensureQueueServerPinned();
|
||||
set(state => {
|
||||
const items = itemsOf(state);
|
||||
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
|
||||
@@ -183,7 +172,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
return;
|
||||
}
|
||||
pushQueueUndoFromGetter(get);
|
||||
ensureQueueServerPinned(get());
|
||||
ensureQueueServerPinned();
|
||||
set(state => {
|
||||
seedIncoming(state, tracks);
|
||||
const items = itemsOf(state);
|
||||
@@ -202,7 +191,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
playNext: (tracks) => {
|
||||
if (tracks.length === 0) return;
|
||||
if (blockCrossServerEnqueue()) return;
|
||||
ensureQueueServerPinned(get());
|
||||
ensureQueueServerPinned();
|
||||
const state = get();
|
||||
const tagged = tracks.map(t => ({ ...t, playNextAdded: true as const }));
|
||||
if (!state.currentTrack) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Regression cluster for the queue auto-add server-pin contract.
|
||||
*
|
||||
* The infinite-queue top-up and radio top-up paths in `nextAction.ts` used to
|
||||
* read `state.queueServerId ?? ''` directly inside their `set(state => ...)`
|
||||
* callbacks. When the queue was populated via paths that don't replace it (e.g.
|
||||
* single-track enqueue from a SongRow + button), `queueServerId` stayed null,
|
||||
* `seedQueueResolver` skipped its store-write under the `if (serverId)` guard,
|
||||
* and the auto-added refs landed with an empty server key — every new row
|
||||
* rendered as the resolver placeholder ('…' / 0:00) in the queue panel.
|
||||
*
|
||||
* PR #892 fixed the manual-enqueue surface via `ensureQueueServerPinned` inside
|
||||
* `queueMutationActions.ts`. This file pins the contract for the auto-add
|
||||
* paths that share the same helper (see `nextAction.ts`).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
|
||||
import { ensureQueueServerPinned } from '@/utils/playback/playbackServer';
|
||||
|
||||
const SERVER_A = {
|
||||
id: 'uuid-a',
|
||||
name: 'A',
|
||||
url: 'http://a.test',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
};
|
||||
const SERVER_B = {
|
||||
id: 'uuid-b',
|
||||
name: 'B',
|
||||
url: 'http://b.test',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
};
|
||||
const KEY_A = 'a.test';
|
||||
const KEY_B = 'b.test';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
resetPlayerStore();
|
||||
useAuthStore.setState({
|
||||
servers: [SERVER_A, SERVER_B],
|
||||
activeServerId: SERVER_A.id,
|
||||
isLoggedIn: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureQueueServerPinned', () => {
|
||||
it('binds the active server when queueServerId is null and returns the canonical key', () => {
|
||||
expect(usePlayerStore.getState().queueServerId).toBeNull();
|
||||
|
||||
const serverId = ensureQueueServerPinned();
|
||||
|
||||
expect(serverId).toBe(KEY_A);
|
||||
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
|
||||
});
|
||||
|
||||
it('is idempotent when queueServerId is already bound — no overwrite, no extra writes', () => {
|
||||
usePlayerStore.setState({ queueServerId: KEY_B });
|
||||
// Pin via the helper while the active server is A but the queue is bound to B
|
||||
// (e.g. cross-server enqueue blocked elsewhere; we should not silently rebind here).
|
||||
const serverId = ensureQueueServerPinned();
|
||||
|
||||
expect(serverId).toBe(KEY_B);
|
||||
expect(usePlayerStore.getState().queueServerId).toBe(KEY_B);
|
||||
});
|
||||
|
||||
it('returns an empty string and leaves queueServerId null when no active server is available', () => {
|
||||
useAuthStore.setState({ activeServerId: null });
|
||||
expect(usePlayerStore.getState().queueServerId).toBeNull();
|
||||
|
||||
const serverId = ensureQueueServerPinned();
|
||||
|
||||
// Matches the pre-existing no-active-server fallback in
|
||||
// `bindQueueServerForPlayback`: an empty key signals "do not seed the
|
||||
// resolver" without crashing the caller (unit tests, pre-login state).
|
||||
expect(serverId).toBe('');
|
||||
expect(usePlayerStore.getState().queueServerId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the canonical key after a fresh pin even when the active server id is the raw UUID', () => {
|
||||
// `bindQueueServerForPlayback` converts uuid → canonical index key.
|
||||
// This documents that the returned value is the same value `toQueueItemRefs`
|
||||
// should be called with, not the auth-store uuid.
|
||||
useAuthStore.setState({ activeServerId: SERVER_B.id });
|
||||
usePlayerStore.setState({ queueServerId: null });
|
||||
|
||||
const serverId = ensureQueueServerPinned();
|
||||
|
||||
expect(serverId).toBe(KEY_B);
|
||||
expect(serverId).not.toBe(SERVER_B.id);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,27 @@ export function bindQueueServerForPlayback(): void {
|
||||
usePlayerStore.setState({ queueServerId: canonical });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind `queueServerId` via {@link bindQueueServerForPlayback} when it is still
|
||||
* null, then return the (now-bound) server identifier. Call this synchronously
|
||||
* before any state mutation that adds new tracks to the queue.
|
||||
*
|
||||
* Without the pin, refs land with an empty server key, {@link seedQueueResolver}
|
||||
* skips its store-write, and queue rows render as the resolver placeholder
|
||||
* (`…` / 0:00) until something else binds the server (see PR #892). Affects
|
||||
* both the manual enqueue mutations and the auto-add paths (infinite-queue
|
||||
* top-up, radio top-up).
|
||||
*
|
||||
* Idempotent: no-op when already pinned. Returns `''` when no active server is
|
||||
* available to pin (e.g. unit tests without an authed store).
|
||||
*/
|
||||
export function ensureQueueServerPinned(): string {
|
||||
if (usePlayerStore.getState().queueServerId == null) {
|
||||
bindQueueServerForPlayback();
|
||||
}
|
||||
return usePlayerStore.getState().queueServerId ?? '';
|
||||
}
|
||||
|
||||
export function clearQueueServerForPlayback(): void {
|
||||
usePlayerStore.setState({ queueServerId: null });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user