mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
feat(tracking): add a paginated watch history and Last watched library sort
Adds tracking.history (cursor-paginated, movies and episodes merged), a last_watched library sort, and a /history web page. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
30 files changed
+1711
-6
No files matched your search
@@ -7,7 +7,14 @@ import { MenuTrigger } from "@/components/ui/menu-trigger";
|
||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
type SortBy = "added_at" | "title" | "release_date" | "user_rating" | "vote_average" | "popularity";
|
||||
type SortBy =
|
||||
| "added_at"
|
||||
| "title"
|
||||
| "release_date"
|
||||
| "user_rating"
|
||||
| "vote_average"
|
||||
| "popularity"
|
||||
| "last_watched";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
interface SortOption {
|
||||
@@ -33,6 +40,7 @@ export function SortMenu({ sortBy, sortDirection, onSortChange }: SortMenuProps)
|
||||
{ sortBy: "release_date", sortDirection: "desc", label: t`Release Date` },
|
||||
{ sortBy: "user_rating", sortDirection: "desc", label: t`User Rating` },
|
||||
{ sortBy: "vote_average", sortDirection: "desc", label: t`TMDB Rating` },
|
||||
{ sortBy: "last_watched", sortDirection: "desc", label: t`Last watched` },
|
||||
{ sortBy: "popularity", sortDirection: "desc", label: t`Popularity` },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ export type SortBy =
|
||||
| "release_date"
|
||||
| "user_rating"
|
||||
| "vote_average"
|
||||
| "popularity";
|
||||
| "popularity"
|
||||
| "last_watched";
|
||||
|
||||
export const libraryActiveFilterCountAtom = atom(0);
|
||||
export const librarySortByAtom = atom<SortBy>("added_at");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
|
||||
import {
|
||||
getDisplayStatusesByTitleIds,
|
||||
getUserTitleInfo,
|
||||
listWatchHistory,
|
||||
logEpisodeWatch,
|
||||
logEpisodeWatchBatch,
|
||||
logMovieWatch,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
watchSeason,
|
||||
} from "@sofa/core/tracking";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
@@ -122,3 +124,21 @@ export const stats = os.tracking.stats.use(authed).handler(({ input, context })
|
||||
const history = getWatchHistory(context.user.id, coreType, input.period);
|
||||
return { count, history };
|
||||
});
|
||||
|
||||
export const history = os.tracking.history.use(authed).handler(({ input, context }) => {
|
||||
const result = listWatchHistory(context.user.id, input);
|
||||
return {
|
||||
items: result.items.map((item) => ({
|
||||
watchId: item.watchId,
|
||||
kind: item.kind,
|
||||
watchedAt: item.watchedAt.toISOString(),
|
||||
source: item.source,
|
||||
title: {
|
||||
...item.title,
|
||||
posterPath: tmdbImageUrl(item.title.posterPath, "posters"),
|
||||
},
|
||||
episode: item.episode,
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
};
|
||||
});
|
||||
@@ -21,6 +21,7 @@ export const implementedRouter = {
|
||||
rate: tracking.rate,
|
||||
userInfo: tracking.userInfo,
|
||||
stats: tracking.stats,
|
||||
history: tracking.history,
|
||||
},
|
||||
library: {
|
||||
list: library.list,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconMovie } from "@tabler/icons-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { thumbHashToUrl } from "@/lib/thumbhash";
|
||||
import type { WatchHistoryItemSchema } from "@sofa/api/schemas";
|
||||
import { formatDate } from "@sofa/i18n/format";
|
||||
|
||||
type WatchHistoryItem = z.infer<typeof WatchHistoryItemSchema>;
|
||||
|
||||
export function HistoryRow({ item }: { item: WatchHistoryItem }) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const sourceLabels = {
|
||||
manual: t`Manual`,
|
||||
import: t`Import`,
|
||||
plex: t`Plex`,
|
||||
jellyfin: t`Jellyfin`,
|
||||
emby: t`Emby`,
|
||||
} as const;
|
||||
|
||||
let subtitle: string | null = null;
|
||||
if (item.episode) {
|
||||
const season = item.episode.seasonNumber;
|
||||
const episode = item.episode.episodeNumber;
|
||||
const name = item.episode.name;
|
||||
subtitle = name ? t`S${season} E${episode} · ${name}` : t`S${season} E${episode}`;
|
||||
}
|
||||
|
||||
const time = formatDate(item.watchedAt, {
|
||||
year: undefined,
|
||||
month: undefined,
|
||||
day: undefined,
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/titles/$id"
|
||||
params={{ id: item.title.id }}
|
||||
className="group bg-card/40 hover:bg-card/60 hover:ring-primary/25 flex items-center gap-4 rounded-xl p-3 ring-1 ring-white/[0.06] transition-[background,ring-color] duration-200"
|
||||
>
|
||||
<div className="relative h-[66px] w-11 shrink-0 overflow-hidden rounded-lg ring-1 ring-white/[0.06]">
|
||||
{item.title.posterPath ? (
|
||||
<img
|
||||
src={item.title.posterPath}
|
||||
alt=""
|
||||
className="size-full object-cover"
|
||||
loading="lazy"
|
||||
{...(item.title.posterThumbHash
|
||||
? {
|
||||
style: {
|
||||
background: `url(${thumbHashToUrl(item.title.posterThumbHash)}) center/cover`,
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted flex size-full items-center justify-center">
|
||||
<IconMovie className="text-muted-foreground size-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{item.title.title}</div>
|
||||
{subtitle && <div className="text-muted-foreground mt-1 truncate text-xs">{subtitle}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className="text-muted-foreground text-xs tabular-nums">{time}</span>
|
||||
<span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase">
|
||||
{sourceLabels[item.source]}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export function LibraryToolbar({
|
||||
{ label: t`Release Date`, sortBy: "release_date", direction: "desc" },
|
||||
{ label: t`User Rating`, sortBy: "user_rating", direction: "desc" },
|
||||
{ label: t`TMDB Rating`, sortBy: "vote_average", direction: "desc" },
|
||||
{ label: t`Last watched`, sortBy: "last_watched", direction: "desc" },
|
||||
{ label: t`Popularity`, sortBy: "popularity", direction: "desc" },
|
||||
],
|
||||
[t],
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
IconBooks,
|
||||
IconCalendarEvent,
|
||||
IconCompass,
|
||||
IconHistory,
|
||||
IconHome,
|
||||
IconLogout,
|
||||
IconSearch,
|
||||
@@ -125,6 +126,7 @@ export function NavBar({
|
||||
{ href: "/library", label: t`Library` },
|
||||
{ href: "/explore", label: t`Explore` },
|
||||
{ href: "/upcoming", label: t`Upcoming` },
|
||||
{ href: "/history", label: t`History` },
|
||||
] as const;
|
||||
|
||||
const initial = userName?.charAt(0).toUpperCase() ?? "?";
|
||||
@@ -289,6 +291,7 @@ export function MobileTabBar() {
|
||||
{ href: "/library", label: t`Library`, icon: IconBooks },
|
||||
{ href: "/explore", label: t`Explore`, icon: IconCompass },
|
||||
{ href: "/upcoming", label: t`Upcoming`, icon: IconCalendarEvent },
|
||||
{ href: "/history", label: t`History`, icon: IconHistory },
|
||||
{ href: "/settings", label: t`Settings`, icon: IconSettings },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Route as AuthRouteImport } from './routes/_auth'
|
||||
import { Route as SetupRouteImport } from './routes/setup'
|
||||
import { Route as AppDashboardRouteImport } from './routes/_app/dashboard'
|
||||
import { Route as AppExploreRouteImport } from './routes/_app/explore'
|
||||
import { Route as AppHistoryRouteImport } from './routes/_app/history'
|
||||
import { Route as AppLibraryRouteImport } from './routes/_app/library'
|
||||
import { Route as AppOnboardingRouteImport } from './routes/_app/onboarding'
|
||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||
@@ -52,6 +53,11 @@ const AppExploreRoute = AppExploreRouteImport.update({
|
||||
path: '/explore',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppHistoryRoute = AppHistoryRouteImport.update({
|
||||
id: '/history',
|
||||
path: '/history',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppLibraryRoute = AppLibraryRouteImport.update({
|
||||
id: '/library',
|
||||
path: '/library',
|
||||
@@ -98,6 +104,7 @@ export interface FileRoutesByFullPath {
|
||||
'/setup': typeof SetupRoute
|
||||
'/dashboard': typeof AppDashboardRoute
|
||||
'/explore': typeof AppExploreRoute
|
||||
'/history': typeof AppHistoryRoute
|
||||
'/library': typeof AppLibraryRoute
|
||||
'/onboarding': typeof AppOnboardingRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
@@ -112,6 +119,7 @@ export interface FileRoutesByTo {
|
||||
'/setup': typeof SetupRoute
|
||||
'/dashboard': typeof AppDashboardRoute
|
||||
'/explore': typeof AppExploreRoute
|
||||
'/history': typeof AppHistoryRoute
|
||||
'/library': typeof AppLibraryRoute
|
||||
'/onboarding': typeof AppOnboardingRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
@@ -129,6 +137,7 @@ export interface FileRoutesById {
|
||||
'/setup': typeof SetupRoute
|
||||
'/_app/dashboard': typeof AppDashboardRoute
|
||||
'/_app/explore': typeof AppExploreRoute
|
||||
'/_app/history': typeof AppHistoryRoute
|
||||
'/_app/library': typeof AppLibraryRoute
|
||||
'/_app/onboarding': typeof AppOnboardingRoute
|
||||
'/_app/settings': typeof AppSettingsRoute
|
||||
@@ -145,6 +154,7 @@ export interface FileRouteTypes {
|
||||
| '/setup'
|
||||
| '/dashboard'
|
||||
| '/explore'
|
||||
| '/history'
|
||||
| '/library'
|
||||
| '/onboarding'
|
||||
| '/settings'
|
||||
@@ -159,6 +169,7 @@ export interface FileRouteTypes {
|
||||
| '/setup'
|
||||
| '/dashboard'
|
||||
| '/explore'
|
||||
| '/history'
|
||||
| '/library'
|
||||
| '/onboarding'
|
||||
| '/settings'
|
||||
@@ -175,6 +186,7 @@ export interface FileRouteTypes {
|
||||
| '/setup'
|
||||
| '/_app/dashboard'
|
||||
| '/_app/explore'
|
||||
| '/_app/history'
|
||||
| '/_app/library'
|
||||
| '/_app/onboarding'
|
||||
| '/_app/settings'
|
||||
@@ -236,6 +248,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppExploreRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/_app/history': {
|
||||
id: '/_app/history'
|
||||
path: '/history'
|
||||
fullPath: '/history'
|
||||
preLoaderRoute: typeof AppHistoryRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/_app/library': {
|
||||
id: '/_app/library'
|
||||
path: '/library'
|
||||
@@ -298,6 +317,7 @@ declare module '@tanstack/react-router' {
|
||||
interface AppRouteChildren {
|
||||
AppDashboardRoute: typeof AppDashboardRoute
|
||||
AppExploreRoute: typeof AppExploreRoute
|
||||
AppHistoryRoute: typeof AppHistoryRoute
|
||||
AppLibraryRoute: typeof AppLibraryRoute
|
||||
AppOnboardingRoute: typeof AppOnboardingRoute
|
||||
AppSettingsRoute: typeof AppSettingsRoute
|
||||
@@ -309,6 +329,7 @@ interface AppRouteChildren {
|
||||
const AppRouteChildren: AppRouteChildren = {
|
||||
AppDashboardRoute: AppDashboardRoute,
|
||||
AppExploreRoute: AppExploreRoute,
|
||||
AppHistoryRoute: AppHistoryRoute,
|
||||
AppLibraryRoute: AppLibraryRoute,
|
||||
AppOnboardingRoute: AppOnboardingRoute,
|
||||
AppSettingsRoute: AppSettingsRoute,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { IconHistory } from "@tabler/icons-react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { HistoryRow } from "@/components/history/history-row";
|
||||
import { RouteError } from "@/components/route-error";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import { formatLocalDate } from "@sofa/i18n/date-buckets";
|
||||
import { formatDate } from "@sofa/i18n/format";
|
||||
|
||||
const historySearchSchema = z.object({
|
||||
type: z.enum(["all", "movie", "tv"]).optional().catch(undefined),
|
||||
source: z
|
||||
.enum(["all", "manual", "import", "plex", "jellyfin", "emby"])
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
});
|
||||
|
||||
type SourceFilter = "manual" | "import" | "plex" | "jellyfin" | "emby";
|
||||
|
||||
export const Route = createFileRoute("/_app/history")({
|
||||
validateSearch: zodValidator(historySearchSchema),
|
||||
staleTime: 30_000,
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureInfiniteQueryData(
|
||||
orpc.tracking.history.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
limit: 30,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
head: () => ({ meta: [{ title: "History — Sofa" }] }),
|
||||
pendingComponent: HistorySkeleton,
|
||||
errorComponent: RouteError,
|
||||
component: HistoryPage,
|
||||
});
|
||||
|
||||
function HistorySkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-64" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="flex items-center gap-3.5 py-2">
|
||||
<Skeleton className="size-[52px] rounded-md" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryPage() {
|
||||
const { t } = useLingui();
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const typeFilter = search.type ?? "all";
|
||||
const sourceFilter = search.source ?? "all";
|
||||
|
||||
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } =
|
||||
useInfiniteQuery(
|
||||
orpc.tracking.history.infiniteOptions({
|
||||
// Omit filter keys (rather than sending "all") so the default input
|
||||
// matches the loader's prefetch key.
|
||||
input: (pageParam: string | undefined) => ({
|
||||
limit: 30,
|
||||
cursor: pageParam,
|
||||
...(typeFilter !== "all" ? { type: typeFilter } : {}),
|
||||
...(sourceFilter !== "all" ? { source: sourceFilter } : {}),
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const sentinelRef = useInfiniteScroll({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
});
|
||||
|
||||
function setTypeFilter(value: string) {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
type: value === "all" ? undefined : (value as "movie" | "tv"),
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
function setSourceFilter(value: string) {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
source: value === "all" ? undefined : (value as SourceFilter),
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
const header = (
|
||||
<div>
|
||||
<h1 className="font-display text-2xl tracking-tight">{t`History`}</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Everything you've watched, newest first.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const filterToggles = (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ToggleGroup
|
||||
value={[typeFilter]}
|
||||
onValueChange={(values) => setTypeFilter(values.find((v) => v !== typeFilter) ?? "all")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="movie">{t`Movies`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tv">{t`TV Shows`}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
value={[sourceFilter]}
|
||||
onValueChange={(values) => setSourceFilter(values.find((v) => v !== sourceFilter) ?? "all")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="manual">{t`Manual`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="import">{t`Import`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="plex">{t`Plex`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="jellyfin">{t`Jellyfin`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="emby">{t`Emby`}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isPending) return <HistorySkeleton />;
|
||||
|
||||
const allItems = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (allItems.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
{header}
|
||||
{filterToggles}
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<IconHistory className="text-muted-foreground/40 size-12" />
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
<Trans>Nothing watched yet.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group by local calendar day, preserving newest-first order.
|
||||
const days: { key: string; label: string; items: typeof allItems }[] = [];
|
||||
for (const item of allItems) {
|
||||
const date = new Date(item.watchedAt);
|
||||
const key = formatLocalDate(date);
|
||||
const last = days[days.length - 1];
|
||||
if (last && last.key === key) {
|
||||
last.items.push(item);
|
||||
} else {
|
||||
days.push({
|
||||
key,
|
||||
label: formatDate(date, { weekday: "long" }),
|
||||
items: [item],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
{header}
|
||||
{filterToggles}
|
||||
{days.map((day) => (
|
||||
<section key={day.key}>
|
||||
<h2 className="font-display text-muted-foreground mb-2 text-sm font-medium tracking-wider uppercase">
|
||||
{day.label}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{day.items.map((item) => (
|
||||
<HistoryRow key={item.watchId} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<div ref={sentinelRef} />
|
||||
{isFetchingNextPage && <HistorySkeleton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: List watch history
|
||||
full: true
|
||||
_openapi:
|
||||
preload:
|
||||
- ./public/openapi.json
|
||||
method: GET
|
||||
webhook: false
|
||||
toc: []
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: List the user's watches (movies and episodes), newest first, with
|
||||
cursor pagination.
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
export default function Layout(props) {
|
||||
const { OpenAPIPage } = props.components ?? {};
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<OpenAPIPage document="./public/openapi.json" operations={[{"path":"/tracking/history","method":"get"}]} showDescription />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+241
-1
@@ -2294,6 +2294,245 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/tracking/history": {
|
||||
"get": {
|
||||
"operationId": "tracking.history",
|
||||
"summary": "List watch history",
|
||||
"description": "List the user's watches (movies and episodes), newest first, with cursor pagination.",
|
||||
"tags": [
|
||||
"Tracking"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"default": 30,
|
||||
"description": "Maximum items to return"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Opaque cursor from a previous response's nextCursor"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"movie",
|
||||
"tv"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Only movies or only TV episodes"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"manual",
|
||||
"import",
|
||||
"plex",
|
||||
"jellyfin",
|
||||
"emby"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Only watches logged by this source"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A page of watch history",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"watchId": {
|
||||
"type": "string",
|
||||
"description": "Watch record ID"
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"movie",
|
||||
"episode"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Whether this watch is a movie or an episode"
|
||||
},
|
||||
"watchedAt": {
|
||||
"type": "string",
|
||||
"description": "ISO 8601 timestamp of the watch"
|
||||
},
|
||||
"source": {
|
||||
"enum": [
|
||||
"manual",
|
||||
"import",
|
||||
"plex",
|
||||
"jellyfin",
|
||||
"emby"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "How the watch was logged"
|
||||
},
|
||||
"title": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Title ID"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"movie",
|
||||
"tv"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Title type"
|
||||
},
|
||||
"posterPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Poster image URL"
|
||||
},
|
||||
"posterThumbHash": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Poster ThumbHash placeholder"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"type",
|
||||
"posterPath",
|
||||
"posterThumbHash"
|
||||
],
|
||||
"description": "The movie or show"
|
||||
},
|
||||
"episode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Episode ID"
|
||||
},
|
||||
"seasonNumber": {
|
||||
"type": "number",
|
||||
"description": "Season number"
|
||||
},
|
||||
"episodeNumber": {
|
||||
"type": "number",
|
||||
"description": "Episode number"
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Episode name"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"seasonNumber",
|
||||
"episodeNumber",
|
||||
"name"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Episode details (null for movies)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"watchId",
|
||||
"kind",
|
||||
"watchedAt",
|
||||
"source",
|
||||
"title",
|
||||
"episode"
|
||||
],
|
||||
"description": "A single watch event"
|
||||
},
|
||||
"description": "Watches, newest first"
|
||||
},
|
||||
"nextCursor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Cursor for the next page, or null at the end"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items",
|
||||
"nextCursor"
|
||||
],
|
||||
"description": "A page of watch history"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"session": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/library": {
|
||||
"get": {
|
||||
"operationId": "library.list",
|
||||
@@ -2439,7 +2678,8 @@
|
||||
"release_date",
|
||||
"popularity",
|
||||
"user_rating",
|
||||
"vote_average"
|
||||
"vote_average",
|
||||
"last_watched"
|
||||
],
|
||||
"type": "string",
|
||||
"default": "added_at",
|
||||
|
||||
@@ -62,6 +62,8 @@ import {
|
||||
UserInfoOutput,
|
||||
UserPlatformsOutput,
|
||||
WatchHistoryInput,
|
||||
WatchHistoryListInput,
|
||||
WatchHistoryListOutput,
|
||||
WatchHistoryOutput,
|
||||
WatchInput,
|
||||
} from "./schemas";
|
||||
@@ -183,6 +185,18 @@ export const contract = {
|
||||
})
|
||||
.input(WatchHistoryInput)
|
||||
.output(WatchHistoryOutput),
|
||||
history: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/tracking/history",
|
||||
tags: ["Tracking"],
|
||||
summary: "List watch history",
|
||||
description:
|
||||
"List the user's watches (movies and episodes), newest first, with cursor pagination.",
|
||||
successDescription: "A page of watch history",
|
||||
})
|
||||
.input(WatchHistoryListInput)
|
||||
.output(WatchHistoryListOutput),
|
||||
},
|
||||
|
||||
// ─── Library ────────────────────────────────────────────────
|
||||
|
||||
@@ -134,6 +134,53 @@ export const WatchHistoryInput = z
|
||||
})
|
||||
.meta({ description: "Filters for watch history chart data" });
|
||||
|
||||
// ─── Watch history list ────────────────────────────────────────
|
||||
|
||||
const watchSource = z.enum(["manual", "import", "plex", "jellyfin", "emby"]);
|
||||
|
||||
export const WatchHistoryListInput = z
|
||||
.object({
|
||||
limit: z.number().int().min(1).max(50).default(30).describe("Maximum items to return"),
|
||||
cursor: z.string().optional().describe("Opaque cursor from a previous response's nextCursor"),
|
||||
type: z.enum(["movie", "tv"]).optional().describe("Only movies or only TV episodes"),
|
||||
source: watchSource.optional().describe("Only watches logged by this source"),
|
||||
})
|
||||
.meta({ description: "Filters and pagination for watch history" });
|
||||
|
||||
export const WatchHistoryItemSchema = z
|
||||
.object({
|
||||
watchId: z.string().describe("Watch record ID"),
|
||||
kind: z.enum(["movie", "episode"]).describe("Whether this watch is a movie or an episode"),
|
||||
watchedAt: z.string().describe("ISO 8601 timestamp of the watch"),
|
||||
source: watchSource.describe("How the watch was logged"),
|
||||
title: z
|
||||
.object({
|
||||
id: z.string().describe("Title ID"),
|
||||
title: z.string().describe("Display title"),
|
||||
type: z.enum(["movie", "tv"]).describe("Title type"),
|
||||
posterPath: z.string().nullable().describe("Poster image URL"),
|
||||
posterThumbHash: z.string().nullable().describe("Poster ThumbHash placeholder"),
|
||||
})
|
||||
.describe("The movie or show"),
|
||||
episode: z
|
||||
.object({
|
||||
id: z.string().describe("Episode ID"),
|
||||
seasonNumber: z.number().describe("Season number"),
|
||||
episodeNumber: z.number().describe("Episode number"),
|
||||
name: z.string().nullable().describe("Episode name"),
|
||||
})
|
||||
.nullable()
|
||||
.describe("Episode details (null for movies)"),
|
||||
})
|
||||
.meta({ description: "A single watch event" });
|
||||
|
||||
export const WatchHistoryListOutput = z
|
||||
.object({
|
||||
items: z.array(WatchHistoryItemSchema).describe("Watches, newest first"),
|
||||
nextCursor: z.string().nullable().describe("Cursor for the next page, or null at the end"),
|
||||
})
|
||||
.meta({ description: "A page of watch history" });
|
||||
|
||||
// ─── Integration inputs ────────────────────────────────────────
|
||||
|
||||
export const CreateIntegrationInput = z
|
||||
@@ -603,7 +650,15 @@ export const LibraryListInput = z
|
||||
.optional()
|
||||
.describe("Only show titles available on the user's streaming services"),
|
||||
sortBy: z
|
||||
.enum(["title", "added_at", "release_date", "popularity", "user_rating", "vote_average"])
|
||||
.enum([
|
||||
"title",
|
||||
"added_at",
|
||||
"release_date",
|
||||
"popularity",
|
||||
"user_rating",
|
||||
"vote_average",
|
||||
"last_watched",
|
||||
])
|
||||
.default("added_at")
|
||||
.describe("Sort field"),
|
||||
sortDirection: z.enum(["asc", "desc"]).default("desc").describe("Sort direction"),
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
getEpisodeProgressByTitleIds as getEpisodeProgressByTitleIdsQuery,
|
||||
getEpisodeTitleId,
|
||||
getEpisodeTitleIds,
|
||||
getEpisodeWatchHistory,
|
||||
getExistingEpisodeWatchIds,
|
||||
getMovieWatchHistory,
|
||||
getSeasonById,
|
||||
getSeasonEpisodeIds,
|
||||
getTitleStatus,
|
||||
@@ -289,3 +291,133 @@ export function quickAddTitle(
|
||||
export function watchSeason(userId: string, seasonId: string): void {
|
||||
logEpisodeWatchBatch(userId, getSeasonEpisodeIds(seasonId));
|
||||
}
|
||||
|
||||
// ─── Watch history timeline ─────────────────────────────────────────
|
||||
|
||||
type WatchSource = "manual" | "import" | "plex" | "jellyfin" | "emby";
|
||||
|
||||
interface WatchHistoryCursorKey {
|
||||
/** watchedAt in epoch ms */
|
||||
t: number;
|
||||
/** watch row id */
|
||||
i: string;
|
||||
}
|
||||
|
||||
function encodeWatchHistoryCursor(key: WatchHistoryCursorKey): string {
|
||||
return Buffer.from(JSON.stringify(key), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeWatchHistoryCursor(cursor: string): WatchHistoryCursorKey | null {
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
||||
if (
|
||||
typeof parsed?.t !== "number" ||
|
||||
!Number.isFinite(parsed.t) ||
|
||||
typeof parsed?.i !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { t: parsed.t, i: parsed.i };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface WatchHistoryEntry {
|
||||
watchId: string;
|
||||
kind: "movie" | "episode";
|
||||
watchedAt: Date;
|
||||
source: WatchSource;
|
||||
title: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: "movie" | "tv";
|
||||
posterPath: string | null;
|
||||
posterThumbHash: string | null;
|
||||
};
|
||||
episode: {
|
||||
id: string;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the user's watch history (movies + episodes merged), newest first.
|
||||
* Keyset-paginated on (watchedAt desc, id desc). Image paths are raw DB paths.
|
||||
*/
|
||||
export function listWatchHistory(
|
||||
userId: string,
|
||||
input: { limit: number; cursor?: string; type?: "movie" | "tv"; source?: WatchSource },
|
||||
): { items: WatchHistoryEntry[]; nextCursor: string | null } {
|
||||
const { limit, type, source } = input;
|
||||
const key = input.cursor ? decodeWatchHistoryCursor(input.cursor) : null;
|
||||
const opts = {
|
||||
limit: limit + 1,
|
||||
source,
|
||||
before: key ? { watchedAt: new Date(key.t), id: key.i } : undefined,
|
||||
};
|
||||
|
||||
const entries: WatchHistoryEntry[] = [];
|
||||
|
||||
if (type !== "tv") {
|
||||
for (const r of getMovieWatchHistory(userId, opts)) {
|
||||
entries.push({
|
||||
watchId: r.watchId,
|
||||
kind: "movie",
|
||||
watchedAt: r.watchedAt,
|
||||
source: r.source,
|
||||
title: {
|
||||
id: r.titleId,
|
||||
title: r.title,
|
||||
type: r.type,
|
||||
posterPath: r.posterPath,
|
||||
posterThumbHash: r.posterThumbHash,
|
||||
},
|
||||
episode: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type !== "movie") {
|
||||
for (const r of getEpisodeWatchHistory(userId, opts)) {
|
||||
entries.push({
|
||||
watchId: r.watchId,
|
||||
kind: "episode",
|
||||
watchedAt: r.watchedAt,
|
||||
source: r.source,
|
||||
title: {
|
||||
id: r.titleId,
|
||||
title: r.title,
|
||||
type: r.type,
|
||||
posterPath: r.posterPath,
|
||||
posterThumbHash: r.posterThumbHash,
|
||||
},
|
||||
episode: {
|
||||
id: r.episodeId,
|
||||
seasonNumber: r.seasonNumber,
|
||||
episodeNumber: r.episodeNumber,
|
||||
name: r.episodeName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort(
|
||||
(a, b) =>
|
||||
b.watchedAt.getTime() - a.watchedAt.getTime() ||
|
||||
(a.watchId < b.watchId ? 1 : a.watchId > b.watchId ? -1 : 0),
|
||||
);
|
||||
|
||||
const hasMore = entries.length > limit;
|
||||
const items = entries.slice(0, limit);
|
||||
const last = items[items.length - 1];
|
||||
return {
|
||||
items,
|
||||
nextCursor:
|
||||
hasMore && last
|
||||
? encodeWatchHistoryCursor({ t: last.watchedAt.getTime(), i: last.watchId })
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { userEpisodeWatches, userMovieWatches } from "@sofa/db/schema";
|
||||
import { clearAllTables, insertTitle, insertTvShow, insertUser, testDb } from "@sofa/test/db";
|
||||
|
||||
import { listWatchHistory } from "../src/tracking";
|
||||
|
||||
type Source = "manual" | "import" | "plex" | "jellyfin" | "emby";
|
||||
|
||||
function movieWatch(
|
||||
id: string,
|
||||
userId: string,
|
||||
titleId: string,
|
||||
at: number,
|
||||
source: Source = "manual",
|
||||
) {
|
||||
testDb
|
||||
.insert(userMovieWatches)
|
||||
.values({ id, userId, titleId, watchedAt: new Date(at * 1000), source })
|
||||
.run();
|
||||
}
|
||||
|
||||
function episodeWatch(
|
||||
id: string,
|
||||
userId: string,
|
||||
episodeId: string,
|
||||
at: number,
|
||||
source: Source = "manual",
|
||||
) {
|
||||
testDb
|
||||
.insert(userEpisodeWatches)
|
||||
.values({ id, userId, episodeId, watchedAt: new Date(at * 1000), source })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
insertUser("user-1");
|
||||
insertUser("user-2");
|
||||
insertTitle({ id: "m1", tmdbId: 1, title: "Movie One" });
|
||||
insertTitle({ id: "m2", tmdbId: 2, title: "Movie Two" });
|
||||
insertTvShow("tv-1", 100, 1, 3);
|
||||
});
|
||||
|
||||
function ids(items: { watchId: string }[]) {
|
||||
return items.map((i) => i.watchId);
|
||||
}
|
||||
|
||||
describe("listWatchHistory", () => {
|
||||
test("merges movies and episodes newest-first with episode details", () => {
|
||||
movieWatch("w1", "user-1", "m1", 1000);
|
||||
episodeWatch("w2", "user-1", "tv-1-s1e1", 3000);
|
||||
movieWatch("w3", "user-1", "m2", 2000);
|
||||
|
||||
const { items, nextCursor } = listWatchHistory("user-1", { limit: 10 });
|
||||
expect(ids(items)).toEqual(["w2", "w3", "w1"]);
|
||||
expect(nextCursor).toBeNull();
|
||||
expect(items[0].kind).toBe("episode");
|
||||
expect(items[0].episode).toMatchObject({ seasonNumber: 1, episodeNumber: 1 });
|
||||
expect(items[0].title.id).toBe("tv-1");
|
||||
expect(items[1].kind).toBe("movie");
|
||||
expect(items[1].episode).toBeNull();
|
||||
});
|
||||
|
||||
test("respects limit and returns null cursor on the last page", () => {
|
||||
for (let i = 1; i <= 5; i++) movieWatch(`w${i}`, "user-1", "m1", i * 100);
|
||||
|
||||
const p1 = listWatchHistory("user-1", { limit: 2 });
|
||||
expect(ids(p1.items)).toEqual(["w5", "w4"]);
|
||||
expect(p1.nextCursor).not.toBeNull();
|
||||
|
||||
const p2 = listWatchHistory("user-1", { limit: 2, cursor: p1.nextCursor! });
|
||||
expect(ids(p2.items)).toEqual(["w3", "w2"]);
|
||||
expect(p2.nextCursor).not.toBeNull();
|
||||
|
||||
const p3 = listWatchHistory("user-1", { limit: 2, cursor: p2.nextCursor! });
|
||||
expect(ids(p3.items)).toEqual(["w1"]);
|
||||
expect(p3.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
test("paginates without duplicates or gaps when watches share a timestamp", () => {
|
||||
// Same watchedAt across movies and episodes; ties broken by id desc.
|
||||
movieWatch("a1", "user-1", "m1", 500);
|
||||
movieWatch("a3", "user-1", "m2", 500);
|
||||
episodeWatch("a2", "user-1", "tv-1-s1e1", 500);
|
||||
episodeWatch("a4", "user-1", "tv-1-s1e2", 500);
|
||||
movieWatch("a0", "user-1", "m1", 400);
|
||||
episodeWatch("a5", "user-1", "tv-1-s1e3", 600);
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let guard = 0; guard < 10; guard++) {
|
||||
const page = listWatchHistory("user-1", { limit: 2, cursor });
|
||||
seen.push(...ids(page.items));
|
||||
if (!page.nextCursor) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
expect(seen).toEqual(["a5", "a4", "a3", "a2", "a1", "a0"]);
|
||||
});
|
||||
|
||||
test("ignores an invalid cursor", () => {
|
||||
movieWatch("w1", "user-1", "m1", 100);
|
||||
const { items } = listWatchHistory("user-1", { limit: 10, cursor: "not-a-cursor" });
|
||||
expect(items).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("filters by type", () => {
|
||||
movieWatch("w1", "user-1", "m1", 100);
|
||||
episodeWatch("w2", "user-1", "tv-1-s1e1", 200);
|
||||
|
||||
expect(ids(listWatchHistory("user-1", { limit: 10, type: "movie" }).items)).toEqual(["w1"]);
|
||||
expect(ids(listWatchHistory("user-1", { limit: 10, type: "tv" }).items)).toEqual(["w2"]);
|
||||
});
|
||||
|
||||
test("filters by source", () => {
|
||||
movieWatch("w1", "user-1", "m1", 100, "plex");
|
||||
movieWatch("w2", "user-1", "m2", 200, "manual");
|
||||
episodeWatch("w3", "user-1", "tv-1-s1e1", 300, "plex");
|
||||
|
||||
const { items } = listWatchHistory("user-1", { limit: 10, source: "plex" });
|
||||
expect(ids(items)).toEqual(["w3", "w1"]);
|
||||
});
|
||||
|
||||
test("never includes other users' watches", () => {
|
||||
movieWatch("w1", "user-1", "m1", 100);
|
||||
movieWatch("w2", "user-2", "m1", 200);
|
||||
episodeWatch("w3", "user-2", "tv-1-s1e1", 300);
|
||||
|
||||
const { items } = listWatchHistory("user-1", { limit: 10 });
|
||||
expect(ids(items)).toEqual(["w1"]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,14 @@
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { clearAllTables, insertStatus, insertTitle, insertUser } from "@sofa/test/db";
|
||||
import {
|
||||
clearAllTables,
|
||||
insertEpisodeWatch,
|
||||
insertMovieWatch,
|
||||
insertStatus,
|
||||
insertTitle,
|
||||
insertTvShow,
|
||||
insertUser,
|
||||
} from "@sofa/test/db";
|
||||
|
||||
import { getFilteredLibraryFeed, getLibraryGenresList } from "../src/library";
|
||||
|
||||
@@ -136,6 +144,38 @@ describe("getFilteredLibraryFeed", () => {
|
||||
|
||||
// ── getLibraryGenresList ──────────────────────────────────────────────
|
||||
|
||||
describe("getFilteredLibraryFeed last_watched sort", () => {
|
||||
test("orders by most recent watch (movies and shows) with unwatched last", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "old-movie", tmdbId: 1 });
|
||||
insertTitle({ id: "never", tmdbId: 2 });
|
||||
insertTitle({ id: "recent-movie", tmdbId: 3 });
|
||||
const { episodeIds } = insertTvShow("show", 4, 1, 2);
|
||||
for (const id of ["old-movie", "never", "recent-movie", "show"]) {
|
||||
insertStatus("user-1", id, "watchlist");
|
||||
}
|
||||
insertMovieWatch("user-1", "old-movie", new Date("2024-01-01T00:00:00Z"));
|
||||
insertMovieWatch("user-1", "recent-movie", new Date("2024-03-01T00:00:00Z"));
|
||||
insertEpisodeWatch("user-1", episodeIds[0], new Date("2024-02-01T00:00:00Z"));
|
||||
insertEpisodeWatch("user-1", episodeIds[1], new Date("2024-02-10T00:00:00Z"));
|
||||
|
||||
const desc = getFilteredLibraryFeed("user-1", { ...defaultFilters, sortBy: "last_watched" });
|
||||
expect(desc.items.map((i) => i.titleId)).toEqual([
|
||||
"recent-movie",
|
||||
"show",
|
||||
"old-movie",
|
||||
"never",
|
||||
]);
|
||||
|
||||
const asc = getFilteredLibraryFeed("user-1", {
|
||||
...defaultFilters,
|
||||
sortBy: "last_watched",
|
||||
sortDirection: "asc",
|
||||
});
|
||||
expect(asc.items.map((i) => i.titleId)).toEqual(["old-movie", "show", "recent-movie", "never"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLibraryGenresList", () => {
|
||||
test("returns empty array when user has no titles", () => {
|
||||
insertUser();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
titleGenres,
|
||||
titles,
|
||||
userEpisodeWatches,
|
||||
userMovieWatches,
|
||||
userPlatforms,
|
||||
userRatings,
|
||||
userTitleStatus,
|
||||
@@ -220,6 +221,24 @@ export function getFilteredLibrary(userId: string, filters: LibraryFilters) {
|
||||
case "vote_average":
|
||||
sortExpressions.push(dirFn(titles.voteAverage));
|
||||
break;
|
||||
case "last_watched": {
|
||||
// Latest watch per title (movie watch, or any episode watch for a show);
|
||||
// never-watched titles sort last regardless of direction.
|
||||
const lastWatched = sql<number | null>`(
|
||||
SELECT MAX(w.watchedAt) FROM (
|
||||
SELECT ${userMovieWatches.watchedAt} AS watchedAt FROM ${userMovieWatches}
|
||||
WHERE ${userMovieWatches.titleId} = ${titles.id} AND ${userMovieWatches.userId} = ${userId}
|
||||
UNION ALL
|
||||
SELECT uew.watchedAt AS watchedAt FROM ${userEpisodeWatches} uew
|
||||
JOIN ${episodes} e ON e.id = uew.episodeId
|
||||
JOIN ${seasons} s ON s.id = e.seasonId
|
||||
WHERE s.titleId = ${titles.id} AND uew.userId = ${userId}
|
||||
) w
|
||||
)`;
|
||||
sortExpressions.push(asc(sql`CASE WHEN ${lastWatched} IS NULL THEN 1 ELSE 0 END`));
|
||||
sortExpressions.push(dirFn(lastWatched));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
sortExpressions.push(desc(userTitleStatus.addedAt));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm";
|
||||
|
||||
import { localDateString } from "@sofa/config";
|
||||
|
||||
@@ -349,3 +349,92 @@ export function deleteAllEpisodeWatchesForTitle(userId: string, titleId: string)
|
||||
if (titleEpisodeIds.length === 0) return;
|
||||
deleteEpisodeWatches(userId, titleEpisodeIds);
|
||||
}
|
||||
|
||||
// ─── Watch history ──────────────────────────────────────────────────
|
||||
|
||||
type WatchSource = "manual" | "import" | "plex" | "jellyfin" | "emby";
|
||||
|
||||
export interface WatchHistoryCursor {
|
||||
/** watchedAt in epoch ms */
|
||||
t: number;
|
||||
/** watch row id */
|
||||
i: string;
|
||||
}
|
||||
|
||||
export interface WatchHistoryQueryOptions {
|
||||
limit: number;
|
||||
source?: WatchSource;
|
||||
before?: { watchedAt: Date; id: string };
|
||||
}
|
||||
|
||||
export function getMovieWatchHistory(userId: string, opts: WatchHistoryQueryOptions) {
|
||||
const conditions = [eq(userMovieWatches.userId, userId)];
|
||||
if (opts.source) conditions.push(eq(userMovieWatches.source, opts.source));
|
||||
if (opts.before) {
|
||||
conditions.push(
|
||||
or(
|
||||
lt(userMovieWatches.watchedAt, opts.before.watchedAt),
|
||||
and(
|
||||
eq(userMovieWatches.watchedAt, opts.before.watchedAt),
|
||||
lt(userMovieWatches.id, opts.before.id),
|
||||
),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
return db
|
||||
.select({
|
||||
watchId: userMovieWatches.id,
|
||||
watchedAt: userMovieWatches.watchedAt,
|
||||
source: userMovieWatches.source,
|
||||
titleId: titles.id,
|
||||
title: titles.title,
|
||||
type: titles.type,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
})
|
||||
.from(userMovieWatches)
|
||||
.innerJoin(titles, eq(userMovieWatches.titleId, titles.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(userMovieWatches.watchedAt), desc(userMovieWatches.id))
|
||||
.limit(opts.limit)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getEpisodeWatchHistory(userId: string, opts: WatchHistoryQueryOptions) {
|
||||
const conditions = [eq(userEpisodeWatches.userId, userId)];
|
||||
if (opts.source) conditions.push(eq(userEpisodeWatches.source, opts.source));
|
||||
if (opts.before) {
|
||||
conditions.push(
|
||||
or(
|
||||
lt(userEpisodeWatches.watchedAt, opts.before.watchedAt),
|
||||
and(
|
||||
eq(userEpisodeWatches.watchedAt, opts.before.watchedAt),
|
||||
lt(userEpisodeWatches.id, opts.before.id),
|
||||
),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
return db
|
||||
.select({
|
||||
watchId: userEpisodeWatches.id,
|
||||
watchedAt: userEpisodeWatches.watchedAt,
|
||||
source: userEpisodeWatches.source,
|
||||
titleId: titles.id,
|
||||
title: titles.title,
|
||||
type: titles.type,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
episodeId: episodes.id,
|
||||
seasonNumber: seasons.seasonNumber,
|
||||
episodeNumber: episodes.episodeNumber,
|
||||
episodeName: episodes.name,
|
||||
})
|
||||
.from(userEpisodeWatches)
|
||||
.innerJoin(episodes, eq(userEpisodeWatches.episodeId, episodes.id))
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.innerJoin(titles, eq(seasons.titleId, titles.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(userEpisodeWatches.watchedAt), desc(userEpisodeWatches.id))
|
||||
.limit(opts.limit)
|
||||
.all();
|
||||
}
|
||||
@@ -378,6 +378,7 @@ msgstr "العمر {age}"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "الكل"
|
||||
@@ -1096,6 +1097,11 @@ msgstr "البريد الإلكتروني"
|
||||
msgid "Email is required"
|
||||
msgstr "البريد الإلكتروني مطلوب"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "فعّل نوع الإشعار \"Playback Stop\""
|
||||
@@ -1216,6 +1222,10 @@ msgstr "الأخطاء ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "كل {n} ساعة"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "الأعلى تقييماً"
|
||||
msgid "Hindi"
|
||||
msgstr "الهندية"
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "ذاكرة التخزين المؤقت للصور"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "استخدام القرص لذاكرة التخزين المؤقت للصور والنسخ الاحتياطية"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "الاستيراد"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "الإيطالية"
|
||||
msgid "Japanese"
|
||||
msgstr "اليابانية"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "المهمة"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "فشل آخر تشغيل"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "نجح آخر تشغيل"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "انقطع الاتصال بعملية الاستيراد. تحقق من
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "إدارة حسابك وتفضيلاتك"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "نسخة احتياطية يدوية"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "فيلم"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "أفلام"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "يرجى تأكيد كلمة المرور"
|
||||
msgid "Please sign in again."
|
||||
msgstr "يرجى تسجيل الدخول مرة أخرى."
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "تشغيل الآن"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "حلقات المسلسلات"
|
||||
msgid "TV show"
|
||||
msgstr "مسلسل تلفزيوني"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "المسلسلات"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr "Alter {age}"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "Alle"
|
||||
@@ -1096,6 +1097,11 @@ msgstr "E-Mail"
|
||||
msgid "Email is required"
|
||||
msgstr "E-Mail ist erforderlich"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "Benachrichtigungstyp \"Playback Stop\" aktivieren"
|
||||
@@ -1216,6 +1222,10 @@ msgstr "Fehler ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "Alle {n}h"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "Bestbewertet"
|
||||
msgid "Hindi"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "Bild-Cache"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "Speichernutzung für Bild-Cache und Backups"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "Importieren"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "Italienisch"
|
||||
msgid "Japanese"
|
||||
msgstr "Japanisch"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "Aufgabe"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "Letzter Lauf fehlgeschlagen"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "Letzter Lauf erfolgreich"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "Verbindung zum Import verloren. Status in den Einstellungen prüfen."
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "Konto und Einstellungen verwalten"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "Manuelles Backup"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "Film"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "Filme"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "Bitte bestätige dein Passwort"
|
||||
msgid "Please sign in again."
|
||||
msgstr "Bitte melde dich erneut an."
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "Jetzt ausführen"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "TV-Episoden"
|
||||
msgid "TV show"
|
||||
msgstr "Serie"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "Serien"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr ""
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
@@ -1096,6 +1097,11 @@ msgstr ""
|
||||
msgid "Email is required"
|
||||
msgstr "Email is required"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr "Emby"
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr ""
|
||||
@@ -1216,6 +1222,10 @@ msgstr "Errors ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "Every {n}h"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr "Everything you've watched, newest first."
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "Highest rated"
|
||||
msgid "Hindi"
|
||||
msgstr "Hindi"
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr "History"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr ""
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
@@ -1717,6 +1734,11 @@ msgstr "Italian"
|
||||
msgid "Japanese"
|
||||
msgstr "Japanese"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr "Jellyfin"
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr ""
|
||||
@@ -1799,6 +1821,11 @@ msgstr ""
|
||||
msgid "Last run succeeded"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr "Last watched"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr ""
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr "Manual"
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr ""
|
||||
@@ -2031,6 +2063,7 @@ msgstr ""
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr ""
|
||||
@@ -2210,6 +2243,10 @@ msgstr "Nothing to watch"
|
||||
msgid "Nothing upcoming"
|
||||
msgstr "Nothing upcoming"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr "Nothing watched yet."
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "Please confirm your password"
|
||||
msgid "Please sign in again."
|
||||
msgstr "Please sign in again."
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr "Plex"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr ""
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr "S{season} E{episode}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr "S{season} E{episode} · {name}"
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr ""
|
||||
msgid "TV show"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "TV Shows"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr "edad {age}"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "Todo"
|
||||
@@ -1096,6 +1097,11 @@ msgstr "Correo electrónico"
|
||||
msgid "Email is required"
|
||||
msgstr "El correo electrónico es obligatorio"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "Activa el tipo de notificación \"Playback Stop\""
|
||||
@@ -1216,6 +1222,10 @@ msgstr "Errores ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "Cada {n}h"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "Mejor valorados"
|
||||
msgid "Hindi"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "Caché de imágenes"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "Uso del disco de caché de imágenes y copias de seguridad"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "Italiano"
|
||||
msgid "Japanese"
|
||||
msgstr "Japonés"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "Tarea"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "Última ejecución fallida"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "Última ejecución exitosa"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "Se perdió la conexión con la importación. Comprueba el estado en ajus
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "Gestiona tu cuenta y preferencias"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "Copia de seguridad manual"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "Película"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "Películas"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "Por favor, confirma tu contraseña"
|
||||
msgid "Please sign in again."
|
||||
msgstr "Por favor, inicia sesión de nuevo."
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "Ejecutar ahora"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "Episodios de series"
|
||||
msgid "TV show"
|
||||
msgstr "Serie"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "Series de TV"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr "âge {age}"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "Tout"
|
||||
@@ -1096,6 +1097,11 @@ msgstr "E-mail"
|
||||
msgid "Email is required"
|
||||
msgstr "L'adresse e-mail est requise"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "Activez le type de notification « Playback Stop »"
|
||||
@@ -1216,6 +1222,10 @@ msgstr "Erreurs ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "Toutes les {n}h"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "Mieux notés"
|
||||
msgid "Hindi"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "Cache d'images"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "Utilisation du disque : cache d'images et sauvegardes"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "Importation"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "Italien"
|
||||
msgid "Japanese"
|
||||
msgstr "Japonais"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "Tâche"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "Dernière exécution échouée"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "Dernière exécution réussie"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "Connexion à l'importation perdue. Vérifiez l'état dans les paramètre
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "Gérez votre compte et vos préférences"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "Sauvegarde manuelle"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "Film"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "Films"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "Veuillez confirmer votre mot de passe"
|
||||
msgid "Please sign in again."
|
||||
msgstr "Veuillez vous reconnecter."
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "Exécuter maintenant"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "Épisodes de séries"
|
||||
msgid "TV show"
|
||||
msgstr "Série TV"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "Séries TV"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr "גיל {age}"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "הכל"
|
||||
@@ -1096,6 +1097,11 @@ msgstr "דוא\"ל"
|
||||
msgid "Email is required"
|
||||
msgstr "כתובת דוא\"ל נדרשת"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "הפעל את סוג ההתראה \"Playback Stop\""
|
||||
@@ -1216,6 +1222,10 @@ msgstr "שגיאות ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "כל {n} שעות"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "בדירוג הגבוה ביותר"
|
||||
msgid "Hindi"
|
||||
msgstr "הינדי"
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "מטמון תמונות"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "שימוש בדיסק של מטמון תמונות וגיבויים"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "ייבוא"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "איטלקית"
|
||||
msgid "Japanese"
|
||||
msgstr "יפנית"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "משימה"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "ההרצה האחרונה נכשלה"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "ההרצה האחרונה הצליחה"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "אבד החיבור לייבוא. בדוק את הסטטוס בהגדר
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "נהל את החשבון וההעדפות שלך"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "גיבוי ידני"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "סרט"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "סרטים"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "אנא אשר את הסיסמה שלך"
|
||||
msgid "Please sign in again."
|
||||
msgstr "אנא התחבר שוב."
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "הפעל עכשיו"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "פרקי טלוויזיה"
|
||||
msgid "TV show"
|
||||
msgstr "סדרת טלוויזיה"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "תוכניות טלוויזיה"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr "età {age}"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "Tutti"
|
||||
@@ -1096,6 +1097,11 @@ msgstr ""
|
||||
msgid "Email is required"
|
||||
msgstr "L'email è obbligatoria"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "Abilita il tipo di notifica \"Playback Stop\""
|
||||
@@ -1216,6 +1222,10 @@ msgstr "Errori ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "Ogni {n}h"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "Meglio valutati"
|
||||
msgid "Hindi"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "Cache immagini"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "Utilizzo disco: cache immagini e backup"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "Importazione"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "Italiano"
|
||||
msgid "Japanese"
|
||||
msgstr "Giapponese"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "Attività"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "Ultima esecuzione fallita"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "Ultima esecuzione riuscita"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "Connessione all'importazione persa. Controlla lo stato nelle impostazion
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "Gestisci il tuo account e le tue preferenze"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "Backup manuale"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "Film"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "Film"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "Conferma la tua password"
|
||||
msgid "Please sign in again."
|
||||
msgstr "Accedi di nuovo."
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "Esegui ora"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "Episodi TV"
|
||||
msgid "TV show"
|
||||
msgstr "Serie TV"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "Serie TV"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr "年齢 {age}"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "すべて"
|
||||
@@ -1096,6 +1097,11 @@ msgstr "メールアドレス"
|
||||
msgid "Email is required"
|
||||
msgstr "メールアドレスは必須です"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "「Playback Stop」通知タイプを有効にしてください"
|
||||
@@ -1216,6 +1222,10 @@ msgstr "エラー ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "{n}時間ごと"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "評価が高い順"
|
||||
msgid "Hindi"
|
||||
msgstr "ヒンディー語"
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "画像キャッシュ"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "画像キャッシュとバックアップのディスク使用量"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "インポート"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "イタリア語"
|
||||
msgid "Japanese"
|
||||
msgstr "日本語"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "ジョブ"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "最終実行失敗"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "最終実行成功"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "インポートへの接続が切断されました。設定でステー
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "アカウントと設定を管理する"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "手動バックアップ"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "映画"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "映画"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "パスワードを確認してください"
|
||||
msgid "Please sign in again."
|
||||
msgstr "再度サインインしてください。"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "今すぐ実行"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "TVエピソード"
|
||||
msgid "TV show"
|
||||
msgstr "TV番組"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "TVシリーズ"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr "나이 {age}"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "전체"
|
||||
@@ -1096,6 +1097,11 @@ msgstr "이메일"
|
||||
msgid "Email is required"
|
||||
msgstr "이메일을 입력해주세요"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "\"Playback Stop\" 알림 유형 활성화"
|
||||
@@ -1216,6 +1222,10 @@ msgstr "오류 ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "매 {n}시간"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "평점 높은 순"
|
||||
msgid "Hindi"
|
||||
msgstr "힌디어"
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "이미지 캐시"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "이미지 캐시 및 백업 디스크 사용량"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "가져오기"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "이탈리아어"
|
||||
msgid "Japanese"
|
||||
msgstr "일본어"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "작업"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "마지막 실행 실패"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "마지막 실행 성공"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "가져오기 연결이 끊어졌습니다. 설정에서 상태를 확인
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "계정 및 환경설정을 관리하세요"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "수동 백업"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "영화"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "영화"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "비밀번호를 확인해주세요"
|
||||
msgid "Please sign in again."
|
||||
msgstr "다시 로그인해주세요."
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "지금 실행"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "TV 에피소드"
|
||||
msgid "TV show"
|
||||
msgstr "TV 프로그램"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "TV 프로그램"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr "leeftijd {age}"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "Alles"
|
||||
@@ -1096,6 +1097,11 @@ msgstr "E-mail"
|
||||
msgid "Email is required"
|
||||
msgstr "E-mail is vereist"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "Schakel het meldingstype \"Playback Stop\" in"
|
||||
@@ -1216,6 +1222,10 @@ msgstr "Fouten ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "Elke {n}u"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "Hoogst beoordeeld"
|
||||
msgid "Hindi"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "Afbeeldingscache"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "Schijfgebruik voor afbeeldingscache en back-ups"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "Importeren"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "Italiaans"
|
||||
msgid "Japanese"
|
||||
msgstr "Japans"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "Taak"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "Laatste uitvoering mislukt"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "Laatste uitvoering geslaagd"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "Verbinding met de import verbroken. Controleer de status in de instellin
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "Beheer je account en voorkeuren"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "Handmatige back-up"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "Film"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "Films"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "Bevestig je wachtwoord"
|
||||
msgid "Please sign in again."
|
||||
msgstr "Meld je opnieuw aan."
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "Nu uitvoeren"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "Tv-afleveringen"
|
||||
msgid "TV show"
|
||||
msgstr "Tv-serie"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "Tv-series"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr "idade {age}"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "Todos"
|
||||
@@ -1096,6 +1097,11 @@ msgstr "E-mail"
|
||||
msgid "Email is required"
|
||||
msgstr "O email é obrigatório"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "Ative o tipo de notificação \"Playback Stop\""
|
||||
@@ -1216,6 +1222,10 @@ msgstr "Erros ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "A cada {n}h"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "Melhor avaliados"
|
||||
msgid "Hindi"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "Cache de imagens"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "Uso de disco de cache de imagens e backups"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "Italiano"
|
||||
msgid "Japanese"
|
||||
msgstr "Japonês"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "Tarefa"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "Última execução falhou"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "Última execução bem-sucedida"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "Conexão de importação perdida. Verifique o status nas configurações
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "Gerencie sua conta e preferências"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "Backup manual"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "Filme"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "Filmes"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "Por favor confirme a sua palavra-passe"
|
||||
msgid "Please sign in again."
|
||||
msgstr "Por favor inicie sessão novamente."
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "Executar agora"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "Episódios de séries"
|
||||
msgid "TV show"
|
||||
msgstr "Série"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "Séries"
|
||||
|
||||
@@ -378,6 +378,7 @@ msgstr "{age} 岁"
|
||||
#: apps/native/src/components/library/filter-sheet.tsx
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "All"
|
||||
msgstr "全部"
|
||||
@@ -1096,6 +1097,11 @@ msgstr "邮箱"
|
||||
msgid "Email is required"
|
||||
msgstr "邮箱不能为空"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Emby"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/settings/integration-configs.ts
|
||||
msgid "Enable the \"Playback Stop\" notification type"
|
||||
msgstr "启用“Playback Stop”通知类型"
|
||||
@@ -1216,6 +1222,10 @@ msgstr "错误 ({errorCount})"
|
||||
msgid "Every {n}h"
|
||||
msgstr "每 {n} 小时"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Everything you've watched, newest first."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/_layout.tsx
|
||||
#: apps/native/src/app/(tabs)/(home)/index.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
@@ -1570,6 +1580,11 @@ msgstr "评分最高"
|
||||
msgid "Hindi"
|
||||
msgstr "印地语"
|
||||
|
||||
#: apps/web/src/components/nav-bar.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1590,7 +1605,9 @@ msgstr "图片缓存"
|
||||
msgid "Image cache and backup disk usage"
|
||||
msgstr "图片缓存与备份磁盘用量"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/settings.tsx
|
||||
msgid "Import"
|
||||
msgstr "导入"
|
||||
@@ -1717,6 +1734,11 @@ msgstr "意大利语"
|
||||
msgid "Japanese"
|
||||
msgstr "日语"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Jellyfin"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/system-health-section.tsx
|
||||
msgid "Job"
|
||||
msgstr "任务"
|
||||
@@ -1799,6 +1821,11 @@ msgstr "上次运行失败"
|
||||
msgid "Last run succeeded"
|
||||
msgstr "上次运行成功"
|
||||
|
||||
#: apps/native/src/components/library/sort-menu.tsx
|
||||
#: apps/web/src/components/library/library-toolbar.tsx
|
||||
msgid "Last watched"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.android.tsx
|
||||
#: apps/native/src/components/navigation/native-tab-bar.tsx
|
||||
@@ -1837,6 +1864,11 @@ msgstr "导入连接已断开,请在设置中查看状态。"
|
||||
msgid "Manage your account and preferences"
|
||||
msgstr "管理您的账户和偏好设置"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Manual"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/settings/backup-section.tsx
|
||||
msgid "Manual backup"
|
||||
msgstr "手动备份"
|
||||
@@ -2031,6 +2063,7 @@ msgstr "电影"
|
||||
#: apps/web/src/components/people/filmography-grid.tsx
|
||||
#: apps/web/src/components/settings/account-section.tsx
|
||||
#: apps/web/src/components/settings/imports-section.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Movies"
|
||||
msgstr "电影"
|
||||
@@ -2210,6 +2243,10 @@ msgstr ""
|
||||
msgid "Nothing upcoming"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Nothing watched yet."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/library/library-filters.tsx
|
||||
#~ msgid "Older"
|
||||
#~ msgstr "Older"
|
||||
@@ -2399,6 +2436,11 @@ msgstr "请确认您的密码"
|
||||
msgid "Please sign in again."
|
||||
msgstr "请重新登录。"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
msgid "Plex"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(explore)/index.tsx
|
||||
#: apps/web/src/routes/_app/explore.tsx
|
||||
msgid "Popular Movies"
|
||||
@@ -2817,6 +2859,14 @@ msgstr "立即运行"
|
||||
#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}"
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/history/history-row.tsx
|
||||
msgid "S{season} E{episode} · {name}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/dashboard/upcoming-row.tsx
|
||||
#: apps/native/src/lib/widgets.ts
|
||||
#: apps/web/src/components/dashboard/upcoming-item.tsx
|
||||
@@ -3486,6 +3536,7 @@ msgstr "电视剧集"
|
||||
msgid "TV show"
|
||||
msgstr "电视剧"
|
||||
|
||||
#: apps/web/src/routes/_app/history.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "TV Shows"
|
||||
msgstr "电视剧"
|
||||
|
||||
Reference in new issue
Block a user