v1.2 - collections, etc
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { 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('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.`);
|
||||
Reference in New Issue
Block a user