Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions server/trpc/routers/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, number>
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()
Expand All @@ -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 = [] }
Expand Down Expand Up @@ -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))
Expand All @@ -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) => {
Expand Down
37 changes: 36 additions & 1 deletion server/utils/analyticsEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@
}
preferences: {
genreScores: Record<string, number>
genreIntentScores: Record<string, number> // watchlist_add + like + complete
genreEngageScores: Record<string, number> // watch_start + long detail + play_intent
genreBrowseScores: Record<string, number> // hover + view + card_click
actorScores: Record<string, number>
decadeScores: Record<string, number>
runtimeScores: Record<string, number>
Expand Down Expand Up @@ -272,6 +275,9 @@
},
preferences: {
genreScores: {},
genreIntentScores: {},
genreEngageScores: {},
genreBrowseScores: {},
actorScores: {},
decadeScores: {},
runtimeScores: {},
Expand Down Expand Up @@ -524,11 +530,12 @@
if (!m) return
for (const k of Object.keys(m)) {
m[k] *= factor
if (Math.abs(m[k]) < 0.01) delete m[k] // prune negligible entries

Check failure on line 533 in server/utils/analyticsEngine.ts

View workflow job for this annotation

GitHub Actions / Checks

Do not delete dynamically computed property keys
}
}
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)
Expand All @@ -547,7 +554,7 @@
const dayKeys = Object.keys(profile.churn.activeDays)
if (dayKeys.length > 90) {
for (const key of dayKeys.sort().slice(0, dayKeys.length - 90)) {
delete profile.churn.activeDays[key]

Check failure on line 557 in server/utils/analyticsEngine.ts

View workflow job for this annotation

GitHub Actions / Checks

Do not delete dynamically computed property keys
}
}
const last = profile.churn.lastActiveAt ? new Date(profile.churn.lastActiveAt).getTime() : null
Expand Down Expand Up @@ -611,6 +618,30 @@
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<string, any>,
) {
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<string, number>, mediaGenres: string[], amount: number) {
for (const genre of mediaGenres) {
scores[genre] = (scores[genre] || 0) + amount
Expand Down Expand Up @@ -903,11 +934,13 @@
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') {
Expand All @@ -919,6 +952,7 @@
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)
Expand All @@ -931,6 +965,7 @@

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)
Expand Down
41 changes: 41 additions & 0 deletions server/utils/mediaStatsEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,47 @@ function loadAllStats(): Map<string, MediaStatsRow> {
return map
}

// ── Catalog genre frequency (for IDF weighting in scoring) ───────────────────

let genreFreqCache: { at: number; data: Record<string, Record<string, number>> } | 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<string, number> {
if (genreFreqCache && Date.now() - genreFreqCache.at < GENRE_FREQ_TTL) {
return genreFreqCache.data[mediaType ?? 'all'] ?? {}
}
const freqs: Record<string, Record<string, number>> = { 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<string, number> = { all: 0, movie: 0, tv: 0 }
const counts: Record<string, Record<string, number>> = { 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<string, MediaStatsRow> {
if (statsCache && Date.now() - statsCache.at < STATS_CACHE_TTL) return statsCache.map
Expand Down
54 changes: 49 additions & 5 deletions server/utils/recoScoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export type ScoreContext = {
watchlistGenres?: Set<string>
statsModifier?: number
lastQuery?: string | null
/** Fraction of catalog items that carry each genre (0..1). Used for IDF weighting. */
catalogGenreFreqs?: Record<string, number>
}

// ── small helpers ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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')
Expand All @@ -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)
}
}
Expand Down
Loading