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.`);