mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
refactor: organize API around operation domains (#24)
* feat: reorganize API around operation domains
Restructure the oRPC contract from 14 resource-oriented routers to 8
domain-oriented routers for a cleaner public API surface.
- Consolidate 7 watch procedures into unified tracking.watch/unwatch
with scope + ids input (movie, episode, season, series)
- Split dashboard across tracking (stats, history), library
(continueWatching, upcoming), and discover (recommendations)
- Merge explore + search + discover into single discover router
- Absorb integrations into account.integrations
- Merge system.authConfig into system.publicInfo
- Collapse 6 admin setting endpoints into admin.settings.get/update
- Deduplicate platforms.list + explore.watchProviders into
discover.platforms
- Add symmetric unwatchMovie/unwatchSeries core functions
- Rename titles.detail→get, titles.recommendations→similar,
people.detail→get
BREAKING CHANGE: All client API paths have changed. REST paths now
mirror router structure.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review feedback on API reorganization
- Fix updateRating invalidation in native app — was only invalidating
title queries, now calls invalidateTitleQueries() to also refresh
tracking.userInfo (drives the rating UI)
- Make unwatchMovie status revert consistent with unwatchSeries — revert
any non-watchlist status, not just "completed"
- Add sync invariant comment on handleWatch/handleUnwatch loops
- Remove unused queryClient import in native use-title-actions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: updateStatus NOT_FOUND error + rate() invalidation in native
- updateStatus now throws NOT_FOUND when quickAddTitle returns null
(title doesn't exist), instead of silently succeeding
- Add NOT_FOUND error to updateStatus contract definition
- Fix rate() in native title-actions.ts to call invalidateTitleQueries()
instead of only invalidating orpc.titles.key() — mirrors the fix
already applied to the hook-based path in d2ddc0f
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: expand mobile app docs and add Play Store badge
- Add the Google Play badge to the README alongside the App Store badge
- Expand the mobile app docs with the Android/Play Store release details
- Refresh docs site dependencies and related package versions for the updated docs stack
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+314
-453
@@ -3,18 +3,17 @@ import { z } from "zod";
|
||||
|
||||
import { AppErrorCode, appErrorData } from "./errors";
|
||||
import {
|
||||
AuthConfigOutput,
|
||||
AdminSettingsOutput,
|
||||
AdminSettingsUpdateInput,
|
||||
BackupCreateOutput,
|
||||
BackupScheduleOutput,
|
||||
BackupsListOutput,
|
||||
BatchWatchInput,
|
||||
ContinueWatchingOutput,
|
||||
CreateImportJobInput,
|
||||
CreateIntegrationInput,
|
||||
DashboardRecommendationsOutput,
|
||||
DashboardStatsOutput,
|
||||
DiscoverInput,
|
||||
DiscoverOutput,
|
||||
DiscoverRecommendationsOutput,
|
||||
FilenameParam,
|
||||
GenresOutput,
|
||||
IdParam,
|
||||
@@ -26,41 +25,36 @@ import {
|
||||
LibraryGenresOutput,
|
||||
LibraryListInput,
|
||||
LibraryListOutput,
|
||||
LibraryStatsOutput,
|
||||
MediaTypeParam,
|
||||
PageParam,
|
||||
PaginatedInput,
|
||||
ParseFileInput,
|
||||
PlatformsListOutput,
|
||||
ParsePayloadInput,
|
||||
PersonDetailOutput,
|
||||
PlatformsListOutput,
|
||||
PopularOutput,
|
||||
ProviderParam,
|
||||
PublicInfoOutput,
|
||||
PurgeImageCacheOutput,
|
||||
PurgeMetadataCacheOutput,
|
||||
QuickAddOutput,
|
||||
RegistrationOutput,
|
||||
RestoreBackupInput,
|
||||
SearchInput,
|
||||
SearchOutput,
|
||||
SystemHealthOutput,
|
||||
SystemStatusOutput,
|
||||
TelemetryOutput,
|
||||
TitleDetailOutput,
|
||||
TitleRecommendationsOutput,
|
||||
ToggleRegistrationInput,
|
||||
ToggleTelemetryInput,
|
||||
ToggleUpdateCheckInput,
|
||||
TrendingOutput,
|
||||
TrendingTypeParam,
|
||||
TriggerJobInput,
|
||||
TriggerJobOutput,
|
||||
UpdateCheckOutput,
|
||||
UpdateUserPlatformsInput,
|
||||
UnwatchInput,
|
||||
UpdateNameInput,
|
||||
UpdateRatingInput,
|
||||
UpdateScheduleInput,
|
||||
UpdateStatusInput,
|
||||
UpdateUserPlatformsInput,
|
||||
UpcomingInput,
|
||||
UpcomingOutput,
|
||||
UploadAvatarInput,
|
||||
@@ -69,12 +63,20 @@ import {
|
||||
UserPlatformsOutput,
|
||||
WatchHistoryInput,
|
||||
WatchHistoryOutput,
|
||||
WatchProvidersOutput,
|
||||
WatchInput,
|
||||
} from "./schemas";
|
||||
|
||||
const tmdbNotConfiguredError = {
|
||||
PRECONDITION_FAILED: {
|
||||
message: "TMDB API key is not configured",
|
||||
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const contract = {
|
||||
// ─── Titles ─────────────────────────────────────────────────
|
||||
titles: {
|
||||
detail: oc
|
||||
get: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/titles/{id}",
|
||||
@@ -92,167 +94,98 @@ export const contract = {
|
||||
data: appErrorData(AppErrorCode.TITLE_NOT_FOUND),
|
||||
},
|
||||
}),
|
||||
similar: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/titles/{id}/similar",
|
||||
tags: ["Titles"],
|
||||
summary: "Get similar titles",
|
||||
description:
|
||||
"Fetch similar titles based on locally cached recommendation data, along with the user's statuses for each.",
|
||||
successDescription: "Similar titles with user statuses",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(TitleRecommendationsOutput),
|
||||
},
|
||||
|
||||
// ─── Tracking ───────────────────────────────────────────────
|
||||
tracking: {
|
||||
watch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/tracking/watch",
|
||||
tags: ["Tracking"],
|
||||
summary: "Mark items as watched",
|
||||
description:
|
||||
"Mark one or more items as watched. Use scope to indicate what the IDs refer to: movie (title IDs), episode (episode IDs), season (season IDs), or series (title IDs to mark all episodes).",
|
||||
})
|
||||
.input(WatchInput)
|
||||
.output(z.void()),
|
||||
unwatch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/tracking/unwatch",
|
||||
tags: ["Tracking"],
|
||||
summary: "Remove watch records",
|
||||
description:
|
||||
"Remove watch records for one or more items. Use scope to indicate what the IDs refer to: movie, episode, season, or series.",
|
||||
})
|
||||
.input(UnwatchInput)
|
||||
.output(z.void()),
|
||||
updateStatus: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/titles/{id}/status",
|
||||
tags: ["Titles"],
|
||||
path: "/tracking/titles/{id}/status",
|
||||
tags: ["Tracking"],
|
||||
summary: "Update tracking status",
|
||||
description:
|
||||
"Set the user's tracking status for a title. Use null to remove the title from the library entirely.",
|
||||
})
|
||||
.input(UpdateStatusInput)
|
||||
.output(z.void()),
|
||||
updateRating: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/titles/{id}/rating",
|
||||
tags: ["Titles"],
|
||||
summary: "Rate a title",
|
||||
description: "Set a 0-5 star rating for a title. Use 0 to clear the rating.",
|
||||
})
|
||||
.input(UpdateRatingInput)
|
||||
.output(z.void()),
|
||||
watchMovie: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/titles/{id}/watch",
|
||||
tags: ["Titles"],
|
||||
summary: "Mark movie as watched",
|
||||
description: "Log a watch event for a movie. Automatically sets status to completed.",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
watchAll: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/titles/{id}/watch-all",
|
||||
tags: ["Titles"],
|
||||
summary: "Mark all episodes watched",
|
||||
description:
|
||||
"Mark every episode of a TV show as watched. Requires seasons to be hydrated first.",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
userInfo: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/titles/{id}/user-info",
|
||||
tags: ["Titles"],
|
||||
summary: "Get user's title info",
|
||||
description:
|
||||
"Fetch the current user's tracking status, rating, and watched episode IDs for a title.",
|
||||
successDescription: "User's status, rating, and list of watched episode IDs",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(UserInfoOutput),
|
||||
recommendations: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/titles/{id}/recommendations",
|
||||
tags: ["Titles"],
|
||||
summary: "Get title recommendations",
|
||||
description:
|
||||
"Fetch similar titles based on locally cached recommendation data, along with the user's statuses for each.",
|
||||
successDescription: "Recommended titles with user statuses",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(TitleRecommendationsOutput),
|
||||
quickAdd: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/titles/{id}/quick-add",
|
||||
tags: ["Titles"],
|
||||
summary: "Quick add title to library",
|
||||
description:
|
||||
"Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.",
|
||||
successDescription: "Title ID and whether it was already in the library",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(QuickAddOutput)
|
||||
.output(z.void())
|
||||
.errors({
|
||||
NOT_FOUND: {
|
||||
message: "Title not found",
|
||||
data: appErrorData(AppErrorCode.TITLE_NOT_FOUND),
|
||||
},
|
||||
}),
|
||||
},
|
||||
episodes: {
|
||||
watch: oc
|
||||
rate: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/episodes/{id}/watch",
|
||||
tags: ["Episodes"],
|
||||
summary: "Mark episode watched",
|
||||
description: "Record a watch event for a single TV episode.",
|
||||
method: "PUT",
|
||||
path: "/tracking/titles/{id}/rating",
|
||||
tags: ["Tracking"],
|
||||
summary: "Rate a title",
|
||||
description: "Set a 0-5 star rating for a title. Use 0 to clear the rating.",
|
||||
})
|
||||
.input(IdParam)
|
||||
.input(UpdateRatingInput)
|
||||
.output(z.void()),
|
||||
unwatch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/episodes/{id}/unwatch",
|
||||
tags: ["Episodes"],
|
||||
summary: "Mark episode unwatched",
|
||||
description: "Remove the watch record for a single TV episode.",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
batchWatch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/episodes/batch-watch",
|
||||
tags: ["Episodes"],
|
||||
summary: "Batch mark episodes watched",
|
||||
description:
|
||||
"Record watch events for multiple episodes at once. Useful for marking a range of episodes as watched.",
|
||||
})
|
||||
.input(BatchWatchInput)
|
||||
.output(z.void()),
|
||||
},
|
||||
seasons: {
|
||||
watch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/seasons/{id}/watch",
|
||||
tags: ["Seasons"],
|
||||
summary: "Mark season watched",
|
||||
description: "Mark all episodes in a season as watched in a single operation.",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
unwatch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/seasons/{id}/unwatch",
|
||||
tags: ["Seasons"],
|
||||
summary: "Mark season unwatched",
|
||||
description: "Remove all watch records for episodes in a season in a single operation.",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
},
|
||||
people: {
|
||||
detail: oc
|
||||
userInfo: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/people/{id}",
|
||||
tags: ["People"],
|
||||
summary: "Get person details",
|
||||
path: "/tracking/titles/{id}",
|
||||
tags: ["Tracking"],
|
||||
summary: "Get user's tracking info for a title",
|
||||
description:
|
||||
"Fetch a person's profile and paginated filmography. Imports from TMDB on first access if not yet cached locally.",
|
||||
successDescription:
|
||||
"Person profile, paginated filmography credits, and user statuses for their titles",
|
||||
"Fetch the current user's tracking status, rating, and watched episode IDs for a title.",
|
||||
successDescription: "User's status, rating, and list of watched episode IDs",
|
||||
})
|
||||
.input(IdParam.merge(PaginatedInput))
|
||||
.output(PersonDetailOutput)
|
||||
.errors({
|
||||
NOT_FOUND: {
|
||||
message: "Person not found",
|
||||
data: appErrorData(AppErrorCode.PERSON_NOT_FOUND),
|
||||
},
|
||||
}),
|
||||
.input(IdParam)
|
||||
.output(UserInfoOutput),
|
||||
stats: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/tracking/stats",
|
||||
tags: ["Tracking"],
|
||||
summary: "Get watch statistics",
|
||||
description:
|
||||
"Fetch the user's watch counts grouped by time period. Useful for rendering activity charts and dashboard counters.",
|
||||
successDescription: "Watch counts bucketed by time period",
|
||||
})
|
||||
.input(WatchHistoryInput)
|
||||
.output(WatchHistoryOutput),
|
||||
},
|
||||
|
||||
// ─── Library ────────────────────────────────────────────────
|
||||
library: {
|
||||
list: oc
|
||||
.route({
|
||||
@@ -277,46 +210,32 @@ export const contract = {
|
||||
successDescription: "Genres present in the library",
|
||||
})
|
||||
.output(LibraryGenresOutput),
|
||||
},
|
||||
dashboard: {
|
||||
stats: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/stats",
|
||||
tags: ["Dashboard"],
|
||||
summary: "Get dashboard statistics",
|
||||
description:
|
||||
"Fetch summary counts for the current user: movies watched this month, episodes this week, library size, and completed titles.",
|
||||
successDescription: "Aggregate watch statistics",
|
||||
path: "/library/stats",
|
||||
tags: ["Library"],
|
||||
summary: "Get library statistics",
|
||||
description: "Fetch aggregate library counts: total titles and completed titles.",
|
||||
successDescription: "Library size and completed count",
|
||||
})
|
||||
.output(DashboardStatsOutput),
|
||||
.output(LibraryStatsOutput),
|
||||
continueWatching: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/continue-watching",
|
||||
tags: ["Dashboard"],
|
||||
path: "/library/continue-watching",
|
||||
tags: ["Library"],
|
||||
summary: "Get continue watching list",
|
||||
description:
|
||||
"Fetch TV shows the user is currently watching, with the next unwatched episode and progress for each.",
|
||||
successDescription: "In-progress shows with next episode and watch progress",
|
||||
})
|
||||
.output(ContinueWatchingOutput),
|
||||
recommendations: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/recommendations",
|
||||
tags: ["Dashboard"],
|
||||
summary: "Get personalized recommendations",
|
||||
description:
|
||||
"Fetch personalized title recommendations based on the user's library and watch history.",
|
||||
successDescription: "Recommended titles",
|
||||
})
|
||||
.output(DashboardRecommendationsOutput),
|
||||
upcoming: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/upcoming",
|
||||
tags: ["Dashboard"],
|
||||
path: "/library/upcoming",
|
||||
tags: ["Library"],
|
||||
summary: "Get upcoming episodes and movies",
|
||||
description:
|
||||
"Fetch upcoming episodes and movie releases for titles in the user's library, sorted by date. Supports cursor-based pagination.",
|
||||
@@ -324,25 +243,15 @@ export const contract = {
|
||||
})
|
||||
.input(UpcomingInput)
|
||||
.output(UpcomingOutput),
|
||||
watchHistory: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/watch-history",
|
||||
tags: ["Dashboard"],
|
||||
summary: "Get watch history",
|
||||
description:
|
||||
"Fetch the user's watch counts grouped by time period. Useful for rendering activity charts.",
|
||||
successDescription: "Watch counts bucketed by time period",
|
||||
})
|
||||
.input(WatchHistoryInput)
|
||||
.output(WatchHistoryOutput),
|
||||
},
|
||||
explore: {
|
||||
|
||||
// ─── Discover ───────────────────────────────────────────────
|
||||
discover: {
|
||||
trending: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/explore/trending",
|
||||
tags: ["Explore"],
|
||||
path: "/discover/trending",
|
||||
tags: ["Discover"],
|
||||
summary: "Get trending titles",
|
||||
description:
|
||||
"Fetch today's trending movies and/or TV shows from TMDB, including a featured hero title and the user's statuses.",
|
||||
@@ -350,17 +259,12 @@ export const contract = {
|
||||
})
|
||||
.input(TrendingTypeParam.merge(PageParam))
|
||||
.output(TrendingOutput)
|
||||
.errors({
|
||||
PRECONDITION_FAILED: {
|
||||
message: "TMDB API key is not configured",
|
||||
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
||||
},
|
||||
}),
|
||||
.errors(tmdbNotConfiguredError),
|
||||
popular: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/explore/popular",
|
||||
tags: ["Explore"],
|
||||
path: "/discover/popular",
|
||||
tags: ["Discover"],
|
||||
summary: "Get popular titles",
|
||||
description:
|
||||
"Fetch currently popular movies or TV shows from TMDB with the user's tracking statuses.",
|
||||
@@ -368,17 +272,38 @@ export const contract = {
|
||||
})
|
||||
.input(MediaTypeParam.merge(PageParam))
|
||||
.output(PopularOutput)
|
||||
.errors({
|
||||
PRECONDITION_FAILED: {
|
||||
message: "TMDB API key is not configured",
|
||||
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
||||
},
|
||||
}),
|
||||
.errors(tmdbNotConfiguredError),
|
||||
search: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/discover/search",
|
||||
tags: ["Discover"],
|
||||
summary: "Search movies, TV shows, and people",
|
||||
description:
|
||||
"Full-text search across movies, TV shows, and people via TMDB. Optionally filter by media type.",
|
||||
successDescription: "Search results with metadata",
|
||||
})
|
||||
.input(SearchInput)
|
||||
.output(SearchOutput)
|
||||
.errors(tmdbNotConfiguredError),
|
||||
browse: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/discover/browse",
|
||||
tags: ["Discover"],
|
||||
summary: "Browse titles with filters",
|
||||
description:
|
||||
"Browse movies or TV shows filtered by genre, sorted by popularity. Returns user statuses and episode progress.",
|
||||
successDescription: "Filtered titles with user statuses",
|
||||
})
|
||||
.input(DiscoverInput)
|
||||
.output(DiscoverOutput)
|
||||
.errors(tmdbNotConfiguredError),
|
||||
genres: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/explore/genres",
|
||||
tags: ["Explore"],
|
||||
path: "/discover/genres",
|
||||
tags: ["Discover"],
|
||||
summary: "List genres",
|
||||
description:
|
||||
"Fetch the list of TMDB genres for movies or TV shows. Used to populate genre filters for discovery.",
|
||||
@@ -386,64 +311,164 @@ export const contract = {
|
||||
})
|
||||
.input(MediaTypeParam)
|
||||
.output(GenresOutput)
|
||||
.errors({
|
||||
PRECONDITION_FAILED: {
|
||||
message: "TMDB API key is not configured",
|
||||
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
||||
},
|
||||
}),
|
||||
watchProviders: oc
|
||||
.errors(tmdbNotConfiguredError),
|
||||
platforms: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/explore/watch-providers",
|
||||
tags: ["Explore"],
|
||||
summary: "List available watch providers",
|
||||
path: "/discover/platforms",
|
||||
tags: ["Discover"],
|
||||
summary: "List available streaming platforms",
|
||||
description:
|
||||
"Fetch the list of streaming providers available in the configured region. Used to populate provider filter dropdowns.",
|
||||
successDescription: "Provider list with logos",
|
||||
"Fetch all available streaming platforms, ordered by popularity. Includes subscription status for filtering.",
|
||||
successDescription: "Platform list with logos and metadata",
|
||||
})
|
||||
.input(MediaTypeParam)
|
||||
.output(WatchProvidersOutput)
|
||||
.output(PlatformsListOutput),
|
||||
recommendations: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/discover/recommendations",
|
||||
tags: ["Discover"],
|
||||
summary: "Get personalized recommendations",
|
||||
description:
|
||||
"Fetch personalized title recommendations based on the user's library and watch history.",
|
||||
successDescription: "Recommended titles",
|
||||
})
|
||||
.output(DiscoverRecommendationsOutput),
|
||||
},
|
||||
|
||||
// ─── People ─────────────────────────────────────────────────
|
||||
people: {
|
||||
get: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/people/{id}",
|
||||
tags: ["People"],
|
||||
summary: "Get person details",
|
||||
description:
|
||||
"Fetch a person's profile and paginated filmography. Imports from TMDB on first access if not yet cached locally.",
|
||||
successDescription:
|
||||
"Person profile, paginated filmography credits, and user statuses for their titles",
|
||||
})
|
||||
.input(IdParam.merge(PaginatedInput))
|
||||
.output(PersonDetailOutput)
|
||||
.errors({
|
||||
PRECONDITION_FAILED: {
|
||||
message: "TMDB API key is not configured",
|
||||
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
||||
NOT_FOUND: {
|
||||
message: "Person not found",
|
||||
data: appErrorData(AppErrorCode.PERSON_NOT_FOUND),
|
||||
},
|
||||
}),
|
||||
},
|
||||
search: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/search",
|
||||
tags: ["Search"],
|
||||
summary: "Search movies, TV shows, and people",
|
||||
description:
|
||||
"Full-text search across movies, TV shows, and people via TMDB. Optionally filter by media type.",
|
||||
successDescription: "Search results with metadata",
|
||||
})
|
||||
.input(SearchInput)
|
||||
.output(SearchOutput)
|
||||
.errors({
|
||||
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
|
||||
}),
|
||||
discover: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/discover",
|
||||
tags: ["Discover"],
|
||||
summary: "Discover titles by genre",
|
||||
description:
|
||||
"Browse movies or TV shows filtered by genre, sorted by popularity. Returns user statuses and episode progress.",
|
||||
successDescription: "Filtered titles with user statuses",
|
||||
})
|
||||
.input(DiscoverInput)
|
||||
.output(DiscoverOutput)
|
||||
.errors({
|
||||
PRECONDITION_FAILED: {
|
||||
message: "TMDB API key is not configured",
|
||||
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
||||
},
|
||||
}),
|
||||
|
||||
// ─── Account ────────────────────────────────────────────────
|
||||
account: {
|
||||
updateName: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/account/name",
|
||||
tags: ["Account"],
|
||||
summary: "Update display name",
|
||||
description: "Change the current user's display name.",
|
||||
})
|
||||
.input(UpdateNameInput)
|
||||
.output(z.void()),
|
||||
uploadAvatar: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/account/avatar",
|
||||
tags: ["Account"],
|
||||
summary: "Upload avatar",
|
||||
description:
|
||||
"Upload a new profile avatar image. Accepts JPEG, PNG, WebP, or GIF up to 2 MB.",
|
||||
successDescription: "URL of the uploaded avatar image",
|
||||
})
|
||||
.input(UploadAvatarInput)
|
||||
.output(UploadAvatarOutput),
|
||||
removeAvatar: oc
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/account/avatar",
|
||||
tags: ["Account"],
|
||||
summary: "Remove avatar",
|
||||
description: "Delete the current user's profile avatar, reverting to the default.",
|
||||
})
|
||||
.input(z.void())
|
||||
.output(z.void()),
|
||||
platforms: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/account/platforms",
|
||||
tags: ["Account"],
|
||||
summary: "Get user's streaming platforms",
|
||||
description: "Fetch the current user's subscribed streaming platform IDs.",
|
||||
successDescription: "List of platform IDs",
|
||||
})
|
||||
.output(UserPlatformsOutput),
|
||||
updatePlatforms: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/account/platforms",
|
||||
tags: ["Account"],
|
||||
summary: "Update streaming platforms",
|
||||
description: "Set the current user's subscribed streaming platforms.",
|
||||
})
|
||||
.input(UpdateUserPlatformsInput)
|
||||
.output(z.void()),
|
||||
integrations: {
|
||||
list: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/account/integrations",
|
||||
tags: ["Account"],
|
||||
summary: "List integrations",
|
||||
description:
|
||||
"Fetch all configured media server integrations for the current user, including recent webhook/sync events for each.",
|
||||
successDescription: "Integrations with their recent events",
|
||||
})
|
||||
.output(IntegrationsListOutput),
|
||||
create: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/account/integrations",
|
||||
tags: ["Account"],
|
||||
summary: "Create or update integration",
|
||||
description:
|
||||
"Create a new media server integration or update an existing one. Generates a unique webhook token for the provider.",
|
||||
successDescription: "Created or updated integration with token",
|
||||
})
|
||||
.input(CreateIntegrationInput)
|
||||
.output(IntegrationOutput),
|
||||
delete: oc
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/account/integrations/{provider}",
|
||||
tags: ["Account"],
|
||||
summary: "Delete integration",
|
||||
description: "Remove a media server integration and all its event history.",
|
||||
})
|
||||
.input(ProviderParam)
|
||||
.output(z.void()),
|
||||
regenerateToken: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/account/integrations/{provider}/regenerate-token",
|
||||
tags: ["Account"],
|
||||
summary: "Regenerate webhook token",
|
||||
description:
|
||||
"Generate a new webhook token for an integration. The old token is immediately invalidated.",
|
||||
successDescription: "Integration with new token",
|
||||
})
|
||||
.input(ProviderParam)
|
||||
.output(IntegrationOutput)
|
||||
.errors({
|
||||
NOT_FOUND: {
|
||||
message: "Integration not found",
|
||||
data: appErrorData(AppErrorCode.INTEGRATION_NOT_FOUND),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
// ─── System ─────────────────────────────────────────────────
|
||||
system: {
|
||||
publicInfo: oc
|
||||
.route({
|
||||
@@ -452,21 +477,10 @@ export const contract = {
|
||||
tags: ["System"],
|
||||
summary: "Get public instance info",
|
||||
description:
|
||||
"Fetch public information about this Sofa instance. Does not require authentication. Used by the login screen to display instance details.",
|
||||
successDescription: "Instance configuration and status",
|
||||
"Fetch public information about this Sofa instance including authentication configuration. Does not require authentication.",
|
||||
successDescription: "Instance configuration, auth settings, and status",
|
||||
})
|
||||
.output(PublicInfoOutput),
|
||||
authConfig: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/system/auth-config",
|
||||
tags: ["System"],
|
||||
summary: "Get authentication config",
|
||||
description:
|
||||
"Fetch the authentication configuration including OIDC availability, password login status, and registration state. Does not require authentication.",
|
||||
successDescription: "Authentication provider configuration",
|
||||
})
|
||||
.output(AuthConfigOutput),
|
||||
status: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
@@ -479,60 +493,33 @@ export const contract = {
|
||||
})
|
||||
.output(SystemStatusOutput),
|
||||
},
|
||||
integrations: {
|
||||
list: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/integrations",
|
||||
tags: ["Integrations"],
|
||||
summary: "List integrations",
|
||||
description:
|
||||
"Fetch all configured media server integrations for the current user, including recent webhook/sync events for each.",
|
||||
successDescription: "Integrations with their recent events",
|
||||
})
|
||||
.output(IntegrationsListOutput),
|
||||
create: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/integrations",
|
||||
tags: ["Integrations"],
|
||||
summary: "Create or update integration",
|
||||
description:
|
||||
"Create a new media server integration or update an existing one. Generates a unique webhook token for the provider.",
|
||||
successDescription: "Created or updated integration with token",
|
||||
})
|
||||
.input(CreateIntegrationInput)
|
||||
.output(IntegrationOutput),
|
||||
delete: oc
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/integrations/{provider}",
|
||||
tags: ["Integrations"],
|
||||
summary: "Delete integration",
|
||||
description: "Remove a media server integration and all its event history.",
|
||||
})
|
||||
.input(ProviderParam)
|
||||
.output(z.void()),
|
||||
regenerateToken: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/integrations/{provider}/regenerate-token",
|
||||
tags: ["Integrations"],
|
||||
summary: "Regenerate webhook token",
|
||||
description:
|
||||
"Generate a new webhook token for an integration. The old token is immediately invalidated.",
|
||||
successDescription: "Integration with new token",
|
||||
})
|
||||
.input(ProviderParam)
|
||||
.output(IntegrationOutput)
|
||||
.errors({
|
||||
NOT_FOUND: {
|
||||
message: "Integration not found",
|
||||
data: appErrorData(AppErrorCode.INTEGRATION_NOT_FOUND),
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
// ─── Admin ──────────────────────────────────────────────────
|
||||
admin: {
|
||||
settings: {
|
||||
get: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/admin/settings",
|
||||
tags: ["Admin"],
|
||||
summary: "Get admin settings",
|
||||
description:
|
||||
"Fetch all admin-configurable settings: registration, update checks, and telemetry.",
|
||||
successDescription: "Current admin settings",
|
||||
})
|
||||
.output(AdminSettingsOutput),
|
||||
update: oc
|
||||
.route({
|
||||
method: "PATCH",
|
||||
path: "/admin/settings",
|
||||
tags: ["Admin"],
|
||||
summary: "Update admin settings",
|
||||
description:
|
||||
"Partially update admin settings. Only provided sections are changed; omitted sections keep their current values.",
|
||||
})
|
||||
.input(AdminSettingsUpdateInput)
|
||||
.output(z.void()),
|
||||
},
|
||||
backups: {
|
||||
list: oc
|
||||
.route({
|
||||
@@ -615,68 +602,6 @@ export const contract = {
|
||||
.input(UpdateScheduleInput)
|
||||
.output(z.void()),
|
||||
},
|
||||
registration: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/admin/registration",
|
||||
tags: ["Admin"],
|
||||
summary: "Get registration status",
|
||||
description: "Check whether new user registration is currently open or closed.",
|
||||
successDescription: "Registration open/closed state",
|
||||
})
|
||||
.output(RegistrationOutput),
|
||||
toggleRegistration: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/admin/registration",
|
||||
tags: ["Admin"],
|
||||
summary: "Toggle registration",
|
||||
description: "Open or close new user registration.",
|
||||
})
|
||||
.input(ToggleRegistrationInput)
|
||||
.output(z.void()),
|
||||
updateCheck: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/admin/update-check",
|
||||
tags: ["Admin"],
|
||||
summary: "Get update check status",
|
||||
description:
|
||||
"Fetch whether automatic update checks are enabled, and the latest cached check result if available.",
|
||||
successDescription: "Update check configuration and latest result",
|
||||
})
|
||||
.output(UpdateCheckOutput),
|
||||
toggleUpdateCheck: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/admin/update-check",
|
||||
tags: ["Admin"],
|
||||
summary: "Toggle update checks",
|
||||
description: "Enable or disable automatic update checks against the public API.",
|
||||
})
|
||||
.input(ToggleUpdateCheckInput)
|
||||
.output(z.void()),
|
||||
telemetry: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/admin/telemetry",
|
||||
tags: ["Admin"],
|
||||
summary: "Get telemetry status",
|
||||
description:
|
||||
"Fetch whether anonymous telemetry is enabled and when the last report was sent.",
|
||||
successDescription: "Telemetry configuration and last report time",
|
||||
})
|
||||
.output(TelemetryOutput),
|
||||
toggleTelemetry: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/admin/telemetry",
|
||||
tags: ["Admin"],
|
||||
summary: "Toggle telemetry",
|
||||
description: "Enable or disable anonymous telemetry reporting.",
|
||||
})
|
||||
.input(ToggleTelemetryInput)
|
||||
.output(z.void()),
|
||||
triggerJob: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
@@ -729,72 +654,8 @@ export const contract = {
|
||||
})
|
||||
.output(SystemHealthOutput),
|
||||
},
|
||||
account: {
|
||||
updateName: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/account/name",
|
||||
tags: ["Account"],
|
||||
summary: "Update display name",
|
||||
description: "Change the current user's display name.",
|
||||
})
|
||||
.input(UpdateNameInput)
|
||||
.output(z.void()),
|
||||
uploadAvatar: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/account/avatar",
|
||||
tags: ["Account"],
|
||||
summary: "Upload avatar",
|
||||
description:
|
||||
"Upload a new profile avatar image. Accepts JPEG, PNG, WebP, or GIF up to 2 MB.",
|
||||
successDescription: "URL of the uploaded avatar image",
|
||||
})
|
||||
.input(UploadAvatarInput)
|
||||
.output(UploadAvatarOutput),
|
||||
removeAvatar: oc
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/account/avatar",
|
||||
tags: ["Account"],
|
||||
summary: "Remove avatar",
|
||||
description: "Delete the current user's profile avatar, reverting to the default.",
|
||||
})
|
||||
.input(z.void())
|
||||
.output(z.void()),
|
||||
platforms: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/account/platforms",
|
||||
tags: ["Account"],
|
||||
summary: "Get user's streaming platforms",
|
||||
description: "Fetch the current user's subscribed streaming platform IDs.",
|
||||
successDescription: "List of platform IDs",
|
||||
})
|
||||
.output(UserPlatformsOutput),
|
||||
updatePlatforms: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/account/platforms",
|
||||
tags: ["Account"],
|
||||
summary: "Update streaming platforms",
|
||||
description: "Set the current user's subscribed streaming platforms.",
|
||||
})
|
||||
.input(UpdateUserPlatformsInput)
|
||||
.output(z.void()),
|
||||
},
|
||||
platforms: {
|
||||
list: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/platforms",
|
||||
tags: ["Platforms"],
|
||||
summary: "List all platforms",
|
||||
description: "Fetch all available streaming platforms, ordered by popularity.",
|
||||
successDescription: "All platforms with metadata",
|
||||
})
|
||||
.output(PlatformsListOutput),
|
||||
},
|
||||
|
||||
// ─── Imports ────────────────────────────────────────────────
|
||||
imports: {
|
||||
parseFile: oc
|
||||
.route({
|
||||
|
||||
+58
-92
@@ -65,11 +65,23 @@ export const UpdateRatingInput = z
|
||||
})
|
||||
.meta({ description: "Set or clear a star rating for a title" });
|
||||
|
||||
export const BatchWatchInput = z
|
||||
export const WatchScope = z
|
||||
.enum(["movie", "episode", "season", "series"])
|
||||
.describe("What the IDs refer to: movie title, episode, season, or entire TV series");
|
||||
|
||||
export const WatchInput = z
|
||||
.object({
|
||||
episodeIds: z.array(z.string()).min(1).describe("List of episode IDs to mark as watched"),
|
||||
scope: WatchScope,
|
||||
ids: z.array(z.string().min(1)).min(1).describe("IDs to mark as watched"),
|
||||
})
|
||||
.meta({ description: "Batch of episode IDs to mark as watched" });
|
||||
.meta({ description: "Mark one or more items as watched" });
|
||||
|
||||
export const UnwatchInput = z
|
||||
.object({
|
||||
scope: WatchScope,
|
||||
ids: z.array(z.string().min(1)).min(1).describe("IDs to unwatch"),
|
||||
})
|
||||
.meta({ description: "Remove watch records for one or more items" });
|
||||
|
||||
// ─── Search / Discover inputs ──────────────────────────────────
|
||||
|
||||
@@ -138,24 +150,42 @@ export const CreateIntegrationInput = z
|
||||
|
||||
// ─── Admin inputs ──────────────────────────────────────────────
|
||||
|
||||
export const ToggleRegistrationInput = z.object({
|
||||
open: z.boolean().describe("Whether new user registration is allowed"),
|
||||
});
|
||||
export const ToggleUpdateCheckInput = z.object({
|
||||
enabled: z.boolean().describe("Whether automatic update checks are enabled"),
|
||||
});
|
||||
export const ToggleTelemetryInput = z.object({
|
||||
enabled: z.boolean().describe("Whether anonymous telemetry reporting is enabled"),
|
||||
});
|
||||
export const TelemetryOutput = z
|
||||
export const AdminSettingsOutput = z
|
||||
.object({
|
||||
enabled: z.boolean().describe("Whether telemetry is currently enabled"),
|
||||
lastReportedAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("ISO 8601 timestamp of the last telemetry report, or null if never sent"),
|
||||
registration: z.object({
|
||||
open: z.boolean().describe("Whether new user registration is open"),
|
||||
}),
|
||||
updateCheck: z.object({
|
||||
enabled: z.boolean().describe("Whether automatic update checks are enabled"),
|
||||
updateAvailable: z.boolean().nullable().describe("Whether a newer version is available"),
|
||||
currentVersion: z.string().nullable().describe("Currently running version"),
|
||||
latestVersion: z.string().nullable().describe("Latest available version"),
|
||||
releaseUrl: z.string().nullable().describe("URL to the latest release page"),
|
||||
lastCheckedAt: z.string().nullable().describe("When the last check was performed (ISO 8601)"),
|
||||
}),
|
||||
telemetry: z.object({
|
||||
enabled: z.boolean().describe("Whether anonymous telemetry is enabled"),
|
||||
lastReportedAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("ISO 8601 timestamp of the last telemetry report"),
|
||||
}),
|
||||
})
|
||||
.meta({ description: "Telemetry configuration and last report time" });
|
||||
.meta({ description: "Combined admin settings for registration, update checks, and telemetry" });
|
||||
|
||||
export const AdminSettingsUpdateInput = z
|
||||
.object({
|
||||
registration: z
|
||||
.object({ open: z.boolean().describe("Whether new user registration is allowed") })
|
||||
.optional(),
|
||||
updateCheck: z
|
||||
.object({ enabled: z.boolean().describe("Whether automatic update checks are enabled") })
|
||||
.optional(),
|
||||
telemetry: z
|
||||
.object({ enabled: z.boolean().describe("Whether anonymous telemetry is enabled") })
|
||||
.optional(),
|
||||
})
|
||||
.meta({ description: "Partial update to admin settings" });
|
||||
|
||||
const cronJobName = z.enum([
|
||||
"scheduledBackup",
|
||||
@@ -507,14 +537,12 @@ export const PersonDetailOutput = z
|
||||
|
||||
// ─── Dashboard outputs ─────────────────────────────────────────
|
||||
|
||||
export const DashboardStatsOutput = z
|
||||
export const LibraryStatsOutput = z
|
||||
.object({
|
||||
moviesThisMonth: z.number().describe("Movies watched in the current calendar month"),
|
||||
episodesThisWeek: z.number().describe("Episodes watched in the current calendar week"),
|
||||
librarySize: z.number().describe("Total titles in the user's library"),
|
||||
size: z.number().describe("Total titles in the user's library"),
|
||||
completed: z.number().describe("Total titles with completed status"),
|
||||
})
|
||||
.meta({ description: "Aggregate watch statistics for the dashboard" });
|
||||
.meta({ description: "Aggregate library statistics" });
|
||||
|
||||
export const ContinueWatchingOutput = z
|
||||
.object({
|
||||
@@ -616,27 +644,12 @@ export const LibraryGenresOutput = z
|
||||
})
|
||||
.meta({ description: "Genres that exist in the user's library" });
|
||||
|
||||
// ─── Watch providers ──────────────────────────────────────────
|
||||
|
||||
export const WatchProvidersOutput = z
|
||||
.object({
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string().describe("Platform ID"),
|
||||
tmdbProviderIds: z.array(z.number()).describe("TMDB provider IDs"),
|
||||
name: z.string().describe("Provider display name"),
|
||||
logoPath: z.string().nullable().describe("Provider logo image path"),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.meta({ description: "Available streaming providers for the user's region" });
|
||||
|
||||
export const DashboardRecommendationsOutput = z
|
||||
export const DiscoverRecommendationsOutput = z
|
||||
.object({
|
||||
items: z.array(RecommendationItemSchema),
|
||||
})
|
||||
.meta({
|
||||
description: "Personalized title recommendations for the dashboard",
|
||||
description: "Personalized title recommendations based on the user's library",
|
||||
});
|
||||
|
||||
// ─── Upcoming outputs ─────────────────────────────────────────
|
||||
@@ -965,34 +978,6 @@ export const BackupScheduleOutput = z
|
||||
})
|
||||
.meta({ description: "Automated backup schedule configuration" });
|
||||
|
||||
export const RegistrationOutput = z
|
||||
.object({
|
||||
open: z.boolean().describe("Whether new user registration is open"),
|
||||
})
|
||||
.meta({ description: "User registration status" });
|
||||
|
||||
const UpdateCheckResultSchema = z
|
||||
.object({
|
||||
updateAvailable: z.boolean().describe("Whether a newer version is available"),
|
||||
currentVersion: z.string().describe("Currently running version"),
|
||||
latestVersion: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Latest available version, or null if check failed"),
|
||||
releaseUrl: z.string().nullable().describe("URL to the latest release page"),
|
||||
lastCheckedAt: z.string().nullable().describe("When the last check was performed (ISO 8601)"),
|
||||
})
|
||||
.meta({ description: "Result of an update availability check" });
|
||||
|
||||
export const UpdateCheckOutput = z
|
||||
.object({
|
||||
enabled: z.boolean().describe("Whether automatic update checks are enabled"),
|
||||
updateCheck: UpdateCheckResultSchema.nullable().describe(
|
||||
"Latest check result, or null if checks are disabled or never ran",
|
||||
),
|
||||
})
|
||||
.meta({ description: "Update check configuration and latest result" });
|
||||
|
||||
export const TriggerJobOutput = z.object({
|
||||
ok: z.literal(true).describe("Always true on success"),
|
||||
});
|
||||
@@ -1013,17 +998,6 @@ export const PurgeImageCacheOutput = z
|
||||
})
|
||||
.meta({ description: "Result of purging the image cache from disk" });
|
||||
|
||||
// ─── Quick add output ──────────────────────────────────────────
|
||||
|
||||
export const QuickAddOutput = z
|
||||
.object({
|
||||
id: z.string().describe("Internal title ID"),
|
||||
alreadyAdded: z.boolean().describe("True if the title was already in the user's library"),
|
||||
})
|
||||
.meta({
|
||||
description: "Result of a quick-add operation",
|
||||
});
|
||||
|
||||
// ─── System outputs ───────────────────────────────────────────
|
||||
|
||||
export const PublicInfoOutput = z
|
||||
@@ -1033,24 +1007,15 @@ export const PublicInfoOutput = z
|
||||
userCount: z.number().describe("Number of registered users"),
|
||||
registrationOpen: z.boolean().describe("Whether new user registration is open"),
|
||||
posterUrls: z.array(z.string()).describe("Poster image URLs for the login screen collage"),
|
||||
})
|
||||
.meta({
|
||||
description: "Public instance information shown on the login/setup screen",
|
||||
});
|
||||
|
||||
export const AuthConfigOutput = z
|
||||
.object({
|
||||
oidcEnabled: z.boolean().describe("Whether OIDC/SSO login is available"),
|
||||
oidcProviderName: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Display name of the OIDC provider (e.g. Authelia, Keycloak)"),
|
||||
passwordLoginDisabled: z.boolean().describe("Whether password-based login is disabled"),
|
||||
registrationOpen: z.boolean().describe("Whether new user registration is open"),
|
||||
userCount: z.number().describe("Number of registered users"),
|
||||
})
|
||||
.meta({
|
||||
description: "Authentication provider configuration",
|
||||
description: "Public instance information and authentication configuration",
|
||||
});
|
||||
|
||||
// ─── Imports ──────────────────────────────────────────────────
|
||||
@@ -1247,7 +1212,7 @@ export type BackupInfo = z.infer<typeof BackupSchema>;
|
||||
export type CastMember = z.infer<typeof CastMemberSchema>;
|
||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
export type CronJobName = z.infer<typeof cronJobName>;
|
||||
export type DashboardStats = z.infer<typeof DashboardStatsOutput>;
|
||||
export type LibraryStats = z.infer<typeof LibraryStatsOutput>;
|
||||
export type Episode = z.infer<typeof EpisodeSchema>;
|
||||
export type HistoryBucket = z.infer<typeof HistoryBucketSchema>;
|
||||
export type PersonCredit = z.infer<typeof PersonCreditSchema>;
|
||||
@@ -1261,4 +1226,5 @@ export type TimePeriod = z.infer<typeof WatchHistoryInput>["period"];
|
||||
export type ImportJob = z.infer<typeof ImportJobSchema>;
|
||||
export type NormalizedImport = z.infer<typeof NormalizedImportSchema>;
|
||||
export type UpcomingItem = z.infer<typeof UpcomingItemSchema>;
|
||||
export type UpdateCheckResult = z.infer<typeof UpdateCheckResultSchema>;
|
||||
export type AdminSettings = z.infer<typeof AdminSettingsOutput>;
|
||||
export type WatchScopeType = z.infer<typeof WatchScope>;
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
batchInsertEpisodeWatchesTransaction,
|
||||
batchInsertMissingEpisodeWatches,
|
||||
countDistinctEpisodeWatches,
|
||||
deleteAllEpisodeWatchesForTitle,
|
||||
deleteEpisodeWatch,
|
||||
deleteEpisodeWatches,
|
||||
deleteMovieWatches,
|
||||
deleteRating,
|
||||
deleteTitleStatus,
|
||||
getAllEpisodeIdsForTitle,
|
||||
@@ -157,6 +159,24 @@ export function unwatchSeason(userId: string, seasonId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function unwatchMovie(userId: string, titleId: string) {
|
||||
deleteMovieWatches(userId, titleId);
|
||||
|
||||
const existing = getTitleStatus(userId, titleId);
|
||||
if (existing && existing.status !== "watchlist") {
|
||||
setTitleStatus(userId, titleId, "watchlist");
|
||||
}
|
||||
}
|
||||
|
||||
export function unwatchSeries(userId: string, titleId: string) {
|
||||
deleteAllEpisodeWatchesForTitle(userId, titleId);
|
||||
|
||||
const existing = getTitleStatus(userId, titleId);
|
||||
if (existing && existing.status !== "watchlist") {
|
||||
setTitleStatus(userId, titleId, "watchlist");
|
||||
}
|
||||
}
|
||||
|
||||
export function rateTitleStars(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
removeTitleStatus,
|
||||
setTitleStatus,
|
||||
unwatchEpisode,
|
||||
unwatchMovie,
|
||||
unwatchSeason,
|
||||
unwatchSeries,
|
||||
} from "../src/tracking";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -495,6 +497,93 @@ describe("unwatchSeason", () => {
|
||||
|
||||
// ── rateTitleStars ──────────────────────────────────────────────────
|
||||
|
||||
// ── unwatchMovie ─────────────────────────────────────────────────────
|
||||
|
||||
describe("unwatchMovie", () => {
|
||||
test("removes movie watch records and reverts completed to watchlist", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
logMovieWatch("user-1", "title-1");
|
||||
|
||||
// Verify completed status
|
||||
const before = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(before?.status).toBe("completed");
|
||||
|
||||
unwatchMovie("user-1", "title-1");
|
||||
|
||||
// Watch records removed
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userMovieWatches)
|
||||
.where(and(eq(userMovieWatches.userId, "user-1"), eq(userMovieWatches.titleId, "title-1")))
|
||||
.all();
|
||||
expect(watches).toHaveLength(0);
|
||||
|
||||
// Status reverted to watchlist
|
||||
const after = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(after?.status).toBe("watchlist");
|
||||
});
|
||||
|
||||
test("no-op when movie was not watched", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
setTitleStatus("user-1", "title-1", "watchlist");
|
||||
|
||||
unwatchMovie("user-1", "title-1");
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("watchlist");
|
||||
});
|
||||
});
|
||||
|
||||
// ── unwatchSeries ────────────────────────────────────────────────────
|
||||
|
||||
describe("unwatchSeries", () => {
|
||||
test("removes all episode watches and reverts status to watchlist", () => {
|
||||
const { episodeIds } = insertTvShow("tv-1");
|
||||
insertUser();
|
||||
logEpisodeWatch("user-1", episodeIds[0]);
|
||||
logEpisodeWatch("user-1", episodeIds[1]);
|
||||
|
||||
const before = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(before.length).toBeGreaterThan(0);
|
||||
|
||||
unwatchSeries("user-1", "tv-1");
|
||||
|
||||
const after = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(after).toHaveLength(0);
|
||||
|
||||
const statusRow = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "tv-1")))
|
||||
.get();
|
||||
expect(statusRow?.status).toBe("watchlist");
|
||||
});
|
||||
});
|
||||
|
||||
// ── rateTitleStars ───────────────────────────────────────────────────
|
||||
|
||||
describe("rateTitleStars", () => {
|
||||
test("sets rating", () => {
|
||||
insertUser();
|
||||
|
||||
@@ -315,3 +315,22 @@ export function deleteEpisodeWatch(userId: string, episodeId: string): void {
|
||||
.where(and(eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.episodeId, episodeId)))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function deleteMovieWatches(userId: string, titleId: string): void {
|
||||
db.delete(userMovieWatches)
|
||||
.where(and(eq(userMovieWatches.userId, userId), eq(userMovieWatches.titleId, titleId)))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function deleteAllEpisodeWatchesForTitle(userId: string, titleId: string): void {
|
||||
const titleEpisodeIds = db
|
||||
.select({ id: episodes.id })
|
||||
.from(episodes)
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all()
|
||||
.map((r) => r.id);
|
||||
|
||||
if (titleEpisodeIds.length === 0) return;
|
||||
deleteEpisodeWatches(userId, titleEpisodeIds);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user