mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
refactor: consolidate watchlist states (#18)
- Rename `watchlist` → `in_watchlist` and `in_progress` → `watching` across the full stack (DB migration, Drizzle schema, core services, API contract, web, native) - Add a new `caught_up` status for TV shows where all aired episodes are watched but the show is still airing - Add `titles.watchAll` procedure and `markAllWatched` action to mark every episode of a TV show as watched in one step; replace the old "Mark as Watching" / "Mark as Completed" context-menu actions with "Mark All Watched" (TV) and "Mark as Watched" (movie) - Extract `display-status.ts` in `@sofa/api` to share status → display label/color logic between web and native - Add `getDisplayStatusesByTitleIds` to `@sofa/core/tracking` and wire it into the dashboard library feed so clients receive resolved display statuses - Show a destructive Alert confirmation before removing a title from the library (native) - Remove "Mark as Completed" from the continue-watching card context menu - Update i18n catalogs for all 6 locales (de, en, es, fr, it, pt)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
getAllTrackedTitleIds,
|
||||
getCompletedTitleIds,
|
||||
getEngagedTitleIds,
|
||||
getEpisodesBySeasonIds,
|
||||
getEpisodeWatchCountSince,
|
||||
getEpisodeWatchesByEpisodeIds,
|
||||
@@ -299,7 +299,7 @@ export { getLibraryFeed } from "@sofa/db/queries/discovery";
|
||||
|
||||
export function getRecommendationsFeed(userId: string) {
|
||||
// Get recommendations from user's highly-rated or completed titles
|
||||
const userCompletedOrRated = getCompletedTitleIds(userId);
|
||||
const userCompletedOrRated = getEngagedTitleIds(userId);
|
||||
|
||||
const ratedIds = getHighlyRatedTitleIds(userId);
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ export async function getSonarrList(
|
||||
userId: string,
|
||||
statuses: Status[] = ["watchlist"],
|
||||
): Promise<{ TvdbId: number; Title: string }[]> {
|
||||
const rows = getSonarrShows(userId, statuses);
|
||||
// TV never stores 'completed' — map it to 'in_progress' (completion is derived)
|
||||
const mappedStatuses = [
|
||||
...new Set(statuses.map((s) => (s === "completed" ? "in_progress" : s))),
|
||||
] as Status[];
|
||||
const rows = getSonarrShows(userId, mappedStatuses);
|
||||
|
||||
// Resolve missing TVDB IDs in parallel instead of sequentially
|
||||
const needsResolution = rows.filter((r) => r.tvdbId == null);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { DisplayStatus } from "@sofa/api/display-status";
|
||||
import { getDisplayStatus } from "@sofa/api/display-status";
|
||||
import { getTitlesByIds } from "@sofa/db/queries/discovery";
|
||||
import { getTitleById } from "@sofa/db/queries/title";
|
||||
import {
|
||||
batchInsertEpisodeWatchesTransaction,
|
||||
@@ -64,19 +67,16 @@ export function logEpisodeWatch(
|
||||
const now = watchedAt ?? new Date();
|
||||
insertEpisodeWatch(userId, episodeId, now, source);
|
||||
|
||||
// Find the title for this episode (single JOIN instead of 2 queries)
|
||||
// Find the title for this episode
|
||||
const titleId = getEpisodeTitleId(episodeId);
|
||||
if (!titleId) return;
|
||||
|
||||
// Auto-set status to in_progress if not set
|
||||
// Auto-set status to in_progress if not set or still on watchlist
|
||||
const existing = getTitleStatus(userId, titleId);
|
||||
|
||||
if (!existing || existing.status === "watchlist") {
|
||||
setTitleStatus(userId, titleId, "in_progress", source);
|
||||
}
|
||||
|
||||
// Check if all episodes are watched -> auto-complete
|
||||
checkAllEpisodesWatched(userId, titleId);
|
||||
}
|
||||
|
||||
export function logEpisodeWatchBatch(
|
||||
@@ -104,33 +104,24 @@ export function markAllEpisodesWatched(
|
||||
|
||||
batchInsertMissingEpisodeWatches(userId, epIds, existingWatches, source, now);
|
||||
|
||||
setTitleStatus(userId, titleId, "completed", source);
|
||||
}
|
||||
|
||||
function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
const epIds = getAllEpisodeIdsForTitle(titleId);
|
||||
|
||||
const totalEpisodes = epIds.length;
|
||||
if (totalEpisodes === 0) return;
|
||||
|
||||
const watchCount = countDistinctEpisodeWatches(userId, epIds);
|
||||
|
||||
if (watchCount >= totalEpisodes) {
|
||||
setTitleStatus(userId, titleId, "completed");
|
||||
}
|
||||
// TV never stores 'completed' — set in_progress and let display status derive the rest
|
||||
setTitleStatus(userId, titleId, "in_progress", source);
|
||||
}
|
||||
|
||||
export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
deleteEpisodeWatch(userId, episodeId);
|
||||
|
||||
// Find parent title and downgrade from completed to in_progress
|
||||
const titleId = getEpisodeTitleId(episodeId);
|
||||
if (!titleId) return;
|
||||
|
||||
const existing = getTitleStatus(userId, titleId);
|
||||
if (!existing || existing.status !== "in_progress") return;
|
||||
|
||||
if (existing?.status === "completed") {
|
||||
setTitleStatus(userId, titleId, "in_progress");
|
||||
// If no episodes remain watched, downgrade to watchlist
|
||||
const epIds = getAllEpisodeIdsForTitle(titleId);
|
||||
const watchCount = countDistinctEpisodeWatches(userId, epIds);
|
||||
if (watchCount === 0) {
|
||||
setTitleStatus(userId, titleId, "watchlist");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,14 +133,17 @@ export function unwatchSeason(userId: string, seasonId: string) {
|
||||
deleteEpisodeWatches(userId, epIds);
|
||||
}
|
||||
|
||||
// Find parent title and downgrade from completed to in_progress
|
||||
const season = getSeasonById(seasonId);
|
||||
if (!season) return;
|
||||
|
||||
const existing = getTitleStatus(userId, season.titleId);
|
||||
if (!existing || existing.status !== "in_progress") return;
|
||||
|
||||
if (existing?.status === "completed") {
|
||||
setTitleStatus(userId, season.titleId, "in_progress");
|
||||
// If no episodes remain watched, downgrade to watchlist
|
||||
const allEpIds = getAllEpisodeIdsForTitle(season.titleId);
|
||||
const watchCount = countDistinctEpisodeWatches(userId, allEpIds);
|
||||
if (watchCount === 0) {
|
||||
setTitleStatus(userId, season.titleId, "watchlist");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +176,46 @@ export function getUserStatusesByTitleIds(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive display statuses for a set of titles.
|
||||
* Resolves stored status + episode progress + TMDB show status into display status.
|
||||
*/
|
||||
export function getDisplayStatusesByTitleIds(
|
||||
userId: string,
|
||||
titleIds: string[],
|
||||
): Record<string, DisplayStatus> {
|
||||
if (titleIds.length === 0) return {};
|
||||
|
||||
const storedStatuses = getUserStatusesByTitleIds(userId, titleIds);
|
||||
const statusEntries = Object.entries(storedStatuses);
|
||||
if (statusEntries.length === 0) return {};
|
||||
|
||||
// Find TV titles with in_progress status that need episode progress resolution
|
||||
const tvInProgressIds = statusEntries
|
||||
.filter(([, status]) => status === "in_progress")
|
||||
.map(([id]) => id);
|
||||
|
||||
// Fetch title data for type + TMDB status
|
||||
const trackedIds = statusEntries.map(([id]) => id);
|
||||
const titles = getTitlesByIds(trackedIds);
|
||||
const titleMap = new Map(titles.map((t) => [t.id, t]));
|
||||
|
||||
// Fetch episode progress for TV in_progress titles
|
||||
const episodeProgress =
|
||||
tvInProgressIds.length > 0 ? getEpisodeProgressByTitleIds(userId, tvInProgressIds) : {};
|
||||
|
||||
const result: Record<string, DisplayStatus> = {};
|
||||
for (const [titleId, storedStatus] of statusEntries) {
|
||||
const title = titleMap.get(titleId);
|
||||
const titleType = (title?.type ?? "movie") as "movie" | "tv";
|
||||
const tmdbStatus = title?.status ?? null;
|
||||
const progress = episodeProgress[titleId] ?? null;
|
||||
|
||||
result[titleId] = getDisplayStatus(storedStatus, titleType, tmdbStatus, progress);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getEpisodeProgressByTitleIds(
|
||||
userId: string,
|
||||
titleIds: string[],
|
||||
|
||||
Reference in New Issue
Block a user