557 lines
13 KiB
JavaScript
557 lines
13 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
applySectionStandards,
|
|
createBoxSetSection,
|
|
createRecentlyWatchedSection,
|
|
getWatchlistLabelsForTarget
|
|
} from '../src/lib/constants.js';
|
|
import { normalizeLookupItem, rankRecommendationResults } from '../src/lib/collection-tools.js';
|
|
import * as embyUserCache from '../src/lib/server/emby-user-cache.js';
|
|
|
|
const tests = [];
|
|
|
|
function test(name, fn) {
|
|
tests.push({ name, fn });
|
|
}
|
|
|
|
function withTempCache(testFn) {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'homescreenpal-cache-'));
|
|
const cachePath = join(tempDir, 'emby-users.db');
|
|
process.env.EMBY_USER_CACHE_DB_PATH = cachePath;
|
|
|
|
try {
|
|
return testFn(cachePath);
|
|
} finally {
|
|
delete process.env.EMBY_USER_CACHE_DB_PATH;
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test('watchlist labels use the target Emby name for new watchlists', () => {
|
|
const labels = getWatchlistLabelsForTarget(
|
|
{
|
|
Name: "Matt's Watchlist",
|
|
CustomName: "Matt's Watchlist",
|
|
Query: { IsFavorite: true }
|
|
},
|
|
{
|
|
name: 'User 11',
|
|
embyName: 'Bob',
|
|
sections: []
|
|
}
|
|
);
|
|
|
|
assert.equal(labels.Name, "Bob's Watchlist");
|
|
assert.equal(labels.CustomName, "Bob's Watchlist");
|
|
});
|
|
|
|
test('watchlist labels preserve an existing matching target watchlist', () => {
|
|
const labels = getWatchlistLabelsForTarget(
|
|
{
|
|
Name: "Matt's Watchlist",
|
|
CustomName: "Matt's Watchlist",
|
|
Query: { IsFavorite: true }
|
|
},
|
|
{
|
|
name: 'User 14',
|
|
embyName: 'Bob',
|
|
sections: [
|
|
{
|
|
Name: "Bob's Watchlist",
|
|
CustomName: "Bob's Watchlist",
|
|
Query: { IsFavorite: true }
|
|
}
|
|
]
|
|
}
|
|
);
|
|
|
|
assert.equal(labels.Name, "Bob's Watchlist");
|
|
assert.equal(labels.CustomName, "Bob's Watchlist");
|
|
});
|
|
|
|
test('watchlist labels correct an existing mismatched target watchlist', () => {
|
|
const labels = getWatchlistLabelsForTarget(
|
|
{
|
|
Name: "Matt's Watchlist",
|
|
CustomName: "Matt's Watchlist",
|
|
Query: { IsFavorite: true }
|
|
},
|
|
{
|
|
name: 'User 14',
|
|
embyName: 'Bob',
|
|
sections: [
|
|
{
|
|
Name: "Matt's Watchlist",
|
|
CustomName: "Matt's Watchlist",
|
|
Query: { IsFavorite: true }
|
|
}
|
|
]
|
|
}
|
|
);
|
|
|
|
assert.equal(labels.Name, "Bob's Watchlist");
|
|
assert.equal(labels.CustomName, "Bob's Watchlist");
|
|
});
|
|
|
|
test('cached Emby users round-trip through the local SQLite cache', () => {
|
|
withTempCache(() => {
|
|
const written = embyUserCache.writeCachedEmbyUsers([
|
|
{ embyGuid: 'ABC-123', name: 'Matt' },
|
|
{ embyGuid: 'def456', name: 'Bob' }
|
|
]);
|
|
const readBack = embyUserCache.readCachedEmbyUsers();
|
|
|
|
assert.equal(written.users.length, 2);
|
|
assert.deepEqual(readBack.users, [
|
|
{ embyGuid: 'def456', name: 'Bob' },
|
|
{ embyGuid: 'abc123', name: 'Matt' }
|
|
]);
|
|
assert.ok(readBack.lastSyncedAt);
|
|
});
|
|
});
|
|
|
|
test('cached Emby users override DB display names without losing the original DB name', () => {
|
|
withTempCache(() => {
|
|
embyUserCache.writeCachedEmbyUsers([{ embyGuid: 'abc123', name: 'Matt' }]);
|
|
|
|
const enriched = embyUserCache.applyCachedEmbyNames([
|
|
{ id: 11, name: 'User 11', embyGuid: 'abc123' },
|
|
{ id: 12, name: 'Already Good', embyGuid: 'zzz999' }
|
|
]);
|
|
|
|
assert.equal(enriched.users[0].name, 'Matt');
|
|
assert.equal(enriched.users[0].embyName, 'Matt');
|
|
assert.equal(enriched.users[0].dbName, 'User 11');
|
|
assert.equal(enriched.users[1].name, 'Already Good');
|
|
assert.equal(enriched.cache.matchedCount, 1);
|
|
});
|
|
});
|
|
|
|
test('recently watched section template uses played-content defaults', () => {
|
|
const section = createRecentlyWatchedSection('abc123', 'Matt');
|
|
|
|
assert.equal(section.UserId, 'abc123');
|
|
assert.equal(section.Name, 'Recently Watched - Matt');
|
|
assert.equal(section.CustomName, 'Recently Watched - Matt');
|
|
assert.equal(section.SectionType, 'items');
|
|
assert.equal(section.SortBy, 'DatePlayed');
|
|
assert.equal(section.SortOrder, 'Descending');
|
|
assert.deepEqual(section.ItemTypes, ['Movie', 'Series']);
|
|
assert.equal(section.Query.IsPlayed, true);
|
|
});
|
|
|
|
test('box set section template links a created collection', () => {
|
|
const section = createBoxSetSection('abc123', 'Recommended for Matt', 'boxset-55');
|
|
|
|
assert.equal(section.UserId, 'abc123');
|
|
assert.equal(section.SectionType, 'boxset');
|
|
assert.equal(section.Name, 'Recommended for Matt');
|
|
assert.equal(section.CustomName, 'Recommended for Matt');
|
|
assert.equal(section.ParentId, 'boxset-55');
|
|
assert.deepEqual(section.ParentItem, {
|
|
Name: 'Recommended for Matt',
|
|
Id: 'boxset-55'
|
|
});
|
|
});
|
|
|
|
test('section standards normalize up next label', () => {
|
|
const normalized = applySectionStandards(
|
|
{
|
|
Name: 'Resume / Up Next',
|
|
CustomName: 'Resume / Up Next',
|
|
SectionType: 'resume'
|
|
},
|
|
{
|
|
embyName: 'Bob',
|
|
sections: []
|
|
}
|
|
);
|
|
|
|
assert.equal(normalized.Name, 'Up Next');
|
|
assert.equal(normalized.CustomName, 'Up Next');
|
|
});
|
|
|
|
test('section standards normalize watchlist label for target user', () => {
|
|
const normalized = applySectionStandards(
|
|
{
|
|
Name: "Matt's Watchlist",
|
|
CustomName: "Matt's Watchlist",
|
|
SectionType: 'items',
|
|
Query: { IsFavorite: true }
|
|
},
|
|
{
|
|
embyName: 'Bob',
|
|
sections: []
|
|
}
|
|
);
|
|
|
|
assert.equal(normalized.Name, "Bob's Watchlist");
|
|
assert.equal(normalized.CustomName, "Bob's Watchlist");
|
|
});
|
|
|
|
test('section standards keep new to emby fixed to date added descending', () => {
|
|
const normalized = applySectionStandards(
|
|
{
|
|
Name: 'New To Emby',
|
|
CustomName: 'New To Emby',
|
|
SectionType: 'items',
|
|
SortBy: 'ProductionYear,PremiereDate,SortName',
|
|
SortOrder: 'Ascending'
|
|
},
|
|
{
|
|
embyName: 'Bob',
|
|
sections: []
|
|
}
|
|
);
|
|
|
|
assert.equal(normalized.Name, 'New to Emby');
|
|
assert.equal(normalized.CustomName, 'New to Emby');
|
|
assert.equal(normalized.SortBy, 'DateLastContentAdded,SortName');
|
|
assert.equal(normalized.SortOrder, 'Descending');
|
|
});
|
|
|
|
test('section standards randomize regular curated sections', () => {
|
|
const normalized = applySectionStandards(
|
|
{
|
|
Name: 'Crime / Drama Shows',
|
|
CustomName: 'Crime / Drama Shows',
|
|
SectionType: 'items',
|
|
SortBy: 'ProductionYear,PremiereDate,SortName',
|
|
SortOrder: 'Ascending',
|
|
Query: { GenreIds: ['4910', '62'] }
|
|
},
|
|
{
|
|
embyName: 'Bob',
|
|
sections: []
|
|
}
|
|
);
|
|
|
|
assert.equal(normalized.SortBy, 'Random');
|
|
assert.equal(normalized.SortOrder, 'Descending');
|
|
});
|
|
|
|
test('section standards preserve recently watched ordering semantics', () => {
|
|
const normalized = applySectionStandards(
|
|
{
|
|
Name: 'Recently Watched - Matt',
|
|
CustomName: 'Recently Watched - Matt',
|
|
SectionType: 'items',
|
|
SortBy: 'Random',
|
|
SortOrder: 'Ascending',
|
|
Query: { IsPlayed: true }
|
|
},
|
|
{
|
|
embyName: 'Bob',
|
|
sections: []
|
|
}
|
|
);
|
|
|
|
assert.equal(normalized.Name, 'Recently Watched - Bob');
|
|
assert.equal(normalized.CustomName, 'Recently Watched - Bob');
|
|
assert.equal(normalized.SortBy, 'DatePlayed');
|
|
assert.equal(normalized.SortOrder, 'Descending');
|
|
});
|
|
|
|
test('normalizeLookupItem preserves already-normalized recommendation items', () => {
|
|
const normalized = normalizeLookupItem({
|
|
id: 'pick-1',
|
|
name: 'Alpha',
|
|
type: 'Movie',
|
|
overview: 'Overview',
|
|
year: 1998,
|
|
communityRating: 7.4,
|
|
providerIds: { Tmdb: '123' },
|
|
genres: ['Comedy']
|
|
});
|
|
|
|
assert.deepEqual(normalized, {
|
|
id: 'pick-1',
|
|
name: 'Alpha',
|
|
type: 'Movie',
|
|
overview: 'Overview',
|
|
year: 1998,
|
|
communityRating: 7.4,
|
|
providerIds: { Tmdb: '123' },
|
|
genres: ['Comedy'],
|
|
parentId: null
|
|
});
|
|
});
|
|
|
|
test('recommendation ranking deduplicates and prioritizes overlaps across seeds', () => {
|
|
const ranked = rankRecommendationResults(
|
|
['seed-a', 'seed-b'],
|
|
[
|
|
[
|
|
{ Id: 'pick-1', Name: 'Alpha', Type: 'Movie' },
|
|
{ Id: 'pick-2', Name: 'Bravo', Type: 'Movie' }
|
|
],
|
|
[
|
|
{ Id: 'pick-2', Name: 'Bravo', Type: 'Movie' },
|
|
{ Id: 'pick-3', Name: 'Charlie', Type: 'Movie' }
|
|
]
|
|
],
|
|
5
|
|
);
|
|
|
|
assert.deepEqual(
|
|
ranked.map((item) => item.id),
|
|
['pick-2', 'pick-1', 'pick-3']
|
|
);
|
|
assert.equal(ranked[0].matchCount, 2);
|
|
});
|
|
|
|
test('recommendation ranking supports already-normalized result items', () => {
|
|
const ranked = rankRecommendationResults(
|
|
['seed-a'],
|
|
[
|
|
[
|
|
{ id: 'pick-1', name: 'Alpha', type: 'Movie' },
|
|
{ id: 'pick-2', name: 'Bravo', type: 'Movie' }
|
|
]
|
|
],
|
|
5
|
|
);
|
|
|
|
assert.deepEqual(
|
|
ranked.map((item) => item.id),
|
|
['pick-1', 'pick-2']
|
|
);
|
|
});
|
|
|
|
test('recommendation ranking filters results to the selected seed media type', () => {
|
|
const ranked = rankRecommendationResults(
|
|
['seed-a'],
|
|
[
|
|
[
|
|
{ Id: 'movie-1', Name: 'Movie Match', Type: 'Movie', Genres: ['Drama'] },
|
|
{ Id: 'series-1', Name: 'Series Match', Type: 'Series', Genres: ['Drama'] }
|
|
]
|
|
],
|
|
{
|
|
limit: 5,
|
|
allowedTypes: ['Movie']
|
|
}
|
|
);
|
|
|
|
assert.deepEqual(
|
|
ranked.map((item) => item.id),
|
|
['movie-1']
|
|
);
|
|
});
|
|
|
|
test('recommendation ranking heavily favors TMDB similar and recommendation sources over discover', () => {
|
|
const ranked = rankRecommendationResults(
|
|
['seed-a'],
|
|
[
|
|
{
|
|
items: [{ Id: 'pick-seed-like', Name: 'Seed Like', Type: 'Movie', Genres: ['Drama', 'Romance'] }],
|
|
sourceWeight: 6
|
|
},
|
|
{
|
|
items: [{ Id: 'pick-discover', Name: 'Broad Discover', Type: 'Movie', Genres: ['Drama'] }],
|
|
sourceWeight: 1
|
|
},
|
|
{
|
|
items: [{ Id: 'pick-discover', Name: 'Broad Discover', Type: 'Movie', Genres: ['Drama'] }],
|
|
sourceWeight: 1
|
|
},
|
|
{
|
|
items: [{ Id: 'pick-discover', Name: 'Broad Discover', Type: 'Movie', Genres: ['Drama'] }],
|
|
sourceWeight: 1
|
|
}
|
|
],
|
|
5
|
|
);
|
|
|
|
assert.deepEqual(
|
|
ranked.map((item) => item.id),
|
|
['pick-seed-like', 'pick-discover']
|
|
);
|
|
});
|
|
|
|
test('recommendation ranking excludes watched items passed in options', () => {
|
|
const ranked = rankRecommendationResults(
|
|
['seed-a'],
|
|
[
|
|
[
|
|
{ Id: 'watched-1', Name: 'Already Seen', Type: 'Movie' },
|
|
{ Id: 'pick-1', Name: 'Fresh Pick', Type: 'Movie' }
|
|
],
|
|
[
|
|
{ Id: 'pick-1', Name: 'Fresh Pick', Type: 'Movie' },
|
|
{ Id: 'pick-2', Name: 'Another Pick', Type: 'Movie' }
|
|
]
|
|
],
|
|
{
|
|
limit: 5,
|
|
excludeIds: ['watched-1']
|
|
}
|
|
);
|
|
|
|
assert.deepEqual(
|
|
ranked.map((item) => item.id),
|
|
['pick-1', 'pick-2']
|
|
);
|
|
});
|
|
|
|
test('recommendation ranking penalizes horror and sci-fi mismatches for romance drama seeds', () => {
|
|
const ranked = rankRecommendationResults(
|
|
['seed-a'],
|
|
[
|
|
[
|
|
{
|
|
Id: 'pick-warm',
|
|
Name: 'Quiet Hearts',
|
|
Type: 'Movie',
|
|
Overview: 'A love story about family, friendship, and second chances.',
|
|
Genres: ['Romance', 'Drama']
|
|
},
|
|
{
|
|
Id: 'pick-mismatch',
|
|
Name: 'Night Terrors',
|
|
Type: 'Movie',
|
|
Overview: 'A horror thriller about murder, zombies, and survival.',
|
|
Genres: ['Horror', 'Science Fiction', 'Thriller']
|
|
}
|
|
]
|
|
],
|
|
{
|
|
limit: 5,
|
|
seeds: [
|
|
{
|
|
Id: 'seed-a',
|
|
Name: 'The English Patient',
|
|
Type: 'Movie',
|
|
Genres: ['Romance', 'Drama']
|
|
}
|
|
]
|
|
}
|
|
);
|
|
|
|
assert.deepEqual(
|
|
ranked.map((item) => item.id),
|
|
['pick-warm', 'pick-mismatch']
|
|
);
|
|
assert.ok(ranked[0].affinityScore > ranked[1].affinityScore);
|
|
});
|
|
|
|
test('recommendation ranking ignores duplicate items within a single seed result set', () => {
|
|
const ranked = rankRecommendationResults(
|
|
['seed-a'],
|
|
[
|
|
[
|
|
{ Id: 'pick-1', Name: 'Alpha', Type: 'Movie' },
|
|
{ Id: 'pick-1', Name: 'Alpha', Type: 'Movie' },
|
|
{ Id: 'pick-2', Name: 'Bravo', Type: 'Movie' }
|
|
]
|
|
],
|
|
5
|
|
);
|
|
|
|
assert.deepEqual(
|
|
ranked.map((item) => item.id),
|
|
['pick-1', 'pick-2']
|
|
);
|
|
assert.equal(ranked[0].matchCount, 1);
|
|
});
|
|
|
|
test('classic comfort profile filters modern and mismatched picks while promoting older warm titles', () => {
|
|
const ranked = rankRecommendationResults(
|
|
['seed-a', 'seed-b'],
|
|
[
|
|
[
|
|
{
|
|
Id: 'pick-classic',
|
|
Name: 'Warm Hearts',
|
|
Type: 'Movie',
|
|
Overview: 'A romantic family comedy about friendship, second chances, and love.',
|
|
ProductionYear: 1998,
|
|
CommunityRating: 7.6,
|
|
Genres: ['Romance', 'Comedy']
|
|
},
|
|
{
|
|
Id: 'pick-modern',
|
|
Name: 'Future War',
|
|
Type: 'Movie',
|
|
Overview: 'A modern action thriller about combat and alien invasion.',
|
|
ProductionYear: 2021,
|
|
CommunityRating: 7.8,
|
|
Genres: ['Action', 'Science Fiction']
|
|
}
|
|
],
|
|
[
|
|
{
|
|
Id: 'pick-classic',
|
|
Name: 'Warm Hearts',
|
|
Type: 'Movie',
|
|
Overview: 'A romantic family comedy about friendship, second chances, and love.',
|
|
ProductionYear: 1998,
|
|
CommunityRating: 7.6,
|
|
Genres: ['Romance', 'Comedy']
|
|
},
|
|
{
|
|
Id: 'pick-animation',
|
|
Name: 'Toon Town',
|
|
Type: 'Movie',
|
|
Overview: 'An animated adventure about a magical family.',
|
|
ProductionYear: 2002,
|
|
CommunityRating: 8.0,
|
|
Genres: ['Animation', 'Family']
|
|
}
|
|
]
|
|
],
|
|
{
|
|
limit: 5,
|
|
profile: 'classicComfort',
|
|
seeds: [
|
|
{
|
|
Id: 'seed-a',
|
|
Name: 'Sleepless Style',
|
|
Type: 'Movie',
|
|
Overview: 'A romantic comedy about love and second chances.',
|
|
ProductionYear: 1993,
|
|
CommunityRating: 7.1,
|
|
Genres: ['Romance', 'Comedy']
|
|
},
|
|
{
|
|
Id: 'seed-b',
|
|
Name: 'Family Ties',
|
|
Type: 'Movie',
|
|
Overview: 'A family friendship story with warmth.',
|
|
ProductionYear: 1995,
|
|
CommunityRating: 7.0,
|
|
Genres: ['Comedy', 'Drama']
|
|
}
|
|
]
|
|
}
|
|
);
|
|
|
|
assert.deepEqual(ranked.map((item) => item.id), ['pick-classic']);
|
|
assert.equal(ranked[0].matchCount, 2);
|
|
assert.ok(ranked[0].styleScore >= 16);
|
|
assert.equal(ranked[0].qualityScore, 76);
|
|
});
|
|
|
|
let failures = 0;
|
|
|
|
for (const { name, fn } of tests) {
|
|
try {
|
|
await fn();
|
|
console.log(`PASS ${name}`);
|
|
} catch (error) {
|
|
failures++;
|
|
console.error(`FAIL ${name}`);
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
if (failures > 0) {
|
|
console.error(`\n${failures} test(s) failed.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`\n${tests.length} test(s) passed.`);
|