Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ Alpine.data('app', (): AppData => {
prefEdits: {},
prefSaveStatus: null,
lightboxImg: null,
watchUsers: [],
watchUserId: (() => {
try { return localStorage.getItem('wb_watchUser') || ''; }
catch { return ''; }
})(),
showUserPicker: false,
// Random per-browser key so the manually chosen episode is not shared between viewers.
clientId: (() => {
const fresh = () => crypto.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
try {
const stored = localStorage.getItem('wb_clientId');
if (stored && /^[A-Za-z0-9_-]{1,64}$/.test(stored)) return stored;
const id = fresh();
localStorage.setItem('wb_clientId', id);
return id;
} catch { return fresh(); }
})(),
groupByThread: localStorage.getItem('wb_groupByThread') === 'true',
theme: localStorage.getItem('wb_theme') || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'),
collapsedSections: (() => {
Expand Down
59 changes: 55 additions & 4 deletions frontend/src/modules/sync.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import type { AppData, MediaSearchResult, SeasonSummary, EpisodeResult } from '../types';
import type { AppData, MediaSearchResult, SeasonSummary, EpisodeResult, WatchUser } from '../types';
import { uiLog } from '../utils/uiLogger';

const syncMethods: Record<string, unknown> & ThisType<AppData> = {
// Identifies this viewer to the server: the chosen Jellyfin user (so browsers signed in as the
// same user share state) and this browser's own id (so everyone else stays independent).
viewerQuery() {
const params = new URLSearchParams();
if (this.watchUserId) params.set('userId', this.watchUserId);
if (this.clientId) params.set('clientId', this.clientId);
const query = params.toString();
return query ? '?' + query : '';
},

async sync() {
console.debug("[WatchBack] Triggering sync...");
uiLog("sync.trigger", "Sync trigger sent", undefined, "Information");
this.data = null;
try {
const res = await fetch('/api/sync/trigger', { method: 'POST' });
const res = await fetch('/api/sync/trigger' + this.viewerQuery(), { method: 'POST' });
uiLog("sync.trigger.response", "Sync trigger response", { status: res.status });
} catch (e) {
console.error("[WatchBack] Trigger failed:", e);
Expand All @@ -16,6 +26,47 @@ const syncMethods: Record<string, unknown> & ThisType<AppData> = {
}
},

// Asks the server who can be followed. The picker is only offered when the
// watch source has more than one user; a single user needs no choice.
async loadWatchUsers() {
let users: WatchUser[] = [];
try {
const res = await fetch('/api/watchstate/users', { signal: AbortSignal.timeout(8000) });
if (res.ok) users = await res.json() as WatchUser[];
} catch (e) {
uiLog("watchUsers.error", "Watch user list failed", { error: String(e) }, "Warning");
}
this.watchUsers = users;
if (users.length < 2) {
this.watchUserId = '';
this.showUserPicker = false;
try { localStorage.removeItem('wb_watchUser'); } catch { /* storage unavailable */ }
return;
}
if (!users.some(u => u.id === this.watchUserId)) {
this.watchUserId = '';
this.showUserPicker = true;
}
},

selectWatchUser(id: string) {
uiLog("watchUsers.select", "Watch user selected", { id });
this.watchUserId = id;
this.showUserPicker = false;
try { localStorage.setItem('wb_watchUser', id); } catch { /* storage unavailable */ }
this.data = null;
this.setupSSE();
void this.sync();
},

currentWatchUserName() {
return this.watchUsers.find(u => u.id === this.watchUserId)?.name ?? '';
},

manualStateUrl() {
return '/api/watchstate/manual' + this.viewerQuery();
},

async searchMedia() {
const q = this.searchQuery.trim();
if (!q) return;
Expand Down Expand Up @@ -113,7 +164,7 @@ const syncMethods: Record<string, unknown> & ThisType<AppData> = {
async setManualWatchState(context: Record<string, unknown>) {
uiLog("sync.manualState.set", "Setting manual watch state", { title: context['title'] }, "Information");
try {
const res = await fetch('/api/watchstate/manual', {
const res = await fetch(this.manualStateUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down Expand Up @@ -146,7 +197,7 @@ const syncMethods: Record<string, unknown> & ThisType<AppData> = {
async clearManualWatchState() {
uiLog("sync.manualState.clear", "Clearing manual watch state", undefined, "Information");
try {
await fetch('/api/watchstate/manual', { method: 'DELETE' });
await fetch(this.manualStateUrl(), { method: 'DELETE' });
await this.sync();
} catch (e) {
uiLog("sync.manualState.clearError", "Clear manual watch state failed", { error: String(e) }, "Error");
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/modules/system.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { AppData, SyncData, LogEntry, SyncHistoryStatus, SyncHistoryEntry } from '../types';
import { uiLog } from '../utils/uiLogger';

// Held outside Alpine's reactive state: proxying an EventSource breaks its internals.
let syncStream: EventSource | null = null;

const systemMethods: Record<string, unknown> & ThisType<AppData> = {
setupSSE() {
// Bar visibility is driven by intermediate-progress events: the backend
Expand Down Expand Up @@ -33,10 +36,16 @@ const systemMethods: Record<string, unknown> & ThisType<AppData> = {
if (clearTimer !== null) { clearTimeout(clearTimer); clearTimer = null; }
};

// Reconnecting (e.g. after switching user) must not leave the old stream feeding this view.
syncStream?.close();
resetBarState();

uiLog("sse.open", "Sync SSE connecting", undefined, "Information");
const streamUrl = '/api/sync/stream' + this.viewerQuery();
const es = new (window as unknown as Window & { ReconnectingEventSource: new (url: string, opts: Record<string, unknown>) => EventSource }).ReconnectingEventSource(
'/api/sync/stream', { max_retry_time: 60000 }
streamUrl, { max_retry_time: 60000 }
);
syncStream = es;
es.onmessage = (e: MessageEvent) => {
if (!e.data) return;
try {
Expand Down Expand Up @@ -189,7 +198,7 @@ const systemMethods: Record<string, unknown> & ThisType<AppData> = {
}
} catch { /* ignore */ }
try {
const res = await fetch('/api/diagnostics/status');
const res = await fetch('/api/diagnostics/status' + this.viewerQuery());
if (res.ok) {
const data = await res.json() as Record<string, unknown>;
this.appVersion = (data['version'] as string | undefined) ?? null;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/modules/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ const uiMethods: Record<string, unknown> & ThisType<AppData> = {

this.initialized = true;
uiLog("app.initApp.ready", "App initialized, starting SSE and sync", undefined, "Information");
await this.loadWatchUsers();
this.setupSSE();
void this.sync();

Expand Down
14 changes: 14 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
export type SaveStatus = 'saving' | 'saved' | 'error' | null;
export type TestStatus = 'testing' | 'ok' | 'error' | 'warn' | null;

export interface WatchUser {
id: string;
name: string;
}

export interface Thought {
source: string;
brandColor?: string;
Expand Down Expand Up @@ -167,6 +172,10 @@ export interface AppData {
prefEdits: Record<string, unknown>;
prefSaveStatus: SaveStatus;
lightboxImg: string | null;
watchUsers: WatchUser[];
watchUserId: string;
clientId: string;
showUserPicker: boolean;
groupByThread: boolean;
theme: string;
collapsedSections: Record<string, boolean>;
Expand Down Expand Up @@ -298,6 +307,11 @@ export interface AppData {
clearManualWatchState(): Promise<void>;

setupSSE(): void;
viewerQuery(): string;
manualStateUrl(): string;
loadWatchUsers(): Promise<void>;
selectWatchUser(id: string): void;
currentWatchUserName(): string;
clearCache(): Promise<void>;
restart(): Promise<void>;
waitForServerRestart(): Promise<void>;
Expand Down
103 changes: 100 additions & 3 deletions frontend/tests/modules/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ function makeCtx(overrides: Record<string, unknown> = {}): Record<string, unknow
drilldownEpisodes: [] as unknown[],
drilldownLoading: false,
data: null,
watchUsers: [] as unknown[],
watchUserId: '',
showUserPicker: false,
clientId: 'client-1',
viewerQuery: methods.viewerQuery,
manualStateUrl: methods.manualStateUrl,
setupSSE: vi.fn(),
t: (key: string) => key,
showError: vi.fn(),
sync: vi.fn(),
Expand All @@ -30,7 +37,7 @@ describe('sync', () => {
vi.stubGlobal('fetch', fetchMock);
const ctx = makeCtx();
await methods.sync.call(ctx);
expect(fetchMock).toHaveBeenCalledWith('/api/sync/trigger', { method: 'POST' });
expect(fetchMock).toHaveBeenCalledWith('/api/sync/trigger?clientId=client-1', { method: 'POST' });
});

it('calls showError on network failure', async () => {
Expand Down Expand Up @@ -243,7 +250,7 @@ describe('setManualWatchState', () => {
externalIds: { imdb: 'tt123' },
});
const [url, opts] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/watchstate/manual');
expect(url).toBe('/api/watchstate/manual?clientId=client-1');
expect(opts.method).toBe('POST');
const body = JSON.parse(opts.body as string);
expect(body.title).toBe('Show');
Expand All @@ -262,7 +269,7 @@ describe('clearManualWatchState', () => {
const ctx = makeCtx();
await methods.clearManualWatchState.call(ctx);
const [url, opts] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/watchstate/manual');
expect(url).toBe('/api/watchstate/manual?clientId=client-1');
expect(opts.method).toBe('DELETE');
expect((ctx.sync as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
});
Expand Down Expand Up @@ -329,3 +336,93 @@ describe('selectEpisode', () => {
expect(call.externalIds).toEqual({ imdb: 'tt0001' });
});
});

// ── viewer identity ───────────────────────────────────────────────────────────

describe('viewerQuery', () => {
it('carries both the chosen user and this browser id', () => {
expect(methods.viewerQuery.call(makeCtx({ watchUserId: 'u1', clientId: 'c-1' }))).toBe('?userId=u1&clientId=c-1');
});

it('falls back to the browser id when no user is chosen', () => {
expect(methods.viewerQuery.call(makeCtx())).toBe('?clientId=client-1');
});

it('is empty without any identity', () => {
expect(methods.viewerQuery.call(makeCtx({ clientId: '' }))).toBe('');
});
});

// ── watch user selection ──────────────────────────────────────────────────────

describe('loadWatchUsers', () => {
const users = [{ id: 'a', name: 'Alice' }, { id: 'b', name: 'Bob' }];

function stubUsers(body: unknown, ok = true) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok, json: async () => body }));
}

afterEach(() => { localStorage.clear(); });

it('prompts when several users exist and none is chosen', async () => {
stubUsers(users);
const ctx = makeCtx();
await methods.loadWatchUsers.call(ctx);
expect(ctx.watchUsers).toEqual(users);
expect(ctx.showUserPicker).toBe(true);
});

it('keeps a stored choice that is still valid without prompting', async () => {
stubUsers(users);
const ctx = makeCtx({ watchUserId: 'b' });
await methods.loadWatchUsers.call(ctx);
expect(ctx.watchUserId).toBe('b');
expect(ctx.showUserPicker).toBe(false);
});

it('drops a stale stored choice and prompts again', async () => {
stubUsers(users);
const ctx = makeCtx({ watchUserId: 'gone' });
await methods.loadWatchUsers.call(ctx);
expect(ctx.watchUserId).toBe('');
expect(ctx.showUserPicker).toBe(true);
});

it('does not prompt and clears the choice with a single user', async () => {
localStorage.setItem('wb_watchUser', 'a');
stubUsers([users[0]]);
const ctx = makeCtx({ watchUserId: 'a' });
await methods.loadWatchUsers.call(ctx);
expect(ctx.watchUserId).toBe('');
expect(ctx.showUserPicker).toBe(false);
expect(localStorage.getItem('wb_watchUser')).toBeNull();
});

it('does not prompt when the request rejects or times out', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('timed out', 'TimeoutError')));
const ctx = makeCtx();
await methods.loadWatchUsers.call(ctx);
expect(ctx.watchUsers).toEqual([]);
expect(ctx.showUserPicker).toBe(false);
});

it('does not prompt when the request fails', async () => {
stubUsers(null, false);
const ctx = makeCtx();
await methods.loadWatchUsers.call(ctx);
expect(ctx.showUserPicker).toBe(false);
});
});

describe('selectWatchUser', () => {
afterEach(() => { localStorage.clear(); });

it('stores the choice, closes the picker and reconnects the stream', () => {
const ctx = makeCtx({ showUserPicker: true });
methods.selectWatchUser.call(ctx, 'b');
expect(ctx.watchUserId).toBe('b');
expect(ctx.showUserPicker).toBe(false);
expect(localStorage.getItem('wb_watchUser')).toBe('b');
expect(ctx.setupSSE as ReturnType<typeof vi.fn>).toHaveBeenCalled();
});
});
40 changes: 39 additions & 1 deletion frontend/tests/modules/system.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import systemMethods from '../../src/modules/system';
import syncMethods from '../../src/modules/sync';

const methods = systemMethods as Record<string, Function>;

Expand All @@ -17,10 +18,11 @@ const methods = systemMethods as Record<string, Function>;
interface FakeSSE {
onmessage: ((e: MessageEvent) => void) | null;
onerror: (() => void) | null;
close: () => void;
}

function installFakeEventSource(): FakeSSE {
const sse: FakeSSE = { onmessage: null, onerror: null };
const sse: FakeSSE = { onmessage: null, onerror: null, close: vi.fn() };
(globalThis as unknown as { window: unknown }).window = (globalThis as unknown as { window: Record<string, unknown> }).window || {};
(globalThis as unknown as { window: Record<string, unknown> }).window.ReconnectingEventSource = function () {
return sse;
Expand All @@ -38,10 +40,46 @@ function makeCtx(overrides: Record<string, unknown> = {}): Record<string, unknow
showSyncBar: false,
syncSegments: [],
data: null,
watchUserId: '',
clientId: '',
viewerQuery: (syncMethods as Record<string, Function>).viewerQuery,
...overrides,
};
}

describe('setupSSE stream URL', () => {
function captureUrl(ctx: Record<string, unknown>): string {
let url = '';
(window as unknown as Record<string, unknown>).ReconnectingEventSource = function (u: string) {
url = u;
return { onmessage: null, onerror: null, close: vi.fn() };
} as unknown;
methods.setupSSE.call(ctx);
return url;
}

it('follows any session when no user is chosen', () => {
expect(captureUrl(makeCtx())).toBe('/api/sync/stream');
});

it('passes the chosen user id to the stream', () => {
expect(captureUrl(makeCtx({ watchUserId: 'abc 123' }))).toBe('/api/sync/stream?userId=abc+123');
});

it('passes the per-browser client id alongside the user id', () => {
expect(captureUrl(makeCtx({ watchUserId: 'u1', clientId: 'c-1' })))
.toBe('/api/sync/stream?userId=u1&clientId=c-1');
});

it('closes the previous stream when reconnecting', () => {
const first = installFakeEventSource();
methods.setupSSE.call(makeCtx());
installFakeEventSource();
methods.setupSSE.call(makeCtx({ watchUserId: 'x' }));
expect(first.close).toHaveBeenCalled();
});
});

describe('setupSSE', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
Expand Down
Loading
Loading