mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
Add @sofa/native Expo React Native app (#7)
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./contract": "./src/contract.ts",
|
||||
"./schemas": "./src/schemas.ts"
|
||||
"./schemas": "./src/schemas.ts",
|
||||
"./utils": "./src/utils.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check",
|
||||
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
DiscoverOutput,
|
||||
FilenameParam,
|
||||
GenresOutput,
|
||||
HydrateSeasonsInput,
|
||||
HydrateSeasonsOutput,
|
||||
IdParam,
|
||||
IntegrationOutput,
|
||||
IntegrationsListOutput,
|
||||
IntegrationTokenOutput,
|
||||
LibraryOutput,
|
||||
MediaTypeParam,
|
||||
PersonDetailOutput,
|
||||
@@ -31,12 +31,12 @@ import {
|
||||
RestoreBackupInput,
|
||||
SearchInput,
|
||||
SearchOutput,
|
||||
StatsInput,
|
||||
StatsOutput,
|
||||
SystemHealthOutput,
|
||||
SystemStatusOutput,
|
||||
TitleDetailOutput,
|
||||
TitleRecommendationsOutput,
|
||||
TitleResolveOutput,
|
||||
TmdbIdParam,
|
||||
TmdbIdTypeParam,
|
||||
ToggleRegistrationInput,
|
||||
ToggleUpdateCheckInput,
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
UploadAvatarInput,
|
||||
UploadAvatarOutput,
|
||||
UserInfoOutput,
|
||||
WatchHistoryInput,
|
||||
WatchHistoryOutput,
|
||||
} from "./schemas";
|
||||
|
||||
export const contract = {
|
||||
@@ -65,15 +67,27 @@ export const contract = {
|
||||
.input(TmdbIdTypeParam)
|
||||
.output(TitleResolveOutput),
|
||||
updateStatus: oc
|
||||
.route({ method: "PUT", path: "/titles/{id}/status", tags: ["Titles"] })
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/titles/{id}/status",
|
||||
tags: ["Titles"],
|
||||
})
|
||||
.input(UpdateStatusInput)
|
||||
.output(z.void()),
|
||||
updateRating: oc
|
||||
.route({ method: "PUT", path: "/titles/{id}/rating", tags: ["Titles"] })
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/titles/{id}/rating",
|
||||
tags: ["Titles"],
|
||||
})
|
||||
.input(UpdateRatingInput)
|
||||
.output(z.void()),
|
||||
watchMovie: oc
|
||||
.route({ method: "POST", path: "/titles/{id}/watch", tags: ["Titles"] })
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/titles/{id}/watch",
|
||||
tags: ["Titles"],
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
watchAll: oc
|
||||
@@ -106,8 +120,16 @@ export const contract = {
|
||||
path: "/titles/{id}/hydrate-seasons",
|
||||
tags: ["Titles"],
|
||||
})
|
||||
.input(z.object({ id: z.string(), tmdbId: z.number().int() }))
|
||||
.input(HydrateSeasonsInput)
|
||||
.output(HydrateSeasonsOutput),
|
||||
quickAdd: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/titles/quick-add",
|
||||
tags: ["Titles"],
|
||||
})
|
||||
.input(TmdbIdTypeParam)
|
||||
.output(QuickAddOutput),
|
||||
},
|
||||
episodes: {
|
||||
watch: oc
|
||||
@@ -160,12 +182,16 @@ export const contract = {
|
||||
.output(PersonDetailOutput),
|
||||
resolve: oc
|
||||
.route({ method: "POST", path: "/people/resolve", tags: ["People"] })
|
||||
.input(z.object({ tmdbId: z.number().int() }))
|
||||
.input(TmdbIdParam)
|
||||
.output(PersonResolveOutput),
|
||||
},
|
||||
dashboard: {
|
||||
stats: oc
|
||||
.route({ method: "GET", path: "/dashboard/stats", tags: ["Dashboard"] })
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/stats",
|
||||
tags: ["Dashboard"],
|
||||
})
|
||||
.output(DashboardStatsOutput),
|
||||
continueWatching: oc
|
||||
.route({
|
||||
@@ -188,18 +214,38 @@ export const contract = {
|
||||
tags: ["Dashboard"],
|
||||
})
|
||||
.output(DashboardRecommendationsOutput),
|
||||
watchHistory: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/watch-history",
|
||||
tags: ["Dashboard"],
|
||||
})
|
||||
.input(WatchHistoryInput)
|
||||
.output(WatchHistoryOutput),
|
||||
},
|
||||
explore: {
|
||||
trending: oc
|
||||
.route({ method: "GET", path: "/explore/trending", tags: ["Explore"] })
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/explore/trending",
|
||||
tags: ["Explore"],
|
||||
})
|
||||
.input(TrendingTypeParam)
|
||||
.output(TrendingOutput),
|
||||
popular: oc
|
||||
.route({ method: "GET", path: "/explore/popular", tags: ["Explore"] })
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/explore/popular",
|
||||
tags: ["Explore"],
|
||||
})
|
||||
.input(MediaTypeParam)
|
||||
.output(PopularOutput),
|
||||
genres: oc
|
||||
.route({ method: "GET", path: "/explore/genres", tags: ["Explore"] })
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/explore/genres",
|
||||
tags: ["Explore"],
|
||||
})
|
||||
.input(MediaTypeParam)
|
||||
.output(GenresOutput),
|
||||
},
|
||||
@@ -211,27 +257,42 @@ export const contract = {
|
||||
.route({ method: "GET", path: "/discover", tags: ["Discover"] })
|
||||
.input(DiscoverInput)
|
||||
.output(DiscoverOutput),
|
||||
stats: oc
|
||||
.route({ method: "GET", path: "/stats", tags: ["Stats"] })
|
||||
.input(StatsInput)
|
||||
.output(StatsOutput),
|
||||
systemStatus: oc
|
||||
.route({ method: "GET", path: "/system-status", tags: ["System"] })
|
||||
.output(SystemStatusOutput),
|
||||
system: {
|
||||
publicInfo: oc
|
||||
.route({ method: "GET", path: "/system/public-info", tags: ["System"] })
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/system/public-info",
|
||||
tags: ["System"],
|
||||
})
|
||||
.output(PublicInfoOutput),
|
||||
authConfig: oc
|
||||
.route({ method: "GET", path: "/system/auth-config", tags: ["System"] })
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/system/auth-config",
|
||||
tags: ["System"],
|
||||
})
|
||||
.output(AuthConfigOutput),
|
||||
status: oc
|
||||
.route({ method: "GET", path: "/system/status", tags: ["System"] })
|
||||
.output(SystemStatusOutput),
|
||||
health: oc
|
||||
.route({ method: "GET", path: "/system/health", tags: ["System"] })
|
||||
.output(SystemHealthOutput),
|
||||
},
|
||||
integrations: {
|
||||
list: oc
|
||||
.route({ method: "GET", path: "/integrations", tags: ["Integrations"] })
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/integrations",
|
||||
tags: ["Integrations"],
|
||||
})
|
||||
.output(IntegrationsListOutput),
|
||||
create: oc
|
||||
.route({ method: "POST", path: "/integrations", tags: ["Integrations"] })
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/integrations",
|
||||
tags: ["Integrations"],
|
||||
})
|
||||
.input(CreateIntegrationInput)
|
||||
.output(IntegrationOutput),
|
||||
delete: oc
|
||||
@@ -249,7 +310,7 @@ export const contract = {
|
||||
tags: ["Integrations"],
|
||||
})
|
||||
.input(ProviderParam)
|
||||
.output(IntegrationTokenOutput),
|
||||
.output(IntegrationOutput),
|
||||
},
|
||||
admin: {
|
||||
backups: {
|
||||
@@ -325,18 +386,12 @@ export const contract = {
|
||||
.input(UploadAvatarInput)
|
||||
.output(UploadAvatarOutput),
|
||||
removeAvatar: oc
|
||||
.route({ method: "DELETE", path: "/account/avatar", tags: ["Account"] })
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/account/avatar",
|
||||
tags: ["Account"],
|
||||
})
|
||||
.input(z.void())
|
||||
.output(z.void()),
|
||||
},
|
||||
watchlist: {
|
||||
quickAdd: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/watchlist/quick-add",
|
||||
tags: ["Watchlist"],
|
||||
})
|
||||
.input(TmdbIdTypeParam)
|
||||
.output(QuickAddOutput),
|
||||
},
|
||||
};
|
||||
|
||||
+89
-97
@@ -2,11 +2,12 @@ import { z } from "zod";
|
||||
|
||||
// ─── Shared input schemas ─────────────────────────────────────
|
||||
|
||||
export const IdParam = z.object({ id: z.string() });
|
||||
export const IdParam = z.object({ id: z.string().min(1) });
|
||||
export const ProviderParam = z.object({
|
||||
provider: z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]),
|
||||
});
|
||||
export const FilenameParam = z.object({ filename: z.string() });
|
||||
export const TmdbIdParam = z.object({ tmdbId: z.number().int() });
|
||||
export const FilenameParam = z.object({ filename: z.string().min(1) });
|
||||
export const MediaTypeParam = z.object({
|
||||
type: z.enum(["movie", "tv"]),
|
||||
});
|
||||
@@ -21,12 +22,12 @@ export const TmdbIdTypeParam = z.object({
|
||||
// ─── Title inputs ──────────────────────────────────────────────
|
||||
|
||||
export const UpdateStatusInput = z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["in_progress"]).nullable(),
|
||||
id: z.string().min(1),
|
||||
status: z.enum(["in_progress", "completed"]).nullable(),
|
||||
});
|
||||
|
||||
export const UpdateRatingInput = z.object({
|
||||
id: z.string(),
|
||||
id: z.string().min(1),
|
||||
stars: z.number().int().min(0).max(5),
|
||||
});
|
||||
|
||||
@@ -34,22 +35,27 @@ export const BatchWatchInput = z.object({
|
||||
episodeIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export const HydrateSeasonsInput = z.object({
|
||||
id: z.string().min(1),
|
||||
tmdbId: z.number().int(),
|
||||
});
|
||||
|
||||
// ─── Search / Discover inputs ──────────────────────────────────
|
||||
|
||||
export const SearchInput = z.object({
|
||||
query: z.string().min(1),
|
||||
query: z.string().min(1).max(200),
|
||||
type: z.enum(["movie", "tv", "person"]).optional(),
|
||||
});
|
||||
|
||||
export const DiscoverInput = z.object({
|
||||
mediaType: z.enum(["movie", "tv"]),
|
||||
type: z.enum(["movie", "tv"]),
|
||||
genreId: z.number().int(),
|
||||
});
|
||||
|
||||
// ─── Stats input ───────────────────────────────────────────────
|
||||
// ─── Watch history input ──────────────────────────────────────
|
||||
|
||||
export const StatsInput = z.object({
|
||||
type: z.enum(["movies", "episodes"]),
|
||||
export const WatchHistoryInput = z.object({
|
||||
type: z.enum(["movie", "episode"]),
|
||||
period: z.enum(["today", "this_week", "this_month", "this_year"]),
|
||||
});
|
||||
|
||||
@@ -64,11 +70,25 @@ export const CreateIntegrationInput = z.object({
|
||||
|
||||
export const ToggleRegistrationInput = z.object({ open: z.boolean() });
|
||||
export const ToggleUpdateCheckInput = z.object({ enabled: z.boolean() });
|
||||
export const TriggerJobInput = z.object({ name: z.string() });
|
||||
|
||||
const cronJobName = z.enum([
|
||||
"scheduledBackup",
|
||||
"nightlyRefreshLibrary",
|
||||
"refreshAvailability",
|
||||
"refreshRecommendations",
|
||||
"refreshTvChildren",
|
||||
"cacheImages",
|
||||
"refreshCredits",
|
||||
"updateCheck",
|
||||
]);
|
||||
|
||||
export const TriggerJobInput = z.object({ name: cronJobName });
|
||||
|
||||
const backupFrequency = z.enum(["6h", "12h", "1d", "7d"]);
|
||||
|
||||
export const UpdateScheduleInput = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
frequency: z.enum(["6h", "12h", "1d", "7d"]).optional(),
|
||||
frequency: backupFrequency.optional(),
|
||||
time: z
|
||||
.string()
|
||||
.regex(/^\d{2}:\d{2}$/, "Invalid time format")
|
||||
@@ -217,7 +237,20 @@ const TmdbBrowseItem = z.object({
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
voteAverage: z.number(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
});
|
||||
|
||||
/** Recommendation item (shared by title and dashboard recommendations) */
|
||||
const RecommendationItemSchema = z.object({
|
||||
id: z.string(),
|
||||
tmdbId: z.number(),
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
});
|
||||
|
||||
const userStatusMap = z.record(
|
||||
@@ -255,18 +288,7 @@ export const UserInfoOutput = z.object({
|
||||
});
|
||||
|
||||
export const TitleRecommendationsOutput = z.object({
|
||||
recommendations: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tmdbId: z.number(),
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
}),
|
||||
),
|
||||
recommendations: z.array(RecommendationItemSchema),
|
||||
userStatuses: userStatusMap,
|
||||
});
|
||||
|
||||
@@ -320,6 +342,7 @@ export const LibraryOutput = z.object({
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
userStatus: z.enum(["watchlist", "in_progress", "completed"]).nullable(),
|
||||
}),
|
||||
@@ -327,17 +350,7 @@ export const LibraryOutput = z.object({
|
||||
});
|
||||
|
||||
export const DashboardRecommendationsOutput = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tmdbId: z.number(),
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
}),
|
||||
),
|
||||
items: z.array(RecommendationItemSchema),
|
||||
});
|
||||
|
||||
// ─── Explore outputs ───────────────────────────────────────────
|
||||
@@ -372,14 +385,14 @@ export const SearchOutput = z.object({
|
||||
tmdbId: z.number(),
|
||||
type: z.enum(["movie", "tv", "person"]),
|
||||
title: z.string(),
|
||||
overview: z.string().optional(),
|
||||
posterPath: z.string().nullable().optional(),
|
||||
profilePath: z.string().nullable().optional(),
|
||||
releaseDate: z.string().nullable().optional(),
|
||||
popularity: z.number().optional(),
|
||||
voteAverage: z.number().optional(),
|
||||
knownForDepartment: z.string().optional(),
|
||||
knownFor: z.array(z.string()).optional(),
|
||||
overview: z.string().nullable(),
|
||||
posterPath: z.string().nullable(),
|
||||
profilePath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
popularity: z.number().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
knownForDepartment: z.string().nullable(),
|
||||
knownFor: z.array(z.string()).nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -388,11 +401,16 @@ export const SearchOutput = z.object({
|
||||
|
||||
export const DiscoverOutput = BrowseOutput;
|
||||
|
||||
// ─── Stats output ──────────────────────────────────────────────
|
||||
// ─── Watch history output ──────────────────────────────────────
|
||||
|
||||
export const StatsOutput = z.object({
|
||||
const HistoryBucketSchema = z.object({
|
||||
bucket: z.string(),
|
||||
count: z.number(),
|
||||
history: z.array(z.object({ bucket: z.string(), count: z.number() })),
|
||||
});
|
||||
|
||||
export const WatchHistoryOutput = z.object({
|
||||
count: z.number(),
|
||||
history: z.array(HistoryBucketSchema),
|
||||
});
|
||||
|
||||
// ─── System status output ──────────────────────────────────────
|
||||
@@ -452,9 +470,10 @@ const SystemHealthSchema = z.object({
|
||||
|
||||
export const SystemStatusOutput = z.object({
|
||||
tmdbConfigured: z.boolean(),
|
||||
health: SystemHealthSchema.optional(),
|
||||
});
|
||||
|
||||
export const SystemHealthOutput = SystemHealthSchema;
|
||||
|
||||
// ─── Integration outputs ───────────────────────────────────────
|
||||
|
||||
const IntegrationSchema = z.object({
|
||||
@@ -486,11 +505,9 @@ export const IntegrationsListOutput = z.object({
|
||||
|
||||
export const IntegrationOutput = IntegrationSchema;
|
||||
|
||||
export const IntegrationTokenOutput = IntegrationSchema;
|
||||
|
||||
// ─── Admin outputs ─────────────────────────────────────────────
|
||||
|
||||
const BackupSchema = z.object({
|
||||
export const BackupSchema = z.object({
|
||||
filename: z.string(),
|
||||
sizeBytes: z.number(),
|
||||
createdAt: z.string(),
|
||||
@@ -506,36 +523,36 @@ export const BackupCreateOutput = BackupSchema;
|
||||
export const BackupScheduleOutput = z.object({
|
||||
enabled: z.boolean(),
|
||||
maxRetention: z.number(),
|
||||
frequency: z.string(),
|
||||
frequency: backupFrequency,
|
||||
time: z.string(),
|
||||
dayOfWeek: z.number(),
|
||||
});
|
||||
|
||||
export const RegistrationOutput = z.object({ open: z.boolean() });
|
||||
|
||||
const UpdateCheckResultSchema = z.object({
|
||||
updateAvailable: z.boolean(),
|
||||
currentVersion: z.string(),
|
||||
latestVersion: z.string().nullable(),
|
||||
releaseUrl: z.string().nullable(),
|
||||
lastCheckedAt: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const UpdateCheckOutput = z.object({
|
||||
enabled: z.boolean(),
|
||||
updateCheck: z
|
||||
.object({
|
||||
updateAvailable: z.boolean(),
|
||||
currentVersion: z.string(),
|
||||
latestVersion: z.string().nullable(),
|
||||
releaseUrl: z.string().nullable(),
|
||||
lastCheckedAt: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
updateCheck: UpdateCheckResultSchema.nullable(),
|
||||
});
|
||||
|
||||
export const TriggerJobOutput = z.object({ ok: z.literal(true) });
|
||||
|
||||
// ─── Watchlist outputs ─────────────────────────────────────────
|
||||
// ─── Quick add output ──────────────────────────────────────────
|
||||
|
||||
export const QuickAddOutput = z.object({
|
||||
id: z.string(),
|
||||
alreadyAdded: z.boolean(),
|
||||
});
|
||||
|
||||
// ─── System outputs (new) ─────────────────────────────────────
|
||||
// ─── System outputs ───────────────────────────────────────────
|
||||
|
||||
export const PublicInfoOutput = z.object({
|
||||
tmdbConfigured: z.boolean(),
|
||||
@@ -558,19 +575,6 @@ export const HydrateSeasonsOutput = z.object({
|
||||
seasons: z.array(SeasonSchema),
|
||||
});
|
||||
|
||||
// ─── Types used by web app (moved from services) ─────────────
|
||||
|
||||
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
|
||||
|
||||
export type BackupInfo = {
|
||||
filename: string;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
source: "manual" | "scheduled" | "pre-restore";
|
||||
};
|
||||
|
||||
export type SystemHealthData = z.infer<typeof SystemHealthSchema>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Inferred types — use these instead of hand-written interfaces
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -583,25 +587,13 @@ export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
export type ResolvedTitle = z.infer<typeof ResolvedTitleSchema>;
|
||||
export type ResolvedPerson = z.infer<typeof PersonSchema>;
|
||||
export type PersonCredit = z.infer<typeof PersonCreditSchema>;
|
||||
export type RecommendationItem = z.infer<typeof RecommendationItemSchema>;
|
||||
|
||||
export type UpdateCheckResult = {
|
||||
updateAvailable: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string | null;
|
||||
releaseUrl: string | null;
|
||||
lastCheckedAt: string | null;
|
||||
};
|
||||
|
||||
export type TimePeriod = "today" | "this_week" | "this_month" | "this_year";
|
||||
|
||||
export interface HistoryBucket {
|
||||
bucket: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
moviesThisMonth: number;
|
||||
episodesThisWeek: number;
|
||||
librarySize: number;
|
||||
completed: number;
|
||||
}
|
||||
export type BackupFrequency = z.infer<typeof backupFrequency>;
|
||||
export type BackupInfo = z.infer<typeof BackupSchema>;
|
||||
export type SystemHealthData = z.infer<typeof SystemHealthSchema>;
|
||||
export type UpdateCheckResult = z.infer<typeof UpdateCheckResultSchema>;
|
||||
export type TimePeriod = z.infer<typeof WatchHistoryInput>["period"];
|
||||
export type HistoryBucket = z.infer<typeof HistoryBucketSchema>;
|
||||
export type DashboardStats = z.infer<typeof DashboardStatsOutput>;
|
||||
export type CronJobName = z.infer<typeof cronJobName>;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Season } from "./schemas";
|
||||
|
||||
interface NextEpisodeInfo {
|
||||
id: string;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
stillPath: string | null;
|
||||
}
|
||||
|
||||
export interface NextEpisodeResult {
|
||||
nextEpisode: NextEpisodeInfo | null;
|
||||
totalEpisodes: number;
|
||||
watchedEpisodes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next unwatched aired episode from seasons + watch history.
|
||||
* Mirrors the server-side logic in `getContinueWatchingFeed`.
|
||||
*/
|
||||
export function getNextEpisode(
|
||||
seasons: Season[],
|
||||
watchedEpisodeIds: Set<string>,
|
||||
): NextEpisodeResult {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let nextEpisode: NextEpisodeInfo | null = null;
|
||||
let totalEpisodes = 0;
|
||||
let watchedEpisodes = 0;
|
||||
|
||||
for (const season of seasons) {
|
||||
for (const ep of season.episodes) {
|
||||
totalEpisodes++;
|
||||
if (watchedEpisodeIds.has(ep.id)) {
|
||||
watchedEpisodes++;
|
||||
} else if (!nextEpisode) {
|
||||
// Skip episodes not yet aired
|
||||
if (ep.airDate && ep.airDate > today) continue;
|
||||
nextEpisode = {
|
||||
id: ep.id,
|
||||
seasonNumber: season.seasonNumber,
|
||||
episodeNumber: ep.episodeNumber,
|
||||
name: ep.name,
|
||||
stillPath: ep.stillPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nextEpisode, totalEpisodes, watchedEpisodes };
|
||||
}
|
||||
@@ -13,7 +13,8 @@
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/drizzle-adapter": "1.5.4",
|
||||
"@better-auth/drizzle-adapter": "1.5.5",
|
||||
"@better-auth/expo": "catalog:",
|
||||
"@sofa/core": "workspace:*",
|
||||
"@sofa/db": "workspace:*",
|
||||
"@sofa/logger": "workspace:*",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
||||
import { expo } from "@better-auth/expo";
|
||||
import {
|
||||
getUserCount,
|
||||
isRegistrationOpen,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
const authLog = createLogger("auth");
|
||||
|
||||
export const auth = betterAuth({
|
||||
trustedOrigins: ["sofa://"],
|
||||
logger: {
|
||||
// Suppress unset secret/low entropy warnings during build
|
||||
disabled: process.env.NEXT_PHASE === "phase-production-build",
|
||||
@@ -69,9 +71,7 @@ export const auth = betterAuth({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// NOTE: nextCookies() removed — Better Auth works with standard
|
||||
// Request/Response natively on Hono. The web app proxies auth
|
||||
// requests to the API server.
|
||||
expo(),
|
||||
],
|
||||
advanced: {
|
||||
database: {
|
||||
|
||||
@@ -487,8 +487,26 @@ export function getRecommendationsForTitle(titleId: string) {
|
||||
|
||||
if (recs.length === 0) return [];
|
||||
|
||||
const sourcePriority = {
|
||||
tmdb_recommendations: 0,
|
||||
tmdb_similar: 1,
|
||||
} as const;
|
||||
const orderedRecs = [...recs].sort(
|
||||
(a, b) =>
|
||||
a.rank - b.rank || sourcePriority[a.source] - sourcePriority[b.source],
|
||||
);
|
||||
|
||||
const seenRecommendedTitleIds = new Set<string>();
|
||||
const uniqueRecs = orderedRecs.filter((rec) => {
|
||||
if (seenRecommendedTitleIds.has(rec.recommendedTitleId)) {
|
||||
return false;
|
||||
}
|
||||
seenRecommendedTitleIds.add(rec.recommendedTitleId);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Batch fetch all recommended titles (1 query)
|
||||
const recTitleIds = recs.map((r) => r.recommendedTitleId);
|
||||
const recTitleIds = uniqueRecs.map((r) => r.recommendedTitleId);
|
||||
const recTitles = db
|
||||
.select()
|
||||
.from(titles)
|
||||
@@ -496,7 +514,7 @@ export function getRecommendationsForTitle(titleId: string) {
|
||||
.all();
|
||||
const recTitleMap = new Map(recTitles.map((t) => [t.id, t]));
|
||||
|
||||
return recs
|
||||
return uniqueRecs
|
||||
.map((rec) => {
|
||||
const r = recTitleMap.get(rec.recommendedTitleId);
|
||||
if (!r) return null;
|
||||
|
||||
@@ -213,7 +213,7 @@ async function getImageCacheHealth(): Promise<SystemHealthData["imageCache"]> {
|
||||
return { enabled: false, totalSizeBytes: 0, imageCount: 0, categories: {} };
|
||||
}
|
||||
|
||||
const categoryNames = ["posters", "backdrops", "stills", "logos"];
|
||||
const categoryNames = ["posters", "backdrops", "stills", "logos", "profiles"];
|
||||
const categories: Record<string, { count: number; sizeBytes: number }> = {};
|
||||
let totalSizeBytes = 0;
|
||||
let imageCount = 0;
|
||||
|
||||
@@ -336,4 +336,26 @@ describe("getRecommendationsForTitle", () => {
|
||||
const recs = getRecommendationsForTitle("m1");
|
||||
expect(recs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("deduplicates titles returned by multiple recommendation sources", () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertTitle({ id: "rec1", tmdbId: 10, title: "Rec One" });
|
||||
insertTitle({ id: "rec2", tmdbId: 20, title: "Rec Two" });
|
||||
insertRecommendation("m1", "rec1", {
|
||||
source: "tmdb_similar",
|
||||
rank: 1,
|
||||
});
|
||||
insertRecommendation("m1", "rec1", {
|
||||
source: "tmdb_recommendations",
|
||||
rank: 2,
|
||||
});
|
||||
insertRecommendation("m1", "rec2", {
|
||||
source: "tmdb_recommendations",
|
||||
rank: 3,
|
||||
});
|
||||
|
||||
const recs = getRecommendationsForTitle("m1");
|
||||
expect(recs).toHaveLength(2);
|
||||
expect(recs.map((rec) => rec.id)).toEqual(["rec1", "rec2"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
"dependencies": {
|
||||
"@sofa/config": "workspace:*",
|
||||
"@sofa/logger": "workspace:*",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6"
|
||||
"drizzle-orm": "1.0.0-beta.17-67b1795"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-kit": "1.0.0-beta.17-67b1795",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user