feat: refactor platforms to support multiple TMDB provider IDs per platform

- Replace `tmdbProviderId` (single int) with a many-to-many `platformTmdbProviders` join table so one platform entry can map to multiple TMDB provider IDs (e.g. a single "Max" platform covers several regional TMDB IDs)
- Add `isSubscription` flag to platforms to distinguish subscription services from transactional ones, replacing the old `displayOrder` field in API responses
- Add `getPlatformTmdbIds` / `getPlatformTmdbIdMap` helpers in `@sofa/core/platforms` and thread them through discover, explore, and platform list procedures
- Replace `providerId: number` with `platformId: string` (UUID) in discover input schema so the client never needs to resolve TMDB IDs
- Add `scripts/sync-tmdb-providers.ts` script to pull live provider data from TMDB and update `platforms.json`
- Split native title detail availability into separate "Stream" and "Buy or Rent" sections matching the web pattern
- Add two new DB migrations for the join table and platform schema changes
This commit is contained in:
2026-03-24 20:54:34 -04:00
parent 7fedccb4fd
commit b0172a2b25
58 changed files with 8291 additions and 907 deletions
+3 -6
View File
@@ -2,7 +2,7 @@ import {
deleteIntegrationByUserAndProvider,
getIntegrationByToken,
getIntegrationByUserAndProvider,
getRecentEventsForIntegration,
getRecentEventsForIntegrations,
getUserIntegrations,
insertIntegration,
regenerateIntegrationToken,
@@ -38,11 +38,8 @@ function serializeIntegration(row: {
export function listUserIntegrations(userId: string) {
const userIntegrations = getUserIntegrations(userId);
const eventsByIntegration = new Map<string, ReturnType<typeof getRecentEventsForIntegration>>();
for (const integration of userIntegrations) {
const events = getRecentEventsForIntegration(integration.id);
eventsByIntegration.set(integration.id, events);
}
const integrationIds = userIntegrations.map((i) => i.id);
const eventsByIntegration = getRecentEventsForIntegrations(integrationIds);
const result = userIntegrations.map((integration) => {
const events = eventsByIntegration.get(integration.id) ?? [];
+55 -8
View File
@@ -684,19 +684,66 @@ async function ensureEnriched(
return false;
}
const STREAM_TYPES = new Set(["flatrate", "free", "ads"]);
const STREAM_PRIORITY: Record<string, number> = { flatrate: 0, free: 1, ads: 2 };
const PURCHASE_PRIORITY: Record<string, number> = { rent: 0, buy: 1 };
function readAvailability(
titleId: string,
titleName: string,
userPlatformIds?: Set<string>,
): AvailabilityOffer[] {
return getAvailabilityOffersForTitle(titleId).map((a) => ({
platformId: a.platformId,
providerName: a.providerName,
logoPath: tmdbImageUrl(a.logoPath, "logos"),
offerType: a.offerType,
watchUrl: generateProviderUrl(a.urlTemplate, titleName),
isUserSubscribed: userPlatformIds ? userPlatformIds.has(a.platformId) : false,
}));
const raw = getAvailabilityOffersForTitle(titleId);
// Group by platformId to deduplicate
const byPlatform = new Map<string, (typeof raw)[number][]>();
for (const offer of raw) {
let list = byPlatform.get(offer.platformId);
if (!list) {
list = [];
byPlatform.set(offer.platformId, list);
}
list.push(offer);
}
const result: AvailabilityOffer[] = [];
for (const [platformId, offers] of byPlatform) {
const streamOffers = offers.filter((o) => STREAM_TYPES.has(o.offerType));
const purchaseOffers = offers.filter((o) => !STREAM_TYPES.has(o.offerType));
// Emit one "stream" entry per platform (best offer type wins)
if (streamOffers.length > 0) {
const best = streamOffers.sort(
(a, b) => (STREAM_PRIORITY[a.offerType] ?? 99) - (STREAM_PRIORITY[b.offerType] ?? 99),
)[0];
result.push({
platformId,
providerName: best.providerName,
logoPath: tmdbImageUrl(best.logoPath, "logos"),
offerType: "stream",
watchUrl: generateProviderUrl(best.urlTemplate, titleName),
isUserSubscribed: userPlatformIds ? userPlatformIds.has(platformId) : false,
});
}
// Emit one "purchase" entry per platform (rent preferred over buy)
if (purchaseOffers.length > 0) {
const best = purchaseOffers.sort(
(a, b) => (PURCHASE_PRIORITY[a.offerType] ?? 99) - (PURCHASE_PRIORITY[b.offerType] ?? 99),
)[0];
result.push({
platformId,
providerName: best.providerName,
logoPath: tmdbImageUrl(best.logoPath, "logos"),
offerType: "purchase",
watchUrl: generateProviderUrl(best.urlTemplate, titleName),
isUserSubscribed: userPlatformIds ? userPlatformIds.has(platformId) : false,
});
}
}
return result;
}
export async function getOrFetchTitle(
+10
View File
@@ -1,5 +1,7 @@
import {
getAllPlatforms,
getTmdbProviderIdsByPlatformIds,
getTmdbProviderIdsForPlatform,
getUserPlatformIds,
getUserPlatforms,
hasUserPlatforms,
@@ -29,3 +31,11 @@ export function updateUserPlatforms(userId: string, platformIds: string[]): void
export function hasUserSetPlatforms(userId: string): boolean {
return hasUserPlatforms(userId);
}
export function getPlatformTmdbIds(platformId: string): number[] {
return getTmdbProviderIdsForPlatform(platformId);
}
export function getPlatformTmdbIdMap(platformIds: string[]): Map<string, number[]> {
return getTmdbProviderIdsByPlatformIds(platformIds);
}
+2 -8
View File
@@ -2,8 +2,7 @@ import { access, constants, readdir } from "node:fs/promises";
import path from "node:path";
import { CACHE_DIR, DATA_DIR, DATABASE_URL, TMDB_API_BASE_URL } from "@sofa/config";
import { getLatestCronRun, getTableCounts } from "@sofa/db/queries/system-health";
import type { cronRuns } from "@sofa/db/schema";
import { getLatestCronRuns, getTableCounts } from "@sofa/db/queries/system-health";
import { listBackups } from "./backup";
import { imageCacheEnabled } from "./image-cache";
@@ -153,12 +152,7 @@ function getJobsHealth(): SystemHealthData["jobs"] {
const schedules = _getJobSchedules?.() ?? [];
const scheduleMap = new Map(schedules.map((s) => [s.jobName, s]));
// Fetch only the latest cron run per job (index-optimized LIMIT 1 each)
const latestByJob = new Map<string, typeof cronRuns.$inferSelect>();
for (const jobName of JOB_NAMES) {
const latest = getLatestCronRun(jobName);
if (latest) latestByJob.set(jobName, latest);
}
const latestByJob = getLatestCronRuns([...JOB_NAMES]);
return JOB_NAMES.map((jobName) => {
const latest = latestByJob.get(jobName);
+6 -14
View File
@@ -11,9 +11,10 @@ import {
getAllEpisodeIdsForTitle,
getEpisodeProgressByTitleIds as getEpisodeProgressByTitleIdsQuery,
getEpisodeTitleId,
getEpisodeTitleIds,
getExistingEpisodeWatchIds,
getSeasonById,
getSeasonEpisodes,
getSeasonEpisodeIds,
getTitleStatus,
getUserStatusesByTitleIds as getUserStatusesByTitleIdsQuery,
getUserTitleInfo as getUserTitleInfoQuery,
@@ -91,11 +92,8 @@ export function logEpisodeWatchBatch(
batchInsertEpisodeWatchesTransaction(userId, episodeIds, source, watchedAt);
// Auto-set title status to in_progress for affected titles
const titleIds = new Set<string>();
for (const episodeId of episodeIds) {
const titleId = getEpisodeTitleId(episodeId);
if (titleId) titleIds.add(titleId);
}
const episodeTitleMap = getEpisodeTitleIds(episodeIds);
const titleIds = new Set(episodeTitleMap.values());
for (const titleId of titleIds) {
const existing = getTitleStatus(userId, titleId);
if (!existing || existing.status === "watchlist") {
@@ -140,9 +138,7 @@ export function unwatchEpisode(userId: string, episodeId: string) {
}
export function unwatchSeason(userId: string, seasonId: string) {
const seasonEps = getSeasonEpisodes(seasonId);
const epIds = seasonEps.map((ep) => ep.id);
const epIds = getSeasonEpisodeIds(seasonId);
if (epIds.length > 0) {
deleteEpisodeWatches(userId, epIds);
}
@@ -271,9 +267,5 @@ export function quickAddTitle(
}
export function watchSeason(userId: string, seasonId: string): void {
const seasonEps = getSeasonEpisodes(seasonId);
logEpisodeWatchBatch(
userId,
seasonEps.map((ep) => ep.id),
);
logEpisodeWatchBatch(userId, getSeasonEpisodeIds(seasonId));
}
+18 -18
View File
@@ -2,8 +2,7 @@ import { findEpisodeBySeasonAndNumber, findSeasonByTitleAndNumber } from "@sofa/
import {
getRecentEpisodeWatch,
getRecentMovieWatch,
insertIntegrationEvent,
updateIntegrationLastEvent,
insertIntegrationEventTransaction,
} from "@sofa/db/queries/webhooks";
import { createLogger } from "@sofa/logger";
import { getTvDetails } from "@sofa/tmdb/client";
@@ -189,22 +188,23 @@ function logEvent(
status: "success" | "ignored" | "error",
errorMessage?: string,
) {
insertIntegrationEvent({
integrationId: connectionId,
eventType:
event?.provider === "plex"
? "media.scrobble"
: event?.provider === "emby"
? "playback.stop"
: "PlaybackStop",
mediaType: event?.mediaType ?? null,
mediaTitle: event?.title ?? null,
status,
errorMessage: errorMessage ?? null,
receivedAt: new Date(),
});
updateIntegrationLastEvent(connectionId);
insertIntegrationEventTransaction(
{
integrationId: connectionId,
eventType:
event?.provider === "plex"
? "media.scrobble"
: event?.provider === "emby"
? "playback.stop"
: "PlaybackStop",
mediaType: event?.mediaType ?? null,
mediaTitle: event?.title ?? null,
status,
errorMessage: errorMessage ?? null,
receivedAt: new Date(),
},
connectionId,
);
}
// ─── Main Processing ────────────────────────────────────────────────
+29 -1
View File
@@ -294,7 +294,35 @@ describe("streaming provider", () => {
});
});
test("returns null when no flatrate provider exists", () => {
test("attaches ads-supported streaming provider", () => {
const tomorrow = daysFromNow(1);
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
insertStatus("user-1", "tv-1", "in_progress");
const pId = insertPlatform({ id: "p-tubi", name: "Tubi", tmdbProviderId: 73 });
insertTitleAvailability("tv-1", pId, { offerType: "ads" });
const result = getUpcomingFeed("user-1", { days: 7 });
expect(result.items[0].streamingProvider).toEqual({
platformId: "p-tubi",
providerName: "Tubi",
logoPath: "/logo.png",
});
});
test("prefers flatrate over ads when both exist", () => {
const tomorrow = daysFromNow(1);
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
insertStatus("user-1", "tv-1", "in_progress");
const pAds = insertPlatform({ id: "p-tubi", name: "Tubi", tmdbProviderId: 73 });
const pFlat = insertPlatform({ id: "p-netflix", name: "Netflix", tmdbProviderId: 8 });
insertTitleAvailability("tv-1", pAds, { offerType: "ads" });
insertTitleAvailability("tv-1", pFlat, { offerType: "flatrate" });
const result = getUpcomingFeed("user-1", { days: 7 });
expect(result.items[0].streamingProvider!.platformId).toBe("p-netflix");
});
test("returns null when only purchase providers exist", () => {
const tomorrow = daysFromNow(1);
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
insertStatus("user-1", "tv-1", "in_progress");