diff --git a/server/trpc/routers/media.ts b/server/trpc/routers/media.ts index 1e491db..48becb3 100644 --- a/server/trpc/routers/media.ts +++ b/server/trpc/routers/media.ts @@ -7,7 +7,7 @@ import { media, audioTracks, subtitleTracks, watchProgress, mediaRatings, downlo import { eq, and, like, desc, asc, sql, or, isNull } from 'drizzle-orm' import { getTmdbInfo, searchTmdb, tmdbInfoToMediaFields } from '../../utils/tmdb' import { calculateUserPreferences, calculateMatchScore } from '../../utils/preferences' -import { getAllMediaStatsCached, mediaQualityModifier } from '../../utils/mediaStatsEngine' +import { getAllMediaStatsCached, mediaQualityModifier, getCatalogGenreFreqs } from '../../utils/mediaStatsEngine' import { scoreCandidate, applyMMR } from '../../utils/recoScoring' import { promises as fs } from 'fs' import { join } from 'path' @@ -1063,6 +1063,24 @@ export const mediaRouter = router({ for (const row of ratedRows) if (row.rating === -1) excludedIds.add(row.media_id) for (const row of watchlistRows) excludedIds.add(row.id) + // Catalog genre frequency map for IDF weighting in scoreCandidate. + const catalogGenreFreqs = getCatalogGenreFreqs(params.mediaType === 'all' ? null : params.mediaType) + + // Actor score proxy for "probably already seen": if 2+ of the top-3 cast + // all have an actorScore above the 75th percentile, the film was likely watched. + const actorScores = (profileData?.preferences?.actorScores || {}) as Record + const actorVals = Object.values(actorScores).filter(v => v > 0).sort((a, b) => a - b) + const actorP75 = actorVals[Math.floor(actorVals.length * 0.75)] ?? 0 + function likelySeen(candidate: any): boolean { + if (!actorP75 || actorP75 < 5) return false + const cast: string[] = (Array.isArray(candidate.cast) + ? candidate.cast + : (() => { try { return JSON.parse(candidate.cast || '[]') } catch { return [] } })() + ).map((a: any) => (typeof a === 'string' ? a : a?.name)).filter(Boolean).slice(0, 3) + if (cast.length < 2) return false + return cast.filter((a: string) => (actorScores[a] || 0) >= actorP75).length >= 2 + } + const typeFilter = params.mediaType === 'all' ? '' : `AND m.media_type = '${params.mediaType}'` const statsMap = getAllMediaStatsCached() // cached materialized view, off the hot path const now = new Date() @@ -1075,7 +1093,7 @@ export const mediaRouter = router({ const buildItem = (candidate: any) => { const { score, reasons } = scoreCandidate(candidate, preferences, profileData, { statsModifier: mediaQualityModifier(statsMap.get(candidate.id)), - watchlistGenres, now, + watchlistGenres, catalogGenreFreqs, now, }) let genres: string[] = [] try { genres = candidate.genres ? JSON.parse(candidate.genres) : [] } catch { genres = [] } @@ -1120,7 +1138,7 @@ export const mediaRouter = router({ `).all() as any[] const exploitScored = exploitRows - .filter(c => !excludedIds.has(c.id)) + .filter(c => !excludedIds.has(c.id) && !likelySeen(c)) .map(buildItem) .filter(c => c.matchScore >= 25) .sort((a, b) => b.matchScore - a.matchScore || (b.rating || 0) - (a.rating || 0)) @@ -1140,7 +1158,7 @@ export const mediaRouter = router({ LIMIT 60 `).all() as any[] const explorePool = exploreRows - .filter(c => !excludedIds.has(c.id) && !exploitIds.has(c.id)) + .filter(c => !excludedIds.has(c.id) && !exploitIds.has(c.id) && !likelySeen(c)) .map(buildItem) shuffle(explorePool) const explorePicks = explorePool.slice(0, exploreCount).map((it) => { diff --git a/server/utils/analyticsEngine.ts b/server/utils/analyticsEngine.ts index 59bc1a0..878dd62 100644 --- a/server/utils/analyticsEngine.ts +++ b/server/utils/analyticsEngine.ts @@ -87,6 +87,9 @@ type ProfileData = { } preferences: { genreScores: Record + genreIntentScores: Record // watchlist_add + like + complete + genreEngageScores: Record // watch_start + long detail + play_intent + genreBrowseScores: Record // hover + view + card_click actorScores: Record decadeScores: Record runtimeScores: Record @@ -272,6 +275,9 @@ function createEmptyProfileData(): ProfileData { }, preferences: { genreScores: {}, + genreIntentScores: {}, + genreEngageScores: {}, + genreBrowseScores: {}, actorScores: {}, decadeScores: {}, runtimeScores: {}, @@ -528,7 +534,8 @@ function applyRecencyDecay(profile: ProfileData, scores: Record, } } const p = profile.preferences - decayMap(p.genreScores); decayMap(p.actorScores); decayMap(p.decadeScores) + decayMap(p.genreScores); decayMap(p.genreIntentScores); decayMap(p.genreEngageScores) + decayMap(p.genreBrowseScores); decayMap(p.actorScores); decayMap(p.decadeScores) decayMap(p.runtimeScores); decayMap(p.recencyScores); decayMap(p.keywordScores) decayMap(p.directorScores); decayMap(p.composerScores); decayMap(p.certificationScores) decayMap(p.collectionScores) @@ -611,6 +618,30 @@ function addPreferenceScore(profile: ProfileData, mediaSnapshot: MediaSnapshot, pushUnique(profile.preferences.lastGenres, mediaSnapshot.genres) } +const INTENT_EVENTS = new Set(['WATCHLIST_ADD', 'MEDIA_LIKE', 'WATCH_COMPLETE']) +const ENGAGE_EVENTS = new Set(['WATCH_START', 'MEDIA_PLAY_INTENT', 'MEDIA_DETAIL_ENGAGEMENT']) + +function addTieredPreferenceScore( + profile: ProfileData, + mediaSnapshot: MediaSnapshot, + eventType: string, + amount: number, + metadata: Record, +) { + if (!mediaSnapshot.genres.length || amount <= 0) return + const p = profile.preferences + for (const genre of mediaSnapshot.genres) { + if (INTENT_EVENTS.has(eventType)) { + inc(p.genreIntentScores, genre, amount) + } else if (ENGAGE_EVENTS.has(eventType) && + (eventType !== 'MEDIA_DETAIL_ENGAGEMENT' || (metadata.timeOnPageMs || 0) > 5000)) { + inc(p.genreEngageScores, genre, amount) + } else { + inc(p.genreBrowseScores, genre, amount) + } + } +} + function updateTopLevelGenreScores(scores: Record, mediaGenres: string[], amount: number) { for (const genre of mediaGenres) { scores[genre] = (scores[genre] || 0) + amount @@ -903,11 +934,13 @@ export async function processEvent(userId: string, event: AnalyticsEvent) { if (weight !== 0 && mediaSnapshot.genres.length) { updateTopLevelGenreScores(scores, mediaSnapshot.genres, weight) addPreferenceScore(profileData, mediaSnapshot, weight) + addTieredPreferenceScore(profileData, mediaSnapshot, event.type, weight, metadata) } if (event.type === 'MEDIA_LIKE') { updateTopLevelGenreScores(scores, mediaSnapshot.genres, 8) addPreferenceScore(profileData, mediaSnapshot, 8) + addTieredPreferenceScore(profileData, mediaSnapshot, 'MEDIA_LIKE', 8, metadata) } if (event.type === 'MEDIA_DISLIKE') { @@ -919,6 +952,7 @@ export async function processEvent(userId: string, event: AnalyticsEvent) { if (event.type === 'WATCHLIST_ADD') { updateTopLevelGenreScores(scores, mediaSnapshot.genres, 7) addPreferenceScore(profileData, mediaSnapshot, 7) + addTieredPreferenceScore(profileData, mediaSnapshot, 'WATCHLIST_ADD', 7, metadata) } if (event.type === 'WATCHLIST_REMOVE') { updateTopLevelGenreScores(scores, mediaSnapshot.genres, -3) @@ -931,6 +965,7 @@ export async function processEvent(userId: string, event: AnalyticsEvent) { if (event.type === 'WATCH_COMPLETE' && Number(metadata.positionRatio || 0) >= 0.9) { addPreferenceScore(profileData, mediaSnapshot, 3) + addTieredPreferenceScore(profileData, mediaSnapshot, 'WATCH_COMPLETE', 3, metadata) } updateTags(profileData, event, metadata, weight) diff --git a/server/utils/mediaStatsEngine.ts b/server/utils/mediaStatsEngine.ts index b9e552a..321ae75 100644 --- a/server/utils/mediaStatsEngine.ts +++ b/server/utils/mediaStatsEngine.ts @@ -132,6 +132,47 @@ function loadAllStats(): Map { return map } +// ── Catalog genre frequency (for IDF weighting in scoring) ─────────────────── + +let genreFreqCache: { at: number; data: Record> } | null = null +const GENRE_FREQ_TTL = 30 * 60 * 1000 // 30 min + +/** + * Returns a map of genre → fraction of catalog items carrying that genre, + * keyed by media_type ('movie', 'tv'). Pass null for the combined catalog. + * Result is cached for 30 minutes. + */ +export function getCatalogGenreFreqs(mediaType: string | null): Record { + if (genreFreqCache && Date.now() - genreFreqCache.at < GENRE_FREQ_TTL) { + return genreFreqCache.data[mediaType ?? 'all'] ?? {} + } + const freqs: Record> = { all: {}, movie: {}, tv: {} } + try { + const rows = sqlite.prepare(`SELECT genres, media_type FROM media WHERE genres IS NOT NULL`).all() as Array<{ genres: string; media_type: string }> + const totals: Record = { all: 0, movie: 0, tv: 0 } + const counts: Record> = { all: {}, movie: {}, tv: {} } + for (const row of rows) { + let genres: string[] = [] + try { genres = JSON.parse(row.genres) } catch { continue } + totals.all++ + totals[row.media_type] = (totals[row.media_type] || 0) + 1 + for (const g of genres) { + counts.all[g] = (counts.all[g] || 0) + 1 + counts[row.media_type] = counts[row.media_type] || {} + counts[row.media_type][g] = (counts[row.media_type][g] || 0) + 1 + } + } + for (const type of ['all', 'movie', 'tv']) { + const total = totals[type] || 1 + for (const [g, cnt] of Object.entries(counts[type] || {})) { + freqs[type][g] = cnt / total + } + } + } catch {} + genreFreqCache = { at: Date.now(), data: freqs } + return freqs[mediaType ?? 'all'] ?? {} +} + /** Cached snapshot of every title's stats (≤ STATS_CACHE_TTL stale). */ export function getAllMediaStatsCached(): Map { if (statsCache && Date.now() - statsCache.at < STATS_CACHE_TTL) return statsCache.map diff --git a/server/utils/recoScoring.ts b/server/utils/recoScoring.ts index 849421e..82b89a1 100644 --- a/server/utils/recoScoring.ts +++ b/server/utils/recoScoring.ts @@ -29,6 +29,8 @@ export type ScoreContext = { watchlistGenres?: Set statsModifier?: number lastQuery?: string | null + /** Fraction of catalog items that carry each genre (0..1). Used for IDF weighting. */ + catalogGenreFreqs?: Record } // ── small helpers ───────────────────────────────────────────────────────────── @@ -267,10 +269,31 @@ export function scoreCandidate( // 1) Base taste model (genre/decade/rating) — already 0..100, weighted down. add((preferences ? calculateMatchScore(genres, candidate.year, candidate.rating, preferences) : 0) * 0.4, 'taste') - // 2) Genre affinity (+ anti-genre demotion) + // 2) Genre affinity (+ anti-genre demotion). + // If tiered scores are populated, use intent/engage/browse tiers with + // differentiated weights (10 + 4 + 2 = 16 max, same ceiling as before). + // IDF dampens genres that are ubiquitous in the catalog so only discriminating + // genres contribute strongly. const trust = 0.5 + 0.5 * style.trustsProfile + const hasIntentTier = maxPositive(pref.genreIntentScores) > 0 + || maxPositive(pref.genreEngageScores) > 0 + || maxPositive(pref.genreBrowseScores) > 0 for (const genre of genres) { - add(posScore(pref.genreScores, genre, 16) * trust, 'genre', genre) + const idf = ctx.catalogGenreFreqs + ? Math.log2(1 + 1 / Math.max(0.01, ctx.catalogGenreFreqs[genre] || 0)) + : 1 + const idfCapped = Math.min(idf, 3.5) // cap so rare genres don't explode + if (hasIntentTier) { + const intentMax = maxPositive(pref.genreIntentScores) + const engageMax = maxPositive(pref.genreEngageScores) + const browseMax = maxPositive(pref.genreBrowseScores) + const iScore = intentMax > 0 ? ((pref.genreIntentScores?.[genre] || 0) / intentMax) * 10 : 0 + const eScore = engageMax > 0 ? ((pref.genreEngageScores?.[genre] || 0) / engageMax) * 4 : 0 + const bScore = browseMax > 0 ? ((pref.genreBrowseScores?.[genre] || 0) / browseMax) * 2 : 0 + add((iScore + eScore + bScore) * trust * idfCapped, 'genre', genre) + } else { + add(posScore(pref.genreScores, genre, 16) * trust * idfCapped, 'genre', genre) + } add(negScore(pref.genreScores, genre, 14), 'antiGenre', genre) } @@ -358,6 +381,25 @@ export function scoreCandidate( add((style.cinephile + effCaution) * 2 + 1, 'acclaimed') } + // 11b) Completion prediction: cross runtime bucket with the user's measured + // completion rate and current hour to estimate if this title will actually + // get watched to a satisfying depth. + const avgComp = (profile.playback?.avgCompletionRate as number) || 0 + if (avgComp > 0) { + if ((rb === 'long' || rb === 'epic') && avgComp < 0.6) { + // The user abandons most things — long content is a poor bet. + add(-(0.6 - avgComp) * 5, 'completionRisk') + } + if (rb === 'short' && avgComp < 0.5) { + // Short content is the user's only realistic completion bet. + add((0.5 - avgComp) * 3, 'completionFit') + } + // Late night (22h-2h) penalty for epics: session will likely be cut short. + const h = now.getHours() + const lateNight = h >= 22 || h < 2 + if (lateNight && rb === 'epic') add(-1.5, 'completionRisk') + } + // 12) Sessions-to-finish as an effort signal (item #8) if (style.avgSessionsToFinish > 2 && !style.binger && (rb === 'long' || rb === 'epic')) add(-3, 'hardToFinish') if (style.binger && (rb === 'long' || rb === 'epic')) add(2, 'bingeFriendly') @@ -367,13 +409,15 @@ export function scoreCandidate( add(3, 'continueSaga', candidate.collectionName) } - // 14) Household / co-viewing: blend kids sub-profile during likely family moments + // 14) Household / co-viewing: blend kids sub-profile during likely family moments. + // When lastClass is 'adult' and it's not a family moment, skip kids blending entirely. if ((household.coViewing || 0) > 0) { const isFamilyMoment = getDaypart(now.getHours()) === 'afternoon' || now.getDay() === 0 || now.getDay() === 6 const isKids = (candidate.certification && KIDS_CERTS_RUNTIME(candidate.certification)) || genres.some(g => KIDS_GENRES.has(g)) - if (isKids) { + const adultSession = household.lastClass === 'adult' && !isFamilyMoment + if (isKids && !adultSession) { for (const genre of genres) add(posScore(household.kidsGenreScores, genre, isFamilyMoment ? 8 : 4), 'household', genre) - } else { + } else if (!isKids) { for (const genre of genres) add(posScore(household.adultGenreScores, genre, 4), 'household', genre) } }