Add @sofa/native Expo React Native app (#7)

This commit is contained in:
2026-03-12 19:39:54 -04:00
committed by GitHub
parent a326c968b7
commit 83839a0cd7
130 changed files with 9015 additions and 971 deletions
+2 -1
View File
@@ -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",
+90 -35
View File
@@ -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
View File
@@ -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>;
+50
View File
@@ -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 };
}
+2 -1
View File
@@ -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:*",
+3 -3
View File
@@ -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: {
+20 -2
View File
@@ -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;
+1 -1
View File
@@ -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;
+22
View File
@@ -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"]);
});
});
+2 -2
View File
@@ -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:"
}
}