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:
2026-03-26 15:44:05 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3906f6021d
commit dce333e128
123 changed files with 3566 additions and 4325 deletions
+314 -453
View File
@@ -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
View File
@@ -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>;
+20
View File
@@ -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,
+89
View File
@@ -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();
+19
View File
@@ -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);
}