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
+21 -6
View File
@@ -13,7 +13,7 @@ import {
getCachedUpdateCheck,
isUpdateCheckEnabled,
} from "@sofa/core/update-check";
import { rescheduleBackup, triggerJob } from "../../cron";
import { rescheduleBackup, triggerJob as triggerCronJob } from "../../cron";
import { os } from "../context";
import { admin } from "../middleware";
@@ -35,7 +35,16 @@ export const backupsCreate = os.admin.backups.create
export const backupsDelete = os.admin.backups.delete
.use(admin)
.handler(async ({ input }) => {
await deleteBackup(input.filename);
try {
await deleteBackup(input.filename);
} catch (err) {
if (err instanceof ORPCError) throw err;
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("not found")) {
throw new ORPCError("NOT_FOUND", { message: msg });
}
throw new ORPCError("BAD_REQUEST", { message: msg });
}
});
export const backupsRestore = os.admin.backups.restore
@@ -54,7 +63,9 @@ export const backupsRestore = os.admin.backups.restore
// Clean up the upload file if restoreFromBackup didn't consume it
const f = Bun.file(tmpPath);
if (await f.exists()) await f.delete();
throw err;
if (err instanceof ORPCError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new ORPCError("BAD_REQUEST", { message: msg });
}
});
@@ -67,7 +78,11 @@ export const backupsSchedule = os.admin.backups.schedule
getSetting("maxBackupRetention") ?? "7",
10,
),
frequency: getSetting("backupScheduleFrequency") ?? "1d",
frequency: (getSetting("backupScheduleFrequency") ?? "1d") as
| "6h"
| "12h"
| "1d"
| "7d",
time: getSetting("backupScheduleTime") ?? "02:00",
dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10),
};
@@ -119,10 +134,10 @@ export const toggleUpdateCheck = os.admin.toggleUpdateCheck
// ─── Jobs ──────────────────────────────────────────────────────
export const triggerJobProcedure = os.admin.triggerJob
export const triggerJob = os.admin.triggerJob
.use(admin)
.handler(async ({ input }) => {
const triggered = await triggerJob(input.name);
const triggered = await triggerCronJob(input.name);
if (!triggered) {
throw new ORPCError("NOT_FOUND", { message: "Job not found" });
}
+17 -2
View File
@@ -3,11 +3,15 @@ import {
getNewAvailableFeed,
getRecommendationsFeed,
getUserStats,
getWatchCount,
getWatchHistory,
} from "@sofa/core/discovery";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
const watchHistoryTypeMap = { movie: "movies", episode: "episodes" } as const;
export const stats = os.dashboard.stats.use(authed).handler(({ context }) => {
return getUserStats(context.user.id);
});
@@ -46,7 +50,8 @@ export const library = os.dashboard.library
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
releaseDate: t.releaseDate ?? null,
firstAirDate: t.firstAirDate ?? null,
voteAverage: t.voteAverage,
userStatus: t.userStatus,
}));
@@ -66,8 +71,18 @@ export const recommendations = os.dashboard.recommendations
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
releaseDate: t.releaseDate ?? null,
firstAirDate: t.firstAirDate ?? null,
voteAverage: t.voteAverage,
}));
return { items };
});
export const watchHistory = os.dashboard.watchHistory
.use(authed)
.handler(({ input, context }) => {
const coreType = watchHistoryTypeMap[input.type];
const count = getWatchCount(context.user.id, coreType, input.period);
const history = getWatchHistory(context.user.id, coreType, input.period);
return { count, history };
});
+6 -5
View File
@@ -14,11 +14,11 @@ export const discover = os.discover
.handler(async ({ input, context }) => {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured.",
message: "TMDB API key is not configured",
});
}
const results = await discoverTmdb(input.mediaType, {
const results = await discoverTmdb(input.type, {
sort_by: "popularity.desc",
"vote_count.gte": "50",
with_genres: String(input.genreId),
@@ -35,11 +35,12 @@ export const discover = os.discover
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id,
type: input.mediaType,
type: input.type,
title: r.title ?? r.name ?? "",
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
releaseDate: r.release_date ?? r.first_air_date ?? null,
voteAverage: r.vote_average,
releaseDate: (r.release_date as string | undefined) ?? null,
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: r.vote_average ?? null,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
+7 -5
View File
@@ -12,7 +12,7 @@ import { authed } from "../middleware";
function requireTmdb() {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured.",
message: "TMDB API key is not configured",
});
}
}
@@ -40,8 +40,9 @@ export const trending = os.explore.trending
(r.poster_path as string) ?? null,
"posters",
),
releaseDate: ((r.release_date ?? r.first_air_date) as string) ?? null,
voteAverage: r.vote_average as number,
releaseDate: (r.release_date as string | undefined) ?? null,
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: (r.vote_average as number | undefined) ?? null,
};
});
@@ -89,8 +90,9 @@ export const popular = os.explore.popular
type: input.type,
title: ((r.title ?? r.name) as string) || "",
posterPath: tmdbImageUrl((r.poster_path as string) ?? null, "posters"),
releaseDate: ((r.release_date ?? r.first_air_date) as string) ?? null,
voteAverage: r.vote_average as number,
releaseDate: (r.release_date as string | undefined) ?? null,
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: (r.vote_average as number | undefined) ?? null,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
@@ -116,7 +116,7 @@ export const create = os.integrations.create
return serializeIntegration(row);
});
export const deleteProcedure = os.integrations.delete
export const deleteIntegration = os.integrations.delete
.use(authed)
.handler(({ input, context }) => {
db.delete(integrations)
+21 -15
View File
@@ -13,7 +13,7 @@ import { authed } from "../middleware";
export const search = os.search.use(authed).handler(async ({ input }) => {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured.",
message: "TMDB API key is not configured",
});
}
@@ -32,15 +32,16 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
title: r.name ?? "",
posterPath: null,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
overview: "",
overview: null,
releaseDate: null,
popularity: r.popularity,
voteAverage: 0,
knownForDepartment: r.known_for_department,
knownFor: r.known_for
?.slice(0, 3)
.map((k) => k.title ?? (k as { name?: string }).name)
.filter(Boolean) as string[] | undefined,
popularity: r.popularity ?? null,
voteAverage: null,
knownForDepartment: r.known_for_department ?? null,
knownFor:
(r.known_for
?.slice(0, 3)
.map((k) => k.title ?? (k as { name?: string }).name)
.filter((s): s is string => !!s) as string[]) ?? null,
})),
};
}
@@ -75,10 +76,12 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
title: r.name ?? "Unknown",
posterPath: null,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
overview: "",
overview: null,
releaseDate: null,
popularity: r.popularity ?? 0,
voteAverage: 0,
popularity: r.popularity ?? null,
voteAverage: null,
knownForDepartment: null,
knownFor: null,
};
}
@@ -90,11 +93,14 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
tmdbId: r.id,
type: mediaType,
title: r.title ?? r.name ?? "",
overview: r.overview ?? "",
overview: r.overview ?? null,
releaseDate: r.release_date ?? r.first_air_date ?? null,
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
popularity: r.popularity ?? 0,
voteAverage: r.vote_average ?? 0,
profilePath: null,
popularity: r.popularity ?? null,
voteAverage: r.vote_average ?? null,
knownForDepartment: null,
knownFor: null,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
-9
View File
@@ -1,9 +0,0 @@
import { getWatchCount, getWatchHistory } from "@sofa/core/discovery";
import { os } from "../context";
import { authed } from "../middleware";
export const stats = os.stats.use(authed).handler(({ input, context }) => {
const count = getWatchCount(context.user.id, input.type, input.period);
const history = getWatchHistory(context.user.id, input.type, input.period);
return { count, history };
});
+7 -12
View File
@@ -1,17 +1,12 @@
import { getSystemHealth } from "@sofa/core/system-health";
import { isTmdbConfigured } from "@sofa/tmdb/config";
import { os } from "../context";
import { authed } from "../middleware";
import { admin, authed } from "../middleware";
export const systemStatus = os.systemStatus
.use(authed)
.handler(async ({ context }) => {
const tmdbConfigured = isTmdbConfigured();
export const status = os.system.status.use(authed).handler(() => {
return { tmdbConfigured: isTmdbConfigured() };
});
if (context.user.role === "admin") {
const health = await getSystemHealth();
return { tmdbConfigured, health };
}
return { tmdbConfigured };
});
export const health = os.system.health.use(admin).handler(async () => {
return await getSystemHealth();
});
+31
View File
@@ -14,6 +14,9 @@ import {
removeTitleStatus,
setTitleStatus,
} from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { userTitleStatus } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
@@ -86,3 +89,31 @@ export const hydrateSeasons = os.titles.hydrateSeasons
const seasons = await ensureTvHydrated(input.id, input.tmdbId);
return { seasons };
});
export const quickAdd = os.titles.quickAdd
.use(authed)
.handler(async ({ input, context }) => {
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
if (!title) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Failed to import title",
});
}
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, context.user.id),
eq(userTitleStatus.titleId, title.id),
),
)
.get();
if (!existing) {
setTitleStatus(context.user.id, title.id, "watchlist");
}
return { id: title.id, alreadyAdded: !!existing };
});
@@ -1,36 +0,0 @@
import { ORPCError } from "@orpc/server";
import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import { setTitleStatus } from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { userTitleStatus } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
export const quickAdd = os.watchlist.quickAdd
.use(authed)
.handler(async ({ input, context }) => {
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
if (!title) {
throw new ORPCError("BAD_GATEWAY", {
message: "Failed to import title",
});
}
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, context.user.id),
eq(userTitleStatus.titleId, title.id),
),
)
.get();
if (!existing) {
setTitleStatus(context.user.id, title.id, "watchlist");
}
return { id: title.id, alreadyAdded: !!existing };
});