mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
Add @sofa/native Expo React Native app (#7)
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
Reference in New Issue
Block a user