mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
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:
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;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user