Merge master, dedupe the kept copy, and retire superseded switches

Master had moved ten commits ahead. Two conflicts, both resolved without
losing either side: the `XTREAM_PROBE_URL` handler this PR extracted into
`events/stream-probe.ts` stays extracted (master added performance capture
nearby but never touched that handler), and the mock-server scenario table
takes the union of master's `performance` row and this branch's `multisrc`
pair.

Two findings fixed alongside it.

Pinning the copy the route is already on makes discovery return that very
row, so prepending the current source listed one stream twice — a phantom
copy in the grouping and a chip that counted it.

And handing the "playing" badge back to the route row left an in-flight
switch valid, so a slow resolution could arrive afterwards and replace the
playback the user had just started with Play, Resume or Restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 08:44:49 +02:00
commit 9f0a8b4f19
385 files changed
+58306 -2264

No files matched your search

@@ -287,3 +287,110 @@ describe('SettingsStore dashboard rail settings', () => {
);
});
});
describe('SettingsStore storage failure reporting', () => {
let injector: Injector;
let storage: {
get: jest.Mock;
set: jest.Mock;
};
let consoleError: jest.SpyInstance;
beforeEach(() => {
storage = {
get: jest.fn(() => of(null)),
set: jest.fn(() => of(undefined)),
};
consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
injector = Injector.create({
providers: [
SettingsStore,
{
provide: StorageMap,
useValue: storage,
},
],
});
});
afterEach(() => {
consoleError.mockRestore();
});
it('reports no failure while storage works', async () => {
const store = injector.get(SettingsStore);
await store.loadSettings();
await store.updateSettings({ language: Language.FRENCH });
expect(store.storageFailure()).toBeNull();
});
it('flags a failed initial load so defaults are not mistaken for saved values', async () => {
const failingSettings = new Subject<Partial<Settings> | null>();
storage.get.mockReturnValueOnce(failingSettings.asObservable());
const store = injector.get(SettingsStore);
const initialLoad = store.loadSettings();
failingSettings.error(new Error('storage unavailable'));
await initialLoad;
expect(store.storageFailure()).toBe('load');
expect(store.getSettings().language).toBe(Language.ENGLISH);
});
it('flags a failed save and rethrows instead of silently keeping the in-memory change', async () => {
storage.set.mockImplementationOnce(() => {
throw new Error('quota exceeded');
});
const store = injector.get(SettingsStore);
await store.loadSettings();
await expect(
store.updateSettings({ language: Language.FRENCH })
).rejects.toThrow('quota exceeded');
expect(store.storageFailure()).toBe('save');
// The in-memory patch still applied — that is exactly why the flag
// matters: the UI shows French but nothing reached disk.
expect(store.getSettings().language).toBe(Language.FRENCH);
});
it('clears the failure once a later save succeeds', async () => {
storage.set.mockImplementationOnce(() => {
throw new Error('quota exceeded');
});
const store = injector.get(SettingsStore);
await store.loadSettings();
await expect(
store.updateSettings({ language: Language.FRENCH })
).rejects.toThrow('quota exceeded');
expect(store.storageFailure()).toBe('save');
await store.updateSettings({ language: Language.GERMAN });
expect(store.storageFailure()).toBeNull();
});
it('clears a load failure once the retried load succeeds', async () => {
const failingSettings = new Subject<Partial<Settings> | null>();
storage.get
.mockReturnValueOnce(failingSettings.asObservable())
.mockReturnValueOnce(of({ language: Language.FRENCH }));
const store = injector.get(SettingsStore);
const initialLoad = store.loadSettings();
failingSettings.error(new Error('storage unavailable'));
await initialLoad;
expect(store.storageFailure()).toBe('load');
await store.loadSettings();
expect(store.storageFailure()).toBeNull();
expect(store.getSettings().language).toBe(Language.FRENCH);
});
});
@@ -60,6 +60,23 @@ const DEFAULT_SETTINGS: Settings = {
tmdb: DEFAULT_TMDB_SETTINGS,
};
/**
* Which half of the settings persistence round-trip failed, if any.
*
* Settings live in the renderer's IndexedDB, which can be unavailable for
* reasons the app cannot control (a second instance holding the Chromium
* storage lock, a corrupted profile, storage blocked by security software).
* Both failures used to be swallowed: `updateSettings` patches the in-memory
* state before persisting, so a failed write still looked applied until the
* next restart (issue #1156). Recording the failure lets the settings UI say
* so instead of pretending the change stuck.
*/
export type SettingsStorageFailure = 'load' | 'save';
interface SettingsStorageState {
storageFailure: SettingsStorageFailure | null;
}
let embeddedMpvPrepareScheduled = false;
function scheduleEmbeddedMpvPrepare(): void {
@@ -102,6 +119,7 @@ function scheduleEmbeddedMpvPrepare(): void {
export const SettingsStore = signalStore(
{ providedIn: 'root' },
withState<Settings>(DEFAULT_SETTINGS),
withState<SettingsStorageState>({ storageFailure: null }),
withComputed((store) => ({
/**
* Live EPG panel layout with the `'timeline'` default applied — the
@@ -125,6 +143,7 @@ export const SettingsStore = signalStore(
const stored = await firstValueFrom(
storage.get(STORE_KEY.Settings)
);
patchState(store, { storageFailure: null });
if (stored) {
const storedSettings = stored as Partial<Settings>;
patchState(store, {
@@ -148,7 +167,10 @@ export const SettingsStore = signalStore(
})().catch((error) => {
settingsLoadPromise = undefined;
console.error('Failed to load settings:', error);
// Keep default settings if loading fails
// Keep default settings if loading fails, but remember
// that they are defaults-by-failure rather than by choice
// so the settings UI can warn about it.
patchState(store, { storageFailure: 'load' });
});
return settingsLoadPromise;
@@ -177,11 +199,15 @@ export const SettingsStore = signalStore(
await firstValueFrom(
storage.set(STORE_KEY.Settings, completeSettings)
);
patchState(store, { storageFailure: null });
if (completeSettings.player === VideoPlayer.EmbeddedMpv) {
scheduleEmbeddedMpvPrepare();
}
} catch (error) {
console.error('Failed to save settings:', error);
// The in-memory patch above already applied, so without
// this flag the change looks saved until the next restart.
patchState(store, { storageFailure: 'save' });
throw error;
}
},