fix(server-storage): merge user data on move, keep failed writes, sign-in route gate

- A merged playlist gains the browser document's favorites and history;
  Xtream values for one server entry are combined (lists unioned, maps
  merged, the server winning conflicts) and written once.
- A failed user-data write stays queued and survives a re-read; the
  page-hide flush waits for a batch still travelling.
- The start-up screen lets the sign-in route through whatever its check
  reports.
- The backend refuses IPTVNATOR_ACCESS_TOKEN without server storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:56 +02:00
1 parent 676e3adcd4
commit fc32934ce3
17 files changed
+474 -90

No files matched your search

@@ -6,7 +6,7 @@ import {
selectPlaylistsLoadFailed,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { ServerStorageStatusService } from '@iptvnator/services';
import { provideRouter, Router } from '@angular/router';
import { AppStartupStatusComponent } from './app-startup-status.component';
describe('AppStartupStatusComponent', () => {
@@ -14,6 +14,7 @@ describe('AppStartupStatusComponent', () => {
TestBed.configureTestingModule({
imports: [AppStartupStatusComponent, TranslateModule.forRoot()],
providers: [
provideRouter([{ path: 'server-sign-in', children: [] }]),
provideMockStore({
selectors: [
{ selector: selectPlaylistsLoadingFlag, value: false },
@@ -49,26 +50,18 @@ describe('AppStartupStatusComponent', () => {
expect(fixture.componentInstance.complete()).toBe(true);
});
it('shows the server sign-in page although sources could not load', () => {
it('shows the server sign-in page although sources could not load', async () => {
TestBed.inject(MockStore).overrideSelector(
selectPlaylistsLoadFailed,
true
);
const status = TestBed.inject(ServerStorageStatusService);
status.activate(
{
service: 'iptvnator',
apiVersion: 1,
instanceId: 'i',
storage: 'server',
auth: 'token',
},
() => undefined
);
status.reportUnauthorized();
const fixture = TestBed.createComponent(AppStartupStatusComponent);
fixture.componentRef.setInput('routeReady', true);
fixture.detectChanges();
expect(fixture.componentInstance.complete()).toBe(false);
await TestBed.inject(Router).navigateByUrl('/server-sign-in');
fixture.detectChanges();
expect(fixture.componentInstance.complete()).toBe(true);
expect(fixture.nativeElement.querySelector('section')).toBeNull();
@@ -5,16 +5,19 @@ import {
inject,
input,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { filter, map } from 'rxjs';
import {
PlaylistActions,
selectPlaylistsLoadFailed,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { ServerStorageStatusService } from '@iptvnator/services';
import { SERVER_SIGN_IN_PATH } from '@iptvnator/services';
/** The first route also waits for settings and XMLTV source reconciliation. */
@Component({
@@ -131,14 +134,23 @@ export class AppStartupStatusComponent {
selectPlaylistsLoadingFlag
);
readonly failed = this.store.selectSignal(selectPlaylistsLoadFailed);
private readonly serverStatus = inject(ServerStorageStatusService);
/** Server storage waiting for a token: only its sign-in page can load. */
private readonly signInRequired = computed(
() => this.serverStatus.state() === 'unauthorized'
private readonly router = inject(Router);
private readonly url = toSignal(
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
map((event) => event.urlAfterRedirects)
),
{ initialValue: this.router.url }
);
/**
* Server storage asking for a token: the playlists cannot load, and the
* sign-in page must stay up whatever its check reports.
*/
private readonly onSignInPage = computed(() =>
this.url().startsWith(SERVER_SIGN_IN_PATH)
);
readonly complete = computed(
() =>
this.routeReady() && (this.sourcesReady() || this.signInRequired())
() => this.routeReady() && (this.sourcesReady() || this.onSignInPage())
);
retry(): void {
@@ -1,11 +1,14 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ServerStorageStatusService } from '@iptvnator/services';
import {
SERVER_SIGN_IN_PATH,
ServerStorageStatusService,
} from '@iptvnator/services';
/** Without a valid token the workspace would show an empty library. */
export const serverAccessGuard: CanActivateFn = () =>
inject(ServerStorageStatusService).state() === 'unauthorized'
? inject(Router).parseUrl('/server-sign-in')
? inject(Router).parseUrl(SERVER_SIGN_IN_PATH)
: true;
export const serverSignInGuard: CanActivateFn = () =>