Release 0.2.5: harden proxy and streamline metadata fetching

Remove obsolete rule formats, batch SQLite work, preserve artwork source URLs, and coordinate refresh workers with maintenance. Add regression coverage and document backup format changes.
This commit is contained in:
ajp_anton
2026-09-09 01:18:43 +00:00
parent 7eb62b156e
commit e36775f1a3
49 changed files with 2126 additions and 1813 deletions
+87 -2
View File
@@ -101,9 +101,15 @@ test('pre-caches the fixture movie library after user activity', async ({ reques
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') {
@@ -112,7 +118,10 @@ test('shows configured translations and uses the injected ItemsProxy route', asy
});
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();
@@ -151,7 +160,6 @@ test('applies a structured category rule before fallback translation actions', a
Id: 'browser-fixture-swedish',
Label: 'Swedish originals',
Requirements: [{
Field: 'original_language',
Fields: ['original_language'],
Relation: 'is',
Values: ['sv'],
@@ -161,7 +169,6 @@ test('applies a structured category rule before fallback translation actions', a
MatchAllConditions: true,
Scopes: ['M'],
FieldActionLists: categoryActions,
FieldActions: {}
}];
await apiPost(request, '/Multilang/UserRules/self', rules);
@@ -257,3 +264,81 @@ test('saves a structured classification rule through the user settings page', as
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);
}
});