diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.html b/apps/web/src/app/server-storage/server-migration-dialog.component.html
new file mode 100644
index 000000000..b5c06a9da
--- /dev/null
+++ b/apps/web/src/app/server-storage/server-migration-dialog.component.html
@@ -0,0 +1,69 @@
+
+ {{
+ 'SERVER_STORAGE.MIGRATION_TITLE'
+ | translate: { count: playlists.length }
+ }}
+
+
+
+ @if (result(); as done) {
+
+ {{ 'SERVER_STORAGE.MIGRATION_DONE' | translate: done }}
+
+ } @else {
+ {{ 'SERVER_STORAGE.MIGRATION_DESCRIPTION' | translate }}
+
+ @for (playlist of playlists; track playlist.id) {
+ -
+ {{
+ icons[playlist.kind]
+ }}
+ {{ playlist.title }}
+
+ }
+
+
+ {{ 'SERVER_STORAGE.MIGRATION_LATER_HINT' | translate }}
+
+ @if (failed()) {
+
+ {{ 'SERVER_STORAGE.MIGRATION_FAILED' | translate }}
+
+ }
+ }
+
+
+
+ @if (result()) {
+
+ } @else {
+
+
+ }
+
diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.scss b/apps/web/src/app/server-storage/server-migration-dialog.component.scss
new file mode 100644
index 000000000..a5030ec0b
--- /dev/null
+++ b/apps/web/src/app/server-storage/server-migration-dialog.component.scss
@@ -0,0 +1,46 @@
+.server-migration {
+ p {
+ margin: 0 0 12px;
+ color: var(--app-body-color);
+ }
+}
+
+.server-migration__list {
+ list-style: none;
+ margin: 0 0 12px;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+
+ li {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: var(--app-card-hover-bg);
+ color: var(--app-heading-color);
+
+ mat-icon {
+ flex-shrink: 0;
+ }
+
+ span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+}
+
+.server-migration .server-migration__hint {
+ color: var(--app-muted-color);
+ font-size: 0.8rem;
+}
+
+.server-migration .server-migration__error {
+ color: var(--app-expiry-expired-text);
+}
diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.spec.ts b/apps/web/src/app/server-storage/server-migration-dialog.component.spec.ts
new file mode 100644
index 000000000..2abb52a8f
--- /dev/null
+++ b/apps/web/src/app/server-storage/server-migration-dialog.component.spec.ts
@@ -0,0 +1,72 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { TranslateModule } from '@ngx-translate/core';
+import { ServerMigrationService } from '@iptvnator/services/server-storage';
+import { ServerMigrationDialogComponent } from './server-migration-dialog.component';
+
+describe('ServerMigrationDialogComponent', () => {
+ let fixture: ComponentFixture;
+ const migration = { skip: jest.fn(), moveBrowserData: jest.fn() };
+ const dialogRef = { close: jest.fn() };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ ServerMigrationDialogComponent,
+ TranslateModule.forRoot(),
+ ],
+ providers: [
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: [{ id: 'm', title: 'Family', kind: 'm3u' }],
+ },
+ { provide: ServerMigrationService, useValue: migration },
+ { provide: MatDialogRef, useValue: dialogRef },
+ ],
+ }).compileComponents();
+ fixture = TestBed.createComponent(ServerMigrationDialogComponent);
+ fixture.detectChanges();
+ });
+
+ afterEach(() => jest.resetAllMocks());
+
+ const byTestId = (id: string) =>
+ fixture.nativeElement.querySelector(`[data-test-id="${id}"]`);
+
+ it('lists the browser playlists', () => {
+ expect(fixture.nativeElement.textContent).toContain('Family');
+ });
+
+ it('records "not now" and closes', () => {
+ byTestId('server-migration-not-now').click();
+
+ expect(migration.skip).toHaveBeenCalled();
+ expect(dialogRef.close).toHaveBeenCalled();
+ });
+
+ it('moves the data and offers a reload', async () => {
+ const result = { imported: 1, merged: 0, failed: 0 };
+ migration.moveBrowserData.mockResolvedValue(result);
+
+ await fixture.componentInstance.move();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.result()).toEqual(result);
+ expect(byTestId('server-migration-result')).not.toBeNull();
+ expect(byTestId('server-migration-reload')).not.toBeNull();
+ expect(byTestId('server-migration-move')).toBeNull();
+ });
+
+ it('keeps the dialog open with a message when moving fails', async () => {
+ migration.moveBrowserData.mockRejectedValue(new Error('offline'));
+
+ await fixture.componentInstance.move();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.failed()).toBe(true);
+ expect(fixture.nativeElement.textContent).toContain(
+ 'SERVER_STORAGE.MIGRATION_FAILED'
+ );
+ expect(dialogRef.close).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.ts b/apps/web/src/app/server-storage/server-migration-dialog.component.ts
new file mode 100644
index 000000000..dd741a96d
--- /dev/null
+++ b/apps/web/src/app/server-storage/server-migration-dialog.component.ts
@@ -0,0 +1,70 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ inject,
+ signal,
+} from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import {
+ MAT_DIALOG_DATA,
+ MatDialogModule,
+ MatDialogRef,
+} from '@angular/material/dialog';
+import { MatIconModule } from '@angular/material/icon';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { TranslatePipe } from '@ngx-translate/core';
+import {
+ ServerMigrationService,
+ type ServerMigrationPlaylist,
+ type ServerMigrationResult,
+} from '@iptvnator/services/server-storage';
+import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
+
+@Component({
+ selector: 'app-server-migration-dialog',
+ imports: [
+ MatButtonModule,
+ MatDialogModule,
+ MatIconModule,
+ MatProgressSpinnerModule,
+ TranslatePipe,
+ ],
+ templateUrl: './server-migration-dialog.component.html',
+ styleUrl: './server-migration-dialog.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ServerMigrationDialogComponent {
+ private readonly migration = inject(ServerMigrationService);
+ private readonly dialogRef = inject(
+ MatDialogRef
+ );
+
+ readonly playlists =
+ inject(MAT_DIALOG_DATA);
+ readonly icons = SOURCE_TYPE_ICONS;
+ readonly busy = signal(false);
+ readonly failed = signal(false);
+ readonly result = signal(null);
+
+ notNow(): void {
+ this.migration.skip();
+ this.dialogRef.close();
+ }
+
+ async move(): Promise {
+ this.busy.set(true);
+ this.failed.set(false);
+ try {
+ this.result.set(await this.migration.moveBrowserData());
+ } catch {
+ this.failed.set(true);
+ } finally {
+ this.busy.set(false);
+ }
+ }
+
+ /** The library was replaced underneath every open view. */
+ reload(): void {
+ window.location.reload();
+ }
+}
diff --git a/apps/web/src/app/server-storage/server-sign-in.component.html b/apps/web/src/app/server-storage/server-sign-in.component.html
new file mode 100644
index 000000000..00eda3fdc
--- /dev/null
+++ b/apps/web/src/app/server-storage/server-sign-in.component.html
@@ -0,0 +1,62 @@
+
+
+
diff --git a/apps/web/src/app/server-storage/server-sign-in.component.scss b/apps/web/src/app/server-storage/server-sign-in.component.scss
new file mode 100644
index 000000000..14880084f
--- /dev/null
+++ b/apps/web/src/app/server-storage/server-sign-in.component.scss
@@ -0,0 +1,56 @@
+:host {
+ display: block;
+ min-height: 100%;
+ background: var(--app-shell-bg);
+}
+
+.server-sign-in {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 16px;
+ box-sizing: border-box;
+}
+
+.server-sign-in__card {
+ width: min(100%, 420px);
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 28px;
+ box-sizing: border-box;
+ border-radius: 20px;
+ background: var(--app-widget-bg);
+ box-shadow: var(--app-widget-shadow);
+ color: var(--app-on-surface);
+
+ h1 {
+ margin: 0;
+ font-size: 1.3rem;
+ font-weight: 600;
+ color: var(--app-heading-color);
+ }
+
+ p {
+ margin: 0;
+ color: var(--app-body-color);
+ font-size: 0.9rem;
+ line-height: 1.45;
+ }
+
+ button[type='submit'] {
+ align-self: flex-end;
+ }
+}
+
+.server-sign-in__logo {
+ border-radius: 12px;
+}
+
+.server-sign-in .server-sign-in__feedback {
+ color: var(--app-expiry-expired-text);
+
+ &:empty {
+ display: none;
+ }
+}
diff --git a/apps/web/src/app/server-storage/server-sign-in.component.spec.ts b/apps/web/src/app/server-storage/server-sign-in.component.spec.ts
new file mode 100644
index 000000000..748cf6096
--- /dev/null
+++ b/apps/web/src/app/server-storage/server-sign-in.component.spec.ts
@@ -0,0 +1,85 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TranslateModule } from '@ngx-translate/core';
+import {
+ ServerApiError,
+ ServerStorageSession,
+} from '@iptvnator/services/server-storage';
+import { ServerSignInComponent } from './server-sign-in.component';
+
+const TOKEN_KEY = 'iptvnator-server:access-token';
+
+describe('ServerSignInComponent', () => {
+ let fixture: ComponentFixture;
+ let component: ServerSignInComponent;
+ const session = { fetchStatus: jest.fn() };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ServerSignInComponent, TranslateModule.forRoot()],
+ providers: [{ provide: ServerStorageSession, useValue: session }],
+ }).compileComponents();
+ fixture = TestBed.createComponent(ServerSignInComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ jest.resetAllMocks();
+ });
+
+ const feedbackText = () =>
+ fixture.nativeElement
+ .querySelector('[data-test-id="server-sign-in-feedback"]')
+ .textContent.trim();
+
+ it('keeps an accepted token and reloads into the workspace', async () => {
+ session.fetchStatus.mockResolvedValue({ playlists: 0 });
+ const open = jest
+ .spyOn(component, 'openWorkspace')
+ .mockImplementation(() => undefined);
+
+ component.edit(' right ');
+ await component.submit(new Event('submit'));
+
+ expect(localStorage.getItem(TOKEN_KEY)).toBe('right');
+ expect(open).toHaveBeenCalled();
+ });
+
+ it('says the token was refused and forgets it', async () => {
+ session.fetchStatus.mockRejectedValue(
+ new ServerApiError(401, 'unauthorized', 'refused')
+ );
+
+ component.edit('wrong');
+ await component.submit(new Event('submit'));
+ fixture.detectChanges();
+
+ expect(component.feedback()).toBe('rejected');
+ expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_REJECTED');
+ expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
+ });
+
+ it('tells an unreachable server apart and clears the message on edit', async () => {
+ session.fetchStatus.mockRejectedValue(
+ new ServerApiError(0, undefined, 'offline')
+ );
+
+ component.edit('token');
+ await component.submit(new Event('submit'));
+ fixture.detectChanges();
+ expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_UNREACHABLE');
+
+ component.edit('token2');
+ fixture.detectChanges();
+ expect(feedbackText()).toBe('');
+ });
+
+ it('does not submit an empty token', async () => {
+ component.edit(' ');
+ await component.submit(new Event('submit'));
+
+ expect(session.fetchStatus).not.toHaveBeenCalled();
+ expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
+ });
+});
diff --git a/apps/web/src/app/server-storage/server-sign-in.component.ts b/apps/web/src/app/server-storage/server-sign-in.component.ts
new file mode 100644
index 000000000..dd7da1bb3
--- /dev/null
+++ b/apps/web/src/app/server-storage/server-sign-in.component.ts
@@ -0,0 +1,78 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ inject,
+ signal,
+} from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatIconModule } from '@angular/material/icon';
+import { MatInputModule } from '@angular/material/input';
+import { TranslatePipe } from '@ngx-translate/core';
+import {
+ clearServerAccessToken,
+ writeServerAccessToken,
+} from '@iptvnator/services';
+import {
+ ServerApiError,
+ ServerStorageSession,
+} from '@iptvnator/services/server-storage';
+import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
+
+export type ServerSignInFeedback = 'rejected' | 'unreachable';
+
+@Component({
+ selector: 'app-server-sign-in',
+ imports: [
+ MatButtonModule,
+ MatFormFieldModule,
+ MatIconModule,
+ MatInputModule,
+ PasswordVisibilityToggleDirective,
+ TranslatePipe,
+ ],
+ templateUrl: './server-sign-in.component.html',
+ styleUrl: './server-sign-in.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ServerSignInComponent {
+ private readonly session = inject(ServerStorageSession);
+
+ readonly token = signal('');
+ readonly busy = signal(false);
+ readonly feedback = signal(null);
+
+ edit(value: string): void {
+ this.token.set(value);
+ this.feedback.set(null);
+ }
+
+ async submit(event: Event): Promise {
+ event.preventDefault();
+ const token = this.token().trim();
+ if (!token || this.busy()) {
+ return;
+ }
+ this.busy.set(true);
+ this.feedback.set(null);
+ writeServerAccessToken(token);
+ try {
+ await this.session.fetchStatus();
+ this.openWorkspace();
+ } catch (error) {
+ clearServerAccessToken();
+ this.feedback.set(
+ error instanceof ServerApiError && error.status === 401
+ ? 'rejected'
+ : 'unreachable'
+ );
+ } finally {
+ this.busy.set(false);
+ }
+ }
+
+ /** Full reload so every service starts with the token. */
+ openWorkspace(): void {
+ window.location.assign(new URL('workspace', document.baseURI).href);
+ }
+}