const fs = require('node:fs'); const { expect, test } = require('@playwright/test'); const fixturePath = process.env.MULTILANG_BROWSER_FIXTURE; if (!fixturePath) { throw new Error('MULTILANG_BROWSER_FIXTURE must point to the temporary JF12 fixture description.'); } const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); const actionFields = ['title', 'overview', 'tagline', 'poster', 'logo', 'banner', 'thumb', 'backdrop']; function authHeaders() { return { Authorization: `MediaBrowser Token="${fixture.token}"` }; } function jellyfinActions() { return Object.fromEntries(actionFields.map(field => [field, ['Jellyfin']])); } async function apiGet(request, path) { const response = await request.get(path, { headers: authHeaders() }); expect(response.ok()).toBeTruthy(); return response.json(); } async function apiPost(request, path, data) { const response = await request.post(path, { headers: { ...authHeaders(), 'Content-Type': 'application/json' }, data }); expect(response.ok()).toBeTruthy(); return response.json(); } async function restoreFixtureRules(request) { const config = await apiGet(request, '/Multilang/AdminConfig'); Object.assign(config, { Languages: ['en', 'fi'], ItemsProxyCacheThresholdMs: 0, ItemsProxyCacheTtlMinutes: 5, ItemsProxyCacheMaxMiB: 8, PrecacheMovieLibraries: true, PrecacheTvShowLibraries: false }); await apiPost(request, '/Multilang/AdminConfig', config); const rules = await apiGet(request, '/Multilang/UserRules/self'); Object.assign(rules, { Enabled: true, SortLocale: 'fi-FI', Categories: [], FallbackFieldActions: { ...jellyfinActions(), title: ['Language:fi', 'Jellyfin'], overview: ['Language:fi', 'Jellyfin'], tagline: ['Language:fi', 'Jellyfin'] } }); await apiPost(request, '/Multilang/UserRules/self', rules); await apiPost(request, '/Multilang/ClearCache', {}); } async function signIn(page) { await page.goto('/web/index.html'); await page.getByRole('textbox', { name: 'User' }).fill(fixture.username); await page.getByRole('textbox', { name: 'Password' }).fill(fixture.password); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page).not.toHaveURL(/login/i); } async function openFixtureLibrary(page, expectedTitle) { await page.goto(`/web/#/movies?topParentId=${fixture.libraryId}&collectionType=movies`); await expect(page.getByText(expectedTitle, { exact: true })).toBeVisible(); } async function getFixtureItems(request) { const upstream = `/Users/${fixture.userId}/Items?ParentId=${fixture.libraryId}&IncludeItemTypes=Movie&Recursive=true&SortBy=SortName&SortOrder=Ascending&Fields=Overview,Genres,GenreItems`; const response = await request.get('/Multilang/ItemsProxy', { headers: { Authorization: `MediaBrowser Token="${fixture.token}"` }, params: { url: upstream, mlLocale: 'fi-FI' } }); expect(response.ok()).toBeTruthy(); return response.json(); } test.beforeEach(async ({ request }) => { await restoreFixtureRules(request); }); test('pre-caches the fixture movie library after user activity', async ({ request }) => { const trigger = await request.get('/Multilang/ItemsProxy', { headers: authHeaders(), params: { url: `/Users/${fixture.userId}/Items/Latest?Limit=1`, mlLocale: 'fi-FI' } }); expect(trigger.ok()).toBeTruthy(); await expect.poll(async () => { const entries = await apiGet(request, '/Multilang/CacheEntries'); return entries.some(entry => entry.Url.includes(`ParentId=${fixture.libraryId}`)); }, { timeout: 60000 }).toBeTruthy(); }); test('shows configured translations and uses the injected ItemsProxy route', async ({ page, request }) => { const proxiedRequests = []; page.on('request', request => { if (new URL(request.url()).pathname === '/Multilang/ItemsProxy') { proxiedRequests.push(request.url()); } }); await signIn(page); await openFixtureLibrary(page, fixture.translatedTitles[0]); for (const title of fixture.translatedTitles) { await expect(page.getByText(title, { exact: true })).toBeVisible(); } const payload = await getFixtureItems(request); expect(payload.Items.map(item => item.Id)).toEqual(fixture.translatedOrderedItemIds); expect(payload.Items.map(item => item.Name)).toEqual(fixture.translatedSortedTitles); expect(payload.Items.find(item => item.Name === 'The Clock').Overview).toBe('English overview for The Clock.'); expect(payload.Items.find(item => item.Name === 'Ankkuri').Genres).toEqual(['Draama']); expect(payload.Items.find(item => item.Name === 'Majakka').Genres).toEqual(['Komedia']); await expect.poll(() => proxiedRequests.length).toBeGreaterThan(0); }); test('returns to Jellyfin titles when Multilang is disabled for the user', async ({ page, request }) => { const rules = await apiGet(request, '/Multilang/UserRules/self'); rules.Enabled = false; await apiPost(request, '/Multilang/UserRules/self', rules); await signIn(page); await openFixtureLibrary(page, fixture.jellyfinTitles[0]); for (const title of fixture.jellyfinTitles) { await expect(page.getByText(title, { exact: true })).toBeVisible(); } const payload = await getFixtureItems(request); expect(payload.Items.map(item => item.Id)).toEqual(fixture.orderedItemIds); expect(payload.Items.map(item => item.Name)).toEqual(fixture.jellyfinTitles); expect(payload.Items.find(item => item.Name === 'The Anchor').Genres).toEqual(['Drama']); expect(payload.Items.find(item => item.Name === 'The Beacon').Genres).toEqual(['Comedy']); }); test('applies a structured category rule before fallback translation actions', async ({ request }) => { const rules = await apiGet(request, '/Multilang/UserRules/self'); const categoryActions = jellyfinActions(); categoryActions.title = ['Jellyfin']; rules.Categories = [{ Id: 'browser-fixture-swedish', Label: 'Swedish originals', Requirements: [{ Field: 'original_language', Fields: ['original_language'], Relation: 'is', Values: ['sv'], UseOr: false, UseFieldOr: false }], MatchAllConditions: true, Scopes: ['M'], FieldActionLists: categoryActions, FieldActions: {} }]; await apiPost(request, '/Multilang/UserRules/self', rules); const payload = await getFixtureItems(request); expect(payload.Items.map(item => item.Name)).toEqual(['Ankkuri', 'The Beacon', 'The Clock']); }); test('saves admin configuration through the dashboard page', async ({ page, request }) => { await signIn(page); await page.goto('/web/#/configurationpage?name=Multilang'); await expect(page.locator('#ml-admin')).toBeVisible(); await page.locator('#ml-languages').fill('en, fi, sv'); await page.locator('#ml-precache-movies').check(); await page.locator('#ml-precache-tvshows').uncheck(); await page.locator('#ml-save').click(); await expect(page.locator('#ml-status')).toHaveText('Saved.'); const config = await apiGet(request, '/Multilang/AdminConfig'); expect(config.Languages).toEqual(['en', 'fi', 'sv']); expect(config.PrecacheMovieLibraries).toBeTruthy(); expect(config.PrecacheTvShowLibraries).toBeFalsy(); }); test('saves a structured classification rule through the user settings page', async ({ page, request }) => { await signIn(page); await page.goto(`/web/#/mypreferences?userId=${fixture.userId}`); await page.getByRole('button', { name: 'User Menu' }).click(); await expect(page.locator('#mlUserRulesMenuItem')).toBeVisible(); await page.locator('#mlUserRulesMenuItem').click(); await expect(page.locator('#ml-user')).toBeVisible(); await expect(page.locator('#ml-sort-locale option[value="en"]')).toHaveCount(1); await page.locator('#ml-sort-locale').selectOption('en'); await page.getByRole('button', { name: 'Add classification rule' }).click(); const rule = page.locator('.ml-classification-rule').last(); await rule.locator('[data-category-label]').fill('Browser fixture rule'); await rule.locator('.ml-field-dropdown button').click(); await rule.locator('.ml-field-dropdown input[value="original_language"]').check(); await rule.locator('select.ml-relation-select').selectOption('is'); await rule.locator('.ml-value-dropdown').nth(1).locator('button').click(); await rule.locator('.ml-value-dropdown').nth(1).locator('input[value="sv"]').check(); await page.locator('#ml-save-user').click(); await expect(page.locator('#ml-user-status')).toHaveText('Saved.'); const rules = await apiGet(request, '/Multilang/UserRules/self'); expect(rules.SortLocale).toBe('en'); expect(rules.Categories).toHaveLength(1); expect(rules.Categories[0].Label).toBe('Browser fixture rule'); expect(rules.Categories[0].Requirements).toEqual([expect.objectContaining({ Fields: ['original_language'], Relation: 'is', Values: ['sv'] })]); });