mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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) ?? [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 +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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user