mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -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:
@@ -106,7 +106,7 @@ export const DiscoverInput = z
|
||||
.regex(/^[a-z]{2}$/)
|
||||
.optional()
|
||||
.describe("ISO 639-1 original language code"),
|
||||
providerId: z.number().int().optional().describe("TMDB watch provider ID"),
|
||||
platformId: z.string().optional().describe("Platform ID to filter by"),
|
||||
})
|
||||
.merge(PageParam)
|
||||
.meta({ description: "Genre-based discovery filters" });
|
||||
@@ -283,7 +283,7 @@ export const AvailabilityOfferSchema = z
|
||||
platformId: z.string().describe("Platform ID"),
|
||||
providerName: z.string().describe("Display name (e.g. Netflix, Hulu)"),
|
||||
logoPath: z.string().nullable().describe("Provider logo image path"),
|
||||
offerType: z.string().describe("Offer type: flatrate, rent, buy, free, ads"),
|
||||
offerType: z.string().describe("Offer category: stream or purchase"),
|
||||
watchUrl: z.string().nullable().describe("Direct link to watch on this provider"),
|
||||
isUserSubscribed: z.boolean().describe("Whether the user subscribes to this platform"),
|
||||
})
|
||||
@@ -293,9 +293,11 @@ export const PlatformSchema = z
|
||||
.object({
|
||||
id: z.string().describe("Platform ID"),
|
||||
name: z.string().describe("Display name"),
|
||||
tmdbProviderId: z.number().nullable().describe("TMDB provider ID (null for custom platforms)"),
|
||||
tmdbProviderIds: z.array(z.number()).describe("TMDB provider IDs mapped to this platform"),
|
||||
logoPath: z.string().nullable().describe("Logo image path"),
|
||||
displayOrder: z.number().describe("Sort order"),
|
||||
isSubscription: z
|
||||
.boolean()
|
||||
.describe("True for subscription services, false for purchase/rental stores"),
|
||||
})
|
||||
.meta({ description: "A streaming platform" });
|
||||
|
||||
@@ -621,7 +623,7 @@ export const WatchProvidersOutput = z
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string().describe("Platform ID"),
|
||||
tmdbProviderId: z.number().nullable().describe("TMDB provider ID"),
|
||||
tmdbProviderIds: z.array(z.number()).describe("TMDB provider IDs"),
|
||||
name: z.string().describe("Provider display name"),
|
||||
logoPath: z.string().nullable().describe("Provider logo image path"),
|
||||
}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE `platformTmdbIds` (
|
||||
`platformId` text NOT NULL,
|
||||
`tmdbProviderId` integer NOT NULL,
|
||||
CONSTRAINT `fk_platformTmdbIds_platformId_platforms_id_fk` FOREIGN KEY (`platformId`) REFERENCES `platforms`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `platformTmdbIds` (`platformId`, `tmdbProviderId`)
|
||||
SELECT `id`, `tmdbProviderId` FROM `platforms` WHERE `tmdbProviderId` IS NOT NULL;
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `platforms_tmdbProviderId_unique`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `platformTmdbIds_tmdbProviderId_unique` ON `platformTmdbIds` (`tmdbProviderId`);--> statement-breakpoint
|
||||
CREATE INDEX `platformTmdbIds_platformId` ON `platformTmdbIds` (`platformId`);--> statement-breakpoint
|
||||
ALTER TABLE `platforms` DROP COLUMN `tmdbProviderId`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `platforms` ADD `isSubscription` integer DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `platforms_displayOrder`;--> statement-breakpoint
|
||||
ALTER TABLE `platforms` DROP COLUMN `displayOrder`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import { db } from "./client";
|
||||
const log = createLogger("db");
|
||||
|
||||
export function runMigrations(migrationsFolder = path.join(import.meta.dir, "../drizzle")) {
|
||||
log.info("Running database migrations...");
|
||||
log.debug("Running database migrations...");
|
||||
migrate(db, { migrationsFolder });
|
||||
log.info("Database migrations complete");
|
||||
log.debug("Database migrations complete");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
[
|
||||
{
|
||||
"tmdbProviderIds": [8, 175, 1796],
|
||||
"name": "Netflix",
|
||||
"logoPath": "/pbpMk2JmcoNnQwx5JGpXngfoWtp.jpg",
|
||||
"urlTemplate": "https://www.netflix.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [9, 119, 2100],
|
||||
"name": "Amazon Prime Video",
|
||||
"logoPath": "/qR6FKvnPBx2O37FDg8PNM7efwF3.jpg",
|
||||
"urlTemplate": "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [10],
|
||||
"name": "Amazon Video",
|
||||
"logoPath": "/seGSXajazLMCKGB5hnRCidtjay1.jpg",
|
||||
"urlTemplate": "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
"isSubscription": false
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [337, 2739],
|
||||
"name": "Disney+",
|
||||
"logoPath": "/97yvRBw1GzX7fXprcF80er19ot.jpg",
|
||||
"urlTemplate": "https://www.disneyplus.com/search/{title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [350],
|
||||
"name": "Apple TV+",
|
||||
"logoPath": "/SPnB1qiCkYfirS2it3hZORwGVn.jpg",
|
||||
"urlTemplate": "https://tv.apple.com/search?term={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [2],
|
||||
"name": "Apple TV Store",
|
||||
"logoPath": "/mcbz1LgtErU9p4UdbZ0rG6RTWHX.jpg",
|
||||
"urlTemplate": "https://tv.apple.com/search?term={title}",
|
||||
"isSubscription": false
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [384, 1825, 1899],
|
||||
"name": "HBO Max",
|
||||
"logoPath": "/jbe4gVSfRlbPTdESXhEKpornsfu.jpg",
|
||||
"urlTemplate": "https://play.max.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [15],
|
||||
"name": "Hulu",
|
||||
"logoPath": "/bxBlRPEPpMVDc4jMhSrTf2339DW.jpg",
|
||||
"urlTemplate": "https://www.hulu.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [531, 582, 633, 1770, 1853, 2303, 2616, 37],
|
||||
"name": "Paramount+",
|
||||
"logoPath": "/fts6X10Jn4QT0X6ac3udKEn2tJA.jpg",
|
||||
"urlTemplate": "https://www.paramountplus.com/search/?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [386, 387],
|
||||
"name": "Peacock",
|
||||
"logoPath": "/2aGrp1xw3qhwCYvNGAJZPdjfeeX.jpg",
|
||||
"urlTemplate": "https://www.peacocktv.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [3],
|
||||
"name": "Google Play Movies",
|
||||
"logoPath": "/8z7rC8uIDaTM91X0ZfkRf04ydj2.jpg",
|
||||
"urlTemplate": "https://play.google.com/store/search?q={title}&c=movies",
|
||||
"isSubscription": false
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [7, 332],
|
||||
"name": "Fandango at Home",
|
||||
"logoPath": "/19fkcOz0xeUgCVW8tO85uOYnYK9.jpg",
|
||||
"urlTemplate": "https://www.vudu.com/content/movies/search?searchString={title}",
|
||||
"isSubscription": false
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [68],
|
||||
"name": "Microsoft Store",
|
||||
"logoPath": "/shq88b09gTBYC4hA7K7MUL8Q4zP.jpg",
|
||||
"urlTemplate": "https://www.microsoft.com/en-us/store/movies-and-tv",
|
||||
"isSubscription": false
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [188, 192],
|
||||
"name": "YouTube",
|
||||
"logoPath": "/pTnn5JwWr4p3pG8H6VrpiQo7Vs0.jpg",
|
||||
"urlTemplate": "https://www.youtube.com/results?search_query={title}",
|
||||
"isSubscription": false
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [2528],
|
||||
"name": "YouTube TV",
|
||||
"logoPath": "/x9zOHTUkQzt3PgPVKbMH9CKBwLK.jpg",
|
||||
"urlTemplate": "https://tv.youtube.com/",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [283, 1968],
|
||||
"name": "Crunchyroll",
|
||||
"logoPath": "/fzN5Jok5Ig1eJ7gyNGoMhnLSCfh.jpg",
|
||||
"urlTemplate": "https://www.crunchyroll.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [43, 634, 1794, 1855],
|
||||
"name": "Starz",
|
||||
"logoPath": "/yIKwylTLP1u8gl84Is7FItpYLGL.jpg",
|
||||
"urlTemplate": "https://www.starz.com/search?query={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [526, 528, 635, 1854, 352],
|
||||
"name": "AMC+",
|
||||
"logoPath": "/ovmu6uot1XVvsemM2dDySXLiX57.jpg",
|
||||
"urlTemplate": "https://www.amcplus.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [520, 584],
|
||||
"name": "Discovery+",
|
||||
"logoPath": "/eMTnWwNVtThkjvQA6zwxaoJG9NE.jpg",
|
||||
"urlTemplate": "https://www.discoveryplus.com/search/{title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [11, 201],
|
||||
"name": "MUBI",
|
||||
"logoPath": "/x570VpH2C9EKDf1riP83rYc5dnL.jpg",
|
||||
"urlTemplate": "https://mubi.com/search?query={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [99, 204],
|
||||
"name": "Shudder",
|
||||
"logoPath": "/vEtdiYRPRbDCp1Tcn3BEPF1Ni76.jpg",
|
||||
"urlTemplate": "https://www.shudder.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [151, 1852, 197],
|
||||
"name": "BritBox",
|
||||
"logoPath": "/8oA7IcDNNUtBa9JYB5kQ8hrDz5o.jpg",
|
||||
"urlTemplate": "https://www.britbox.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [87, 196],
|
||||
"name": "Acorn TV",
|
||||
"logoPath": "/doCc555FPPgGtuaZJxf9QZVpIp5.jpg",
|
||||
"urlTemplate": "https://acorn.tv/search/?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [190],
|
||||
"name": "Curiosity Stream",
|
||||
"logoPath": "/oR1aNm1Qu9jQBkW4VrGPWhqbC3P.jpg",
|
||||
"urlTemplate": "https://curiositystream.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [34, 583, 636],
|
||||
"name": "MGM+",
|
||||
"logoPath": "/ctiRpS16dlaTXQBSsiFncMrgWmh.jpg",
|
||||
"urlTemplate": "https://www.mgmplus.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [143, 205],
|
||||
"name": "Sundance Now",
|
||||
"logoPath": "/1Edma9SrJnqkQW3BqFd2rJNHZvX.jpg",
|
||||
"urlTemplate": "https://www.sundancenow.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [251, 343],
|
||||
"name": "ALLBLK",
|
||||
"logoPath": "/4cKdiYEPW1BsWLb9UmNzAyUlD5p.jpg",
|
||||
"urlTemplate": "https://www.allblk.tv/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [73],
|
||||
"name": "Tubi",
|
||||
"logoPath": "/zLYr7OPvpskMA4S79E3vlCi71iC.jpg",
|
||||
"urlTemplate": "https://tubitv.com/search/{title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [300],
|
||||
"name": "Pluto TV",
|
||||
"logoPath": "/dB8G41Q6tSL5NBisrIeqByfepBc.jpg",
|
||||
"urlTemplate": "https://pluto.tv/search/details?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [257],
|
||||
"name": "fuboTV",
|
||||
"logoPath": "/9BgaNQRMDvVlji1JBZi6tcfxpKx.jpg",
|
||||
"urlTemplate": "https://www.fubo.tv/search/{title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [2383],
|
||||
"name": "Philo",
|
||||
"logoPath": "/ptmbGSttkyzawLbxx9MElmxKuVo.jpg",
|
||||
"urlTemplate": "https://www.philo.com/",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [191],
|
||||
"name": "Kanopy",
|
||||
"logoPath": "/rcBwnERpNfPfWB5DaSTyEMCZbCA.jpg",
|
||||
"urlTemplate": "https://www.kanopy.com/search?q={title}",
|
||||
"isSubscription": false
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [212],
|
||||
"name": "Hoopla",
|
||||
"logoPath": "/j7D006Uy3UWwZ6G0xH6BMgIWTzH.jpg",
|
||||
"urlTemplate": "https://www.hoopladigital.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [207],
|
||||
"name": "The Roku Channel",
|
||||
"logoPath": "/wQzSN83BnWVgO7xEh0SeTVqtrFv.jpg",
|
||||
"urlTemplate": "https://therokuchannel.roku.com/search/{title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [258],
|
||||
"name": "Criterion Channel",
|
||||
"logoPath": "/yhrtzYd43pFIhRq0ruO8umJPuyn.jpg",
|
||||
"urlTemplate": "https://www.criterionchannel.com/search?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [83],
|
||||
"name": "The CW",
|
||||
"logoPath": "/spcwROYevucLluqZZ8Fv75UuTBt.jpg",
|
||||
"urlTemplate": "https://www.cwtv.com/search/?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [209, 293, 294],
|
||||
"name": "PBS",
|
||||
"logoPath": "/iLjStQKQwzyxXJb3jyNpvDmW9mx.jpg",
|
||||
"urlTemplate": "https://www.pbs.org/search/?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [538, 2077],
|
||||
"name": "Plex",
|
||||
"logoPath": "/vLZKlXUNDcZR7ilvfY9Wr9k80FZ.jpg",
|
||||
"urlTemplate": "https://www.plex.tv/search/?q={title}",
|
||||
"isSubscription": true
|
||||
},
|
||||
{
|
||||
"tmdbProviderIds": [457],
|
||||
"name": "ViX",
|
||||
"logoPath": "/jwRPknT20dfU1GeVqbcDXFyvtdG.jpg",
|
||||
"urlTemplate": "https://vix.com/es-es/search?q={title}",
|
||||
"isSubscription": true
|
||||
}
|
||||
]
|
||||
@@ -4,25 +4,17 @@ import { db } from "../client";
|
||||
import { session, verification } from "../schema";
|
||||
|
||||
export function deleteExpiredSessions(): number {
|
||||
const now = new Date();
|
||||
const count = db
|
||||
.select({ id: session.id })
|
||||
.from(session)
|
||||
.where(lt(session.expiresAt, now))
|
||||
return db
|
||||
.delete(session)
|
||||
.where(lt(session.expiresAt, new Date()))
|
||||
.returning({ id: session.id })
|
||||
.all().length;
|
||||
if (count === 0) return 0;
|
||||
db.delete(session).where(lt(session.expiresAt, now)).run();
|
||||
return count;
|
||||
}
|
||||
|
||||
export function deleteExpiredVerifications(): number {
|
||||
const now = new Date();
|
||||
const count = db
|
||||
.select({ id: verification.id })
|
||||
.from(verification)
|
||||
.where(lt(verification.expiresAt, now))
|
||||
return db
|
||||
.delete(verification)
|
||||
.where(lt(verification.expiresAt, new Date()))
|
||||
.returning({ id: verification.id })
|
||||
.all().length;
|
||||
if (count === 0) return 0;
|
||||
db.delete(verification).where(lt(verification.expiresAt, now)).run();
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { platforms, titleAvailability } from "../schema";
|
||||
import { platformTmdbIds, platforms, titleAvailability } from "../schema";
|
||||
|
||||
export function replaceAvailabilityTransaction(
|
||||
titleId: string,
|
||||
@@ -26,7 +26,6 @@ export function getAvailabilityForTitle(titleId: string) {
|
||||
providerName: platforms.name,
|
||||
logoPath: platforms.logoPath,
|
||||
urlTemplate: platforms.urlTemplate,
|
||||
tmdbProviderId: platforms.tmdbProviderId,
|
||||
offerType: titleAvailability.offerType,
|
||||
})
|
||||
.from(titleAvailability)
|
||||
@@ -36,7 +35,9 @@ export function getAvailabilityForTitle(titleId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a platform row exists for a TMDB provider. Upserts by tmdbProviderId.
|
||||
* Ensure a platform row exists for a TMDB provider.
|
||||
* Looks up via the platformTmdbIds junction table.
|
||||
* Uses a transaction to prevent orphaned platform rows under concurrent refreshes.
|
||||
* Returns the platform ID.
|
||||
*/
|
||||
export function ensurePlatformForTmdbProvider(
|
||||
@@ -44,23 +45,38 @@ export function ensurePlatformForTmdbProvider(
|
||||
name: string,
|
||||
logoPath: string | null,
|
||||
): string {
|
||||
const row = db
|
||||
.insert(platforms)
|
||||
.values({
|
||||
tmdbProviderId,
|
||||
name,
|
||||
logoPath,
|
||||
displayOrder: 999,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: platforms.tmdbProviderId,
|
||||
set: {
|
||||
name: sql`excluded.name`,
|
||||
logoPath: sql`excluded.logoPath`,
|
||||
},
|
||||
})
|
||||
.returning({ id: platforms.id })
|
||||
.get();
|
||||
return db.transaction((tx) => {
|
||||
const existing = tx
|
||||
.select({ platformId: platformTmdbIds.platformId })
|
||||
.from(platformTmdbIds)
|
||||
.where(eq(platformTmdbIds.tmdbProviderId, tmdbProviderId))
|
||||
.get();
|
||||
|
||||
return row!.id;
|
||||
if (existing) {
|
||||
tx.update(platforms).set({ logoPath }).where(eq(platforms.id, existing.platformId)).run();
|
||||
return existing.platformId;
|
||||
}
|
||||
|
||||
const id = Bun.randomUUIDv7();
|
||||
tx.insert(platforms).values({ id, name, logoPath }).run();
|
||||
tx.insert(platformTmdbIds)
|
||||
.values({ platformId: id, tmdbProviderId })
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
|
||||
// Verify our insert won (handles race with another writer)
|
||||
const mapping = tx
|
||||
.select({ platformId: platformTmdbIds.platformId })
|
||||
.from(platformTmdbIds)
|
||||
.where(eq(platformTmdbIds.tmdbProviderId, tmdbProviderId))
|
||||
.get()!;
|
||||
|
||||
if (mapping.platformId !== id) {
|
||||
// Another transaction beat us — clean up our orphan
|
||||
tx.delete(platforms).where(eq(platforms.id, id)).run();
|
||||
tx.update(platforms).set({ logoPath }).where(eq(platforms.id, mapping.platformId)).run();
|
||||
}
|
||||
|
||||
return mapping.platformId;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ function collectTitleImagePaths(titleIds: string[]): OrphanedImage[] {
|
||||
if (r.backdropPath) images.push({ category: "backdrops", path: r.backdropPath });
|
||||
}
|
||||
|
||||
// Season posters
|
||||
// Season posters + IDs
|
||||
const seasonRows = db
|
||||
.select({ posterPath: seasons.posterPath })
|
||||
.select({ id: seasons.id, posterPath: seasons.posterPath })
|
||||
.from(seasons)
|
||||
.where(inArray(seasons.titleId, batch))
|
||||
.all();
|
||||
@@ -45,12 +45,7 @@ function collectTitleImagePaths(titleIds: string[]): OrphanedImage[] {
|
||||
}
|
||||
|
||||
// Episode stills (via seasons)
|
||||
const seasonIds = db
|
||||
.select({ id: seasons.id })
|
||||
.from(seasons)
|
||||
.where(inArray(seasons.titleId, batch))
|
||||
.all()
|
||||
.map((s) => s.id);
|
||||
const seasonIds = seasonRows.map((s) => s.id);
|
||||
|
||||
for (let j = 0; j < seasonIds.length; j += BATCH_SIZE) {
|
||||
const sBatch = seasonIds.slice(j, j + BATCH_SIZE);
|
||||
@@ -68,19 +63,44 @@ function collectTitleImagePaths(titleIds: string[]): OrphanedImage[] {
|
||||
return images;
|
||||
}
|
||||
|
||||
/** Collect profile paths for persons that will be orphaned (no remaining titleCast). */
|
||||
function collectOrphanedPersonImages(): OrphanedImage[] {
|
||||
const orphaned = db
|
||||
.select({ profilePath: persons.profilePath })
|
||||
/** Find person IDs that have no remaining titleCast entries. */
|
||||
function getOrphanedPersonIds(): string[] {
|
||||
return db
|
||||
.select({ id: persons.id })
|
||||
.from(persons)
|
||||
.where(
|
||||
notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)),
|
||||
)
|
||||
.all();
|
||||
.all()
|
||||
.map((p) => p.id);
|
||||
}
|
||||
|
||||
return orphaned
|
||||
.filter((p): p is { profilePath: string } => p.profilePath != null)
|
||||
.map((p) => ({ category: "profiles" as const, path: p.profilePath }));
|
||||
/** Collect profile paths for a set of orphaned person IDs. */
|
||||
function collectOrphanedPersonImages(orphanedIds: string[]): OrphanedImage[] {
|
||||
if (orphanedIds.length === 0) return [];
|
||||
const images: OrphanedImage[] = [];
|
||||
for (let i = 0; i < orphanedIds.length; i += BATCH_SIZE) {
|
||||
const batch = orphanedIds.slice(i, i + BATCH_SIZE);
|
||||
const rows = db
|
||||
.select({ profilePath: persons.profilePath })
|
||||
.from(persons)
|
||||
.where(inArray(persons.id, batch))
|
||||
.all();
|
||||
for (const r of rows) {
|
||||
if (r.profilePath) images.push({ category: "profiles", path: r.profilePath });
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/** Delete orphaned persons by IDs, returning the count deleted. */
|
||||
function deleteOrphanedPersons(orphanedIds: string[]): number {
|
||||
if (orphanedIds.length === 0) return 0;
|
||||
for (let i = 0; i < orphanedIds.length; i += BATCH_SIZE) {
|
||||
const batch = orphanedIds.slice(i, i + BATCH_SIZE);
|
||||
db.delete(persons).where(inArray(persons.id, batch)).run();
|
||||
}
|
||||
return orphanedIds.length;
|
||||
}
|
||||
|
||||
export function purgeShellTitlesTransaction(): PurgeResult {
|
||||
@@ -92,8 +112,13 @@ export function purgeShellTitlesTransaction(): PurgeResult {
|
||||
.all();
|
||||
|
||||
if (shellTitles.length === 0) {
|
||||
const orphanedImages = collectOrphanedPersonImages();
|
||||
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons(), orphanedImages };
|
||||
const orphanedIds = getOrphanedPersonIds();
|
||||
const orphanedImages = collectOrphanedPersonImages(orphanedIds);
|
||||
return {
|
||||
deletedTitles: 0,
|
||||
deletedPersons: deleteOrphanedPersons(orphanedIds),
|
||||
orphanedImages,
|
||||
};
|
||||
}
|
||||
|
||||
const libraryTitleIds = new Set(
|
||||
@@ -107,8 +132,13 @@ export function purgeShellTitlesTransaction(): PurgeResult {
|
||||
const toDelete = shellTitles.map((t) => t.id).filter((id) => !libraryTitleIds.has(id));
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
const orphanedImages = collectOrphanedPersonImages();
|
||||
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons(), orphanedImages };
|
||||
const orphanedIds = getOrphanedPersonIds();
|
||||
const orphanedImages = collectOrphanedPersonImages(orphanedIds);
|
||||
return {
|
||||
deletedTitles: 0,
|
||||
deletedPersons: deleteOrphanedPersons(orphanedIds),
|
||||
orphanedImages,
|
||||
};
|
||||
}
|
||||
|
||||
// Collect image paths BEFORE cascade-deleting the title rows
|
||||
@@ -119,32 +149,17 @@ export function purgeShellTitlesTransaction(): PurgeResult {
|
||||
db.delete(titles).where(inArray(titles.id, batch)).run();
|
||||
}
|
||||
|
||||
// After title cascade deletes, collect orphaned person images then delete persons
|
||||
orphanedImages.push(...collectOrphanedPersonImages());
|
||||
// After title cascade deletes, find orphaned persons once and use for both operations
|
||||
const orphanedIds = getOrphanedPersonIds();
|
||||
orphanedImages.push(...collectOrphanedPersonImages(orphanedIds));
|
||||
return {
|
||||
deletedTitles: toDelete.length,
|
||||
deletedPersons: purgeOrphanedPersons(),
|
||||
deletedPersons: deleteOrphanedPersons(orphanedIds),
|
||||
orphanedImages,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function purgeOrphanedPersons(): number {
|
||||
const orphanedPersons = db
|
||||
.select({ id: persons.id })
|
||||
.from(persons)
|
||||
.where(
|
||||
notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)),
|
||||
)
|
||||
.all();
|
||||
|
||||
if (orphanedPersons.length === 0) return 0;
|
||||
|
||||
const ids = orphanedPersons.map((p) => p.id);
|
||||
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
|
||||
const batch = ids.slice(i, i + BATCH_SIZE);
|
||||
db.delete(persons).where(inArray(persons.id, batch)).run();
|
||||
}
|
||||
|
||||
return ids.length;
|
||||
return deleteOrphanedPersons(getOrphanedPersonIds());
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function getStaleTitles(titleIds: string[], staleDate: Date) {
|
||||
|
||||
export function getStaleNonLibraryTitles(staleDate: Date, limit: number) {
|
||||
return db
|
||||
.select()
|
||||
.select({ id: titles.id })
|
||||
.from(titles)
|
||||
.where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, staleDate)))
|
||||
.limit(limit)
|
||||
@@ -94,7 +94,7 @@ export function getTitlesWithStaleOffersFetchedBefore(titleIds: string[], staleD
|
||||
export function getReturningTvShows() {
|
||||
const returningStatuses = ["Returning Series", "In Production"];
|
||||
return db
|
||||
.select()
|
||||
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||
.from(titles)
|
||||
.where(
|
||||
and(
|
||||
@@ -128,19 +128,11 @@ export function getCastEntryForTitle(titleId: string) {
|
||||
}
|
||||
|
||||
export function deleteOldCronRuns(beforeDate: Date): number {
|
||||
const old = db
|
||||
.select({ id: cronRuns.id })
|
||||
.from(cronRuns)
|
||||
return db
|
||||
.delete(cronRuns)
|
||||
.where(lt(cronRuns.startedAt, beforeDate))
|
||||
.all();
|
||||
if (old.length === 0) return 0;
|
||||
const ids = old.map((r) => r.id);
|
||||
for (let i = 0; i < ids.length; i += 500) {
|
||||
db.delete(cronRuns)
|
||||
.where(inArray(cronRuns.id, ids.slice(i, i + 500)))
|
||||
.run();
|
||||
}
|
||||
return old.length;
|
||||
.returning({ id: cronRuns.id })
|
||||
.all().length;
|
||||
}
|
||||
|
||||
export function getTitlesWithFreshRecommendations(
|
||||
|
||||
@@ -351,6 +351,7 @@ export function getUpcomingMovies(
|
||||
|
||||
export function getAvailabilityByTitleIds(titleIds: string[]) {
|
||||
if (titleIds.length === 0) return [];
|
||||
// Order by offerType so flatrate is picked first when the caller deduplicates
|
||||
return db
|
||||
.select({
|
||||
titleId: titleAvailability.titleId,
|
||||
@@ -363,8 +364,11 @@ export function getAvailabilityByTitleIds(titleIds: string[]) {
|
||||
.where(
|
||||
and(
|
||||
inArray(titleAvailability.titleId, titleIds),
|
||||
eq(titleAvailability.offerType, "flatrate"),
|
||||
inArray(titleAvailability.offerType, ["flatrate", "free", "ads"]),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
sql`CASE ${titleAvailability.offerType} WHEN 'flatrate' THEN 0 WHEN 'free' THEN 1 ELSE 2 END`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
@@ -39,19 +39,11 @@ export function getSeasonPostersForTitle(titleId: string) {
|
||||
// ─── Episode stills ──────────────────────────────────────────────────
|
||||
|
||||
export function getEpisodeStillsForTitle(titleId: string) {
|
||||
const allSeasons = db
|
||||
.select({ id: seasons.id })
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
|
||||
const seasonIds = allSeasons.map((s) => s.id);
|
||||
if (seasonIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.select({ stillPath: episodes.stillPath })
|
||||
.from(episodes)
|
||||
.where(inArray(episodes.seasonId, seasonIds))
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
}
|
||||
|
||||
|
||||
@@ -89,22 +89,14 @@ export function getActiveImportJobForUser(userId: string) {
|
||||
|
||||
/** Mark any running/pending import jobs as errored — called on server startup to recover from crashes. */
|
||||
export function recoverStaleImportJobs(): number {
|
||||
const stale = db
|
||||
.select({ id: importJobs.id })
|
||||
.from(importJobs)
|
||||
return db
|
||||
.update(importJobs)
|
||||
.set({
|
||||
status: "error",
|
||||
finishedAt: new Date(),
|
||||
errors: JSON.stringify(["Import interrupted by server restart"]),
|
||||
})
|
||||
.where(inArray(importJobs.status, ["pending", "running"]))
|
||||
.all();
|
||||
if (stale.length === 0) return 0;
|
||||
const now = new Date();
|
||||
for (const { id } of stale) {
|
||||
db.update(importJobs)
|
||||
.set({
|
||||
status: "error",
|
||||
finishedAt: now,
|
||||
errors: JSON.stringify(["Import interrupted by server restart"]),
|
||||
})
|
||||
.where(eq(importJobs.id, id))
|
||||
.run();
|
||||
}
|
||||
return stale.length;
|
||||
.returning({ id: importJobs.id })
|
||||
.all().length;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,18 @@ export function getRecentEventsForIntegration(integrationId: string, limit = 10)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getRecentEventsForIntegrations(integrationIds: string[], limit = 10) {
|
||||
if (integrationIds.length === 0)
|
||||
return new Map<string, ReturnType<typeof getRecentEventsForIntegration>>();
|
||||
|
||||
const result = new Map<string, ReturnType<typeof getRecentEventsForIntegration>>();
|
||||
for (const integrationId of integrationIds) {
|
||||
const events = getRecentEventsForIntegration(integrationId, limit);
|
||||
if (events.length > 0) result.set(integrationId, events);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getIntegrationByUserAndProvider(userId: string, provider: string) {
|
||||
return db
|
||||
.select()
|
||||
|
||||
@@ -224,6 +224,10 @@ export function getFilteredLibrary(userId: string, filters: LibraryFilters) {
|
||||
|
||||
// Build query with display status when needed for filtering
|
||||
if (needsDisplayStatus) {
|
||||
// Filter by display status in SQL so pagination works at the DB level
|
||||
const statusValues = filters.statuses!.map((s) => sql`${s}`);
|
||||
conditions.push(sql`(${displayStatusExpr()}) IN (${sql.join(statusValues, sql`, `)})`);
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
@@ -239,6 +243,7 @@ export function getFilteredLibrary(userId: string, filters: LibraryFilters) {
|
||||
userStatus: userTitleStatus.status,
|
||||
userRating: userRatings.ratingStars,
|
||||
displayStatus: displayStatusExpr().as("display_status"),
|
||||
totalCount: sql<number>`count(*) over()`.as("totalCount"),
|
||||
})
|
||||
.from(titles)
|
||||
.innerJoin(
|
||||
@@ -251,18 +256,15 @@ export function getFilteredLibrary(userId: string, filters: LibraryFilters) {
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(...sortExpressions)
|
||||
.limit(filters.limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
// Post-filter by display status
|
||||
const filtered = rows.filter((r) => filters.statuses!.includes(r.displayStatus));
|
||||
const totalResults = filtered.length;
|
||||
const paged = filtered.slice(offset, offset + filters.limit);
|
||||
const totalResults = rows[0]?.totalCount ?? 0;
|
||||
const items = rows.map(({ totalCount: _, ...item }) => item);
|
||||
|
||||
return {
|
||||
items: paged.map((row) => {
|
||||
const { displayStatus, ...item } = row;
|
||||
return Object.assign(item, { displayStatus });
|
||||
}),
|
||||
items,
|
||||
page: filters.page,
|
||||
totalPages: Math.max(1, Math.ceil(totalResults / filters.limit)),
|
||||
totalResults,
|
||||
|
||||
@@ -267,7 +267,6 @@ export function getAvailabilityOffersForTitle(titleId: string) {
|
||||
providerName: platforms.name,
|
||||
logoPath: platforms.logoPath,
|
||||
urlTemplate: platforms.urlTemplate,
|
||||
tmdbProviderId: platforms.tmdbProviderId,
|
||||
offerType: titleAvailability.offerType,
|
||||
})
|
||||
.from(titleAvailability)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import { count, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { cronRuns, episodes, titles, user } from "../schema";
|
||||
@@ -23,3 +23,26 @@ export function getLatestCronRun(jobName: string) {
|
||||
.limit(1)
|
||||
.get();
|
||||
}
|
||||
|
||||
export function getLatestCronRuns(jobNames: string[]) {
|
||||
if (jobNames.length === 0) return new Map<string, typeof cronRuns.$inferSelect>();
|
||||
const rows = db
|
||||
.select({
|
||||
id: cronRuns.id,
|
||||
jobName: cronRuns.jobName,
|
||||
status: cronRuns.status,
|
||||
startedAt: cronRuns.startedAt,
|
||||
finishedAt: cronRuns.finishedAt,
|
||||
durationMs: cronRuns.durationMs,
|
||||
errorMessage: cronRuns.errorMessage,
|
||||
rn: sql<number>`row_number() over (partition by ${cronRuns.jobName} order by ${cronRuns.startedAt} desc)`.as(
|
||||
"rn",
|
||||
),
|
||||
})
|
||||
.from(cronRuns)
|
||||
.where(inArray(cronRuns.jobName, jobNames))
|
||||
.all()
|
||||
.filter((r) => r.rn === 1);
|
||||
|
||||
return new Map(rows.map((r) => [r.jobName, r]));
|
||||
}
|
||||
|
||||
@@ -69,6 +69,17 @@ export function getEpisodeTitleId(episodeId: string): string | null {
|
||||
return row?.titleId ?? null;
|
||||
}
|
||||
|
||||
export function getEpisodeTitleIds(episodeIds: string[]): Map<string, string> {
|
||||
if (episodeIds.length === 0) return new Map();
|
||||
const rows = db
|
||||
.select({ episodeId: episodes.id, titleId: seasons.titleId })
|
||||
.from(episodes)
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(inArray(episodes.id, episodeIds))
|
||||
.all();
|
||||
return new Map(rows.map((r) => [r.episodeId, r.titleId]));
|
||||
}
|
||||
|
||||
export function batchInsertEpisodeWatchesTransaction(
|
||||
userId: string,
|
||||
episodeIds: string[],
|
||||
@@ -168,7 +179,7 @@ export function batchInsertMissingEpisodeWatches(
|
||||
|
||||
export function countDistinctEpisodeWatches(userId: string, episodeIds: string[]): number {
|
||||
if (episodeIds.length === 0) return 0;
|
||||
const [result] = db
|
||||
const result = db
|
||||
.select({
|
||||
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
|
||||
})
|
||||
@@ -176,8 +187,8 @@ export function countDistinctEpisodeWatches(userId: string, episodeIds: string[]
|
||||
.where(
|
||||
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, episodeIds)),
|
||||
)
|
||||
.all();
|
||||
return result.count;
|
||||
.get();
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
export function upsertRating(
|
||||
@@ -277,6 +288,15 @@ export function getSeasonEpisodes(seasonId: string) {
|
||||
return db.select().from(episodes).where(eq(episodes.seasonId, seasonId)).all();
|
||||
}
|
||||
|
||||
export function getSeasonEpisodeIds(seasonId: string): string[] {
|
||||
return db
|
||||
.select({ id: episodes.id })
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, seasonId))
|
||||
.all()
|
||||
.map((e) => e.id);
|
||||
}
|
||||
|
||||
export function getSeasonById(seasonId: string) {
|
||||
return db.select().from(seasons).where(eq(seasons.id, seasonId)).get();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { asc, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { platforms, userPlatforms } from "../schema";
|
||||
import { platformTmdbIds, platforms, userPlatforms } from "../schema";
|
||||
|
||||
export function getUserPlatformIds(userId: string): string[] {
|
||||
return db
|
||||
@@ -17,15 +17,14 @@ export function getUserPlatforms(userId: string) {
|
||||
.select({
|
||||
id: platforms.id,
|
||||
name: platforms.name,
|
||||
tmdbProviderId: platforms.tmdbProviderId,
|
||||
logoPath: platforms.logoPath,
|
||||
urlTemplate: platforms.urlTemplate,
|
||||
displayOrder: platforms.displayOrder,
|
||||
isSubscription: platforms.isSubscription,
|
||||
})
|
||||
.from(userPlatforms)
|
||||
.innerJoin(platforms, eq(userPlatforms.platformId, platforms.id))
|
||||
.where(eq(userPlatforms.userId, userId))
|
||||
.orderBy(platforms.displayOrder)
|
||||
.orderBy(desc(platforms.isSubscription), asc(platforms.name))
|
||||
.all();
|
||||
}
|
||||
|
||||
@@ -42,7 +41,11 @@ export function setUserPlatforms(userId: string, platformIds: string[]): void {
|
||||
}
|
||||
|
||||
export function getAllPlatforms() {
|
||||
return db.select().from(platforms).orderBy(platforms.displayOrder).all();
|
||||
return db
|
||||
.select()
|
||||
.from(platforms)
|
||||
.orderBy(desc(platforms.isSubscription), asc(platforms.name))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function hasUserPlatforms(userId: string): boolean {
|
||||
@@ -66,3 +69,32 @@ export function platformIdsExist(platformIds: string[]): boolean {
|
||||
.all();
|
||||
return found.length === unique.length;
|
||||
}
|
||||
|
||||
export function getTmdbProviderIdsForPlatform(platformId: string): number[] {
|
||||
return db
|
||||
.select({ tmdbProviderId: platformTmdbIds.tmdbProviderId })
|
||||
.from(platformTmdbIds)
|
||||
.where(eq(platformTmdbIds.platformId, platformId))
|
||||
.all()
|
||||
.map((r) => r.tmdbProviderId);
|
||||
}
|
||||
|
||||
export function getTmdbProviderIdsByPlatformIds(platformIds: string[]): Map<string, number[]> {
|
||||
if (platformIds.length === 0) return new Map();
|
||||
const rows = db
|
||||
.select({
|
||||
platformId: platformTmdbIds.platformId,
|
||||
tmdbProviderId: platformTmdbIds.tmdbProviderId,
|
||||
})
|
||||
.from(platformTmdbIds)
|
||||
.where(inArray(platformTmdbIds.platformId, platformIds))
|
||||
.all();
|
||||
|
||||
const map = new Map<string, number[]>();
|
||||
for (const row of rows) {
|
||||
const arr = map.get(row.platformId) ?? [];
|
||||
arr.push(row.tmdbProviderId);
|
||||
map.set(row.platformId, arr);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gte, inArray, lt } from "drizzle-orm";
|
||||
import { and, eq, gte, lt } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { integrationEvents, integrations, userEpisodeWatches, userMovieWatches } from "../schema";
|
||||
@@ -31,29 +31,23 @@ export function getRecentEpisodeWatch(userId: string, episodeId: string, since:
|
||||
.get();
|
||||
}
|
||||
|
||||
export function insertIntegrationEvent(values: typeof integrationEvents.$inferInsert): void {
|
||||
db.insert(integrationEvents).values(values).run();
|
||||
}
|
||||
|
||||
export function updateIntegrationLastEvent(connectionId: string): void {
|
||||
db.update(integrations)
|
||||
.set({ lastEventAt: new Date() })
|
||||
.where(eq(integrations.id, connectionId))
|
||||
.run();
|
||||
export function insertIntegrationEventTransaction(
|
||||
values: typeof integrationEvents.$inferInsert,
|
||||
connectionId: string,
|
||||
): void {
|
||||
db.transaction((tx) => {
|
||||
tx.insert(integrationEvents).values(values).run();
|
||||
tx.update(integrations)
|
||||
.set({ lastEventAt: new Date() })
|
||||
.where(eq(integrations.id, connectionId))
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOldIntegrationEvents(beforeDate: Date): number {
|
||||
const old = db
|
||||
.select({ id: integrationEvents.id })
|
||||
.from(integrationEvents)
|
||||
return db
|
||||
.delete(integrationEvents)
|
||||
.where(lt(integrationEvents.receivedAt, beforeDate))
|
||||
.all();
|
||||
if (old.length === 0) return 0;
|
||||
const ids = old.map((r) => r.id);
|
||||
for (let i = 0; i < ids.length; i += 500) {
|
||||
db.delete(integrationEvents)
|
||||
.where(inArray(integrationEvents.id, ids.slice(i, i + 500)))
|
||||
.run();
|
||||
}
|
||||
return old.length;
|
||||
.returning({ id: integrationEvents.id })
|
||||
.all().length;
|
||||
}
|
||||
|
||||
+16
-10
@@ -251,19 +251,25 @@ export const userRatings = sqliteTable(
|
||||
|
||||
// ─── Platforms & Availability ────────────────────────────────────────
|
||||
|
||||
export const platforms = sqliteTable(
|
||||
"platforms",
|
||||
export const platforms = sqliteTable("platforms", {
|
||||
id: uuidPk(),
|
||||
name: text("name").notNull(),
|
||||
logoPath: text("logoPath"),
|
||||
urlTemplate: text("urlTemplate"),
|
||||
isSubscription: int("isSubscription", { mode: "boolean" }).notNull().default(true),
|
||||
});
|
||||
|
||||
export const platformTmdbIds = sqliteTable(
|
||||
"platformTmdbIds",
|
||||
{
|
||||
id: uuidPk(),
|
||||
name: text("name").notNull(),
|
||||
tmdbProviderId: int("tmdbProviderId"),
|
||||
logoPath: text("logoPath"),
|
||||
urlTemplate: text("urlTemplate"),
|
||||
displayOrder: int("displayOrder").notNull().default(0),
|
||||
platformId: text("platformId")
|
||||
.notNull()
|
||||
.references(() => platforms.id, { onDelete: "cascade" }),
|
||||
tmdbProviderId: int("tmdbProviderId").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("platforms_tmdbProviderId_unique").on(table.tmdbProviderId),
|
||||
index("platforms_displayOrder").on(table.displayOrder),
|
||||
uniqueIndex("platformTmdbIds_tmdbProviderId_unique").on(table.tmdbProviderId),
|
||||
index("platformTmdbIds_platformId").on(table.platformId),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+112
-184
@@ -1,198 +1,126 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { db } from "./client";
|
||||
import { platforms } from "./schema";
|
||||
import platformData from "./platforms.json";
|
||||
import { platformTmdbIds, platforms, titleAvailability, userPlatforms } from "./schema";
|
||||
|
||||
interface SeedPlatform {
|
||||
tmdbProviderId: number;
|
||||
const log = createLogger("seed-platforms");
|
||||
|
||||
export interface SeedPlatform {
|
||||
tmdbProviderIds: number[];
|
||||
name: string;
|
||||
logoPath: string | null;
|
||||
urlTemplate: string;
|
||||
displayOrder: number;
|
||||
isSubscription: boolean;
|
||||
}
|
||||
|
||||
const SEED_DATA: SeedPlatform[] = [
|
||||
// Netflix
|
||||
{
|
||||
tmdbProviderId: 8,
|
||||
name: "Netflix",
|
||||
urlTemplate: "https://www.netflix.com/search?q={title}",
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 1796,
|
||||
name: "Netflix basic with Ads",
|
||||
urlTemplate: "https://www.netflix.com/search?q={title}",
|
||||
displayOrder: 2,
|
||||
},
|
||||
|
||||
// Amazon
|
||||
{
|
||||
tmdbProviderId: 9,
|
||||
name: "Amazon Prime Video",
|
||||
urlTemplate: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
displayOrder: 3,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 10,
|
||||
name: "Amazon Video",
|
||||
urlTemplate: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
displayOrder: 4,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 119,
|
||||
name: "Amazon Prime Video",
|
||||
urlTemplate: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
displayOrder: 5,
|
||||
},
|
||||
|
||||
// Disney+
|
||||
{
|
||||
tmdbProviderId: 337,
|
||||
name: "Disney+",
|
||||
urlTemplate: "https://www.disneyplus.com/search/{title}",
|
||||
displayOrder: 6,
|
||||
},
|
||||
|
||||
// Apple
|
||||
{
|
||||
tmdbProviderId: 2,
|
||||
name: "Apple iTunes",
|
||||
urlTemplate: "https://tv.apple.com/search?term={title}",
|
||||
displayOrder: 7,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 350,
|
||||
name: "Apple TV+",
|
||||
urlTemplate: "https://tv.apple.com/search?term={title}",
|
||||
displayOrder: 8,
|
||||
},
|
||||
|
||||
// Hulu
|
||||
{
|
||||
tmdbProviderId: 15,
|
||||
name: "Hulu",
|
||||
urlTemplate: "https://www.hulu.com/search?q={title}",
|
||||
displayOrder: 9,
|
||||
},
|
||||
|
||||
// Max (HBO)
|
||||
{
|
||||
tmdbProviderId: 384,
|
||||
name: "HBO Max",
|
||||
urlTemplate: "https://play.max.com/search?q={title}",
|
||||
displayOrder: 10,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 1899,
|
||||
name: "Max",
|
||||
urlTemplate: "https://play.max.com/search?q={title}",
|
||||
displayOrder: 11,
|
||||
},
|
||||
|
||||
// Paramount+
|
||||
{
|
||||
tmdbProviderId: 531,
|
||||
name: "Paramount+",
|
||||
urlTemplate: "https://www.paramountplus.com/search/?q={title}",
|
||||
displayOrder: 12,
|
||||
},
|
||||
|
||||
// Peacock
|
||||
{
|
||||
tmdbProviderId: 386,
|
||||
name: "Peacock",
|
||||
urlTemplate: "https://www.peacocktv.com/search?q={title}",
|
||||
displayOrder: 13,
|
||||
},
|
||||
|
||||
// Google Play
|
||||
{
|
||||
tmdbProviderId: 3,
|
||||
name: "Google Play Movies",
|
||||
urlTemplate: "https://play.google.com/store/search?q={title}&c=movies",
|
||||
displayOrder: 14,
|
||||
},
|
||||
|
||||
// YouTube
|
||||
{
|
||||
tmdbProviderId: 192,
|
||||
name: "YouTube",
|
||||
urlTemplate: "https://www.youtube.com/results?search_query={title}",
|
||||
displayOrder: 15,
|
||||
},
|
||||
|
||||
// Crunchyroll
|
||||
{
|
||||
tmdbProviderId: 283,
|
||||
name: "Crunchyroll",
|
||||
urlTemplate: "https://www.crunchyroll.com/search?q={title}",
|
||||
displayOrder: 16,
|
||||
},
|
||||
|
||||
// Free / ad-supported
|
||||
{
|
||||
tmdbProviderId: 73,
|
||||
name: "Tubi",
|
||||
urlTemplate: "https://tubitv.com/search/{title}",
|
||||
displayOrder: 17,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 300,
|
||||
name: "Pluto TV",
|
||||
urlTemplate: "https://pluto.tv/search/details?q={title}",
|
||||
displayOrder: 18,
|
||||
},
|
||||
|
||||
// Other
|
||||
{
|
||||
tmdbProviderId: 257,
|
||||
name: "fuboTV",
|
||||
urlTemplate: "https://www.fubo.tv/search/{title}",
|
||||
displayOrder: 19,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 43,
|
||||
name: "Starz",
|
||||
urlTemplate: "https://www.starz.com/search?query={title}",
|
||||
displayOrder: 20,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 37,
|
||||
name: "Showtime",
|
||||
urlTemplate: "https://www.sho.com/search?q={title}",
|
||||
displayOrder: 21,
|
||||
},
|
||||
];
|
||||
export const SEED_DATA: SeedPlatform[] = platformData;
|
||||
|
||||
export function seedPlatforms(): void {
|
||||
const insert = db
|
||||
.insert(platforms)
|
||||
.values({
|
||||
id: sql.placeholder("id"),
|
||||
tmdbProviderId: sql.placeholder("tmdbProviderId"),
|
||||
name: sql.placeholder("name"),
|
||||
urlTemplate: sql.placeholder("urlTemplate"),
|
||||
displayOrder: sql.placeholder("displayOrder"),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: platforms.tmdbProviderId,
|
||||
set: {
|
||||
name: sql`excluded.name`,
|
||||
urlTemplate: sql`excluded.urlTemplate`,
|
||||
displayOrder: sql`excluded.displayOrder`,
|
||||
},
|
||||
})
|
||||
.prepare();
|
||||
log.debug(`Seeding ${SEED_DATA.length} platforms`);
|
||||
|
||||
db.transaction(() => {
|
||||
for (const p of SEED_DATA) {
|
||||
insert.execute({
|
||||
id: Bun.randomUUIDv7(),
|
||||
tmdbProviderId: p.tmdbProviderId,
|
||||
name: p.name,
|
||||
urlTemplate: p.urlTemplate,
|
||||
displayOrder: p.displayOrder,
|
||||
});
|
||||
const existingPlatforms = new Map(
|
||||
db
|
||||
.select()
|
||||
.from(platforms)
|
||||
.all()
|
||||
.map((p) => [p.id, p]),
|
||||
);
|
||||
const existingMappings = db.select().from(platformTmdbIds).all();
|
||||
const tmdbToPlatform = new Map(existingMappings.map((m) => [m.tmdbProviderId, m.platformId]));
|
||||
|
||||
for (const seed of SEED_DATA) {
|
||||
// Find all existing platform IDs that any of this seed's TMDB IDs map to
|
||||
const existingPlatformIds = [
|
||||
...new Set(
|
||||
seed.tmdbProviderIds
|
||||
.map((id) => tmdbToPlatform.get(id))
|
||||
.filter((id): id is string => id != null),
|
||||
),
|
||||
];
|
||||
|
||||
let canonicalId: string;
|
||||
|
||||
if (existingPlatformIds.length === 0) {
|
||||
// Brand new platform
|
||||
canonicalId = Bun.randomUUIDv7();
|
||||
db.insert(platforms)
|
||||
.values({
|
||||
id: canonicalId,
|
||||
name: seed.name,
|
||||
logoPath: seed.logoPath,
|
||||
urlTemplate: seed.urlTemplate,
|
||||
isSubscription: seed.isSubscription,
|
||||
})
|
||||
.run();
|
||||
log.debug(`Created platform "${seed.name}"`);
|
||||
} else {
|
||||
// Use the first existing platform as canonical
|
||||
canonicalId = existingPlatformIds[0]!;
|
||||
|
||||
// Only update if metadata actually changed
|
||||
const existing = existingPlatforms.get(canonicalId);
|
||||
if (
|
||||
!existing ||
|
||||
existing.name !== seed.name ||
|
||||
existing.logoPath !== seed.logoPath ||
|
||||
existing.urlTemplate !== seed.urlTemplate ||
|
||||
existing.isSubscription !== seed.isSubscription
|
||||
) {
|
||||
db.update(platforms)
|
||||
.set({
|
||||
name: seed.name,
|
||||
logoPath: seed.logoPath,
|
||||
urlTemplate: seed.urlTemplate,
|
||||
isSubscription: seed.isSubscription,
|
||||
})
|
||||
.where(eq(platforms.id, canonicalId))
|
||||
.run();
|
||||
log.debug(`Updated platform "${seed.name}"`);
|
||||
}
|
||||
|
||||
// Merge duplicates into the canonical platform
|
||||
for (const oldId of existingPlatformIds.slice(1)) {
|
||||
// Re-point titleAvailability rows, ignoring conflicts from duplicates
|
||||
db.run(
|
||||
sql`UPDATE OR IGNORE ${titleAvailability} SET "platformId" = ${canonicalId} WHERE "platformId" = ${oldId}`,
|
||||
);
|
||||
// Delete any titleAvailability rows that couldn't be moved due to unique constraint
|
||||
db.delete(titleAvailability).where(eq(titleAvailability.platformId, oldId)).run();
|
||||
|
||||
// Re-point userPlatforms rows, ignoring conflicts
|
||||
db.run(
|
||||
sql`UPDATE OR IGNORE ${userPlatforms} SET "platformId" = ${canonicalId} WHERE "platformId" = ${oldId}`,
|
||||
);
|
||||
db.delete(userPlatforms).where(eq(userPlatforms.platformId, oldId)).run();
|
||||
|
||||
// Remove old TMDB ID mappings (cascade won't fire since we delete platform next)
|
||||
db.delete(platformTmdbIds).where(eq(platformTmdbIds.platformId, oldId)).run();
|
||||
|
||||
// Delete the duplicate platform
|
||||
db.delete(platforms).where(eq(platforms.id, oldId)).run();
|
||||
log.debug(`Merged duplicate platform ${oldId} into "${seed.name}" (${canonicalId})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch insert missing TMDB ID mappings
|
||||
const missingTmdbIds = seed.tmdbProviderIds.filter(
|
||||
(id) => tmdbToPlatform.get(id) !== canonicalId,
|
||||
);
|
||||
if (missingTmdbIds.length > 0) {
|
||||
db.insert(platformTmdbIds)
|
||||
.values(
|
||||
missingTmdbIds.map((tmdbProviderId) => ({ platformId: canonicalId, tmdbProviderId })),
|
||||
)
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
log.debug(`Added ${missingTmdbIds.length} TMDB mapping(s) for "${seed.name}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
log.debug(`Seeded ${SEED_DATA.length} platforms`);
|
||||
}
|
||||
|
||||
+41
-20
@@ -275,6 +275,10 @@ msgstr "4★+"
|
||||
msgid "5★"
|
||||
msgstr "5★"
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "70s and earlier"
|
||||
msgstr "70s and earlier"
|
||||
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
msgid "80s"
|
||||
msgstr "80s"
|
||||
@@ -284,7 +288,7 @@ msgid "90s"
|
||||
msgstr "90s"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
@@ -451,8 +455,8 @@ msgid "Any year"
|
||||
msgstr "Any year"
|
||||
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "App Settings"
|
||||
msgstr ""
|
||||
#~ msgid "App Settings"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Application"
|
||||
@@ -540,8 +544,13 @@ msgid "backups."
|
||||
msgstr "backups."
|
||||
|
||||
#: apps/web/src/components/titles/title-availability.tsx
|
||||
msgid "Buy"
|
||||
msgstr ""
|
||||
#~ msgid "Buy"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
#: apps/web/src/components/titles/title-availability.tsx
|
||||
msgid "Buy or Rent"
|
||||
msgstr "Buy or Rent"
|
||||
|
||||
#: apps/web/src/components/settings/danger-section.tsx
|
||||
msgid "Cache management"
|
||||
@@ -661,8 +670,12 @@ msgstr "Choose how to import your {sourceLabel} data."
|
||||
#~ msgstr "Choose your preferred display language"
|
||||
|
||||
#: apps/web/src/components/settings/streaming-services-section.tsx
|
||||
msgid "Choose your subscriptions"
|
||||
msgstr "Choose your subscriptions"
|
||||
msgid "Choose your preferred streaming platforms"
|
||||
msgstr "Choose your preferred streaming platforms"
|
||||
|
||||
#: apps/web/src/components/settings/streaming-services-section.tsx
|
||||
#~ msgid "Choose your subscriptions"
|
||||
#~ msgstr "Choose your subscriptions"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
@@ -1427,8 +1440,8 @@ msgid "Finished importing your Sofa export."
|
||||
msgstr "Finished importing your Sofa export."
|
||||
|
||||
#: apps/web/src/components/titles/title-availability.tsx
|
||||
msgid "Free"
|
||||
msgstr ""
|
||||
#~ msgid "Free"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/danger-section.tsx
|
||||
msgid "Free up disk space by clearing cached metadata and images"
|
||||
@@ -1573,7 +1586,7 @@ msgid "Image cache and backup disk usage"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
@@ -1673,7 +1686,7 @@ msgid "Install the Webhook plugin from Jellyfin's plugin catalog"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integrations-section.tsx
|
||||
#: apps/web/src/components/settings/integrations-section.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Integrations"
|
||||
msgstr ""
|
||||
|
||||
@@ -2346,7 +2359,6 @@ msgstr "Pre-1970"
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
#: apps/native/src/app/(tabs)/(library)/index.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
msgid "Pre-1980"
|
||||
msgstr "Pre-1980"
|
||||
|
||||
@@ -2653,8 +2665,8 @@ msgid "Removed from library"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/title-availability.tsx
|
||||
msgid "Rent"
|
||||
msgstr ""
|
||||
#~ msgid "Rent"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/web/src/routes/setup.tsx
|
||||
msgid "Request an API key"
|
||||
@@ -3114,6 +3126,7 @@ msgstr ""
|
||||
msgid "Storage"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/title/[id].tsx
|
||||
#: apps/web/src/components/titles/title-availability.tsx
|
||||
msgid "Stream"
|
||||
msgstr ""
|
||||
@@ -3123,8 +3136,16 @@ msgid "Streaming"
|
||||
msgstr "Streaming"
|
||||
|
||||
#: apps/web/src/components/settings/streaming-services-section.tsx
|
||||
msgid "Streaming Services"
|
||||
msgstr "Streaming Services"
|
||||
msgid "Streaming data provided by <0/><1>JustWatch</1>"
|
||||
msgstr "Streaming data provided by <0/><1>JustWatch</1>"
|
||||
|
||||
#: apps/web/src/components/settings/streaming-services-section.tsx
|
||||
#~ msgid "Streaming Services"
|
||||
#~ msgstr "Streaming Services"
|
||||
|
||||
#: apps/web/src/components/settings/streaming-services-section.tsx
|
||||
msgid "Subscriptions"
|
||||
msgstr "Subscriptions"
|
||||
|
||||
#: apps/web/src/components/settings/backup-schedule-section.tsx
|
||||
msgid "Sunday"
|
||||
@@ -3567,8 +3588,8 @@ msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/title-availability.tsx
|
||||
#: apps/web/src/components/titles/title-availability.tsx
|
||||
msgid "Watch on {name}"
|
||||
msgstr ""
|
||||
#~ msgid "Watch on {name}"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/components/titles/season-accordion.tsx
|
||||
#: apps/native/src/lib/title-actions.ts
|
||||
@@ -3651,8 +3672,8 @@ msgid "Where to Watch"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/title-availability.tsx
|
||||
msgid "With Ads"
|
||||
msgstr ""
|
||||
#~ msgid "With Ads"
|
||||
#~ msgstr ""
|
||||
|
||||
#: apps/native/src/app/person/[id].tsx
|
||||
#: apps/native/src/components/search/recently-viewed-row-content.tsx
|
||||
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
userEpisodeWatches,
|
||||
userTitleStatus,
|
||||
userRatings,
|
||||
platformTmdbIds,
|
||||
platforms,
|
||||
titleAvailability,
|
||||
userPlatforms,
|
||||
@@ -178,9 +179,9 @@ export function insertPlatform(
|
||||
id?: string;
|
||||
name?: string;
|
||||
tmdbProviderId?: number;
|
||||
tmdbProviderIds?: number[];
|
||||
logoPath?: string;
|
||||
urlTemplate?: string;
|
||||
displayOrder?: number;
|
||||
} = {},
|
||||
) {
|
||||
const id = overrides.id ?? "platform-1";
|
||||
@@ -189,12 +190,15 @@ export function insertPlatform(
|
||||
.values({
|
||||
id,
|
||||
name: overrides.name ?? "Netflix",
|
||||
tmdbProviderId: overrides.tmdbProviderId ?? 8,
|
||||
logoPath: overrides.logoPath ?? "/logo.png",
|
||||
urlTemplate: overrides.urlTemplate ?? "https://www.netflix.com/search?q={title}",
|
||||
displayOrder: overrides.displayOrder ?? 1,
|
||||
})
|
||||
.run();
|
||||
|
||||
const tmdbIds = overrides.tmdbProviderIds ?? [overrides.tmdbProviderId ?? 8];
|
||||
for (const tmdbId of tmdbIds) {
|
||||
testDb.insert(platformTmdbIds).values({ platformId: id, tmdbProviderId: tmdbId }).run();
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user