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:
@@ -471,30 +471,76 @@ export default function TitleDetailScreen() {
|
|||||||
iconColor={titleAccent}
|
iconColor={titleAccent}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView
|
{(() => {
|
||||||
horizontal
|
const stream = availability.filter((o) => o.offerType === "stream");
|
||||||
showsHorizontalScrollIndicator={false}
|
const purchase = availability.filter((o) => o.offerType === "purchase");
|
||||||
contentContainerStyle={titleAvailabilityContentStyle}
|
return (
|
||||||
>
|
<View className="gap-3">
|
||||||
{availability.map((offer) => (
|
{stream.length > 0 && (
|
||||||
<View key={`${offer.platformId}-${offer.offerType}`} className="items-center">
|
<View>
|
||||||
{offer.logoPath && (
|
<Text className="text-muted-foreground/60 mb-1.5 px-4 text-[10px] font-medium tracking-wider uppercase">
|
||||||
<Image
|
{t`Stream`}
|
||||||
source={{ uri: offer.logoPath }}
|
</Text>
|
||||||
style={titleDetailStyles.providerLogo}
|
<ScrollView
|
||||||
contentFit="cover"
|
horizontal
|
||||||
/>
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={titleAvailabilityContentStyle}
|
||||||
|
>
|
||||||
|
{stream.map((offer) => (
|
||||||
|
<View key={offer.platformId} className="items-center">
|
||||||
|
{offer.logoPath && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: offer.logoPath }}
|
||||||
|
style={titleDetailStyles.providerLogo}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
maxFontSizeMultiplier={1.0}
|
||||||
|
className="text-muted-foreground mt-1 max-w-[60px] text-center text-xs"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{offer.providerName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{purchase.length > 0 && (
|
||||||
|
<View>
|
||||||
|
<Text className="text-muted-foreground/60 mb-1.5 px-4 text-[10px] font-medium tracking-wider uppercase">
|
||||||
|
{t`Buy or Rent`}
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={titleAvailabilityContentStyle}
|
||||||
|
>
|
||||||
|
{purchase.map((offer) => (
|
||||||
|
<View key={offer.platformId} className="items-center">
|
||||||
|
{offer.logoPath && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: offer.logoPath }}
|
||||||
|
style={titleDetailStyles.providerLogo}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
maxFontSizeMultiplier={1.0}
|
||||||
|
className="text-muted-foreground mt-1 max-w-[60px] text-center text-xs"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{offer.providerName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text
|
|
||||||
maxFontSizeMultiplier={1.0}
|
|
||||||
className="text-muted-foreground mt-1 max-w-[60px] text-center text-xs"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{offer.providerName}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
);
|
||||||
</ScrollView>
|
})()}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ function schedule(name: string, cron: string, handler: () => Promise<void>) {
|
|||||||
jobs.set(
|
jobs.set(
|
||||||
name,
|
name,
|
||||||
new Cron(cron, { name, protect: true }, async () => {
|
new Cron(cron, { name, protect: true }, async () => {
|
||||||
log.info(`Running job: ${name}`);
|
log.debug(`Running job: ${name}`);
|
||||||
const startMs = performance.now();
|
const startMs = performance.now();
|
||||||
const run = startCronRun(name);
|
const run = startCronRun(name);
|
||||||
try {
|
try {
|
||||||
await handler();
|
await handler();
|
||||||
const durationMs = Math.round(performance.now() - startMs);
|
const durationMs = Math.round(performance.now() - startMs);
|
||||||
completeCronRun(run.id, durationMs);
|
completeCronRun(run.id, durationMs);
|
||||||
log.info(`Completed job: ${name} (${durationMs}ms)`);
|
log.debug(`Completed job: ${name} (${durationMs}ms)`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const durationMs = Math.round(performance.now() - startMs);
|
const durationMs = Math.round(performance.now() - startMs);
|
||||||
failCronRun(run.id, durationMs, err);
|
failCronRun(run.id, durationMs, err);
|
||||||
@@ -285,7 +285,7 @@ export function rescheduleBackup() {
|
|||||||
jobs.delete("scheduledBackup");
|
jobs.delete("scheduledBackup");
|
||||||
}
|
}
|
||||||
const cron = getBackupCronFromSettings();
|
const cron = getBackupCronFromSettings();
|
||||||
log.info(`Rescheduling backup job with cron: ${cron}`);
|
log.debug(`Rescheduling backup job with cron: ${cron}`);
|
||||||
schedule("scheduledBackup", cron, scheduledBackupJob);
|
schedule("scheduledBackup", cron, scheduledBackupJob);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ export function startJobs() {
|
|||||||
optimizeDatabase();
|
optimizeDatabase();
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info(`Started ${jobs.size} jobs`);
|
log.debug(`Registered ${jobs.size} scheduled jobs`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pauseJobs() {
|
export function pauseJobs() {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ seedPlatforms();
|
|||||||
// Recover import jobs left in running/pending state from a previous crash
|
// Recover import jobs left in running/pending state from a previous crash
|
||||||
const recoveredJobs = recoverStaleImportJobs();
|
const recoveredJobs = recoverStaleImportJobs();
|
||||||
if (recoveredJobs > 0) {
|
if (recoveredJobs > 0) {
|
||||||
log.info(`Recovered ${recoveredJobs} stale import job(s) from previous shutdown`);
|
log.warn(`Recovered ${recoveredJobs} stale import job(s) from previous shutdown`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire up job schedule provider for system health
|
// Wire up job schedule provider for system health
|
||||||
@@ -157,9 +157,17 @@ process.on("SIGTERM", shutdown);
|
|||||||
process.on("SIGINT", shutdown);
|
process.on("SIGINT", shutdown);
|
||||||
|
|
||||||
const port = Number(process.env.PORT || process.env.API_PORT || 3001);
|
const port = Number(process.env.PORT || process.env.API_PORT || 3001);
|
||||||
log.info(`API server listening on port ${port}`);
|
const server = Bun.serve({
|
||||||
|
|
||||||
export default {
|
|
||||||
port,
|
port,
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
};
|
});
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`🍿 Sofa${process.env.APP_VERSION ? ` v${process.env.APP_VERSION}` : ""}: Now playing at ${server.url.origin}`,
|
||||||
|
{
|
||||||
|
address: server.hostname,
|
||||||
|
port: server.port,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default server;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ORPCError } from "@orpc/server";
|
|||||||
import { AppErrorCode } from "@sofa/api/errors";
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import { WATCH_REGION } from "@sofa/config";
|
import { WATCH_REGION } from "@sofa/config";
|
||||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||||
|
import { getPlatformTmdbIds } from "@sofa/core/platforms";
|
||||||
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||||
import { discover as discoverTmdb } from "@sofa/tmdb/client";
|
import { discover as discoverTmdb } from "@sofa/tmdb/client";
|
||||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||||
@@ -34,9 +35,12 @@ export const discover = os.discover.use(authed).handler(async ({ input, context
|
|||||||
}
|
}
|
||||||
if (input.ratingMin != null) params["vote_average.gte"] = String(input.ratingMin);
|
if (input.ratingMin != null) params["vote_average.gte"] = String(input.ratingMin);
|
||||||
if (input.language) params.with_original_language = input.language;
|
if (input.language) params.with_original_language = input.language;
|
||||||
if (input.providerId) {
|
if (input.platformId) {
|
||||||
params.with_watch_providers = String(input.providerId);
|
const tmdbIds = getPlatformTmdbIds(input.platformId);
|
||||||
params.watch_region = WATCH_REGION;
|
if (tmdbIds.length > 0) {
|
||||||
|
params.with_watch_providers = tmdbIds.join("|");
|
||||||
|
params.watch_region = WATCH_REGION;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await discoverTmdb(input.type, params, input.page);
|
const results = await discoverTmdb(input.type, params, input.page);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ORPCError } from "@orpc/server";
|
|||||||
|
|
||||||
import { AppErrorCode } from "@sofa/api/errors";
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||||
import { listPlatforms } from "@sofa/core/platforms";
|
import { getPlatformTmdbIdMap, listPlatforms } from "@sofa/core/platforms";
|
||||||
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||||
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
|
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
|
||||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||||
@@ -165,10 +165,11 @@ export const genres = os.explore.genres.use(authed).handler(async ({ input }) =>
|
|||||||
|
|
||||||
export const watchProviders = os.explore.watchProviders.use(authed).handler(async () => {
|
export const watchProviders = os.explore.watchProviders.use(authed).handler(async () => {
|
||||||
const allPlatforms = listPlatforms();
|
const allPlatforms = listPlatforms();
|
||||||
|
const tmdbIdsMap = getPlatformTmdbIdMap(allPlatforms.map((p) => p.id));
|
||||||
return {
|
return {
|
||||||
providers: allPlatforms.map((p) => ({
|
providers: allPlatforms.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
tmdbProviderId: p.tmdbProviderId,
|
tmdbProviderIds: tmdbIdsMap.get(p.id) ?? [],
|
||||||
name: p.name,
|
name: p.name,
|
||||||
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listPlatforms } from "@sofa/core/platforms";
|
import { listPlatforms, getPlatformTmdbIdMap } from "@sofa/core/platforms";
|
||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
|
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
@@ -6,13 +6,14 @@ import { authed } from "../middleware";
|
|||||||
|
|
||||||
export const list = os.platforms.list.use(authed).handler(async () => {
|
export const list = os.platforms.list.use(authed).handler(async () => {
|
||||||
const allPlatforms = listPlatforms();
|
const allPlatforms = listPlatforms();
|
||||||
|
const tmdbIdsMap = getPlatformTmdbIdMap(allPlatforms.map((p) => p.id));
|
||||||
return {
|
return {
|
||||||
platforms: allPlatforms.map((p) => ({
|
platforms: allPlatforms.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
tmdbProviderId: p.tmdbProviderId,
|
tmdbProviderIds: tmdbIdsMap.get(p.id) ?? [],
|
||||||
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
||||||
displayOrder: p.displayOrder,
|
isSubscription: p.isSubscription,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,8 +109,6 @@ export function Sparkline({ data, color }: SparklineProps) {
|
|||||||
return computeMonotonePath(points, size.height);
|
return computeMonotonePath(points, size.height);
|
||||||
}, [data, size.width, size.height]);
|
}, [data, size.width, size.height]);
|
||||||
|
|
||||||
if (!data.some((d) => d.count > 0)) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ function PeriodSelect({
|
|||||||
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
|
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
|
<SelectContent align="start" className="p-1">
|
||||||
{periods.map((p) => (
|
{periods.map((p) => (
|
||||||
<SelectItem key={p} value={p}>
|
<SelectItem key={p} value={p}>
|
||||||
{periodLabels[p]}
|
{periodLabels[p]}
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ export function DiscoverSection() {
|
|||||||
| "primary_release_date.asc";
|
| "primary_release_date.asc";
|
||||||
const [sortBy, setSortBy] = useState<DiscoverSortBy | undefined>(undefined);
|
const [sortBy, setSortBy] = useState<DiscoverSortBy | undefined>(undefined);
|
||||||
const [language, setLanguage] = useState<string | undefined>(undefined);
|
const [language, setLanguage] = useState<string | undefined>(undefined);
|
||||||
const [providerId, setProviderId] = useState<number | undefined>(undefined);
|
const [platformId, setPlatformId] = useState<string | undefined>(undefined);
|
||||||
const [selectedPlatformId, setSelectedPlatformId] = useState("");
|
|
||||||
|
|
||||||
const { data: genreData } = useQuery(orpc.explore.genres.queryOptions({ input: { type } }));
|
const { data: genreData } = useQuery(orpc.explore.genres.queryOptions({ input: { type } }));
|
||||||
const { data: providerData } = useQuery(orpc.platforms.list.queryOptions());
|
const { data: providerData } = useQuery(orpc.platforms.list.queryOptions());
|
||||||
@@ -83,7 +82,7 @@ export function DiscoverSection() {
|
|||||||
ratingMin,
|
ratingMin,
|
||||||
sortBy,
|
sortBy,
|
||||||
language,
|
language,
|
||||||
providerId,
|
platformId,
|
||||||
page: pageParam,
|
page: pageParam,
|
||||||
}),
|
}),
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
@@ -154,14 +153,7 @@ export function DiscoverSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleProviderChange(value: string | null) {
|
function handleProviderChange(value: string | null) {
|
||||||
setSelectedPlatformId(value ?? "");
|
setPlatformId(value || undefined);
|
||||||
if (!value) {
|
|
||||||
setProviderId(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Resolve platform UUID to TMDB provider ID for the discover API
|
|
||||||
const platform = providers.find((p) => p.id === value);
|
|
||||||
setProviderId(platform?.tmdbProviderId ?? undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGenreChange(value: string | null) {
|
function handleGenreChange(value: string | null) {
|
||||||
@@ -183,8 +175,7 @@ export function DiscoverSection() {
|
|||||||
if (next === "movie" || next === "tv") {
|
if (next === "movie" || next === "tv") {
|
||||||
setType(next);
|
setType(next);
|
||||||
setGenreId(undefined);
|
setGenreId(undefined);
|
||||||
setProviderId(undefined);
|
setPlatformId(undefined);
|
||||||
setSelectedPlatformId("");
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -217,7 +208,7 @@ export function DiscoverSection() {
|
|||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
<SelectContent className="p-1">
|
||||||
<SelectItem value="">{t`All genres`}</SelectItem>
|
<SelectItem value="">{t`All genres`}</SelectItem>
|
||||||
{genres.map((genre) => (
|
{genres.map((genre) => (
|
||||||
<SelectItem key={genre.id} value={String(genre.id)}>
|
<SelectItem key={genre.id} value={String(genre.id)}>
|
||||||
@@ -248,7 +239,7 @@ export function DiscoverSection() {
|
|||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
<SelectContent className="p-1">
|
||||||
<SelectItem value="">{t`Any year`}</SelectItem>
|
<SelectItem value="">{t`Any year`}</SelectItem>
|
||||||
{DECADE_PRESETS.map((d) => (
|
{DECADE_PRESETS.map((d) => (
|
||||||
<SelectItem key={d.min} value={String(d.min)}>
|
<SelectItem key={d.min} value={String(d.min)}>
|
||||||
@@ -277,7 +268,7 @@ export function DiscoverSection() {
|
|||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
<SelectContent className="p-1">
|
||||||
<SelectItem value="">{t`Any rating`}</SelectItem>
|
<SelectItem value="">{t`Any rating`}</SelectItem>
|
||||||
{RATING_PRESETS.map((r) => (
|
{RATING_PRESETS.map((r) => (
|
||||||
<SelectItem key={r.value} value={String(r.value)}>
|
<SelectItem key={r.value} value={String(r.value)}>
|
||||||
@@ -306,7 +297,7 @@ export function DiscoverSection() {
|
|||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
<SelectContent className="p-1">
|
||||||
<SelectItem value="">{t`Default`}</SelectItem>
|
<SelectItem value="">{t`Default`}</SelectItem>
|
||||||
{SORT_OPTIONS.map((s) => (
|
{SORT_OPTIONS.map((s) => (
|
||||||
<SelectItem key={s.value} value={s.value}>
|
<SelectItem key={s.value} value={s.value}>
|
||||||
@@ -335,7 +326,7 @@ export function DiscoverSection() {
|
|||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
<SelectContent className="p-1">
|
||||||
<SelectItem value="">{t`Any language`}</SelectItem>
|
<SelectItem value="">{t`Any language`}</SelectItem>
|
||||||
{LANGUAGE_OPTIONS.map((lang) => (
|
{LANGUAGE_OPTIONS.map((lang) => (
|
||||||
<SelectItem key={lang.code} value={lang.code}>
|
<SelectItem key={lang.code} value={lang.code}>
|
||||||
@@ -347,14 +338,14 @@ export function DiscoverSection() {
|
|||||||
|
|
||||||
{/* Provider select */}
|
{/* Provider select */}
|
||||||
<Select
|
<Select
|
||||||
value={selectedPlatformId}
|
value={platformId ?? ""}
|
||||||
onValueChange={handleProviderChange}
|
onValueChange={handleProviderChange}
|
||||||
modal={false}
|
modal={false}
|
||||||
aria-label={t`Provider`}
|
aria-label={t`Provider`}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
size="sm"
|
size="sm"
|
||||||
data-active={selectedPlatformId ? "" : undefined}
|
data-active={platformId ? "" : undefined}
|
||||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||||
>
|
>
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
@@ -365,10 +356,10 @@ export function DiscoverSection() {
|
|||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
<SelectContent className="p-1">
|
||||||
<SelectItem value="">{t`All providers`}</SelectItem>
|
<SelectItem value="">{t`All providers`}</SelectItem>
|
||||||
{providers
|
{providers
|
||||||
.filter((p) => p.tmdbProviderId != null)
|
.filter((p) => p.tmdbProviderIds.length > 0)
|
||||||
.map((platform) => (
|
.map((platform) => (
|
||||||
<SelectItem key={platform.id} value={platform.id}>
|
<SelectItem key={platform.id} value={platform.id}>
|
||||||
{platform.name}
|
{platform.name}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export function LibraryFilters({
|
|||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
<SelectContent className="p-1">
|
||||||
<SelectItem value="">{t`All genres`}</SelectItem>
|
<SelectItem value="">{t`All genres`}</SelectItem>
|
||||||
{genreData?.genres.map((genre) => (
|
{genreData?.genres.map((genre) => (
|
||||||
<SelectItem key={genre.id} value={String(genre.id)}>
|
<SelectItem key={genre.id} value={String(genre.id)}>
|
||||||
@@ -235,7 +235,7 @@ export function LibraryFilters({
|
|||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
<SelectContent className="p-1">
|
||||||
<SelectItem value="">{t`Any`}</SelectItem>
|
<SelectItem value="">{t`Any`}</SelectItem>
|
||||||
<SelectItem value="1">1★+</SelectItem>
|
<SelectItem value="1">1★+</SelectItem>
|
||||||
<SelectItem value="2">2★+</SelectItem>
|
<SelectItem value="2">2★+</SelectItem>
|
||||||
@@ -261,14 +261,14 @@ export function LibraryFilters({
|
|||||||
}}
|
}}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
<SelectContent className="p-1">
|
||||||
<SelectItem value="">{t`Any year`}</SelectItem>
|
<SelectItem value="">{t`Any year`}</SelectItem>
|
||||||
{DECADES.map((d) => (
|
{DECADES.map((d) => (
|
||||||
<SelectItem key={d.yearMin} value={String(d.yearMin)}>
|
<SelectItem key={d.yearMin} value={String(d.yearMin)}>
|
||||||
{d.label}
|
{d.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectItem value="older">{t`Pre-1980`}</SelectItem>
|
<SelectItem value="older">{t`70s and earlier`}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ export function LibraryFilters({
|
|||||||
>
|
>
|
||||||
<SelectValue>{(value: string | null) => (value ? value : t`Age`)}</SelectValue>
|
<SelectValue>{(value: string | null) => (value ? value : t`Age`)}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
<SelectContent className="p-1">
|
||||||
<SelectItem value="">{t`All`}</SelectItem>
|
<SelectItem value="">{t`All`}</SelectItem>
|
||||||
{CONTENT_RATINGS.map((rating) => (
|
{CONTENT_RATINGS.map((rating) => (
|
||||||
<SelectItem key={rating} value={rating}>
|
<SelectItem key={rating} value={rating}>
|
||||||
|
|||||||
@@ -68,20 +68,20 @@ export function LibraryToolbar({
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
placeholder={t`Search library...`}
|
placeholder={t`Search library...`}
|
||||||
className="pl-7"
|
className="py-3 pl-7"
|
||||||
aria-label={t`Search library`}
|
aria-label={t`Search library`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground text-sm">
|
<span className="text-muted-foreground text-[13px]">
|
||||||
{plural(totalResults, { one: "# result", other: "# results" })}
|
{plural(totalResults, { one: "# result", other: "# results" })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Filter toggle */}
|
{/* Filter toggle */}
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
render={
|
render={
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline">
|
||||||
<IconFilter aria-hidden={true} className="size-3.5" />
|
<IconFilter aria-hidden={true} className="size-3.5" />
|
||||||
{t`Filters`}
|
{t`Filters`}
|
||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
@@ -101,7 +101,7 @@ export function LibraryToolbar({
|
|||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline">
|
||||||
<IconSortDescending aria-hidden={true} className="size-3.5" />
|
<IconSortDescending aria-hidden={true} className="size-3.5" />
|
||||||
{t`Sort`}
|
{t`Sort`}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps)
|
|||||||
}
|
}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent align="end" alignItemWithTrigger={false} className="p-1">
|
<SelectContent align="end" className="p-1">
|
||||||
<SelectItem value="newest">
|
<SelectItem value="newest">
|
||||||
<Trans>Newest</Trans>
|
<Trans>Newest</Trans>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function PlatformGrid({ platforms, selectedIds, onToggle }: PlatformGridP
|
|||||||
className={`group relative flex flex-col items-center gap-2 rounded-xl border p-3 motion-safe:transition-colors motion-safe:duration-150 ${
|
className={`group relative flex flex-col items-center gap-2 rounded-xl border p-3 motion-safe:transition-colors motion-safe:duration-150 ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-primary/50 bg-primary/8"
|
? "border-primary/50 bg-primary/8"
|
||||||
: "border-border/20 hover:border-primary/30 hover:bg-primary/5"
|
: "border-border/50 hover:border-primary/30 hover:bg-primary/5"
|
||||||
}`}
|
}`}
|
||||||
aria-label={(() => {
|
aria-label={(() => {
|
||||||
const name = platform.name;
|
const name = platform.name;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
IconLogout,
|
IconLogout,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconUser,
|
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
@@ -164,208 +163,197 @@ export function AccountSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Card className="pb-0">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<CardContent className="flex items-center gap-4">
|
||||||
<IconUser aria-hidden={true} className="text-muted-foreground size-4" />
|
{/* Avatar: click to upload (no avatar) or remove (has avatar) */}
|
||||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
<Tooltip>
|
||||||
<Trans>Account</Trans>
|
<TooltipTrigger
|
||||||
</h2>
|
render={
|
||||||
</div>
|
<button
|
||||||
<Card className="pb-0">
|
type="button"
|
||||||
<CardContent className="flex items-center gap-4">
|
onClick={avatarUrl ? handleRemoveAvatar : () => fileInputRef.current?.click()}
|
||||||
{/* Avatar: click to upload (no avatar) or remove (has avatar) */}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
<Tooltip>
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
<TooltipTrigger
|
disabled={isAvatarPending}
|
||||||
render={
|
/>
|
||||||
<button
|
}
|
||||||
type="button"
|
className="focus-visible:ring-ring focus-visible:ring-offset-background relative shrink-0 cursor-pointer rounded-full focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
onClick={avatarUrl ? handleRemoveAvatar : () => fileInputRef.current?.click()}
|
aria-label={avatarUrl ? t`Remove profile picture` : t`Upload profile picture`}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
>
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
<Avatar className="size-12 overflow-hidden">
|
||||||
disabled={isAvatarPending}
|
<AvatarImage src={isAvatarPending ? undefined : avatarUrl} alt={displayName} />
|
||||||
/>
|
<AvatarFallback className="bg-primary/10 font-display text-primary text-lg">
|
||||||
}
|
{initial}
|
||||||
className="focus-visible:ring-ring focus-visible:ring-offset-background relative shrink-0 cursor-pointer rounded-full focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
</AvatarFallback>
|
||||||
aria-label={avatarUrl ? t`Remove profile picture` : t`Upload profile picture`}
|
</Avatar>
|
||||||
>
|
|
||||||
<Avatar className="size-12 overflow-hidden">
|
|
||||||
<AvatarImage src={isAvatarPending ? undefined : avatarUrl} alt={displayName} />
|
|
||||||
<AvatarFallback className="bg-primary/10 font-display text-primary text-lg">
|
|
||||||
{initial}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{(isHovered || isAvatarPending) && (
|
{(isHovered || isAvatarPending) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className={`text-foreground/70 absolute inset-0 flex items-center justify-center rounded-full backdrop-blur-sm ${
|
className={`text-foreground/70 absolute inset-0 flex items-center justify-center rounded-full backdrop-blur-sm ${
|
||||||
avatarUrl && !isAvatarPending ? "bg-destructive/40" : "bg-black/50"
|
avatarUrl && !isAvatarPending ? "bg-destructive/40" : "bg-black/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isAvatarPending ? (
|
{isAvatarPending ? (
|
||||||
<Spinner className="size-4.5" />
|
<Spinner className="size-4.5" />
|
||||||
) : avatarUrl ? (
|
) : avatarUrl ? (
|
||||||
<IconTrash className="size-4.5" />
|
<IconTrash className="size-4.5" />
|
||||||
) : (
|
) : (
|
||||||
<IconCamera className="size-4.5" />
|
<IconCamera className="size-4.5" />
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{avatarUrl ? <Trans>Remove picture</Trans> : <Trans>Upload picture</Trans>}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<CardTitle className="mb-0.5">
|
|
||||||
<AnimatePresence mode="wait" initial={false}>
|
|
||||||
{isEditingName ? (
|
|
||||||
<motion.div
|
|
||||||
key="editing"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.1 }}
|
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<div className="relative inline-grid items-center">
|
|
||||||
<span
|
|
||||||
className="invisible col-start-1 row-start-1 text-sm font-medium whitespace-pre"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{editValue || " "}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
ref={nameInputRef}
|
|
||||||
type="text"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onKeyDown={handleNameKeyDown}
|
|
||||||
onBlur={handleNameSave}
|
|
||||||
disabled={isNamePending}
|
|
||||||
maxLength={100}
|
|
||||||
className="border-primary/40 focus:border-primary col-start-1 row-start-1 min-w-4 border-0 border-b border-dashed bg-transparent text-sm font-medium transition-colors outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isNamePending ? (
|
|
||||||
<Spinner className="text-muted-foreground size-3.5 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleNameSave();
|
|
||||||
}}
|
|
||||||
className="text-muted-foreground hover:text-primary shrink-0 rounded-md p-0.5 transition-colors"
|
|
||||||
aria-label={t`Save name`}
|
|
||||||
>
|
|
||||||
<IconCheck className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleNameCancel();
|
|
||||||
}}
|
|
||||||
className="text-muted-foreground hover:text-destructive shrink-0 rounded-md p-0.5 transition-colors"
|
|
||||||
aria-label={t`Cancel editing`}
|
|
||||||
>
|
|
||||||
<IconX className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<motion.button
|
|
||||||
key="display"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.1 }}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsEditingName(true)}
|
|
||||||
className="group/name hover:text-primary inline-flex items-center gap-1.5 rounded-md px-0 text-start transition-colors"
|
|
||||||
>
|
|
||||||
{displayName}
|
|
||||||
<IconPencil className="group-hover/name:text-muted-foreground size-3 text-transparent transition-colors" />
|
|
||||||
</motion.button>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{user.email}
|
|
||||||
{user.role === "admin" && (
|
|
||||||
<Badge className="bg-primary/10 text-primary ms-1.5 rounded-md border-0 align-middle">
|
|
||||||
<Trans>Admin</Trans>
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</AnimatePresence>
|
||||||
<p className="text-muted-foreground/60 mt-0.5 text-xs">
|
</TooltipTrigger>
|
||||||
<Trans>Member since {memberSince}</Trans>
|
<TooltipContent>
|
||||||
|
{avatarUrl ? <Trans>Remove picture</Trans> : <Trans>Upload picture</Trans>}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="mb-0.5">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{isEditingName ? (
|
||||||
|
<motion.div
|
||||||
|
key="editing"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<div className="relative inline-grid items-center">
|
||||||
|
<span
|
||||||
|
className="invisible col-start-1 row-start-1 text-sm font-medium whitespace-pre"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{editValue || " "}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={nameInputRef}
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleNameKeyDown}
|
||||||
|
onBlur={handleNameSave}
|
||||||
|
disabled={isNamePending}
|
||||||
|
maxLength={100}
|
||||||
|
className="border-primary/40 focus:border-primary col-start-1 row-start-1 min-w-4 border-0 border-b border-dashed bg-transparent text-sm font-medium transition-colors outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isNamePending ? (
|
||||||
|
<Spinner className="text-muted-foreground size-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNameSave();
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-primary shrink-0 rounded-md p-0.5 transition-colors"
|
||||||
|
aria-label={t`Save name`}
|
||||||
|
>
|
||||||
|
<IconCheck className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNameCancel();
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-destructive shrink-0 rounded-md p-0.5 transition-colors"
|
||||||
|
aria-label={t`Cancel editing`}
|
||||||
|
>
|
||||||
|
<IconX className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.button
|
||||||
|
key="display"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditingName(true)}
|
||||||
|
className="group/name hover:text-primary inline-flex items-center gap-1.5 rounded-md px-0 text-start transition-colors"
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
<IconPencil className="group-hover/name:text-muted-foreground size-3 text-transparent transition-colors" />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{user.email}
|
||||||
|
{user.role === "admin" && (
|
||||||
|
<Badge className="bg-primary/10 text-primary ms-1.5 rounded-md border-0 align-middle">
|
||||||
|
<Trans>Admin</Trans>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
<p className="text-muted-foreground/60 mt-0.5 text-xs">
|
||||||
|
<Trans>Member since {memberSince}</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
|
||||||
|
<ChangePasswordDialog />
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await signOut();
|
||||||
|
void navigate({ to: "/" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconLogout aria-hidden={true} />
|
||||||
|
<Trans>Sign out</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<div className="border-border/30 space-y-px border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = "/api/export/user-data";
|
||||||
|
}}
|
||||||
|
className="group hover:bg-muted/40 flex w-full items-center gap-3 px-4 py-3 text-start transition-colors"
|
||||||
|
>
|
||||||
|
<div className="bg-muted group-hover:bg-primary/10 flex size-7.5 shrink-0 items-center justify-center rounded-lg">
|
||||||
|
<IconCloudDownload
|
||||||
|
aria-hidden={true}
|
||||||
|
className="text-muted-foreground group-hover:text-primary size-3.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<p className="text-[13px] leading-none font-medium">
|
||||||
|
<Trans>Export</Trans>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-[11px]">
|
||||||
|
<Trans>Download your library, watch history, and ratings as JSON</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<IconArrowRight aria-hidden={true} className="text-muted-foreground size-3.5 shrink-0" />
|
||||||
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
|
</button>
|
||||||
<ChangePasswordDialog />
|
<SofaImportDialog />
|
||||||
<Button
|
</div>
|
||||||
variant="destructive"
|
</Card>
|
||||||
onClick={async () => {
|
|
||||||
await signOut();
|
|
||||||
void navigate({ to: "/" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconLogout aria-hidden={true} />
|
|
||||||
<Trans>Sign out</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<div className="border-border/30 space-y-px border-t">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
window.location.href = "/api/export/user-data";
|
|
||||||
}}
|
|
||||||
className="group hover:bg-muted/40 flex w-full items-center gap-3 px-4 py-3 text-start transition-colors"
|
|
||||||
>
|
|
||||||
<div className="bg-muted group-hover:bg-primary/10 flex size-7.5 shrink-0 items-center justify-center rounded-lg">
|
|
||||||
<IconCloudDownload
|
|
||||||
aria-hidden={true}
|
|
||||||
className="text-muted-foreground group-hover:text-primary size-3.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 space-y-0.5">
|
|
||||||
<p className="text-[13px] leading-none font-medium">
|
|
||||||
<Trans>Export</Trans>
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-[11px]">
|
|
||||||
<Trans>Download your library, watch history, and ratings as JSON</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<IconArrowRight
|
|
||||||
aria-hidden={true}
|
|
||||||
className="text-muted-foreground size-3.5 shrink-0"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<SofaImportDialog />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export function BackupScheduleSection() {
|
|||||||
}
|
}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
|
<SelectContent align="start" className="p-1">
|
||||||
{[3, 5, 7, 14, 30, 0].map((n) => (
|
{[3, 5, 7, 14, 30, 0].map((n) => (
|
||||||
<SelectItem key={n} value={String(n)}>
|
<SelectItem key={n} value={String(n)}>
|
||||||
{n === 0 ? t`unlimited` : t`last ${n}`}
|
{n === 0 ? t`unlimited` : t`last ${n}`}
|
||||||
@@ -319,7 +319,7 @@ export function BackupScheduleSection() {
|
|||||||
}
|
}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
|
<SelectContent align="start" className="p-1">
|
||||||
{DAYS_OF_WEEK.map((day, i) => (
|
{DAYS_OF_WEEK.map((day, i) => (
|
||||||
<SelectItem key={day} value={String(i)}>
|
<SelectItem key={day} value={String(i)}>
|
||||||
{day}
|
{day}
|
||||||
@@ -368,7 +368,7 @@ export function BackupScheduleSection() {
|
|||||||
}
|
}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
|
<SelectContent align="start" className="p-1">
|
||||||
{HOURS.map((h) => {
|
{HOURS.map((h) => {
|
||||||
const val = `${String(h).padStart(2, "0")}:00`;
|
const val = `${String(h).padStart(2, "0")}:00`;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { IconCloudUpload, IconFileImport, IconLink } from "@tabler/icons-react";
|
import { IconCloudUpload, IconLink } from "@tabler/icons-react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -99,7 +99,7 @@ const SOURCES: SourceConfig[] = [
|
|||||||
{
|
{
|
||||||
source: "trakt",
|
source: "trakt",
|
||||||
label: "Trakt",
|
label: "Trakt",
|
||||||
description: "Connect your Trakt account or upload a JSON export.",
|
description: "Connect your Trakt account or upload a JSON export",
|
||||||
accept: ".json",
|
accept: ".json",
|
||||||
icon: <TraktLogo className="text-primary size-4" />,
|
icon: <TraktLogo className="text-primary size-4" />,
|
||||||
supportsOAuth: true,
|
supportsOAuth: true,
|
||||||
@@ -107,7 +107,7 @@ const SOURCES: SourceConfig[] = [
|
|||||||
{
|
{
|
||||||
source: "simkl",
|
source: "simkl",
|
||||||
label: "Simkl",
|
label: "Simkl",
|
||||||
description: "Connect your Simkl account or upload a JSON export.",
|
description: "Connect your Simkl account or upload a JSON export",
|
||||||
accept: ".json",
|
accept: ".json",
|
||||||
icon: <SimklLogo className="text-primary size-4" />,
|
icon: <SimklLogo className="text-primary size-4" />,
|
||||||
supportsOAuth: true,
|
supportsOAuth: true,
|
||||||
@@ -115,7 +115,7 @@ const SOURCES: SourceConfig[] = [
|
|||||||
{
|
{
|
||||||
source: "letterboxd",
|
source: "letterboxd",
|
||||||
label: "Letterboxd",
|
label: "Letterboxd",
|
||||||
description: "Upload the ZIP export from your Letterboxd account settings.",
|
description: "Upload the ZIP export from your Letterboxd account settings",
|
||||||
accept: ".zip",
|
accept: ".zip",
|
||||||
icon: <LetterboxdLogo className="text-primary size-4" />,
|
icon: <LetterboxdLogo className="text-primary size-4" />,
|
||||||
supportsOAuth: false,
|
supportsOAuth: false,
|
||||||
@@ -168,18 +168,10 @@ type DialogStep =
|
|||||||
|
|
||||||
export function ImportsSection() {
|
export function ImportsSection() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-2.5">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
{SOURCES.map((config) => (
|
||||||
<IconFileImport aria-hidden className="text-muted-foreground size-4" />
|
<ImportSourceCard key={config.source} config={config} />
|
||||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
))}
|
||||||
<Trans>Import</Trans>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2.5">
|
|
||||||
{SOURCES.map((config) => (
|
|
||||||
<ImportSourceCard key={config.source} config={config} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Trans } from "@lingui/react/macro";
|
|
||||||
import { IconWebhook } from "@tabler/icons-react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -25,35 +23,24 @@ export function IntegrationsSection() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return isPending ? (
|
||||||
<div>
|
<div className="space-y-2.5">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
{INTEGRATION_CONFIGS.map((c) => (
|
||||||
<IconWebhook aria-hidden={true} className="text-muted-foreground size-4" />
|
<Skeleton key={c.provider} className="h-20 w-full rounded-xl" />
|
||||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
))}
|
||||||
<Trans>Integrations</Trans>
|
</div>
|
||||||
</h2>
|
) : (
|
||||||
</div>
|
<div className="space-y-2.5">
|
||||||
{isPending ? (
|
{INTEGRATION_CONFIGS.map((config) => (
|
||||||
<div className="space-y-2.5">
|
<IntegrationCard
|
||||||
{INTEGRATION_CONFIGS.map((c) => (
|
key={config.provider}
|
||||||
<Skeleton key={c.provider} className="h-20 w-full rounded-xl" />
|
config={config}
|
||||||
))}
|
connection={
|
||||||
</div>
|
connections.find((c: IntegrationConnection) => c.provider === config.provider) ?? null
|
||||||
) : (
|
}
|
||||||
<div className="space-y-2.5">
|
setConnections={handleSetConnections}
|
||||||
{INTEGRATION_CONFIGS.map((config) => (
|
/>
|
||||||
<IntegrationCard
|
))}
|
||||||
key={config.provider}
|
|
||||||
config={config}
|
|
||||||
connection={
|
|
||||||
connections.find((c: IntegrationConnection) => c.provider === config.provider) ??
|
|
||||||
null
|
|
||||||
}
|
|
||||||
setConnections={handleSetConnections}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const sectionVariants = {
|
|||||||
export function SettingsShell({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
export function SettingsShell({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mx-auto max-w-2xl space-y-6"
|
className="mx-auto max-w-2xl space-y-8"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
variants={{
|
variants={{
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { IconChevronDown, IconDeviceTv } from "@tabler/icons-react";
|
import { IconChevronDown, IconCircleCheck, IconDeviceTv } from "@tabler/icons-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { SVGProps, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { PlatformGrid } from "@/components/platforms/platform-grid";
|
import { PlatformGrid } from "@/components/platforms/platform-grid";
|
||||||
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||||
@@ -86,26 +86,27 @@ export function StreamingServicesSection() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-start">
|
<div className="text-start">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
<Trans>Streaming Services</Trans>
|
<Trans>Subscriptions</Trans>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
t`${selectedCount} selected`
|
t`${selectedCount} selected`
|
||||||
) : (
|
) : (
|
||||||
<Trans>Choose your subscriptions</Trans>
|
<Trans>Choose your preferred streaming platforms</Trans>
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{saved && (
|
{saved && (
|
||||||
<motion.span
|
<motion.span
|
||||||
initial={{ opacity: 0, x: 4 }}
|
initial={{ opacity: 0, x: 4 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: 4 }}
|
exit={{ opacity: 0, x: 4 }}
|
||||||
className="text-primary text-xs"
|
className="text-primary flex items-center gap-1.5 text-xs"
|
||||||
>
|
>
|
||||||
|
<IconCircleCheck className="size-3.5" />
|
||||||
<Trans>Saved</Trans>
|
<Trans>Saved</Trans>
|
||||||
</motion.span>
|
</motion.span>
|
||||||
)}
|
)}
|
||||||
@@ -121,9 +122,38 @@ export function StreamingServicesSection() {
|
|||||||
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
|
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
|
||||||
<CardContent className="border-border/30 border-t pt-4">
|
<CardContent className="border-border/30 border-t pt-4">
|
||||||
<PlatformGrid platforms={platforms} selectedIds={selectedIds} onToggle={handleToggle} />
|
<PlatformGrid platforms={platforms} selectedIds={selectedIds} onToggle={handleToggle} />
|
||||||
|
<div className="text-muted-foreground mt-4 text-center text-xs">
|
||||||
|
<a
|
||||||
|
href="https://justwatch.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-primary"
|
||||||
|
>
|
||||||
|
<Trans>
|
||||||
|
Streaming data provided by{" "}
|
||||||
|
<JustWatchLogo
|
||||||
|
className="mx-0.5 inline-block size-3.5 translate-y-[-1px] text-[#FBD446]"
|
||||||
|
aria-hidden={true}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold">JustWatch</span>
|
||||||
|
</Trans>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function JustWatchLogo(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
|
||||||
|
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M11.727 11.7L8.1 9.85s-.646-.344-.617.38s0 3.591 0 3.591s-.1.767.612.427s3.094-1.571 3.627-1.841c.715-.367.005-.707.005-.707m-.006 5.023l-3.628-1.854s-.647-.345-.618.38s0 3.591 0 3.591s-.1.768.613.427s3.093-1.571 3.626-1.841c.717-.367.007-.703.007-.703m4.953-2.464l-3.628-1.854s-.647-.344-.617.38s-.005 3.591-.005 3.591s-.1.768.612.427s3.094-1.57 3.626-1.841c.722-.362.012-.703.012-.703m4.205-2.918L17.844 9.8c-.506-.257-.457.333-.457.333v3.824s-.054.564.468.3l3.025-1.526c1.398-.702-.001-1.39-.001-1.39m-4.203-2.229l-3.629-1.855s-.647-.343-.617.38s0 3.592 0 3.592s-.1.767.612.427s3.094-1.571 3.626-1.841c.717-.367.008-.703.008-.703M7.012 4.257s-1.494-.74-2.945-1.479s-1.539.693-1.539.693v3.128c0 .369.373.217.373.217s3.7-1.886 4.113-2.1s-.002-.459-.002-.459m1.071 4.924c.715-.34 3.094-1.57 3.626-1.841c.722-.366.013-.7.013-.7L8.093 4.783s-.646-.344-.618.38s0 3.591 0 3.591s-.107.768.608.427m-4.961 2.573c.716-.341 3.094-1.571 3.626-1.841c.723-.367.013-.7.013-.7L3.133 7.355s-.647-.343-.618.38s-.005 3.592-.005 3.592s-.103.767.612.427m-.005 4.954c.716-.34 3.094-1.57 3.627-1.841c.722-.366.012-.7.012-.7L3.128 12.31s-.647-.343-.618.38s0 3.591 0 3.591v.101c-.01.196.058.588.607.326M7 19.334c-.418-.216-4.115-2.1-4.115-2.1s-.374-.151-.373.218l.006 3.128s.09 1.434 1.54.692S7 19.791 7 19.791s.415-.24 0-.457"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function SystemHealthCards() {
|
|||||||
if (isPending || !data) return <SkeletonCards />;
|
if (isPending || !data) return <SkeletonCards />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2.5">
|
||||||
<SystemStatusCard
|
<SystemStatusCard
|
||||||
checkedAt={data.checkedAt}
|
checkedAt={data.checkedAt}
|
||||||
database={data.database}
|
database={data.database}
|
||||||
|
|||||||
@@ -17,19 +17,13 @@ function ProviderBadge({
|
|||||||
logoPath: string | null;
|
logoPath: string | null;
|
||||||
watchUrl: string | null;
|
watchUrl: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui();
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
{...(watchUrl
|
{...(watchUrl
|
||||||
? {
|
? {
|
||||||
render: (
|
render: (
|
||||||
<a
|
<a href={watchUrl} target="_blank" rel="noopener noreferrer" aria-label={name} />
|
||||||
href={watchUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={t`Watch on ${name}`}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
@@ -50,7 +44,7 @@ function ProviderBadge({
|
|||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="bg-popover text-popover-foreground px-2 py-1 text-[10px] font-medium shadow-md [&>:last-child]:hidden">
|
<TooltipContent className="bg-popover text-popover-foreground px-2 py-1 text-[10px] font-medium shadow-md [&>:last-child]:hidden">
|
||||||
{watchUrl ? t`Watch on ${name}` : name}
|
{name}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -159,11 +153,8 @@ function OffersByType({
|
|||||||
export function TitleAvailability({ availability }: { availability: AvailabilityOffer[] }) {
|
export function TitleAvailability({ availability }: { availability: AvailabilityOffer[] }) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const offerLabels: Record<string, string> = {
|
const offerLabels: Record<string, string> = {
|
||||||
flatrate: t`Stream`,
|
stream: t`Stream`,
|
||||||
rent: t`Rent`,
|
purchase: t`Buy or Rent`,
|
||||||
buy: t`Buy`,
|
|
||||||
free: t`Free`,
|
|
||||||
ads: t`With Ads`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (availability.length === 0) return null;
|
if (availability.length === 0) return null;
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { Trans } from "@lingui/react/macro";
|
|||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconDatabaseExport,
|
IconDatabaseExport,
|
||||||
IconDeviceDesktopCog,
|
IconFileImport,
|
||||||
IconServer2,
|
IconServer2,
|
||||||
IconShieldLock,
|
IconShieldLock,
|
||||||
|
IconUser,
|
||||||
|
IconWebhook,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
@@ -77,78 +79,50 @@ function SettingsPage() {
|
|||||||
const isAdmin = session.user.role === "admin";
|
const isAdmin = session.user.role === "admin";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsShell
|
<SettingsShell>
|
||||||
footer={
|
|
||||||
<footer className="border-border/50 text-muted-foreground border-t pt-6 pb-2 text-center text-xs">
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
href="https://sofa.watch"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary/80 hover:text-primary font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Sofa
|
|
||||||
</a>{" "}
|
|
||||||
v{__APP_VERSION__}
|
|
||||||
{__GIT_COMMIT_SHA__ && (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
(
|
|
||||||
<a
|
|
||||||
href={`https://github.com/${GITHUB_REPO}/commit/${__GIT_COMMIT_SHA__}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-primary font-mono transition-colors"
|
|
||||||
>
|
|
||||||
{__GIT_COMMIT_SHA__}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex flex-col items-center gap-2">
|
|
||||||
<a
|
|
||||||
href="https://www.themoviedb.org/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="transition-opacity hover:opacity-70"
|
|
||||||
>
|
|
||||||
<TmdbLogo className="h-3" />
|
|
||||||
</a>
|
|
||||||
<p className="text-muted-foreground text-[10px] leading-relaxed">
|
|
||||||
<Trans>
|
|
||||||
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AccountSection
|
|
||||||
user={{
|
|
||||||
name: session.user.name,
|
|
||||||
email: session.user.email,
|
|
||||||
image: session.user.image || undefined,
|
|
||||||
createdAt: new Date(session.user.createdAt).toISOString(),
|
|
||||||
role: session.user.role ?? undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* App Settings */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<IconDeviceDesktopCog aria-hidden={true} className="text-muted-foreground size-4" />
|
<IconUser aria-hidden={true} className="text-muted-foreground size-4" />
|
||||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||||
<Trans>App Settings</Trans>
|
<Trans>Account</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<LanguageSection />
|
<div className="space-y-2.5">
|
||||||
<StreamingServicesSection />
|
<AccountSection
|
||||||
|
user={{
|
||||||
|
name: session.user.name,
|
||||||
|
email: session.user.email,
|
||||||
|
image: session.user.image || undefined,
|
||||||
|
createdAt: new Date(session.user.createdAt).toISOString(),
|
||||||
|
role: session.user.role ?? undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StreamingServicesSection />
|
||||||
|
<LanguageSection />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IntegrationsSection />
|
{/* Integrations */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<IconWebhook aria-hidden={true} className="text-muted-foreground size-4" />
|
||||||
|
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||||
|
<Trans>Integrations</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<IntegrationsSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ImportsSection />
|
{/* Import */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<IconFileImport aria-hidden className="text-muted-foreground size-4" />
|
||||||
|
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||||
|
<Trans>Import</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<ImportsSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Server health */}
|
{/* Server health */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -178,7 +152,7 @@ function SettingsPage() {
|
|||||||
<Trans>Admin only</Trans>
|
<Trans>Admin only</Trans>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-2.5">
|
||||||
<Card className="border-s-primary/30 border-s-2">
|
<Card className="border-s-primary/30 border-s-2">
|
||||||
<RegistrationSection />
|
<RegistrationSection />
|
||||||
</Card>
|
</Card>
|
||||||
@@ -201,7 +175,7 @@ function SettingsPage() {
|
|||||||
<Trans>Admin only</Trans>
|
<Trans>Admin only</Trans>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-2.5">
|
||||||
<Card className="border-s-primary/30 border-s-2">
|
<Card className="border-s-primary/30 border-s-2">
|
||||||
<BackupSection />
|
<BackupSection />
|
||||||
</Card>
|
</Card>
|
||||||
@@ -232,6 +206,48 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<footer className="border-border/50 text-muted-foreground border-t pt-6 pb-2 text-center text-xs">
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
href="https://sofa.watch"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary/80 hover:text-primary font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Sofa
|
||||||
|
</a>{" "}
|
||||||
|
v{__APP_VERSION__}
|
||||||
|
{__GIT_COMMIT_SHA__ && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
(
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${GITHUB_REPO}/commit/${__GIT_COMMIT_SHA__}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-primary font-mono transition-colors"
|
||||||
|
>
|
||||||
|
{__GIT_COMMIT_SHA__}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-col items-center gap-2">
|
||||||
|
<a
|
||||||
|
href="https://www.themoviedb.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="transition-opacity hover:opacity-70"
|
||||||
|
>
|
||||||
|
<TmdbLogo className="h-3" />
|
||||||
|
</a>
|
||||||
|
<p className="text-muted-foreground text-[10px] leading-relaxed">
|
||||||
|
<Trans>This product uses the TMDB API but is not endorsed or certified by TMDB.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"apps/native": {
|
"apps/native": {
|
||||||
"name": "@sofa/native",
|
"name": "@sofa/native",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/expo": "catalog:",
|
"@better-auth/expo": "catalog:",
|
||||||
"@expo/metro-runtime": "55.0.6",
|
"@expo/metro-runtime": "55.0.6",
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
},
|
},
|
||||||
"apps/public-api": {
|
"apps/public-api": {
|
||||||
"name": "@sofa/public-api",
|
"name": "@sofa/public-api",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/zod-validator": "0.7.6",
|
"@hono/zod-validator": "0.7.6",
|
||||||
"@vercel/firewall": "1.1.2",
|
"@vercel/firewall": "1.1.2",
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
},
|
},
|
||||||
"apps/server": {
|
"apps/server": {
|
||||||
"name": "@sofa/server",
|
"name": "@sofa/server",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@orpc/contract": "catalog:",
|
"@orpc/contract": "catalog:",
|
||||||
"@orpc/json-schema": "catalog:",
|
"@orpc/json-schema": "catalog:",
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
},
|
},
|
||||||
"apps/web": {
|
"apps/web": {
|
||||||
"name": "@sofa/web",
|
"name": "@sofa/web",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.3.0",
|
"@base-ui/react": "1.3.0",
|
||||||
"@fontsource-variable/dm-sans": "5.2.8",
|
"@fontsource-variable/dm-sans": "5.2.8",
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
},
|
},
|
||||||
"packages/api": {
|
"packages/api": {
|
||||||
"name": "@sofa/api",
|
"name": "@sofa/api",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@orpc/contract": "catalog:",
|
"@orpc/contract": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
},
|
},
|
||||||
"packages/auth": {
|
"packages/auth": {
|
||||||
"name": "@sofa/auth",
|
"name": "@sofa/auth",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/drizzle-adapter": "1.5.6",
|
"@better-auth/drizzle-adapter": "1.5.6",
|
||||||
"@better-auth/expo": "catalog:",
|
"@better-auth/expo": "catalog:",
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
},
|
},
|
||||||
"packages/config": {
|
"packages/config": {
|
||||||
"name": "@sofa/config",
|
"name": "@sofa/config",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sofa/tsconfig": "workspace:*",
|
"@sofa/tsconfig": "workspace:*",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@sofa/core",
|
"name": "@sofa/core",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@orpc/server": "catalog:",
|
"@orpc/server": "catalog:",
|
||||||
"@sofa/api": "workspace:*",
|
"@sofa/api": "workspace:*",
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
},
|
},
|
||||||
"packages/db": {
|
"packages/db": {
|
||||||
"name": "@sofa/db",
|
"name": "@sofa/db",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sofa/config": "workspace:*",
|
"@sofa/config": "workspace:*",
|
||||||
"@sofa/logger": "workspace:*",
|
"@sofa/logger": "workspace:*",
|
||||||
@@ -292,7 +292,7 @@
|
|||||||
},
|
},
|
||||||
"packages/i18n": {
|
"packages/i18n": {
|
||||||
"name": "@sofa/i18n",
|
"name": "@sofa/i18n",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lingui/core": "catalog:",
|
"@lingui/core": "catalog:",
|
||||||
"@lingui/react": "catalog:",
|
"@lingui/react": "catalog:",
|
||||||
@@ -310,7 +310,7 @@
|
|||||||
},
|
},
|
||||||
"packages/logger": {
|
"packages/logger": {
|
||||||
"name": "@sofa/logger",
|
"name": "@sofa/logger",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"pino-pretty": "13.1.3",
|
"pino-pretty": "13.1.3",
|
||||||
@@ -323,7 +323,7 @@
|
|||||||
},
|
},
|
||||||
"packages/test": {
|
"packages/test": {
|
||||||
"name": "@sofa/test",
|
"name": "@sofa/test",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sofa/db": "workspace:*",
|
"@sofa/db": "workspace:*",
|
||||||
"better-sqlite3": "12.8.0",
|
"better-sqlite3": "12.8.0",
|
||||||
@@ -338,7 +338,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tmdb": {
|
"packages/tmdb": {
|
||||||
"name": "@sofa/tmdb",
|
"name": "@sofa/tmdb",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sofa/logger": "workspace:*",
|
"@sofa/logger": "workspace:*",
|
||||||
"openapi-fetch": "0.17.0",
|
"openapi-fetch": "0.17.0",
|
||||||
@@ -351,10 +351,14 @@
|
|||||||
},
|
},
|
||||||
"packages/tsconfig": {
|
"packages/tsconfig": {
|
||||||
"name": "@sofa/tsconfig",
|
"name": "@sofa/tsconfig",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
|
"sharp",
|
||||||
|
"better-sqlite3",
|
||||||
|
"esbuild",
|
||||||
|
"msw",
|
||||||
"@sentry/cli",
|
"@sentry/cli",
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
@@ -383,8 +387,8 @@
|
|||||||
"@vitest/browser-playwright": "4.1.1",
|
"@vitest/browser-playwright": "4.1.1",
|
||||||
"@vitest/coverage-v8": "4.1.1",
|
"@vitest/coverage-v8": "4.1.1",
|
||||||
"better-auth": "1.5.6",
|
"better-auth": "1.5.6",
|
||||||
"drizzle-kit": "1.0.0-beta.18-7eb39f0",
|
"drizzle-kit": "1.0.0-beta.19",
|
||||||
"drizzle-orm": "1.0.0-beta.18-7eb39f0",
|
"drizzle-orm": "1.0.0-beta.19",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
@@ -1875,9 +1879,9 @@
|
|||||||
|
|
||||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.18-7eb39f0", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-F3ozFBjlUr/VWHR0NIz06jM9cKmfFafoXnCDhKvwjhH91hRK/l6sxIjiDOwMxjGuoJH+/p9W8DE6juoGvn7kXA=="],
|
"drizzle-kit": ["drizzle-kit@1.0.0-beta.19", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-LO9cz8iipjyOROaBLmVAhu1WT4JGd8QKSc0XYhnO0FGXP5FappHII52lHcxfj2j+vVuDCTjoqeTTvW1N9NJYZQ=="],
|
||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.18-7eb39f0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-piQ+esfm3krAiwXw3d8U559VdBjzA178jqazAkb0tSo7lJPZUL4SbB5uqg77ZUuCvY37hJuQ/o/gQoMX4m0E6Q=="],
|
"drizzle-orm": ["drizzle-orm@1.0.0-beta.19", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-wL8FoHl9NRVYdrescFWyAVY5rNuEmbY4KDqO6Qo4RTVt1rYMbz7btq5Mhg91GN3x+qeUbQ83l9QkHYd4QmVTvg=="],
|
||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
@@ -3199,6 +3203,8 @@
|
|||||||
|
|
||||||
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@better-auth/drizzle-adapter/drizzle-orm": ["drizzle-orm@1.0.0-beta.18-7eb39f0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-piQ+esfm3krAiwXw3d8U559VdBjzA178jqazAkb0tSo7lJPZUL4SbB5uqg77ZUuCvY37hJuQ/o/gQoMX4m0E6Q=="],
|
||||||
|
|
||||||
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||||
|
|
||||||
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||||
@@ -3351,6 +3357,10 @@
|
|||||||
|
|
||||||
"babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
|
"babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
|
||||||
|
|
||||||
|
"better-auth/drizzle-kit": ["drizzle-kit@1.0.0-beta.18-7eb39f0", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-F3ozFBjlUr/VWHR0NIz06jM9cKmfFafoXnCDhKvwjhH91hRK/l6sxIjiDOwMxjGuoJH+/p9W8DE6juoGvn7kXA=="],
|
||||||
|
|
||||||
|
"better-auth/drizzle-orm": ["drizzle-orm@1.0.0-beta.18-7eb39f0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-piQ+esfm3krAiwXw3d8U559VdBjzA178jqazAkb0tSo7lJPZUL4SbB5uqg77ZUuCvY37hJuQ/o/gQoMX4m0E6Q=="],
|
||||||
|
|
||||||
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||||
|
|
||||||
"body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
"body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|||||||
+22
-32
@@ -2046,7 +2046,7 @@
|
|||||||
},
|
},
|
||||||
"offerType": {
|
"offerType": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Offer type: flatrate, rent, buy, free, ads"
|
"description": "Offer category: stream or purchase"
|
||||||
},
|
},
|
||||||
"watchUrl": {
|
"watchUrl": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
@@ -4343,16 +4343,12 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Platform ID"
|
"description": "Platform ID"
|
||||||
},
|
},
|
||||||
"tmdbProviderId": {
|
"tmdbProviderIds": {
|
||||||
"anyOf": [
|
"type": "array",
|
||||||
{
|
"items": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
{
|
"description": "TMDB provider IDs"
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "TMDB provider ID"
|
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -4372,7 +4368,7 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"tmdbProviderId",
|
"tmdbProviderIds",
|
||||||
"name",
|
"name",
|
||||||
"logoPath"
|
"logoPath"
|
||||||
]
|
]
|
||||||
@@ -4861,14 +4857,12 @@
|
|||||||
"allowReserved": true
|
"allowReserved": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "providerId",
|
"name": "platformId",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"required": false,
|
"required": false,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"minimum": -9007199254740991,
|
"description": "Platform ID to filter by"
|
||||||
"maximum": 9007199254740991,
|
|
||||||
"description": "TMDB watch provider ID"
|
|
||||||
},
|
},
|
||||||
"allowEmptyValue": true,
|
"allowEmptyValue": true,
|
||||||
"allowReserved": true
|
"allowReserved": true
|
||||||
@@ -6773,16 +6767,12 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Display name"
|
"description": "Display name"
|
||||||
},
|
},
|
||||||
"tmdbProviderId": {
|
"tmdbProviderIds": {
|
||||||
"anyOf": [
|
"type": "array",
|
||||||
{
|
"items": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
{
|
"description": "TMDB provider IDs mapped to this platform"
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "TMDB provider ID (null for custom platforms)"
|
|
||||||
},
|
},
|
||||||
"logoPath": {
|
"logoPath": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
@@ -6795,17 +6785,17 @@
|
|||||||
],
|
],
|
||||||
"description": "Logo image path"
|
"description": "Logo image path"
|
||||||
},
|
},
|
||||||
"displayOrder": {
|
"isSubscription": {
|
||||||
"type": "number",
|
"type": "boolean",
|
||||||
"description": "Sort order"
|
"description": "True for subscription services, false for purchase/rental stores"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"tmdbProviderId",
|
"tmdbProviderIds",
|
||||||
"logoPath",
|
"logoPath",
|
||||||
"displayOrder"
|
"isSubscription"
|
||||||
],
|
],
|
||||||
"description": "A streaming platform"
|
"description": "A streaming platform"
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-4
@@ -32,8 +32,8 @@
|
|||||||
"@vitest/browser-playwright": "4.1.1",
|
"@vitest/browser-playwright": "4.1.1",
|
||||||
"@vitest/coverage-v8": "4.1.1",
|
"@vitest/coverage-v8": "4.1.1",
|
||||||
"better-auth": "1.5.6",
|
"better-auth": "1.5.6",
|
||||||
"drizzle-kit": "1.0.0-beta.18-7eb39f0",
|
"drizzle-kit": "1.0.0-beta.19",
|
||||||
"drizzle-orm": "1.0.0-beta.18-7eb39f0",
|
"drizzle-orm": "1.0.0-beta.19",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
@@ -58,7 +58,8 @@
|
|||||||
"clean": "rm -rf .turbo \"apps/*/.turbo\" \"apps/*/node_modules\" \"apps/*/*.tsbuildinfo\" \"apps/*/dist\" \"packages/*/.turbo\" \"packages/*/node_modules\" \"packages/*/*.tsbuildinfo\" node_modules bun.lock",
|
"clean": "rm -rf .turbo \"apps/*/.turbo\" \"apps/*/node_modules\" \"apps/*/*.tsbuildinfo\" \"apps/*/dist\" \"packages/*/.turbo\" \"packages/*/node_modules\" \"packages/*/*.tsbuildinfo\" node_modules bun.lock",
|
||||||
"generate:openapi": "bun scripts/generate-openapi-spec.ts && cd docs && bun run generate:api-docs",
|
"generate:openapi": "bun scripts/generate-openapi-spec.ts && cd docs && bun run generate:api-docs",
|
||||||
"i18n:extract": "lingui extract",
|
"i18n:extract": "lingui extract",
|
||||||
"i18n:claude": "bun packages/i18n/scripts/claude.ts"
|
"i18n:claude": "bun packages/i18n/scripts/claude.ts",
|
||||||
|
"sync:providers": "bun scripts/sync-tmdb-providers.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@e18e/eslint-plugin": "0.3.0",
|
"@e18e/eslint-plugin": "0.3.0",
|
||||||
@@ -82,6 +83,10 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "bun@1.3.11",
|
"packageManager": "bun@1.3.11",
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@sentry/cli"
|
"@sentry/cli",
|
||||||
|
"better-sqlite3",
|
||||||
|
"esbuild",
|
||||||
|
"msw",
|
||||||
|
"sharp"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const DiscoverInput = z
|
|||||||
.regex(/^[a-z]{2}$/)
|
.regex(/^[a-z]{2}$/)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("ISO 639-1 original language code"),
|
.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)
|
.merge(PageParam)
|
||||||
.meta({ description: "Genre-based discovery filters" });
|
.meta({ description: "Genre-based discovery filters" });
|
||||||
@@ -283,7 +283,7 @@ export const AvailabilityOfferSchema = z
|
|||||||
platformId: z.string().describe("Platform ID"),
|
platformId: z.string().describe("Platform ID"),
|
||||||
providerName: z.string().describe("Display name (e.g. Netflix, Hulu)"),
|
providerName: z.string().describe("Display name (e.g. Netflix, Hulu)"),
|
||||||
logoPath: z.string().nullable().describe("Provider logo image path"),
|
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"),
|
watchUrl: z.string().nullable().describe("Direct link to watch on this provider"),
|
||||||
isUserSubscribed: z.boolean().describe("Whether the user subscribes to this platform"),
|
isUserSubscribed: z.boolean().describe("Whether the user subscribes to this platform"),
|
||||||
})
|
})
|
||||||
@@ -293,9 +293,11 @@ export const PlatformSchema = z
|
|||||||
.object({
|
.object({
|
||||||
id: z.string().describe("Platform ID"),
|
id: z.string().describe("Platform ID"),
|
||||||
name: z.string().describe("Display name"),
|
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"),
|
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" });
|
.meta({ description: "A streaming platform" });
|
||||||
|
|
||||||
@@ -621,7 +623,7 @@ export const WatchProvidersOutput = z
|
|||||||
providers: z.array(
|
providers: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().describe("Platform ID"),
|
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"),
|
name: z.string().describe("Provider display name"),
|
||||||
logoPath: z.string().nullable().describe("Provider logo image path"),
|
logoPath: z.string().nullable().describe("Provider logo image path"),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
deleteIntegrationByUserAndProvider,
|
deleteIntegrationByUserAndProvider,
|
||||||
getIntegrationByToken,
|
getIntegrationByToken,
|
||||||
getIntegrationByUserAndProvider,
|
getIntegrationByUserAndProvider,
|
||||||
getRecentEventsForIntegration,
|
getRecentEventsForIntegrations,
|
||||||
getUserIntegrations,
|
getUserIntegrations,
|
||||||
insertIntegration,
|
insertIntegration,
|
||||||
regenerateIntegrationToken,
|
regenerateIntegrationToken,
|
||||||
@@ -38,11 +38,8 @@ function serializeIntegration(row: {
|
|||||||
export function listUserIntegrations(userId: string) {
|
export function listUserIntegrations(userId: string) {
|
||||||
const userIntegrations = getUserIntegrations(userId);
|
const userIntegrations = getUserIntegrations(userId);
|
||||||
|
|
||||||
const eventsByIntegration = new Map<string, ReturnType<typeof getRecentEventsForIntegration>>();
|
const integrationIds = userIntegrations.map((i) => i.id);
|
||||||
for (const integration of userIntegrations) {
|
const eventsByIntegration = getRecentEventsForIntegrations(integrationIds);
|
||||||
const events = getRecentEventsForIntegration(integration.id);
|
|
||||||
eventsByIntegration.set(integration.id, events);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = userIntegrations.map((integration) => {
|
const result = userIntegrations.map((integration) => {
|
||||||
const events = eventsByIntegration.get(integration.id) ?? [];
|
const events = eventsByIntegration.get(integration.id) ?? [];
|
||||||
|
|||||||
@@ -684,19 +684,66 @@ async function ensureEnriched(
|
|||||||
return false;
|
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(
|
function readAvailability(
|
||||||
titleId: string,
|
titleId: string,
|
||||||
titleName: string,
|
titleName: string,
|
||||||
userPlatformIds?: Set<string>,
|
userPlatformIds?: Set<string>,
|
||||||
): AvailabilityOffer[] {
|
): AvailabilityOffer[] {
|
||||||
return getAvailabilityOffersForTitle(titleId).map((a) => ({
|
const raw = getAvailabilityOffersForTitle(titleId);
|
||||||
platformId: a.platformId,
|
|
||||||
providerName: a.providerName,
|
// Group by platformId to deduplicate
|
||||||
logoPath: tmdbImageUrl(a.logoPath, "logos"),
|
const byPlatform = new Map<string, (typeof raw)[number][]>();
|
||||||
offerType: a.offerType,
|
for (const offer of raw) {
|
||||||
watchUrl: generateProviderUrl(a.urlTemplate, titleName),
|
let list = byPlatform.get(offer.platformId);
|
||||||
isUserSubscribed: userPlatformIds ? userPlatformIds.has(a.platformId) : false,
|
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(
|
export async function getOrFetchTitle(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
getAllPlatforms,
|
getAllPlatforms,
|
||||||
|
getTmdbProviderIdsByPlatformIds,
|
||||||
|
getTmdbProviderIdsForPlatform,
|
||||||
getUserPlatformIds,
|
getUserPlatformIds,
|
||||||
getUserPlatforms,
|
getUserPlatforms,
|
||||||
hasUserPlatforms,
|
hasUserPlatforms,
|
||||||
@@ -29,3 +31,11 @@ export function updateUserPlatforms(userId: string, platformIds: string[]): void
|
|||||||
export function hasUserSetPlatforms(userId: string): boolean {
|
export function hasUserSetPlatforms(userId: string): boolean {
|
||||||
return hasUserPlatforms(userId);
|
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 path from "node:path";
|
||||||
|
|
||||||
import { CACHE_DIR, DATA_DIR, DATABASE_URL, TMDB_API_BASE_URL } from "@sofa/config";
|
import { CACHE_DIR, DATA_DIR, DATABASE_URL, TMDB_API_BASE_URL } from "@sofa/config";
|
||||||
import { getLatestCronRun, getTableCounts } from "@sofa/db/queries/system-health";
|
import { getLatestCronRuns, getTableCounts } from "@sofa/db/queries/system-health";
|
||||||
import type { cronRuns } from "@sofa/db/schema";
|
|
||||||
|
|
||||||
import { listBackups } from "./backup";
|
import { listBackups } from "./backup";
|
||||||
import { imageCacheEnabled } from "./image-cache";
|
import { imageCacheEnabled } from "./image-cache";
|
||||||
@@ -153,12 +152,7 @@ function getJobsHealth(): SystemHealthData["jobs"] {
|
|||||||
const schedules = _getJobSchedules?.() ?? [];
|
const schedules = _getJobSchedules?.() ?? [];
|
||||||
const scheduleMap = new Map(schedules.map((s) => [s.jobName, s]));
|
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 = getLatestCronRuns([...JOB_NAMES]);
|
||||||
const latestByJob = new Map<string, typeof cronRuns.$inferSelect>();
|
|
||||||
for (const jobName of JOB_NAMES) {
|
|
||||||
const latest = getLatestCronRun(jobName);
|
|
||||||
if (latest) latestByJob.set(jobName, latest);
|
|
||||||
}
|
|
||||||
|
|
||||||
return JOB_NAMES.map((jobName) => {
|
return JOB_NAMES.map((jobName) => {
|
||||||
const latest = latestByJob.get(jobName);
|
const latest = latestByJob.get(jobName);
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
getAllEpisodeIdsForTitle,
|
getAllEpisodeIdsForTitle,
|
||||||
getEpisodeProgressByTitleIds as getEpisodeProgressByTitleIdsQuery,
|
getEpisodeProgressByTitleIds as getEpisodeProgressByTitleIdsQuery,
|
||||||
getEpisodeTitleId,
|
getEpisodeTitleId,
|
||||||
|
getEpisodeTitleIds,
|
||||||
getExistingEpisodeWatchIds,
|
getExistingEpisodeWatchIds,
|
||||||
getSeasonById,
|
getSeasonById,
|
||||||
getSeasonEpisodes,
|
getSeasonEpisodeIds,
|
||||||
getTitleStatus,
|
getTitleStatus,
|
||||||
getUserStatusesByTitleIds as getUserStatusesByTitleIdsQuery,
|
getUserStatusesByTitleIds as getUserStatusesByTitleIdsQuery,
|
||||||
getUserTitleInfo as getUserTitleInfoQuery,
|
getUserTitleInfo as getUserTitleInfoQuery,
|
||||||
@@ -91,11 +92,8 @@ export function logEpisodeWatchBatch(
|
|||||||
batchInsertEpisodeWatchesTransaction(userId, episodeIds, source, watchedAt);
|
batchInsertEpisodeWatchesTransaction(userId, episodeIds, source, watchedAt);
|
||||||
|
|
||||||
// Auto-set title status to in_progress for affected titles
|
// Auto-set title status to in_progress for affected titles
|
||||||
const titleIds = new Set<string>();
|
const episodeTitleMap = getEpisodeTitleIds(episodeIds);
|
||||||
for (const episodeId of episodeIds) {
|
const titleIds = new Set(episodeTitleMap.values());
|
||||||
const titleId = getEpisodeTitleId(episodeId);
|
|
||||||
if (titleId) titleIds.add(titleId);
|
|
||||||
}
|
|
||||||
for (const titleId of titleIds) {
|
for (const titleId of titleIds) {
|
||||||
const existing = getTitleStatus(userId, titleId);
|
const existing = getTitleStatus(userId, titleId);
|
||||||
if (!existing || existing.status === "watchlist") {
|
if (!existing || existing.status === "watchlist") {
|
||||||
@@ -140,9 +138,7 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function unwatchSeason(userId: string, seasonId: string) {
|
export function unwatchSeason(userId: string, seasonId: string) {
|
||||||
const seasonEps = getSeasonEpisodes(seasonId);
|
const epIds = getSeasonEpisodeIds(seasonId);
|
||||||
|
|
||||||
const epIds = seasonEps.map((ep) => ep.id);
|
|
||||||
if (epIds.length > 0) {
|
if (epIds.length > 0) {
|
||||||
deleteEpisodeWatches(userId, epIds);
|
deleteEpisodeWatches(userId, epIds);
|
||||||
}
|
}
|
||||||
@@ -271,9 +267,5 @@ export function quickAddTitle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function watchSeason(userId: string, seasonId: string): void {
|
export function watchSeason(userId: string, seasonId: string): void {
|
||||||
const seasonEps = getSeasonEpisodes(seasonId);
|
logEpisodeWatchBatch(userId, getSeasonEpisodeIds(seasonId));
|
||||||
logEpisodeWatchBatch(
|
|
||||||
userId,
|
|
||||||
seasonEps.map((ep) => ep.id),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { findEpisodeBySeasonAndNumber, findSeasonByTitleAndNumber } from "@sofa/
|
|||||||
import {
|
import {
|
||||||
getRecentEpisodeWatch,
|
getRecentEpisodeWatch,
|
||||||
getRecentMovieWatch,
|
getRecentMovieWatch,
|
||||||
insertIntegrationEvent,
|
insertIntegrationEventTransaction,
|
||||||
updateIntegrationLastEvent,
|
|
||||||
} from "@sofa/db/queries/webhooks";
|
} from "@sofa/db/queries/webhooks";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { getTvDetails } from "@sofa/tmdb/client";
|
import { getTvDetails } from "@sofa/tmdb/client";
|
||||||
@@ -189,22 +188,23 @@ function logEvent(
|
|||||||
status: "success" | "ignored" | "error",
|
status: "success" | "ignored" | "error",
|
||||||
errorMessage?: string,
|
errorMessage?: string,
|
||||||
) {
|
) {
|
||||||
insertIntegrationEvent({
|
insertIntegrationEventTransaction(
|
||||||
integrationId: connectionId,
|
{
|
||||||
eventType:
|
integrationId: connectionId,
|
||||||
event?.provider === "plex"
|
eventType:
|
||||||
? "media.scrobble"
|
event?.provider === "plex"
|
||||||
: event?.provider === "emby"
|
? "media.scrobble"
|
||||||
? "playback.stop"
|
: event?.provider === "emby"
|
||||||
: "PlaybackStop",
|
? "playback.stop"
|
||||||
mediaType: event?.mediaType ?? null,
|
: "PlaybackStop",
|
||||||
mediaTitle: event?.title ?? null,
|
mediaType: event?.mediaType ?? null,
|
||||||
status,
|
mediaTitle: event?.title ?? null,
|
||||||
errorMessage: errorMessage ?? null,
|
status,
|
||||||
receivedAt: new Date(),
|
errorMessage: errorMessage ?? null,
|
||||||
});
|
receivedAt: new Date(),
|
||||||
|
},
|
||||||
updateIntegrationLastEvent(connectionId);
|
connectionId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main Processing ────────────────────────────────────────────────
|
// ─── 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);
|
const tomorrow = daysFromNow(1);
|
||||||
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
|
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
|
||||||
insertStatus("user-1", "tv-1", "in_progress");
|
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");
|
const log = createLogger("db");
|
||||||
|
|
||||||
export function runMigrations(migrationsFolder = path.join(import.meta.dir, "../drizzle")) {
|
export function runMigrations(migrationsFolder = path.join(import.meta.dir, "../drizzle")) {
|
||||||
log.info("Running database migrations...");
|
log.debug("Running database migrations...");
|
||||||
migrate(db, { migrationsFolder });
|
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";
|
import { session, verification } from "../schema";
|
||||||
|
|
||||||
export function deleteExpiredSessions(): number {
|
export function deleteExpiredSessions(): number {
|
||||||
const now = new Date();
|
return db
|
||||||
const count = db
|
.delete(session)
|
||||||
.select({ id: session.id })
|
.where(lt(session.expiresAt, new Date()))
|
||||||
.from(session)
|
.returning({ id: session.id })
|
||||||
.where(lt(session.expiresAt, now))
|
|
||||||
.all().length;
|
.all().length;
|
||||||
if (count === 0) return 0;
|
|
||||||
db.delete(session).where(lt(session.expiresAt, now)).run();
|
|
||||||
return count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteExpiredVerifications(): number {
|
export function deleteExpiredVerifications(): number {
|
||||||
const now = new Date();
|
return db
|
||||||
const count = db
|
.delete(verification)
|
||||||
.select({ id: verification.id })
|
.where(lt(verification.expiresAt, new Date()))
|
||||||
.from(verification)
|
.returning({ id: verification.id })
|
||||||
.where(lt(verification.expiresAt, now))
|
|
||||||
.all().length;
|
.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 { db } from "../client";
|
||||||
import { platforms, titleAvailability } from "../schema";
|
import { platformTmdbIds, platforms, titleAvailability } from "../schema";
|
||||||
|
|
||||||
export function replaceAvailabilityTransaction(
|
export function replaceAvailabilityTransaction(
|
||||||
titleId: string,
|
titleId: string,
|
||||||
@@ -26,7 +26,6 @@ export function getAvailabilityForTitle(titleId: string) {
|
|||||||
providerName: platforms.name,
|
providerName: platforms.name,
|
||||||
logoPath: platforms.logoPath,
|
logoPath: platforms.logoPath,
|
||||||
urlTemplate: platforms.urlTemplate,
|
urlTemplate: platforms.urlTemplate,
|
||||||
tmdbProviderId: platforms.tmdbProviderId,
|
|
||||||
offerType: titleAvailability.offerType,
|
offerType: titleAvailability.offerType,
|
||||||
})
|
})
|
||||||
.from(titleAvailability)
|
.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.
|
* Returns the platform ID.
|
||||||
*/
|
*/
|
||||||
export function ensurePlatformForTmdbProvider(
|
export function ensurePlatformForTmdbProvider(
|
||||||
@@ -44,23 +45,38 @@ export function ensurePlatformForTmdbProvider(
|
|||||||
name: string,
|
name: string,
|
||||||
logoPath: string | null,
|
logoPath: string | null,
|
||||||
): string {
|
): string {
|
||||||
const row = db
|
return db.transaction((tx) => {
|
||||||
.insert(platforms)
|
const existing = tx
|
||||||
.values({
|
.select({ platformId: platformTmdbIds.platformId })
|
||||||
tmdbProviderId,
|
.from(platformTmdbIds)
|
||||||
name,
|
.where(eq(platformTmdbIds.tmdbProviderId, tmdbProviderId))
|
||||||
logoPath,
|
.get();
|
||||||
displayOrder: 999,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: platforms.tmdbProviderId,
|
|
||||||
set: {
|
|
||||||
name: sql`excluded.name`,
|
|
||||||
logoPath: sql`excluded.logoPath`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.returning({ id: platforms.id })
|
|
||||||
.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 });
|
if (r.backdropPath) images.push({ category: "backdrops", path: r.backdropPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Season posters
|
// Season posters + IDs
|
||||||
const seasonRows = db
|
const seasonRows = db
|
||||||
.select({ posterPath: seasons.posterPath })
|
.select({ id: seasons.id, posterPath: seasons.posterPath })
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(inArray(seasons.titleId, batch))
|
.where(inArray(seasons.titleId, batch))
|
||||||
.all();
|
.all();
|
||||||
@@ -45,12 +45,7 @@ function collectTitleImagePaths(titleIds: string[]): OrphanedImage[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Episode stills (via seasons)
|
// Episode stills (via seasons)
|
||||||
const seasonIds = db
|
const seasonIds = seasonRows.map((s) => s.id);
|
||||||
.select({ id: seasons.id })
|
|
||||||
.from(seasons)
|
|
||||||
.where(inArray(seasons.titleId, batch))
|
|
||||||
.all()
|
|
||||||
.map((s) => s.id);
|
|
||||||
|
|
||||||
for (let j = 0; j < seasonIds.length; j += BATCH_SIZE) {
|
for (let j = 0; j < seasonIds.length; j += BATCH_SIZE) {
|
||||||
const sBatch = seasonIds.slice(j, j + BATCH_SIZE);
|
const sBatch = seasonIds.slice(j, j + BATCH_SIZE);
|
||||||
@@ -68,19 +63,44 @@ function collectTitleImagePaths(titleIds: string[]): OrphanedImage[] {
|
|||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect profile paths for persons that will be orphaned (no remaining titleCast). */
|
/** Find person IDs that have no remaining titleCast entries. */
|
||||||
function collectOrphanedPersonImages(): OrphanedImage[] {
|
function getOrphanedPersonIds(): string[] {
|
||||||
const orphaned = db
|
return db
|
||||||
.select({ profilePath: persons.profilePath })
|
.select({ id: persons.id })
|
||||||
.from(persons)
|
.from(persons)
|
||||||
.where(
|
.where(
|
||||||
notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)),
|
notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)),
|
||||||
)
|
)
|
||||||
.all();
|
.all()
|
||||||
|
.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
return orphaned
|
/** Collect profile paths for a set of orphaned person IDs. */
|
||||||
.filter((p): p is { profilePath: string } => p.profilePath != null)
|
function collectOrphanedPersonImages(orphanedIds: string[]): OrphanedImage[] {
|
||||||
.map((p) => ({ category: "profiles" as const, path: p.profilePath }));
|
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 {
|
export function purgeShellTitlesTransaction(): PurgeResult {
|
||||||
@@ -92,8 +112,13 @@ export function purgeShellTitlesTransaction(): PurgeResult {
|
|||||||
.all();
|
.all();
|
||||||
|
|
||||||
if (shellTitles.length === 0) {
|
if (shellTitles.length === 0) {
|
||||||
const orphanedImages = collectOrphanedPersonImages();
|
const orphanedIds = getOrphanedPersonIds();
|
||||||
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons(), orphanedImages };
|
const orphanedImages = collectOrphanedPersonImages(orphanedIds);
|
||||||
|
return {
|
||||||
|
deletedTitles: 0,
|
||||||
|
deletedPersons: deleteOrphanedPersons(orphanedIds),
|
||||||
|
orphanedImages,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryTitleIds = new Set(
|
const libraryTitleIds = new Set(
|
||||||
@@ -107,8 +132,13 @@ export function purgeShellTitlesTransaction(): PurgeResult {
|
|||||||
const toDelete = shellTitles.map((t) => t.id).filter((id) => !libraryTitleIds.has(id));
|
const toDelete = shellTitles.map((t) => t.id).filter((id) => !libraryTitleIds.has(id));
|
||||||
|
|
||||||
if (toDelete.length === 0) {
|
if (toDelete.length === 0) {
|
||||||
const orphanedImages = collectOrphanedPersonImages();
|
const orphanedIds = getOrphanedPersonIds();
|
||||||
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons(), orphanedImages };
|
const orphanedImages = collectOrphanedPersonImages(orphanedIds);
|
||||||
|
return {
|
||||||
|
deletedTitles: 0,
|
||||||
|
deletedPersons: deleteOrphanedPersons(orphanedIds),
|
||||||
|
orphanedImages,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect image paths BEFORE cascade-deleting the title rows
|
// 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();
|
db.delete(titles).where(inArray(titles.id, batch)).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// After title cascade deletes, collect orphaned person images then delete persons
|
// After title cascade deletes, find orphaned persons once and use for both operations
|
||||||
orphanedImages.push(...collectOrphanedPersonImages());
|
const orphanedIds = getOrphanedPersonIds();
|
||||||
|
orphanedImages.push(...collectOrphanedPersonImages(orphanedIds));
|
||||||
return {
|
return {
|
||||||
deletedTitles: toDelete.length,
|
deletedTitles: toDelete.length,
|
||||||
deletedPersons: purgeOrphanedPersons(),
|
deletedPersons: deleteOrphanedPersons(orphanedIds),
|
||||||
orphanedImages,
|
orphanedImages,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function purgeOrphanedPersons(): number {
|
export function purgeOrphanedPersons(): number {
|
||||||
const orphanedPersons = db
|
return deleteOrphanedPersons(getOrphanedPersonIds());
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function getStaleTitles(titleIds: string[], staleDate: Date) {
|
|||||||
|
|
||||||
export function getStaleNonLibraryTitles(staleDate: Date, limit: number) {
|
export function getStaleNonLibraryTitles(staleDate: Date, limit: number) {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select({ id: titles.id })
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, staleDate)))
|
.where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, staleDate)))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -94,7 +94,7 @@ export function getTitlesWithStaleOffersFetchedBefore(titleIds: string[], staleD
|
|||||||
export function getReturningTvShows() {
|
export function getReturningTvShows() {
|
||||||
const returningStatuses = ["Returning Series", "In Production"];
|
const returningStatuses = ["Returning Series", "In Production"];
|
||||||
return db
|
return db
|
||||||
.select()
|
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -128,19 +128,11 @@ export function getCastEntryForTitle(titleId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deleteOldCronRuns(beforeDate: Date): number {
|
export function deleteOldCronRuns(beforeDate: Date): number {
|
||||||
const old = db
|
return db
|
||||||
.select({ id: cronRuns.id })
|
.delete(cronRuns)
|
||||||
.from(cronRuns)
|
|
||||||
.where(lt(cronRuns.startedAt, beforeDate))
|
.where(lt(cronRuns.startedAt, beforeDate))
|
||||||
.all();
|
.returning({ id: cronRuns.id })
|
||||||
if (old.length === 0) return 0;
|
.all().length;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTitlesWithFreshRecommendations(
|
export function getTitlesWithFreshRecommendations(
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ export function getUpcomingMovies(
|
|||||||
|
|
||||||
export function getAvailabilityByTitleIds(titleIds: string[]) {
|
export function getAvailabilityByTitleIds(titleIds: string[]) {
|
||||||
if (titleIds.length === 0) return [];
|
if (titleIds.length === 0) return [];
|
||||||
|
// Order by offerType so flatrate is picked first when the caller deduplicates
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
titleId: titleAvailability.titleId,
|
titleId: titleAvailability.titleId,
|
||||||
@@ -363,8 +364,11 @@ export function getAvailabilityByTitleIds(titleIds: string[]) {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(titleAvailability.titleId, titleIds),
|
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();
|
.all();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { db } from "../client";
|
import { db } from "../client";
|
||||||
import {
|
import {
|
||||||
@@ -39,19 +39,11 @@ export function getSeasonPostersForTitle(titleId: string) {
|
|||||||
// ─── Episode stills ──────────────────────────────────────────────────
|
// ─── Episode stills ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getEpisodeStillsForTitle(titleId: string) {
|
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
|
return db
|
||||||
.select({ stillPath: episodes.stillPath })
|
.select({ stillPath: episodes.stillPath })
|
||||||
.from(episodes)
|
.from(episodes)
|
||||||
.where(inArray(episodes.seasonId, seasonIds))
|
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||||
|
.where(eq(seasons.titleId, titleId))
|
||||||
.all();
|
.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. */
|
/** Mark any running/pending import jobs as errored — called on server startup to recover from crashes. */
|
||||||
export function recoverStaleImportJobs(): number {
|
export function recoverStaleImportJobs(): number {
|
||||||
const stale = db
|
return db
|
||||||
.select({ id: importJobs.id })
|
.update(importJobs)
|
||||||
.from(importJobs)
|
.set({
|
||||||
|
status: "error",
|
||||||
|
finishedAt: new Date(),
|
||||||
|
errors: JSON.stringify(["Import interrupted by server restart"]),
|
||||||
|
})
|
||||||
.where(inArray(importJobs.status, ["pending", "running"]))
|
.where(inArray(importJobs.status, ["pending", "running"]))
|
||||||
.all();
|
.returning({ id: importJobs.id })
|
||||||
if (stale.length === 0) return 0;
|
.all().length;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ export function getRecentEventsForIntegration(integrationId: string, limit = 10)
|
|||||||
.all();
|
.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) {
|
export function getIntegrationByUserAndProvider(userId: string, provider: string) {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -224,6 +224,10 @@ export function getFilteredLibrary(userId: string, filters: LibraryFilters) {
|
|||||||
|
|
||||||
// Build query with display status when needed for filtering
|
// Build query with display status when needed for filtering
|
||||||
if (needsDisplayStatus) {
|
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
|
const rows = db
|
||||||
.select({
|
.select({
|
||||||
titleId: titles.id,
|
titleId: titles.id,
|
||||||
@@ -239,6 +243,7 @@ export function getFilteredLibrary(userId: string, filters: LibraryFilters) {
|
|||||||
userStatus: userTitleStatus.status,
|
userStatus: userTitleStatus.status,
|
||||||
userRating: userRatings.ratingStars,
|
userRating: userRatings.ratingStars,
|
||||||
displayStatus: displayStatusExpr().as("display_status"),
|
displayStatus: displayStatusExpr().as("display_status"),
|
||||||
|
totalCount: sql<number>`count(*) over()`.as("totalCount"),
|
||||||
})
|
})
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
@@ -251,18 +256,15 @@ export function getFilteredLibrary(userId: string, filters: LibraryFilters) {
|
|||||||
)
|
)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(...sortExpressions)
|
.orderBy(...sortExpressions)
|
||||||
|
.limit(filters.limit)
|
||||||
|
.offset(offset)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
// Post-filter by display status
|
const totalResults = rows[0]?.totalCount ?? 0;
|
||||||
const filtered = rows.filter((r) => filters.statuses!.includes(r.displayStatus));
|
const items = rows.map(({ totalCount: _, ...item }) => item);
|
||||||
const totalResults = filtered.length;
|
|
||||||
const paged = filtered.slice(offset, offset + filters.limit);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: paged.map((row) => {
|
items,
|
||||||
const { displayStatus, ...item } = row;
|
|
||||||
return Object.assign(item, { displayStatus });
|
|
||||||
}),
|
|
||||||
page: filters.page,
|
page: filters.page,
|
||||||
totalPages: Math.max(1, Math.ceil(totalResults / filters.limit)),
|
totalPages: Math.max(1, Math.ceil(totalResults / filters.limit)),
|
||||||
totalResults,
|
totalResults,
|
||||||
|
|||||||
@@ -267,7 +267,6 @@ export function getAvailabilityOffersForTitle(titleId: string) {
|
|||||||
providerName: platforms.name,
|
providerName: platforms.name,
|
||||||
logoPath: platforms.logoPath,
|
logoPath: platforms.logoPath,
|
||||||
urlTemplate: platforms.urlTemplate,
|
urlTemplate: platforms.urlTemplate,
|
||||||
tmdbProviderId: platforms.tmdbProviderId,
|
|
||||||
offerType: titleAvailability.offerType,
|
offerType: titleAvailability.offerType,
|
||||||
})
|
})
|
||||||
.from(titleAvailability)
|
.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 { db } from "../client";
|
||||||
import { cronRuns, episodes, titles, user } from "../schema";
|
import { cronRuns, episodes, titles, user } from "../schema";
|
||||||
@@ -23,3 +23,26 @@ export function getLatestCronRun(jobName: string) {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.get();
|
.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;
|
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(
|
export function batchInsertEpisodeWatchesTransaction(
|
||||||
userId: string,
|
userId: string,
|
||||||
episodeIds: string[],
|
episodeIds: string[],
|
||||||
@@ -168,7 +179,7 @@ export function batchInsertMissingEpisodeWatches(
|
|||||||
|
|
||||||
export function countDistinctEpisodeWatches(userId: string, episodeIds: string[]): number {
|
export function countDistinctEpisodeWatches(userId: string, episodeIds: string[]): number {
|
||||||
if (episodeIds.length === 0) return 0;
|
if (episodeIds.length === 0) return 0;
|
||||||
const [result] = db
|
const result = db
|
||||||
.select({
|
.select({
|
||||||
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
|
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
|
||||||
})
|
})
|
||||||
@@ -176,8 +187,8 @@ export function countDistinctEpisodeWatches(userId: string, episodeIds: string[]
|
|||||||
.where(
|
.where(
|
||||||
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, episodeIds)),
|
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, episodeIds)),
|
||||||
)
|
)
|
||||||
.all();
|
.get();
|
||||||
return result.count;
|
return result?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertRating(
|
export function upsertRating(
|
||||||
@@ -277,6 +288,15 @@ export function getSeasonEpisodes(seasonId: string) {
|
|||||||
return db.select().from(episodes).where(eq(episodes.seasonId, seasonId)).all();
|
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) {
|
export function getSeasonById(seasonId: string) {
|
||||||
return db.select().from(seasons).where(eq(seasons.id, seasonId)).get();
|
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 { db } from "../client";
|
||||||
import { platforms, userPlatforms } from "../schema";
|
import { platformTmdbIds, platforms, userPlatforms } from "../schema";
|
||||||
|
|
||||||
export function getUserPlatformIds(userId: string): string[] {
|
export function getUserPlatformIds(userId: string): string[] {
|
||||||
return db
|
return db
|
||||||
@@ -17,15 +17,14 @@ export function getUserPlatforms(userId: string) {
|
|||||||
.select({
|
.select({
|
||||||
id: platforms.id,
|
id: platforms.id,
|
||||||
name: platforms.name,
|
name: platforms.name,
|
||||||
tmdbProviderId: platforms.tmdbProviderId,
|
|
||||||
logoPath: platforms.logoPath,
|
logoPath: platforms.logoPath,
|
||||||
urlTemplate: platforms.urlTemplate,
|
urlTemplate: platforms.urlTemplate,
|
||||||
displayOrder: platforms.displayOrder,
|
isSubscription: platforms.isSubscription,
|
||||||
})
|
})
|
||||||
.from(userPlatforms)
|
.from(userPlatforms)
|
||||||
.innerJoin(platforms, eq(userPlatforms.platformId, platforms.id))
|
.innerJoin(platforms, eq(userPlatforms.platformId, platforms.id))
|
||||||
.where(eq(userPlatforms.userId, userId))
|
.where(eq(userPlatforms.userId, userId))
|
||||||
.orderBy(platforms.displayOrder)
|
.orderBy(desc(platforms.isSubscription), asc(platforms.name))
|
||||||
.all();
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +41,11 @@ export function setUserPlatforms(userId: string, platformIds: string[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAllPlatforms() {
|
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 {
|
export function hasUserPlatforms(userId: string): boolean {
|
||||||
@@ -66,3 +69,32 @@ export function platformIdsExist(platformIds: string[]): boolean {
|
|||||||
.all();
|
.all();
|
||||||
return found.length === unique.length;
|
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 { db } from "../client";
|
||||||
import { integrationEvents, integrations, userEpisodeWatches, userMovieWatches } from "../schema";
|
import { integrationEvents, integrations, userEpisodeWatches, userMovieWatches } from "../schema";
|
||||||
@@ -31,29 +31,23 @@ export function getRecentEpisodeWatch(userId: string, episodeId: string, since:
|
|||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function insertIntegrationEvent(values: typeof integrationEvents.$inferInsert): void {
|
export function insertIntegrationEventTransaction(
|
||||||
db.insert(integrationEvents).values(values).run();
|
values: typeof integrationEvents.$inferInsert,
|
||||||
}
|
connectionId: string,
|
||||||
|
): void {
|
||||||
export function updateIntegrationLastEvent(connectionId: string): void {
|
db.transaction((tx) => {
|
||||||
db.update(integrations)
|
tx.insert(integrationEvents).values(values).run();
|
||||||
.set({ lastEventAt: new Date() })
|
tx.update(integrations)
|
||||||
.where(eq(integrations.id, connectionId))
|
.set({ lastEventAt: new Date() })
|
||||||
.run();
|
.where(eq(integrations.id, connectionId))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteOldIntegrationEvents(beforeDate: Date): number {
|
export function deleteOldIntegrationEvents(beforeDate: Date): number {
|
||||||
const old = db
|
return db
|
||||||
.select({ id: integrationEvents.id })
|
.delete(integrationEvents)
|
||||||
.from(integrationEvents)
|
|
||||||
.where(lt(integrationEvents.receivedAt, beforeDate))
|
.where(lt(integrationEvents.receivedAt, beforeDate))
|
||||||
.all();
|
.returning({ id: integrationEvents.id })
|
||||||
if (old.length === 0) return 0;
|
.all().length;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-10
@@ -251,19 +251,25 @@ export const userRatings = sqliteTable(
|
|||||||
|
|
||||||
// ─── Platforms & Availability ────────────────────────────────────────
|
// ─── Platforms & Availability ────────────────────────────────────────
|
||||||
|
|
||||||
export const platforms = sqliteTable(
|
export const platforms = sqliteTable("platforms", {
|
||||||
"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(),
|
platformId: text("platformId")
|
||||||
name: text("name").notNull(),
|
.notNull()
|
||||||
tmdbProviderId: int("tmdbProviderId"),
|
.references(() => platforms.id, { onDelete: "cascade" }),
|
||||||
logoPath: text("logoPath"),
|
tmdbProviderId: int("tmdbProviderId").notNull(),
|
||||||
urlTemplate: text("urlTemplate"),
|
|
||||||
displayOrder: int("displayOrder").notNull().default(0),
|
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex("platforms_tmdbProviderId_unique").on(table.tmdbProviderId),
|
uniqueIndex("platformTmdbIds_tmdbProviderId_unique").on(table.tmdbProviderId),
|
||||||
index("platforms_displayOrder").on(table.displayOrder),
|
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 { db } from "./client";
|
||||||
import { platforms } from "./schema";
|
import platformData from "./platforms.json";
|
||||||
|
import { platformTmdbIds, platforms, titleAvailability, userPlatforms } from "./schema";
|
||||||
|
|
||||||
interface SeedPlatform {
|
const log = createLogger("seed-platforms");
|
||||||
tmdbProviderId: number;
|
|
||||||
|
export interface SeedPlatform {
|
||||||
|
tmdbProviderIds: number[];
|
||||||
name: string;
|
name: string;
|
||||||
|
logoPath: string | null;
|
||||||
urlTemplate: string;
|
urlTemplate: string;
|
||||||
displayOrder: number;
|
isSubscription: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEED_DATA: SeedPlatform[] = [
|
export const SEED_DATA: SeedPlatform[] = platformData;
|
||||||
// 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 function seedPlatforms(): void {
|
export function seedPlatforms(): void {
|
||||||
const insert = db
|
log.debug(`Seeding ${SEED_DATA.length} platforms`);
|
||||||
.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();
|
|
||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const p of SEED_DATA) {
|
const existingPlatforms = new Map(
|
||||||
insert.execute({
|
db
|
||||||
id: Bun.randomUUIDv7(),
|
.select()
|
||||||
tmdbProviderId: p.tmdbProviderId,
|
.from(platforms)
|
||||||
name: p.name,
|
.all()
|
||||||
urlTemplate: p.urlTemplate,
|
.map((p) => [p.id, p]),
|
||||||
displayOrder: p.displayOrder,
|
);
|
||||||
});
|
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★"
|
msgid "5★"
|
||||||
msgstr "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
|
#: apps/native/src/components/library/filter-sheet.tsx
|
||||||
msgid "80s"
|
msgid "80s"
|
||||||
msgstr "80s"
|
msgstr "80s"
|
||||||
@@ -284,7 +288,7 @@ msgid "90s"
|
|||||||
msgstr "90s"
|
msgstr "90s"
|
||||||
|
|
||||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
#: 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"
|
msgid "Account"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -451,8 +455,8 @@ msgid "Any year"
|
|||||||
msgstr "Any year"
|
msgstr "Any year"
|
||||||
|
|
||||||
#: apps/web/src/routes/_app/settings.tsx
|
#: apps/web/src/routes/_app/settings.tsx
|
||||||
msgid "App Settings"
|
#~ msgid "App Settings"
|
||||||
msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
@@ -540,8 +544,13 @@ msgid "backups."
|
|||||||
msgstr "backups."
|
msgstr "backups."
|
||||||
|
|
||||||
#: apps/web/src/components/titles/title-availability.tsx
|
#: apps/web/src/components/titles/title-availability.tsx
|
||||||
msgid "Buy"
|
#~ msgid "Buy"
|
||||||
msgstr ""
|
#~ 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
|
#: apps/web/src/components/settings/danger-section.tsx
|
||||||
msgid "Cache management"
|
msgid "Cache management"
|
||||||
@@ -661,8 +670,12 @@ msgstr "Choose how to import your {sourceLabel} data."
|
|||||||
#~ msgstr "Choose your preferred display language"
|
#~ msgstr "Choose your preferred display language"
|
||||||
|
|
||||||
#: apps/web/src/components/settings/streaming-services-section.tsx
|
#: apps/web/src/components/settings/streaming-services-section.tsx
|
||||||
msgid "Choose your subscriptions"
|
msgid "Choose your preferred streaming platforms"
|
||||||
msgstr "Choose your subscriptions"
|
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
|
||||||
#: 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."
|
msgstr "Finished importing your Sofa export."
|
||||||
|
|
||||||
#: apps/web/src/components/titles/title-availability.tsx
|
#: apps/web/src/components/titles/title-availability.tsx
|
||||||
msgid "Free"
|
#~ msgid "Free"
|
||||||
msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#: apps/web/src/components/settings/danger-section.tsx
|
#: apps/web/src/components/settings/danger-section.tsx
|
||||||
msgid "Free up disk space by clearing cached metadata and images"
|
msgid "Free up disk space by clearing cached metadata and images"
|
||||||
@@ -1573,7 +1586,7 @@ msgid "Image cache and backup disk usage"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/web/src/components/settings/account-section.tsx
|
#: 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"
|
msgid "Import"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1673,7 +1686,7 @@ msgid "Install the Webhook plugin from Jellyfin's plugin catalog"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/native/src/components/settings/integrations-section.tsx
|
#: 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"
|
msgid "Integrations"
|
||||||
msgstr ""
|
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/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
|
||||||
#: apps/web/src/components/library/library-filters.tsx
|
|
||||||
msgid "Pre-1980"
|
msgid "Pre-1980"
|
||||||
msgstr "Pre-1980"
|
msgstr "Pre-1980"
|
||||||
|
|
||||||
@@ -2653,8 +2665,8 @@ msgid "Removed from library"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/web/src/components/titles/title-availability.tsx
|
#: apps/web/src/components/titles/title-availability.tsx
|
||||||
msgid "Rent"
|
#~ msgid "Rent"
|
||||||
msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#: apps/web/src/routes/setup.tsx
|
#: apps/web/src/routes/setup.tsx
|
||||||
msgid "Request an API key"
|
msgid "Request an API key"
|
||||||
@@ -3114,6 +3126,7 @@ msgstr ""
|
|||||||
msgid "Storage"
|
msgid "Storage"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: apps/native/src/app/title/[id].tsx
|
||||||
#: apps/web/src/components/titles/title-availability.tsx
|
#: apps/web/src/components/titles/title-availability.tsx
|
||||||
msgid "Stream"
|
msgid "Stream"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -3123,8 +3136,16 @@ msgid "Streaming"
|
|||||||
msgstr "Streaming"
|
msgstr "Streaming"
|
||||||
|
|
||||||
#: apps/web/src/components/settings/streaming-services-section.tsx
|
#: apps/web/src/components/settings/streaming-services-section.tsx
|
||||||
msgid "Streaming Services"
|
msgid "Streaming data provided by <0/><1>JustWatch</1>"
|
||||||
msgstr "Streaming Services"
|
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
|
#: apps/web/src/components/settings/backup-schedule-section.tsx
|
||||||
msgid "Sunday"
|
msgid "Sunday"
|
||||||
@@ -3567,8 +3588,8 @@ msgstr ""
|
|||||||
|
|
||||||
#: apps/web/src/components/titles/title-availability.tsx
|
#: apps/web/src/components/titles/title-availability.tsx
|
||||||
#: apps/web/src/components/titles/title-availability.tsx
|
#: apps/web/src/components/titles/title-availability.tsx
|
||||||
msgid "Watch on {name}"
|
#~ msgid "Watch on {name}"
|
||||||
msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#: apps/native/src/components/titles/season-accordion.tsx
|
#: apps/native/src/components/titles/season-accordion.tsx
|
||||||
#: apps/native/src/lib/title-actions.ts
|
#: apps/native/src/lib/title-actions.ts
|
||||||
@@ -3651,8 +3672,8 @@ msgid "Where to Watch"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: apps/web/src/components/titles/title-availability.tsx
|
#: apps/web/src/components/titles/title-availability.tsx
|
||||||
msgid "With Ads"
|
#~ msgid "With Ads"
|
||||||
msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#: apps/native/src/app/person/[id].tsx
|
#: apps/native/src/app/person/[id].tsx
|
||||||
#: apps/native/src/components/search/recently-viewed-row-content.tsx
|
#: apps/native/src/components/search/recently-viewed-row-content.tsx
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const {
|
|||||||
userEpisodeWatches,
|
userEpisodeWatches,
|
||||||
userTitleStatus,
|
userTitleStatus,
|
||||||
userRatings,
|
userRatings,
|
||||||
|
platformTmdbIds,
|
||||||
platforms,
|
platforms,
|
||||||
titleAvailability,
|
titleAvailability,
|
||||||
userPlatforms,
|
userPlatforms,
|
||||||
@@ -178,9 +179,9 @@ export function insertPlatform(
|
|||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
tmdbProviderId?: number;
|
tmdbProviderId?: number;
|
||||||
|
tmdbProviderIds?: number[];
|
||||||
logoPath?: string;
|
logoPath?: string;
|
||||||
urlTemplate?: string;
|
urlTemplate?: string;
|
||||||
displayOrder?: number;
|
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const id = overrides.id ?? "platform-1";
|
const id = overrides.id ?? "platform-1";
|
||||||
@@ -189,12 +190,15 @@ export function insertPlatform(
|
|||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
name: overrides.name ?? "Netflix",
|
name: overrides.name ?? "Netflix",
|
||||||
tmdbProviderId: overrides.tmdbProviderId ?? 8,
|
|
||||||
logoPath: overrides.logoPath ?? "/logo.png",
|
logoPath: overrides.logoPath ?? "/logo.png",
|
||||||
urlTemplate: overrides.urlTemplate ?? "https://www.netflix.com/search?q={title}",
|
urlTemplate: overrides.urlTemplate ?? "https://www.netflix.com/search?q={title}",
|
||||||
displayOrder: overrides.displayOrder ?? 1,
|
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
const tmdbIds = overrides.tmdbProviderIds ?? [overrides.tmdbProviderId ?? 8];
|
||||||
|
for (const tmdbId of tmdbIds) {
|
||||||
|
testDb.insert(platformTmdbIds).values({ platformId: id, tmdbProviderId: tmdbId }).run();
|
||||||
|
}
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Pulls all watch providers from the TMDB API, updates logo paths for existing
|
||||||
|
* providers, and auto-appends any missing ones to platforms.json.
|
||||||
|
*
|
||||||
|
* Usage: bun scripts/sync-tmdb-providers.ts [--region US]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SeedPlatform } from "../packages/db/src/seed-platforms";
|
||||||
|
import { getWatchProviderList } from "../packages/tmdb/src/client";
|
||||||
|
|
||||||
|
const region = process.argv.includes("--region")
|
||||||
|
? (process.argv[process.argv.indexOf("--region") + 1] ?? "US")
|
||||||
|
: "US";
|
||||||
|
|
||||||
|
console.log(`Fetching TMDB watch providers for region: ${region}\n`);
|
||||||
|
|
||||||
|
const [movieProviders, tvProviders] = await Promise.all([
|
||||||
|
getWatchProviderList("movie", region),
|
||||||
|
getWatchProviderList("tv", region),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Union by provider_id, preferring movie entry for metadata
|
||||||
|
const allProviders = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
provider_id: number;
|
||||||
|
provider_name: string;
|
||||||
|
logo_path: string | null;
|
||||||
|
display_priorities: Record<string, number>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
for (const p of [...movieProviders, ...tvProviders]) {
|
||||||
|
if (!allProviders.has(p.provider_id)) {
|
||||||
|
allProviders.set(p.provider_id, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read current platforms JSON
|
||||||
|
const jsonPath = new URL("../packages/db/src/platforms.json", import.meta.url).pathname;
|
||||||
|
const seedData: SeedPlatform[] = await Bun.file(jsonPath).json();
|
||||||
|
|
||||||
|
// Build set of known TMDB IDs
|
||||||
|
const knownIds = new Set(seedData.flatMap((p) => p.tmdbProviderIds));
|
||||||
|
|
||||||
|
// Update logo paths for existing entries
|
||||||
|
let logosUpdated = 0;
|
||||||
|
for (const entry of seedData) {
|
||||||
|
const firstId = entry.tmdbProviderIds[0];
|
||||||
|
if (firstId == null) continue;
|
||||||
|
const tmdbProvider = allProviders.get(firstId);
|
||||||
|
if (!tmdbProvider?.logo_path) continue;
|
||||||
|
if (entry.logoPath !== tmdbProvider.logo_path) {
|
||||||
|
entry.logoPath = tmdbProvider.logo_path;
|
||||||
|
logosUpdated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Updated ${logosUpdated} logo paths for existing entries\n`);
|
||||||
|
|
||||||
|
// Find missing providers
|
||||||
|
const missing = [...allProviders.values()]
|
||||||
|
.filter((p) => !knownIds.has(p.provider_id))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aPriority = a.display_priorities?.[region] ?? 999;
|
||||||
|
const bPriority = b.display_priorities?.[region] ?? 999;
|
||||||
|
return aPriority - bPriority;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Total TMDB providers for ${region}: ${allProviders.size}`);
|
||||||
|
console.log(`Already in seed data: ${knownIds.size}`);
|
||||||
|
console.log(`Missing: ${missing.length}\n`);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
for (const p of missing) {
|
||||||
|
seedData.push({
|
||||||
|
tmdbProviderIds: [p.provider_id],
|
||||||
|
name: p.provider_name,
|
||||||
|
logoPath: p.logo_path,
|
||||||
|
urlTemplate: "",
|
||||||
|
isSubscription: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Added ${missing.length} new providers`);
|
||||||
|
console.log("\nTop 20 added:");
|
||||||
|
for (const p of missing.slice(0, 20)) {
|
||||||
|
const priority = p.display_priorities?.[region] ?? "?";
|
||||||
|
console.log(` [${p.provider_id}] ${p.provider_name} (priority: ${priority})`);
|
||||||
|
}
|
||||||
|
if (missing.length > 20) {
|
||||||
|
console.log(` ... and ${missing.length - 20} more`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Bun.write(jsonPath, JSON.stringify(seedData, null, 2) + "\n");
|
||||||
|
console.log(`\nWrote ${seedData.length} providers to platforms.json`);
|
||||||
Reference in New Issue
Block a user