Harden proxy permissions and cache behavior, streamline metadata fetching, remove obsolete rules, and validate the stable SDK with expanded regression coverage. Document backup format changes.
345 lines
16 KiB
JavaScript
345 lines
16 KiB
JavaScript
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();
|
|
await getFixtureItems(request);
|
|
const recent = await apiGet(request, '/Multilang/ItemsProxyRequests');
|
|
expect(recent[0].CacheHit).toBeTruthy();
|
|
});
|
|
|
|
test('shows configured translations and uses the injected ItemsProxy route', async ({ page, request }) => {
|
|
const rules = await apiGet(request, '/Multilang/UserRules/self');
|
|
rules.FallbackFieldActions.poster = ['Language:fi', 'Jellyfin'];
|
|
await apiPost(request, '/Multilang/UserRules/self', rules);
|
|
const proxiedRequests = [];
|
|
page.on('request', request => {
|
|
if (new URL(request.url()).pathname === '/Multilang/ItemsProxy') {
|
|
proxiedRequests.push(request.url());
|
|
}
|
|
});
|
|
|
|
await signIn(page);
|
|
const artworkResponse = page.waitForResponse(response =>
|
|
new URL(response.url()).pathname === `/Multilang/Assets/${fixture.orderedItemIds[0]}/fixture.png`);
|
|
await openFixtureLibrary(page, fixture.translatedTitles[0]);
|
|
expect((await artworkResponse).status()).toBe(200);
|
|
|
|
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: [{
|
|
Fields: ['original_language'],
|
|
Relation: 'is',
|
|
Values: ['sv'],
|
|
UseOr: false,
|
|
UseFieldOr: false
|
|
}],
|
|
MatchAllConditions: true,
|
|
Scopes: ['M'],
|
|
FieldActionLists: categoryActions,
|
|
}];
|
|
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('renders provider controls without native replaceChildren support', async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(Element.prototype, 'replaceChildren', { configurable: true, value: undefined });
|
|
});
|
|
await signIn(page);
|
|
await page.goto('/web/#/configurationpage?name=Multilang');
|
|
await expect(page.locator('#ml-provider-inputs input[data-provider]')).toHaveCount(2);
|
|
});
|
|
|
|
test('opens a resolved debug view from both settings pages', async ({ page }) => {
|
|
await signIn(page);
|
|
|
|
await page.goto('/web/#/configurationpage?name=Multilang');
|
|
await expect(page.locator('#ml-admin')).toBeVisible();
|
|
await page.locator('#ml-debug-item-id').fill(fixture.orderedItemIds[0]);
|
|
await page.locator('#ml-debug-user-id').fill(fixture.userId);
|
|
const [adminDebug] = await Promise.all([
|
|
page.waitForEvent('popup'),
|
|
page.locator('#ml-debug-open').click()
|
|
]);
|
|
await expect(adminDebug).toHaveURL(new RegExp(`/Multilang/DebugUi/${fixture.orderedItemIds[0]}.*userId=${fixture.userId}`));
|
|
await expect(adminDebug.locator('#ml-debug-status')).toHaveText('Loaded.');
|
|
await adminDebug.close();
|
|
|
|
await page.goto(`/web/#/mypreferencesmenu?userId=${fixture.userId}`);
|
|
await expect(page.locator('#mlUserRulesMenuItem')).toBeVisible();
|
|
await page.getByRole('button', { name: 'User Menu' }).click();
|
|
await expect(page.locator('.MuiMenu-list #mlUserRulesMenuItem')).toHaveCount(0);
|
|
await page.keyboard.press('Escape');
|
|
await page.locator('#mlUserRulesMenuItem').click();
|
|
await expect(page.locator('#ml-user')).toBeVisible();
|
|
await page.locator('#ml-debug-item-id').fill(fixture.orderedItemIds[0]);
|
|
const [userDebug] = await Promise.all([
|
|
page.waitForEvent('popup'),
|
|
page.locator('#ml-debug-open').click()
|
|
]);
|
|
await expect(userDebug).toHaveURL(new RegExp(`/Multilang/DebugUi/${fixture.orderedItemIds[0]}`));
|
|
await expect(userDebug).not.toHaveURL(/userId=/);
|
|
await expect(userDebug.locator('#ml-debug-status')).toHaveText('Loaded.');
|
|
await userDebug.close();
|
|
});
|
|
|
|
test('saves a structured classification rule through the user settings page', async ({ page, request }) => {
|
|
await signIn(page);
|
|
await page.goto(`/web/#/mypreferencesmenu?userId=${fixture.userId}`);
|
|
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']
|
|
})]);
|
|
});
|
|
|
|
test('rejects unsafe proxy targets and enforces restricted-user item access', async ({ request }) => {
|
|
for (const url of ['//outside.invalid/Items', '/System/Info', '/Multilang/ItemsProxy']) {
|
|
const response = await request.get('/Multilang/ItemsProxy', { headers: authHeaders(), params: { url } });
|
|
expect(response.status()).toBe(400);
|
|
}
|
|
const name = 'multilang-access-test-' + Date.now();
|
|
const user = await apiPost(request, '/Users/New', { Name: name });
|
|
const policy = { ...user.Policy, IsAdministrator: false, EnableAllFolders: true };
|
|
let response = await request.post(`/Users/${user.Id}/Policy`, { headers: authHeaders(), data: policy });
|
|
expect(response.ok()).toBeTruthy();
|
|
response = await request.post('/Users/AuthenticateByName', {
|
|
headers: { Authorization: 'MediaBrowser Client="Multilang tests", Device="test", DeviceId="multilang-access-test", Version="1"' },
|
|
data: { Username: name, Pw: '' }
|
|
});
|
|
expect(response.ok()).toBeTruthy();
|
|
const login = await response.json();
|
|
const headers = { Authorization: `MediaBrowser Token="${login.AccessToken}"` };
|
|
const asset = `/Multilang/Assets/${fixture.orderedItemIds[0]}/fixture.png`;
|
|
expect((await request.get(asset)).status()).toBe(401);
|
|
expect((await request.get(asset, { headers })).status()).toBe(200);
|
|
response = await request.post('/Multilang/UserRules/self', { headers, data: { Enabled: true, FallbackFieldActions: jellyfinActions() } });
|
|
expect(response.ok()).toBeTruthy();
|
|
const params = { url: `/Items?ParentId=${fixture.libraryId}&IncludeItemTypes=Movie&Recursive=true&SortBy=SortName` };
|
|
response = await request.get('/Multilang/ItemsProxy', { headers, params });
|
|
expect(response.ok()).toBeTruthy();
|
|
expect((await response.json()).Items).toHaveLength(3);
|
|
let entries = await apiGet(request, '/Multilang/CacheEntries');
|
|
expect(entries.some(entry => entry.UserId === user.Id)).toBeTruthy();
|
|
|
|
policy.EnableAllFolders = false;
|
|
policy.EnabledFolders = [];
|
|
response = await request.post(`/Users/${user.Id}/Policy`, { headers: authHeaders(), data: policy });
|
|
expect(response.ok()).toBeTruthy();
|
|
response = await request.get('/Multilang/ItemsProxy', { headers, params });
|
|
const native = await request.get(`/Users/${user.Id}/Items?ParentId=${fixture.libraryId}`, { headers });
|
|
expect(native.status()).toBe(401);
|
|
expect(response.status()).toBe(native.status());
|
|
const recent = await apiGet(request, '/Multilang/ItemsProxyRequests');
|
|
expect(recent[0].CacheHit).toBeFalsy();
|
|
expect((await request.get(asset, { headers })).status()).toBe(404);
|
|
entries = await apiGet(request, '/Multilang/CacheEntries');
|
|
expect(entries.some(entry => entry.UserId === user.Id && entry.ItemCount > 0)).toBeFalsy();
|
|
for (const path of [`Debug/${fixture.orderedItemIds[0]}`, `Debug/${fixture.orderedItemIds[0]}/self`]) {
|
|
expect((await request.get(`/Multilang/${path}`, { headers })).status()).toBe(404);
|
|
}
|
|
expect((await request.post(`/Multilang/RefreshItem/${fixture.orderedItemIds[0]}?includeChildren=true`, { headers })).status()).toBe(404);
|
|
response = await request.get('/Multilang/ItemsProxy', { headers, params });
|
|
expect(response.status()).toBe(401);
|
|
});
|
|
|
|
test('reuses a library fetch across different pages and sorting controls', async ({ request }) => {
|
|
const base = `/Items?ParentId=${fixture.libraryId}&IncludeItemTypes=Movie&Recursive=true`;
|
|
let response = await request.get('/Multilang/ItemsProxy', { headers: authHeaders(), params: { url: base + '&SortBy=SortName&Limit=1', mlLocale: 'fi-FI' } });
|
|
expect((await response.json()).Items.map(item => item.Name)).toEqual(['Ankkuri']);
|
|
response = await request.get('/Multilang/ItemsProxy', { headers: authHeaders(), params: { url: base + '&SortBy=SortName&StartIndex=1&Limit=1', mlLocale: 'fi-FI' } });
|
|
expect((await response.json()).Items.map(item => item.Name)).toEqual(['The Clock']);
|
|
const recent = await apiGet(request, '/Multilang/ItemsProxyRequests');
|
|
expect(recent[0].CacheHit).toBeTruthy();
|
|
});
|
|
|
|
test('reopens user settings without duplicate loads or a native replaceChildren dependency', async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(Element.prototype, 'replaceChildren', { configurable: true, value: undefined });
|
|
});
|
|
await signIn(page);
|
|
await page.goto(`/web/#/mypreferencesmenu?userId=${fixture.userId}`);
|
|
let loads = 0;
|
|
page.on('request', request => { if (new URL(request.url()).pathname === '/Multilang/UserRules/self') loads++; });
|
|
for (let i = 0; i < 3; i++) {
|
|
await expect(page.locator('#mlUserRulesMenuItem')).toBeVisible();
|
|
await page.locator('#mlUserRulesMenuItem').click();
|
|
await expect(page.locator('#ml-sort-locale option[value="en"]')).toHaveCount(1);
|
|
expect(loads).toBe(i + 1);
|
|
await page.evaluate(userId => { location.hash = '#/mypreferencesmenu?userId=' + userId; }, fixture.userId);
|
|
await expect(page.locator('#ml-user-shell')).toHaveCount(0);
|
|
}
|
|
});
|