mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
* feat: add filtering and sorting across library, explore, and upcoming Add comprehensive filtering and sorting to all list views on both web and mobile. Library (new dedicated page): - New /library route (web) with URL search param persistence - New Library tab (5th tab, mobile) with FlashList grid - Filters: status (multi-select), type, genre (library-only), user rating range, release year (decade presets + custom), content rating, available to stream - Sort by: title, date added, release date, popularity, user rating, TMDB rating - Text search within collection - Filter popover (web) / bottom sheet with Apply (mobile) - Remove old dashboard.library endpoint in favor of library.list Explore/Discover: - New Discover section on Explore page with inline filter controls - TMDB filters: genre (now optional), year range, min rating, sort order, original language, streaming provider - Watch provider list endpoint (WATCH_REGION env var, default "US") - Default to popular content when no filters selected Upcoming: - Type filter (All/Movies/TV Shows) and status filter (All/Watching/Watchlist) - Toggle chips on both web (URL params) and mobile - Backend: mediaType and statusFilter params on upcoming endpoint Navigation: - Library added to web nav bar (desktop + mobile tab bar) - Library added as 5th tab on mobile (between Home and Explore) - Dashboard library section simplified to compact preview with "See all" link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add user streaming platform subscriptions and redesign filter UI Replace the denormalized availabilityOffers table with a normalized platforms + titleAvailability + userPlatforms schema. Users can now declare which streaming services they subscribe to, enabling personalized "On your services" availability display and filtered library/explore results. Backend: new platforms table seeded from TMDB provider data on startup, new API endpoints (platforms.list, account.platforms, account.updatePlatforms), title detail annotates availability with isUserSubscribed flag, library "on my services" filter checks user's subscribed platforms. Web UI: post-registration onboarding page for platform selection, streaming services settings section, title detail split into "On your services" / "Also available on", explore dropdown uses platforms table with active filter highlighting. Library filters redesigned from cluttered popover to clean collapsible inline strip with consolidated dropdowns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address race conditions, stale state, and edge cases in platforms feature - Fix debounced autosave race in streaming services settings: use ref for latest selection + counter guard so only the most recent save updates UI. Clean up timers on unmount. - Fix explore provider dropdown desyncing from query filter when switching Movie/TV type: reset selectedPlatformId alongside providerId. - Fix platformIdsExist rejecting duplicate valid IDs by deduplicating input before comparing against DB results. - Fix stale platform metadata: always upsert name/logo on TMDB refresh instead of skipping when platform already exists. - Fix invisible ratingMax filter: clear ratingMax when user changes ratingMin since the UI only exposes a single rating dropdown now. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: clear stale saved-state timer and preserve falsy filter values - Clear previous savedTimerRef timeout before scheduling a new one in streaming-services-section, preventing an older timer from hiding the "Saved" indicator too early on rapid successive saves - Replace `value || undefined` with explicit empty-value check in library filter handler so numeric 0 (e.g. ratingMin=0) is not incorrectly dropped Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add TanStack Form and migrate auth + change password forms Set up TanStack Form v1 composition layer with createFormHook, pre-bound field components (TextField, TextareaField, CheckboxField, SwitchField, SubmitButton), and migrate the two traditional submit-based forms: - AuthForm: replace 4 useState calls with useAppForm, use form.Field for each input while preserving motion animation layout - ChangePasswordDialog: replace 6 useState calls with useAppForm + Zod schema validation (cross-field password match, min length), per-field error display, form.reset() on dialog close Also adds @tanstack/react-form to the monorepo catalog and updates both web and native apps to reference it via catalog:. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
893 lines
29 KiB
TypeScript
893 lines
29 KiB
TypeScript
import { eventIterator, oc } from "@orpc/contract";
|
|
import { z } from "zod";
|
|
|
|
import { AppErrorCode, appErrorData } from "./errors";
|
|
import {
|
|
AuthConfigOutput,
|
|
BackupCreateOutput,
|
|
BackupScheduleOutput,
|
|
BackupsListOutput,
|
|
BatchWatchInput,
|
|
ContinueWatchingOutput,
|
|
CreateImportJobInput,
|
|
CreateIntegrationInput,
|
|
DashboardRecommendationsOutput,
|
|
DashboardStatsOutput,
|
|
DiscoverInput,
|
|
DiscoverOutput,
|
|
FilenameParam,
|
|
GenresOutput,
|
|
IdParam,
|
|
ImportJobEvent,
|
|
ImportJobSchema,
|
|
ImportPreviewSchema,
|
|
IntegrationOutput,
|
|
IntegrationsListOutput,
|
|
LibraryGenresOutput,
|
|
LibraryListInput,
|
|
LibraryListOutput,
|
|
MediaTypeParam,
|
|
PageParam,
|
|
PaginatedInput,
|
|
ParseFileInput,
|
|
PlatformsListOutput,
|
|
ParsePayloadInput,
|
|
PersonDetailOutput,
|
|
PopularOutput,
|
|
ProviderParam,
|
|
PublicInfoOutput,
|
|
PurgeImageCacheOutput,
|
|
PurgeMetadataCacheOutput,
|
|
QuickAddOutput,
|
|
RegistrationOutput,
|
|
RestoreBackupInput,
|
|
SearchInput,
|
|
SearchOutput,
|
|
SystemHealthOutput,
|
|
SystemStatusOutput,
|
|
TelemetryOutput,
|
|
TitleDetailOutput,
|
|
TitleRecommendationsOutput,
|
|
ToggleRegistrationInput,
|
|
ToggleTelemetryInput,
|
|
ToggleUpdateCheckInput,
|
|
TrendingOutput,
|
|
TrendingTypeParam,
|
|
TriggerJobInput,
|
|
TriggerJobOutput,
|
|
UpdateCheckOutput,
|
|
UpdateUserPlatformsInput,
|
|
UpdateNameInput,
|
|
UpdateRatingInput,
|
|
UpdateScheduleInput,
|
|
UpdateStatusInput,
|
|
UpcomingInput,
|
|
UpcomingOutput,
|
|
UploadAvatarInput,
|
|
UploadAvatarOutput,
|
|
UserInfoOutput,
|
|
UserPlatformsOutput,
|
|
WatchHistoryInput,
|
|
WatchHistoryOutput,
|
|
WatchProvidersOutput,
|
|
} from "./schemas";
|
|
|
|
export const contract = {
|
|
titles: {
|
|
detail: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/titles/{id}",
|
|
tags: ["Titles"],
|
|
summary: "Get title details",
|
|
description:
|
|
"Fetch full title metadata including seasons, cast, and streaming availability. Imports from TMDB on first access if not yet cached locally.",
|
|
successDescription: "Full title with seasons, cast, and availability offers",
|
|
})
|
|
.input(IdParam)
|
|
.output(TitleDetailOutput)
|
|
.errors({
|
|
NOT_FOUND: {
|
|
message: "Title not found",
|
|
data: appErrorData(AppErrorCode.TITLE_NOT_FOUND),
|
|
},
|
|
}),
|
|
updateStatus: oc
|
|
.route({
|
|
method: "PUT",
|
|
path: "/titles/{id}/status",
|
|
tags: ["Titles"],
|
|
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)
|
|
.errors({
|
|
NOT_FOUND: {
|
|
message: "Title not found",
|
|
data: appErrorData(AppErrorCode.TITLE_NOT_FOUND),
|
|
},
|
|
}),
|
|
},
|
|
episodes: {
|
|
watch: oc
|
|
.route({
|
|
method: "POST",
|
|
path: "/episodes/{id}/watch",
|
|
tags: ["Episodes"],
|
|
summary: "Mark episode watched",
|
|
description: "Record a watch event for a single TV episode.",
|
|
})
|
|
.input(IdParam)
|
|
.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
|
|
.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({
|
|
NOT_FOUND: {
|
|
message: "Person not found",
|
|
data: appErrorData(AppErrorCode.PERSON_NOT_FOUND),
|
|
},
|
|
}),
|
|
},
|
|
library: {
|
|
list: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/library",
|
|
tags: ["Library"],
|
|
summary: "List library with filters",
|
|
description:
|
|
"Fetch paginated, filtered, and sorted titles from the user's library. Supports filtering by status, type, genre, rating, year, content rating, and streaming availability.",
|
|
successDescription: "Filtered library items with user statuses and ratings",
|
|
})
|
|
.input(LibraryListInput)
|
|
.output(LibraryListOutput),
|
|
genres: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/library/genres",
|
|
tags: ["Library"],
|
|
summary: "List genres in user's library",
|
|
description:
|
|
"Get the distinct genres present in the user's library, ordered alphabetically. Used to populate the genre filter dropdown.",
|
|
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",
|
|
})
|
|
.output(DashboardStatsOutput),
|
|
continueWatching: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/dashboard/continue-watching",
|
|
tags: ["Dashboard"],
|
|
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"],
|
|
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.",
|
|
successDescription: "Upcoming items sorted by date with streaming info",
|
|
})
|
|
.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: {
|
|
trending: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/explore/trending",
|
|
tags: ["Explore"],
|
|
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.",
|
|
successDescription: "Trending items, hero spotlight, and user statuses",
|
|
})
|
|
.input(TrendingTypeParam.merge(PageParam))
|
|
.output(TrendingOutput)
|
|
.errors({
|
|
PRECONDITION_FAILED: {
|
|
message: "TMDB API key is not configured",
|
|
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
|
},
|
|
}),
|
|
popular: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/explore/popular",
|
|
tags: ["Explore"],
|
|
summary: "Get popular titles",
|
|
description:
|
|
"Fetch currently popular movies or TV shows from TMDB with the user's tracking statuses.",
|
|
successDescription: "Popular items with user statuses",
|
|
})
|
|
.input(MediaTypeParam.merge(PageParam))
|
|
.output(PopularOutput)
|
|
.errors({
|
|
PRECONDITION_FAILED: {
|
|
message: "TMDB API key is not configured",
|
|
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
|
},
|
|
}),
|
|
genres: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/explore/genres",
|
|
tags: ["Explore"],
|
|
summary: "List genres",
|
|
description:
|
|
"Fetch the list of TMDB genres for movies or TV shows. Used to populate genre filters for discovery.",
|
|
successDescription: "Genre ID/name pairs",
|
|
})
|
|
.input(MediaTypeParam)
|
|
.output(GenresOutput)
|
|
.errors({
|
|
PRECONDITION_FAILED: {
|
|
message: "TMDB API key is not configured",
|
|
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
|
},
|
|
}),
|
|
watchProviders: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/explore/watch-providers",
|
|
tags: ["Explore"],
|
|
summary: "List available watch providers",
|
|
description:
|
|
"Fetch the list of streaming providers available in the configured region. Used to populate provider filter dropdowns.",
|
|
successDescription: "Provider list with logos",
|
|
})
|
|
.input(MediaTypeParam)
|
|
.output(WatchProvidersOutput)
|
|
.errors({
|
|
PRECONDITION_FAILED: {
|
|
message: "TMDB API key is not configured",
|
|
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
|
|
},
|
|
}),
|
|
},
|
|
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),
|
|
},
|
|
}),
|
|
system: {
|
|
publicInfo: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/system/public-info",
|
|
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",
|
|
})
|
|
.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",
|
|
path: "/system/status",
|
|
tags: ["System"],
|
|
summary: "Get internal system config",
|
|
description:
|
|
"Returns internal configuration such as the public API URL. Requires authentication.",
|
|
successDescription: "Internal system configuration",
|
|
})
|
|
.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: {
|
|
backups: {
|
|
list: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/admin/backups",
|
|
tags: ["Admin"],
|
|
summary: "List backups",
|
|
description: "Fetch all database backups with their sizes and creation times.",
|
|
successDescription: "Available backups",
|
|
})
|
|
.output(BackupsListOutput),
|
|
create: oc
|
|
.route({
|
|
method: "POST",
|
|
path: "/admin/backups",
|
|
tags: ["Admin"],
|
|
summary: "Create backup",
|
|
description:
|
|
"Create a new manual database backup. The backup is tagged as a manual backup.",
|
|
successDescription: "Created backup metadata",
|
|
})
|
|
.input(z.void())
|
|
.output(BackupCreateOutput),
|
|
delete: oc
|
|
.route({
|
|
method: "DELETE",
|
|
path: "/admin/backups/{filename}",
|
|
tags: ["Admin"],
|
|
summary: "Delete backup",
|
|
description: "Permanently delete a database backup file by filename.",
|
|
})
|
|
.input(FilenameParam)
|
|
.output(z.void())
|
|
.errors({
|
|
NOT_FOUND: {
|
|
message: "Backup not found",
|
|
data: appErrorData(AppErrorCode.BACKUP_NOT_FOUND),
|
|
},
|
|
BAD_REQUEST: {
|
|
message: "Failed to delete backup",
|
|
data: appErrorData(AppErrorCode.BACKUP_DELETE_FAILED),
|
|
},
|
|
}),
|
|
restore: oc
|
|
.route({
|
|
method: "POST",
|
|
path: "/admin/backups/restore",
|
|
tags: ["Admin"],
|
|
summary: "Restore from backup",
|
|
description:
|
|
"Upload a database backup file and restore it. A pre-restore backup is automatically created before overwriting the current database.",
|
|
})
|
|
.input(RestoreBackupInput)
|
|
.output(z.void())
|
|
.errors({
|
|
BAD_REQUEST: {
|
|
message: "Backup restoration failed",
|
|
data: appErrorData(AppErrorCode.BACKUP_RESTORE_FAILED),
|
|
},
|
|
}),
|
|
schedule: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/admin/backups/schedule",
|
|
tags: ["Admin"],
|
|
summary: "Get backup schedule",
|
|
description: "Fetch the current automated backup schedule configuration.",
|
|
successDescription: "Backup schedule settings",
|
|
})
|
|
.output(BackupScheduleOutput),
|
|
updateSchedule: oc
|
|
.route({
|
|
method: "PUT",
|
|
path: "/admin/backups/schedule",
|
|
tags: ["Admin"],
|
|
summary: "Update backup schedule",
|
|
description:
|
|
"Update the automated backup schedule. Only provided fields are changed; omitted fields keep their current values.",
|
|
})
|
|
.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",
|
|
path: "/admin/jobs/trigger",
|
|
tags: ["Admin"],
|
|
summary: "Trigger cron job",
|
|
description: "Manually trigger a background cron job by name. The job runs asynchronously.",
|
|
})
|
|
.input(TriggerJobInput)
|
|
.output(TriggerJobOutput)
|
|
.errors({
|
|
NOT_FOUND: {
|
|
message: "Job not found",
|
|
data: appErrorData(AppErrorCode.JOB_NOT_FOUND),
|
|
},
|
|
}),
|
|
purgeMetadataCache: oc
|
|
.route({
|
|
method: "POST",
|
|
path: "/admin/cache/purge-metadata",
|
|
tags: ["Admin"],
|
|
summary: "Purge metadata cache",
|
|
description:
|
|
"Delete un-enriched stub titles not in any user's library and clean up orphaned person records.",
|
|
successDescription: "Counts of deleted titles and persons",
|
|
})
|
|
.input(z.void())
|
|
.output(PurgeMetadataCacheOutput),
|
|
purgeImageCache: oc
|
|
.route({
|
|
method: "POST",
|
|
path: "/admin/cache/purge-images",
|
|
tags: ["Admin"],
|
|
summary: "Purge image cache",
|
|
description:
|
|
"Delete all cached TMDB images from disk. Images will be re-downloaded on demand.",
|
|
successDescription: "Count of deleted files and bytes freed",
|
|
})
|
|
.input(z.void())
|
|
.output(PurgeImageCacheOutput),
|
|
systemHealth: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/admin/system-health",
|
|
tags: ["Admin"],
|
|
summary: "Get system health report",
|
|
description:
|
|
"Comprehensive health check covering database, TMDB connectivity, cron jobs, image cache, backups, and environment.",
|
|
successDescription: "Full system health report",
|
|
})
|
|
.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: {
|
|
parseFile: oc
|
|
.route({
|
|
method: "POST",
|
|
path: "/imports/parse-file",
|
|
tags: ["Imports"],
|
|
summary: "Parse import file",
|
|
description:
|
|
"Upload and parse an export file from Trakt, Simkl, Letterboxd, or Sofa. Returns a preview of items found without importing anything.",
|
|
successDescription: "Preview of importable items with counts",
|
|
})
|
|
.input(ParseFileInput)
|
|
.output(ImportPreviewSchema)
|
|
.errors({
|
|
BAD_REQUEST: {
|
|
message: "Invalid import file",
|
|
data: appErrorData(AppErrorCode.IMPORT_INVALID_FILE),
|
|
},
|
|
}),
|
|
parsePayload: oc
|
|
.route({
|
|
method: "POST",
|
|
path: "/imports/parse-payload",
|
|
tags: ["Imports"],
|
|
summary: "Parse raw OAuth import data",
|
|
description:
|
|
"Accept raw API responses from the OAuth proxy, parse them into normalized import format, and return a preview with item counts.",
|
|
successDescription: "Preview of importable items with counts",
|
|
})
|
|
.input(ParsePayloadInput)
|
|
.output(ImportPreviewSchema),
|
|
createJob: oc
|
|
.route({
|
|
method: "POST",
|
|
path: "/imports/jobs",
|
|
tags: ["Imports"],
|
|
summary: "Create import job",
|
|
description: "Create and start a background import job from previously parsed data.",
|
|
successDescription: "Created import job",
|
|
})
|
|
.input(CreateImportJobInput)
|
|
.output(ImportJobSchema)
|
|
.errors({
|
|
BAD_REQUEST: {
|
|
message: "Import payload too large",
|
|
data: appErrorData(AppErrorCode.IMPORT_PAYLOAD_TOO_LARGE),
|
|
},
|
|
CONFLICT: {
|
|
message: "An import is already in progress",
|
|
data: appErrorData(AppErrorCode.IMPORT_ALREADY_RUNNING),
|
|
},
|
|
}),
|
|
getJob: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/imports/jobs/{id}",
|
|
tags: ["Imports"],
|
|
summary: "Get import job status",
|
|
description: "Get the current status and progress of an import job.",
|
|
successDescription: "Current job state",
|
|
})
|
|
.input(IdParam)
|
|
.output(ImportJobSchema),
|
|
cancelJob: oc
|
|
.route({
|
|
method: "POST",
|
|
path: "/imports/jobs/{id}/cancel",
|
|
tags: ["Imports"],
|
|
summary: "Cancel import job",
|
|
description:
|
|
"Cancel a pending or running import job. Already-imported items are not rolled back.",
|
|
successDescription: "Updated job state",
|
|
})
|
|
.input(IdParam)
|
|
.output(ImportJobSchema)
|
|
.errors({
|
|
BAD_REQUEST: {
|
|
message: "Import cannot be cancelled",
|
|
data: appErrorData(AppErrorCode.IMPORT_CANNOT_CANCEL),
|
|
},
|
|
}),
|
|
jobEvents: oc
|
|
.route({
|
|
method: "GET",
|
|
path: "/imports/jobs/{id}/events",
|
|
tags: ["Imports"],
|
|
summary: "Stream import job events",
|
|
description:
|
|
"SSE stream of import job progress events. Yields progress updates and a final complete event.",
|
|
successDescription: "Stream of job progress events",
|
|
})
|
|
.input(IdParam)
|
|
.output(eventIterator(ImportJobEvent)),
|
|
},
|
|
};
|