From 0f31286058ecdc2398b84a9f5ff0666ab095cee4 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 29 Mar 2026 12:16:50 +0200 Subject: [PATCH 01/10] Add `studios` and `staff` filters for shows --- api/src/controllers/shows/logic.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 40627702..b7e1b1ea 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -5,9 +5,11 @@ import { entries, entryVideoJoin, profiles, + roles, showStudioJoin, shows, showTranslations, + staff, studios, studioTranslations, videos, @@ -92,6 +94,24 @@ export const showFilters: FilterDef = { type: "int", }, nextRefresh: { column: shows.nextRefresh, type: "date" }, + studios: { + column: db + .select({ slug: studios.slug }) + .from(showStudioJoin) + .innerJoin(studios, eq(studios.pk, showStudioJoin.studioPk)) + .where(eq(showStudioJoin.showPk, shows.pk)), + type: "string", + isArray: true, + }, + staff: { + column: db + .select({ slug: staff.slug }) + .from(roles) + .innerJoin(staff, eq(staff.pk, roles.staffPk)) + .where(eq(roles.showPk, shows.pk)), + type: "string", + isArray: true, + }, }; export const showSort = Sort( { From ca5b3dc21d5d1512e2a729d7da434f58bb551a91 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 29 Mar 2026 12:16:50 +0200 Subject: [PATCH 02/10] Fix register issues on mobile --- front/src/primitives/input.tsx | 2 +- front/src/ui/login/register.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/front/src/primitives/input.tsx b/front/src/primitives/input.tsx index c0557923..b1ddf025 100644 --- a/front/src/primitives/input.tsx +++ b/front/src/primitives/input.tsx @@ -28,7 +28,7 @@ export const Input = ({ ref={ref} textAlignVertical="center" className={cn( - "h-6 flex-1 font-sans text-base text-slate-600 outline-0 dark:text-slate-400", + "min-h-6 flex-1 font-sans text-base text-slate-600 outline-0 dark:text-slate-400", className, )} {...props} diff --git a/front/src/ui/login/register.tsx b/front/src/ui/login/register.tsx index a14c1448..583742e7 100644 --- a/front/src/ui/login/register.tsx +++ b/front/src/ui/login/register.tsx @@ -22,7 +22,10 @@ export const RegisterPage = () => { const router = useRouter(); const { t } = useTranslation(); - const { data: info } = useFetch(OidcLogin.query(apiUrl)); + const { data: info } = useFetch({ + ...OidcLogin.query(apiUrl), + options: { returnError: true }, + }); if (Platform.OS !== "web" && !apiUrl) return ; if (info?.allowRegister === false) { From 3d3e41334397c5a5167fd3297e11071913a4eebc Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 30 Mar 2026 20:02:31 +0200 Subject: [PATCH 03/10] Fix media type filter --- front/src/ui/browse/header.tsx | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/front/src/ui/browse/header.tsx b/front/src/ui/browse/header.tsx index a95991cd..91c835dc 100644 --- a/front/src/ui/browse/header.tsx +++ b/front/src/ui/browse/header.tsx @@ -99,20 +99,24 @@ export const BrowseSettings = ({ return ( - - {Object.keys(MediaTypeIcons).map((x) => ( - setMediaType(x)} - /> - ))} - + + + {Object.keys(MediaTypeIcons).map((x) => ( + setMediaType(x)} + /> + ))} + + {availableSorts.map((x) => ( From 4bcdc9864709bf96895770632355c4289fa33001 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 29 Mar 2026 19:55:34 +0200 Subject: [PATCH 04/10] Fix searchbar --- front/src/ui/navbar.tsx | 87 ++++++++++++++++++++--------------------- front/src/utils.ts | 9 ++++- 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/front/src/ui/navbar.tsx b/front/src/ui/navbar.tsx index 8b479059..bb200f5f 100644 --- a/front/src/ui/navbar.tsx +++ b/front/src/ui/navbar.tsx @@ -7,7 +7,13 @@ import Person from "@material-symbols/svg-400/rounded/person-fill.svg"; import Search from "@material-symbols/svg-400/rounded/search-fill.svg"; import Settings from "@material-symbols/svg-400/rounded/settings.svg"; import { useIsFocused } from "@react-navigation/native"; -import { useNavigation, usePathname, useRouter } from "expo-router"; +import { + useGlobalSearchParams, + useLocalSearchParams, + useNavigation, + usePathname, + useRouter, +} from "expo-router"; import KyooLongLogo from "public/icon-long.svg"; import { type ComponentProps, @@ -45,7 +51,7 @@ import { } from "~/primitives"; import { useAccount, useAccounts } from "~/providers/account-context"; import { logout } from "~/ui/login/logic"; -import { cn } from "~/utils"; +import { cn, useQueryState } from "~/utils"; export const NavbarLeft = () => { const { t } = useTranslation(); @@ -91,49 +97,47 @@ export const NavbarTitle = ({ }; export const NavbarRight = () => { - const { t } = useTranslation(); - const isAdmin = false; //useHasPermission(AdminPage.requiredPermissions); + const router = useRouter(); + const path = usePathname(); + const [q, setQuery] = useQueryState("q", undefined); return ( - - {isAdmin && ( - - )} + { + if (path === "/browse") setQuery(query); + }} + onSubmit={(query) => { + if (query && path !== "/browse") { + router.push(`/browse?q=${query}`); + } + }} + onClear={() => { + if (path === "/browse") setQuery(undefined); + }} + /> ); }; -const SearchBar = () => { +const SearchBar = ({ + query, + onChange, + onSubmit, + onClear, +}: { + query: string | undefined; + onChange: (query: string) => void; + onSubmit?: (query: string | undefined) => void; + onClear?: () => void; +}) => { const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(!!query); const inputRef = useRef(null); - const router = useRouter(); - const [query, setQuery] = useState(""); - - const path = usePathname(); - const shouldExpand = useRef(false); - useEffect(() => { - if (path === "/browse" && shouldExpand.current) { - shouldExpand.current = false; - // Small delay to allow animation to start before focusing - setTimeout(() => { - setExpanded(true); - inputRef.current?.focus(); - }, 300); - } else if (path === "/") { - inputRef.current?.blur(); - } - }, [path]); - return ( { { - setQuery(q); - router.setParams({ q }); - }} - onFocus={() => router.push(query ? `/browse?q=${query}` : "/browse")} + onChangeText={(q) => onChange(q)} + onSubmitEditing={(e) => onSubmit?.(e.nativeEvent.text)} + onFocus={() => setExpanded(true)} onBlur={() => { - if (query !== "") return; - setExpanded(false); + if (!query) setExpanded(false); }} placeholder={t("navbar.search")} textAlignVertical="center" @@ -179,10 +180,8 @@ const SearchBar = () => { if (expanded) { inputRef.current?.blur(); setExpanded(false); - setQuery(""); - router.setParams({ q: undefined }); + onClear?.(); } else { - shouldExpand.current = true; setExpanded(true); // Small delay to allow animation to start before focusing setTimeout(() => inputRef.current?.focus(), 100); diff --git a/front/src/utils.ts b/front/src/utils.ts index 204d84e5..77d18d3d 100644 --- a/front/src/utils.ts +++ b/front/src/utils.ts @@ -1,5 +1,9 @@ import { type ClassValue, clsx } from "clsx"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { + useGlobalSearchParams, + useLocalSearchParams, + useRouter, +} from "expo-router"; import { useCallback, useReducer } from "react"; import { twMerge } from "tailwind-merge"; @@ -18,9 +22,10 @@ export function getServerData(key: string): any { export const useQueryState = (key: string, initial: S) => { const params = useLocalSearchParams(); + const global = useGlobalSearchParams(); const router = useRouter(); - const state = (params[key] as S) ?? initial; + const state = (params[key] as S) ?? (global[key] as S) ?? initial; const update = useCallback( (val: S | ((old: S) => S)) => { router.setParams({ [key]: val } as any); From 76229adeca001e1ca4f7e7ab4210b3dbe74fca39 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 30 Mar 2026 20:09:24 +0200 Subject: [PATCH 05/10] Implement filters for genres, staff and studios --- front/src/primitives/combobox.web.tsx | 8 +- front/src/primitives/menu.tsx | 4 +- front/src/primitives/menu.web.tsx | 12 +- front/src/primitives/select.web.tsx | 8 +- front/src/ui/admin/videos-modal/headers.tsx | 1 - front/src/ui/browse/header.tsx | 192 +++++++++++++++++++- 6 files changed, 205 insertions(+), 20 deletions(-) diff --git a/front/src/primitives/combobox.web.tsx b/front/src/primitives/combobox.web.tsx index f344ec7e..2bb45cef 100644 --- a/front/src/primitives/combobox.web.tsx +++ b/front/src/primitives/combobox.web.tsx @@ -10,7 +10,7 @@ import { cn } from "~/utils"; import type { ComboBoxProps } from "./combobox"; import { Icon } from "./icons"; import { PressableFeedback } from "./links"; -import { InternalTriger } from "./menu.web"; +import { InternalTrigger } from "./menu.web"; import { Skeleton } from "./skeleton"; import { P } from "./text"; @@ -59,9 +59,9 @@ export const ComboBox = ({ > {Trigger ? ( - + ) : ( - ({ className="group-focus-within:fill-slate-200 group-hover:fill-slate-200" /> - + )} diff --git a/front/src/primitives/menu.tsx b/front/src/primitives/menu.tsx index b1b31105..c6ee7b8a 100644 --- a/front/src/primitives/menu.tsx +++ b/front/src/primitives/menu.tsx @@ -107,12 +107,14 @@ const MenuItem = ({ href, icon, disabled, + closeOnSelect = true, ...props }: { label: string; selected?: boolean; left?: ReactElement; disabled?: boolean; + closeOnSelect?: boolean; icon?: ComponentType; } & ( | { onSelect: () => void; href?: undefined } @@ -131,7 +133,7 @@ const MenuItem = ({ return ( { - setOpen?.call(null, false); + if (closeOnSelect) setOpen?.call(null, false); onSelect?.call(null); if (href) router.push(href); }} diff --git a/front/src/primitives/menu.web.tsx b/front/src/primitives/menu.web.tsx index dcf58c09..0c4186dd 100644 --- a/front/src/primitives/menu.web.tsx +++ b/front/src/primitives/menu.web.tsx @@ -13,7 +13,7 @@ import { Icon } from "./icons"; import { useLinkTo } from "./links"; import { P } from "./text"; -export const InternalTriger = forwardRef(function _Triger( +export const InternalTrigger = forwardRef(function _Triger( { Component, ComponentProps, ...props }, ref, ) { @@ -23,6 +23,7 @@ export const InternalTriger = forwardRef(function _Triger( {...ComponentProps} {...props} onClickCapture={props.onPointerDown} + onPress={props.onPress ?? props.onClick} /> ); }); @@ -54,12 +55,12 @@ const Menu = ({ }} > - + e.stopImmediatePropagation()} - className="z-10 min-w-2xs overflow-hidden rounded bg-popover shadow-xl" + className="z-10 min-w-2xs overflow-y-auto rounded bg-popover shadow-xl" style={{ maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)", @@ -81,6 +82,7 @@ const MenuItem = forwardRef< left?: ReactElement; disabled?: boolean; selected?: boolean; + closeOnSelect?: boolean; className?: string; } & ( | { onSelect: () => void; href?: undefined } @@ -95,6 +97,7 @@ const MenuItem = forwardRef< onSelect, href, disabled, + closeOnSelect = true, className, ...props }, @@ -117,7 +120,8 @@ const MenuItem = forwardRef< { + onSelect={(e) => { + if (!closeOnSelect) e.preventDefault(); onSelect?.(); onPress?.(undefined!); }} diff --git a/front/src/primitives/select.web.tsx b/front/src/primitives/select.web.tsx index 6e89b2ef..6671ddc7 100644 --- a/front/src/primitives/select.web.tsx +++ b/front/src/primitives/select.web.tsx @@ -7,7 +7,7 @@ import { Platform, View } from "react-native"; import { cn } from "~/utils"; import { Icon } from "./icons"; import { PressableFeedback } from "./links"; -import { InternalTriger } from "./menu.web"; +import { InternalTrigger } from "./menu.web"; import { P } from "./text"; export const Select = ({ @@ -26,7 +26,7 @@ export const Select = ({ return ( - - + ( > {label} - )} diff --git a/front/src/ui/browse/header.tsx b/front/src/ui/browse/header.tsx index 91c835dc..8c246512 100644 --- a/front/src/ui/browse/header.tsx +++ b/front/src/ui/browse/header.tsx @@ -1,16 +1,24 @@ import ArrowDownward from "@material-symbols/svg-400/rounded/arrow_downward.svg"; import ArrowUpward from "@material-symbols/svg-400/rounded/arrow_upward.svg"; +import Check from "@material-symbols/svg-400/rounded/check.svg"; +import Close from "@material-symbols/svg-400/rounded/close.svg"; import Collection from "@material-symbols/svg-400/rounded/collections_bookmark.svg"; import FilterList from "@material-symbols/svg-400/rounded/filter_list.svg"; import GridView from "@material-symbols/svg-400/rounded/grid_view.svg"; import Movie from "@material-symbols/svg-400/rounded/movie.svg"; +import Person from "@material-symbols/svg-400/rounded/person.svg"; import Sort from "@material-symbols/svg-400/rounded/sort.svg"; +import TheaterComedy from "@material-symbols/svg-400/rounded/theater_comedy.svg"; import TV from "@material-symbols/svg-400/rounded/tv.svg"; import All from "@material-symbols/svg-400/rounded/view_headline.svg"; import ViewList from "@material-symbols/svg-400/rounded/view_list.svg"; +import type { ComponentType } from "react"; import { useTranslation } from "react-i18next"; import { type PressableProps, View } from "react-native"; +import type { SvgProps } from "react-native-svg"; +import { Genre, Staff, Studio } from "~/models"; import { + ComboBox, HR, Icon, IconButton, @@ -73,6 +81,34 @@ const MediaTypeTrigger = ({ ); }; +const FilterTrigger = ({ + label, + count, + icon, + className, + ...props +}: { + label: string; + count: number; + icon?: ComponentType; +} & PressableProps) => { + return ( + + +

+ {label} + {count > 0 ? ` (${count})` : ""} +

+
+ ); +}; + +const parseFilterValues = (filter: string, pattern: RegExp) => + Array.from(filter.matchAll(pattern)).map((x) => x[1]); + export const BrowseSettings = ({ sortBy, sortOrd, @@ -92,14 +128,56 @@ export const BrowseSettings = ({ }) => { const { t } = useTranslation(); - // TODO: have a proper filter frontend const mediaType = /kind eq (\w+)/.exec(filter)?.[1] ?? "all"; - const setMediaType = (kind: string) => - setFilter(kind !== "all" ? `kind eq ${kind}` : ""); + const includedGenres = parseFilterValues( + filter, + /(? { + const clauses: string[] = []; + kind ??= mediaType; + nextIncludedGenres ??= includedGenres; + nextExcludedGenres ??= excludedGenres; + nextStudios ??= studioSlugs; + nextStaff ??= staffSlugs; + + if (kind !== "all") clauses.push(`kind eq ${kind}`); + for (const studio of nextStudios) clauses.push(`studios has ${studio}`); + for (const person of nextStaff) clauses.push(`staff has ${person}`); + for (const genre of nextIncludedGenres) clauses.push(`genres has ${genre}`); + for (const genre of nextExcludedGenres) + clauses.push(`not genres has ${genre}`); + setFilter(clauses.join(" and ")); + }; return ( - - + + setMediaType(x)} + onSelect={() => applyFilters({ kind: x })} /> ))} + ( + + )} + > + {Genre.options.map((genre) => { + const isIncluded = includedGenres.includes(genre); + const isExcluded = excludedGenres.includes(genre); + return ( + + {(isIncluded || isExcluded) && ( + + )} + + } + closeOnSelect={false} + onSelect={() => { + let nextIncluded = includedGenres; + let nextExcluded = excludedGenres; + if (isIncluded) { + // include -> exclude + nextIncluded = nextIncluded.filter((g) => g !== genre); + nextExcluded = [...nextExcluded, genre]; + } else if (isExcluded) { + // exclude -> neutral + nextExcluded = nextExcluded.filter((g) => g !== genre); + } else { + // neutral -> include + nextIncluded = [...nextIncluded, genre]; + } + applyFilters({ + nextIncludedGenres: nextIncluded, + nextExcludedGenres: nextExcluded, + }); + }} + /> + ); + })} + + ( + + )} + query={(search) => ({ + path: ["api", "studios"], + parser: Studio, + infinite: true, + params: { + query: search, + }, + })} + values={studioSlugs.map((x) => ({ slug: x, name: x }))} + getKey={(studio) => studio.slug} + getLabel={(studio) => studio.name} + onValueChange={(items) => + applyFilters({ nextStudios: items.map((item) => item.slug) }) + } + /> + ( + + )} + query={(search) => ({ + path: ["api", "staff"], + parser: Staff, + infinite: true, + params: { + query: search, + }, + })} + values={staffSlugs.map((x) => ({ slug: x, name: x }))} + getKey={(member) => member.slug} + getLabel={(member) => member.name} + onValueChange={(items) => + applyFilters({ nextStaff: items.map((item) => item.slug) }) + } + /> From 4d64cbaf5dd0b214d98a32be8210ecf928215610 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 31 Mar 2026 11:14:03 +0200 Subject: [PATCH 06/10] Add filter chips --- front/public/translations/en.json | 3 +- front/src/ui/browse/header.tsx | 386 ++++++++++++++++++------------ 2 files changed, 238 insertions(+), 151 deletions(-) diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 90399a83..92224374 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -95,7 +95,8 @@ "desc": "decs" }, "switchToGrid": "Switch to grid view", - "switchToList": "Switch to list view" + "switchToList": "Switch to list view", + "not": "Not" }, "profile": { "history": "History", diff --git a/front/src/ui/browse/header.tsx b/front/src/ui/browse/header.tsx index 8c246512..83062dd6 100644 --- a/front/src/ui/browse/header.tsx +++ b/front/src/ui/browse/header.tsx @@ -1,7 +1,7 @@ import ArrowDownward from "@material-symbols/svg-400/rounded/arrow_downward.svg"; import ArrowUpward from "@material-symbols/svg-400/rounded/arrow_upward.svg"; import Check from "@material-symbols/svg-400/rounded/check.svg"; -import Close from "@material-symbols/svg-400/rounded/close.svg"; +import CloseIcon from "@material-symbols/svg-400/rounded/close.svg"; import Collection from "@material-symbols/svg-400/rounded/collections_bookmark.svg"; import FilterList from "@material-symbols/svg-400/rounded/filter_list.svg"; import GridView from "@material-symbols/svg-400/rounded/grid_view.svg"; @@ -106,6 +106,30 @@ const FilterTrigger = ({ ); }; +const FilterChip = ({ + label, + onRemove, +}: { + label: string; + onRemove: () => void; +}) => { + return ( + +

{label}

+ +
+ ); +}; + const parseFilterValues = (filter: string, pattern: RegExp) => Array.from(filter.matchAll(pattern)).map((x) => x[1]); @@ -176,161 +200,223 @@ export const BrowseSettings = ({ }; return ( - - - - {Object.keys(MediaTypeIcons).map((x) => ( - applyFilters({ kind: x })} - /> - ))} - - ( - - )} - > - {Genre.options.map((genre) => { - const isIncluded = includedGenres.includes(genre); - const isExcluded = excludedGenres.includes(genre); - return ( + + + + + {Object.keys(MediaTypeIcons).map((x) => ( - {(isIncluded || isExcluded) && ( - - )} - - } - closeOnSelect={false} - onSelect={() => { - let nextIncluded = includedGenres; - let nextExcluded = excludedGenres; - if (isIncluded) { - // include -> exclude - nextIncluded = nextIncluded.filter((g) => g !== genre); - nextExcluded = [...nextExcluded, genre]; - } else if (isExcluded) { - // exclude -> neutral - nextExcluded = nextExcluded.filter((g) => g !== genre); - } else { - // neutral -> include - nextIncluded = [...nextIncluded, genre]; - } - applyFilters({ - nextIncludedGenres: nextIncluded, - nextExcludedGenres: nextExcluded, - }); - }} + key={x} + label={t( + `browse.mediatypekey.${x as keyof typeof MediaTypeIcons}`, + )} + selected={mediaType === x} + icon={MediaTypeIcons[x as keyof typeof MediaTypeIcons]} + onSelect={() => applyFilters({ kind: x })} /> - ); - })} - - ( - - )} - query={(search) => ({ - path: ["api", "studios"], - parser: Studio, - infinite: true, - params: { - query: search, - }, - })} - values={studioSlugs.map((x) => ({ slug: x, name: x }))} - getKey={(studio) => studio.slug} - getLabel={(studio) => studio.name} - onValueChange={(items) => - applyFilters({ nextStudios: items.map((item) => item.slug) }) - } - /> - ( - - )} - query={(search) => ({ - path: ["api", "staff"], - parser: Staff, - infinite: true, - params: { - query: search, - }, - })} - values={staffSlugs.map((x) => ({ slug: x, name: x }))} - getKey={(member) => member.slug} - getLabel={(member) => member.name} - onValueChange={(items) => - applyFilters({ nextStaff: items.map((item) => item.slug) }) - } - /> + ))} + + ( + + )} + > + {Genre.options.map((genre) => { + const isIncluded = includedGenres.includes(genre); + const isExcluded = excludedGenres.includes(genre); + return ( + + {(isIncluded || isExcluded) && ( + + )} + + } + closeOnSelect={false} + onSelect={() => { + let nextIncluded = includedGenres; + let nextExcluded = excludedGenres; + if (isIncluded) { + // include -> exclude + nextIncluded = nextIncluded.filter((g) => g !== genre); + nextExcluded = [...nextExcluded, genre]; + } else if (isExcluded) { + // exclude -> neutral + nextExcluded = nextExcluded.filter((g) => g !== genre); + } else { + // neutral -> include + nextIncluded = [...nextIncluded, genre]; + } + applyFilters({ + nextIncludedGenres: nextIncluded, + nextExcludedGenres: nextExcluded, + }); + }} + /> + ); + })} + + ( + + )} + query={(search) => ({ + path: ["api", "studios"], + parser: Studio, + infinite: true, + params: { + query: search, + }, + })} + values={studioSlugs.map((x) => ({ slug: x, name: x }))} + getKey={(studio) => studio.slug} + getLabel={(studio) => studio.name} + onValueChange={(items) => + applyFilters({ nextStudios: items.map((item) => item.slug) }) + } + /> + ( + + )} + query={(search) => ({ + path: ["api", "staff"], + parser: Staff, + infinite: true, + params: { + query: search, + }, + })} + values={staffSlugs.map((x) => ({ slug: x, name: x }))} + getKey={(member) => member.slug} + getLabel={(member) => member.name} + onValueChange={(items) => + applyFilters({ nextStaff: items.map((item) => item.slug) }) + } + /> + + + + {availableSorts.map((x) => ( + + setSort(x, sortBy === x && sortOrd === "asc" ? "desc" : "asc") + } + /> + ))} + +
+ setLayout("grid")} + className="m-1" + iconClassName={cn( + layout === "grid" && "fill-accent dark:fill-accent", + )} + {...tooltip(t("browse.switchToGrid"))} + /> + setLayout("list")} + className="m-1" + iconClassName={cn( + layout === "list" && "fill-accent dark:fill-accent", + )} + {...tooltip(t("browse.switchToList"))} + /> +
- - - {availableSorts.map((x) => ( - - setSort(x, sortBy === x && sortOrd === "asc" ? "desc" : "asc") + {(mediaType !== "all" || + includedGenres.length > 0 || + excludedGenres.length > 0 || + studioSlugs.length > 0 || + staffSlugs.length > 0) && ( + + {mediaType !== "all" && ( + applyFilters({ kind: "all" })} + /> + )} + {includedGenres.map((genre) => ( + + applyFilters({ + nextIncludedGenres: includedGenres.filter((g) => g !== genre), + }) } /> ))} - -
- setLayout("grid")} - className="m-1" - iconClassName={cn( - layout === "grid" && "fill-accent dark:fill-accent", - )} - {...tooltip(t("browse.switchToGrid"))} - /> - setLayout("list")} - className="m-1" - iconClassName={cn( - layout === "list" && "fill-accent dark:fill-accent", - )} - {...tooltip(t("browse.switchToList"))} - /> -
+ {excludedGenres.map((genre) => ( + + applyFilters({ + nextExcludedGenres: excludedGenres.filter((g) => g !== genre), + }) + } + /> + ))} + {studioSlugs.map((studio) => ( + + applyFilters({ + nextStudios: studioSlugs.filter((s) => s !== studio), + }) + } + /> + ))} + {staffSlugs.map((staff) => ( + + applyFilters({ + nextStaff: staffSlugs.filter((s) => s !== staff), + }) + } + /> + ))} + + )} ); }; From 5b4177f34dd87a637eaf76fd3d8d910b23a620a1 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 31 Mar 2026 11:26:15 +0200 Subject: [PATCH 07/10] Fix genres & studios links --- front/src/components/items/item-details.tsx | 2 +- front/src/ui/details/header.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/front/src/components/items/item-details.tsx b/front/src/components/items/item-details.tsx index 1ef29f96..c40bccaf 100644 --- a/front/src/components/items/item-details.tsx +++ b/front/src/components/items/item-details.tsx @@ -123,7 +123,7 @@ export const ItemDetails = ({ diff --git a/front/src/ui/details/header.tsx b/front/src/ui/details/header.tsx index 4b07e629..0639f5cd 100644 --- a/front/src/ui/details/header.tsx +++ b/front/src/ui/details/header.tsx @@ -401,7 +401,9 @@ const Description = ({
    {genres.map((genre) => (
  • - + {t(`genres.${genre}`)}
  • @@ -435,7 +437,7 @@ const Description = ({ {studios.map((x, i) => ( {i !== 0 && ","} - + {x.name} From d3a929855a1d3a9e50a1b2809b0349ffa661e040 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 31 Mar 2026 12:01:48 +0200 Subject: [PATCH 08/10] Add a searchbar for episodes --- front/src/ui/details/season.tsx | 6 ++- front/src/ui/details/serie.tsx | 14 ++++++ front/src/ui/navbar.tsx | 85 ++++++++++++++++----------------- 3 files changed, 61 insertions(+), 44 deletions(-) diff --git a/front/src/ui/details/season.tsx b/front/src/ui/details/season.tsx index a749fe90..262415d1 100644 --- a/front/src/ui/details/season.tsx +++ b/front/src/ui/details/season.tsx @@ -95,6 +95,7 @@ export const EntryList = ({ slug, season, onSelectVideos, + search, ...props }: { slug: string; @@ -104,6 +105,7 @@ export const EntryList = ({ name: string | null; videos: Entry["videos"]; }) => void; + search?: string; } & Partial>>) => { const { t } = useTranslation(); const { items: seasons, error } = useInfiniteFetch(SeasonHeader.query(slug)); @@ -112,7 +114,7 @@ export const EntryList = ({ return ( } Divider={() => ( @@ -176,10 +178,12 @@ type EntryOrSeason = z.infer; EntryList.query = ( slug: string, season: string | number, + query: string | undefined, ): QueryIdentifier => ({ parser: EntryOrSeason, path: ["api", "series", slug, "entries"], params: { + query, // TODO: use a better filter, it removes specials and movies filter: season ? `seasonNumber ge ${season}` : undefined, includeSeasons: true, diff --git a/front/src/ui/details/serie.tsx b/front/src/ui/details/serie.tsx index 4c73fd2f..ff36ea06 100644 --- a/front/src/ui/details/serie.tsx +++ b/front/src/ui/details/serie.tsx @@ -8,6 +8,7 @@ import { EntrySelect } from "~/components/entries/select"; import type { Entry, Serie } from "~/models"; import { Container, H2, Svg, usePopup } from "~/primitives"; import { Fetch } from "~/query"; +import { SearchBar } from "~/ui/navbar"; import { useQueryState } from "~/utils"; import { HeaderBackground, useScrollNavbar } from "../navbar"; import { Header } from "./header"; @@ -88,6 +89,8 @@ const SerieHeader = ({ videos: Entry["videos"]; }) => void; }) => { + const [_, setSearch] = useQueryState("search", ""); + return (
    @@ -104,6 +107,15 @@ const SerieHeader = ({ /> + + + setSearch(q)} + forceExpand + containerClassName="w-2/5 max-w-90" + /> + + ); }; @@ -111,6 +123,7 @@ const SerieHeader = ({ export const SerieDetails = () => { const [slug] = useQueryState("slug", undefined!); const [season] = useQueryState("season", undefined!); + const [search] = useQueryState("search", ""); const insets = useSafeAreaInsets(); const [imageHeight, setHeight] = useState(300); const { scrollHandler, headerProps, headerHeight } = useScrollNavbar({ @@ -142,6 +155,7 @@ export const SerieDetails = () => { ( { { - if (path === "/browse") setQuery(query); + value={path === "/browse" ? q : undefined} + onChangeText={(query) => { + console.log(query); + if (path === "/browse") { + setQuery(query ?? undefined); + } }} - onSubmit={(query) => { + onSubmitEditing={(e) => { + const query = e.nativeEvent.text; if (query && path !== "/browse") { router.push(`/browse?q=${query}`); } }} - onClear={() => { - if (path === "/browse") setQuery(undefined); - }} /> ); }; -const SearchBar = ({ - query, - onChange, - onSubmit, - onClear, -}: { - query: string | undefined; - onChange: (query: string) => void; - onSubmit?: (query: string | undefined) => void; - onClear?: () => void; -}) => { +export const SearchBar = ({ + value, + onChangeText, + onSubmitEditing, + onBlur, + onFocus, + className, + containerClassName, + forceExpand, + ...props +}: TextInputProps & { forceExpand?: boolean; containerClassName?: string }) => { const { t } = useTranslation(); - const [expanded, setExpanded] = useState(!!query); + const [_expanded, setExpanded] = useState(!!value); const inputRef = useRef(null); + const expanded = _expanded || forceExpand; + return ( onChange(q)} - onSubmitEditing={(e) => onSubmit?.(e.nativeEvent.text)} - onFocus={() => setExpanded(true)} - onBlur={() => { - if (!query) setExpanded(false); + value={value} + onChangeText={(q) => onChangeText?.(q)} + onSubmitEditing={(e) => onSubmitEditing?.(e)} + onFocus={(e) => { + onFocus?.(e); + setExpanded(true); + }} + onBlur={(e) => { + onBlur?.(e); + if (!value) setExpanded(false); }} placeholder={t("navbar.search")} textAlignVertical="center" @@ -167,8 +162,10 @@ const SearchBar = ({ "h-full flex-1 font-sans text-base outline-0", "align-middle text-slate-600 dark:text-slate-200", !expanded && "w-0 grow-0", + className, )} placeholderTextColorClassName="accent-slate-400 dark:text-slate-600" + {...props} /> { + console.log(expanded); if (expanded) { inputRef.current?.blur(); + inputRef.current?.clear(); + onChangeText?.(""); setExpanded(false); - onClear?.(); } else { setExpanded(true); // Small delay to allow animation to start before focusing From f126a0592ea0c5be56bae459119cac91c7a01534 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 31 Mar 2026 17:55:39 +0200 Subject: [PATCH 09/10] Handle watchstatus of entries without history entry --- api/drizzle/0030_external_hist.sql | 3 + api/drizzle/meta/0030_snapshot.json | 2016 ++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/controllers/entries.ts | 1 + api/src/controllers/profiles/history.ts | 23 +- api/src/db/schema/history.ts | 4 +- api/src/models/history.ts | 12 + front/src/components/items/context-menus.tsx | 24 + 8 files changed, 2082 insertions(+), 8 deletions(-) create mode 100644 api/drizzle/0030_external_hist.sql create mode 100644 api/drizzle/meta/0030_snapshot.json diff --git a/api/drizzle/0030_external_hist.sql b/api/drizzle/0030_external_hist.sql new file mode 100644 index 00000000..1d5edf38 --- /dev/null +++ b/api/drizzle/0030_external_hist.sql @@ -0,0 +1,3 @@ +ALTER TABLE "kyoo"."history" ADD COLUMN "external" boolean;--> statement-breakpoint +UPDATE "kyoo"."history" SET "external" = false;--> statement-breakpoint +ALTER TABLE "kyoo"."history" ALTER COLUMN "external" SET NOT NULL;--> statement-breakpoint diff --git a/api/drizzle/meta/0030_snapshot.json b/api/drizzle/meta/0030_snapshot.json new file mode 100644 index 00000000..17ccf91d --- /dev/null +++ b/api/drizzle/meta/0030_snapshot.json @@ -0,0 +1,2016 @@ +{ + "id": "34fcf5bf-c0a7-4730-a705-0e7fe759d126", + "prevId": "882399a3-e081-4094-a0aa-c306011f3eec", + "version": "7", + "dialect": "postgresql", + "tables": { + "kyoo.entries": { + "name": "entries", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "entries_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "extra_kind": { + "name": "extra_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "available_since": { + "name": "available_since", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "entry_kind": { + "name": "entry_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "entry_order": { + "name": "entry_order", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entries_show_pk_shows_pk_fk": { + "name": "entries_show_pk_shows_pk_fk", + "tableFrom": "entries", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "entries_showPk_seasonNumber_episodeNumber_unique": { + "name": "entries_showPk_seasonNumber_episodeNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number", "episode_number"] + } + }, + "policies": {}, + "checkConstraints": { + "order_positive": { + "name": "order_positive", + "value": "\"kyoo\".\"entries\".\"order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entry_translations": { + "name": "entry_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "entry_name_trgm": { + "name": "entry_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "entry_translations_pk_entries_pk_fk": { + "name": "entry_translations_pk_entries_pk_fk", + "tableFrom": "entry_translations", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_translations_pk_language_pk": { + "name": "entry_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.history": { + "name": "history", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "history_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "profile_pk": { + "name": "profile_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entry_pk": { + "name": "entry_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percent": { + "name": "percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "time": { + "name": "time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "played_date": { + "name": "played_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "external": { + "name": "external", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "history_play_date": { + "name": "history_play_date", + "columns": [ + { + "expression": "played_date", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "history_profile_pk_profiles_pk_fk": { + "name": "history_profile_pk_profiles_pk_fk", + "tableFrom": "history", + "tableTo": "profiles", + "schemaTo": "kyoo", + "columnsFrom": ["profile_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "history_entry_pk_entries_pk_fk": { + "name": "history_entry_pk_entries_pk_fk", + "tableFrom": "history", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "history_video_pk_videos_pk_fk": { + "name": "history_video_pk_videos_pk_fk", + "tableFrom": "history", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video_pk"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "percent_valid": { + "name": "percent_valid", + "value": "\"kyoo\".\"history\".\"percent\" between 0 and 100" + } + }, + "isRLSEnabled": false + }, + "kyoo.images": { + "name": "images", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "images_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blurhash": { + "name": "blurhash", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "img_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "imgqueue_sort": { + "name": "imgqueue_sort", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "images_id_unique": { + "name": "images_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.mqueue": { + "name": "mqueue", + "schema": "kyoo", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mqueue_created": { + "name": "mqueue_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.profiles": { + "name": "profiles", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "profiles_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_id_unique": { + "name": "profiles_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.season_translations": { + "name": "season_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "season_name_trgm": { + "name": "season_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "season_translations_pk_seasons_pk_fk": { + "name": "season_translations_pk_seasons_pk_fk", + "tableFrom": "season_translations", + "tableTo": "seasons", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_translations_pk_language_pk": { + "name": "season_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.seasons": { + "name": "seasons", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "seasons_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "entries_count": { + "name": "entries_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "show_fk": { + "name": "show_fk", + "columns": [ + { + "expression": "show_pk", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "season_nbr": { + "name": "season_nbr", + "columns": [ + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_show_pk_shows_pk_fk": { + "name": "seasons_show_pk_shows_pk_fk", + "tableFrom": "seasons", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seasons_id_unique": { + "name": "seasons_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "seasons_slug_unique": { + "name": "seasons_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "seasons_showPk_seasonNumber_unique": { + "name": "seasons_showPk_seasonNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.show_translations": { + "name": "show_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trailer_url": { + "name": "trailer_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "name_trgm": { + "name": "name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tags": { + "name": "tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "show_translations_pk_shows_pk_fk": { + "name": "show_translations_pk_shows_pk_fk", + "tableFrom": "show_translations", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_translations_pk_language_pk": { + "name": "show_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.shows": { + "name": "shows", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "shows_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "show_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "genres": { + "name": "genres", + "type": "genres[]", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "original": { + "name": "original", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collection_pk": { + "name": "collection_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entries_count": { + "name": "entries_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kind": { + "name": "kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "startAir": { + "name": "startAir", + "columns": [ + { + "expression": "start_air", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shows_collection_pk_shows_pk_fk": { + "name": "shows_collection_pk_shows_pk_fk", + "tableFrom": "shows", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["collection_pk"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shows_id_unique": { + "name": "shows_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "kind_slug": { + "name": "kind_slug", + "nullsNotDistinct": false, + "columns": ["kind", "slug"] + } + }, + "policies": {}, + "checkConstraints": { + "runtime_valid": { + "name": "runtime_valid", + "value": "\"kyoo\".\"shows\".\"runtime\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.roles": { + "name": "roles", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "roles_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "staff_pk": { + "name": "staff_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "role_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "character": { + "name": "character", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "role_kind": { + "name": "role_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "role_order": { + "name": "role_order", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roles_show_pk_shows_pk_fk": { + "name": "roles_show_pk_shows_pk_fk", + "tableFrom": "roles", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "roles_staff_pk_staff_pk_fk": { + "name": "roles_staff_pk_staff_pk_fk", + "tableFrom": "roles", + "tableTo": "staff", + "schemaTo": "kyoo", + "columnsFrom": ["staff_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.staff": { + "name": "staff", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "staff_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latin_name": { + "name": "latin_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_id_unique": { + "name": "staff_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "staff_slug_unique": { + "name": "staff_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.show_studio_join": { + "name": "show_studio_join", + "schema": "kyoo", + "columns": { + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "studio_pk": { + "name": "studio_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "show_studio_join_show_pk_shows_pk_fk": { + "name": "show_studio_join_show_pk_shows_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "show_studio_join_studio_pk_studios_pk_fk": { + "name": "show_studio_join_studio_pk_studios_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["studio_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_studio_join_show_pk_studio_pk_pk": { + "name": "show_studio_join_show_pk_studio_pk_pk", + "columns": ["show_pk", "studio_pk"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studio_translations": { + "name": "studio_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "studio_name_trgm": { + "name": "studio_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "studio_translations_pk_studios_pk_fk": { + "name": "studio_translations_pk_studios_pk_fk", + "tableFrom": "studio_translations", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "studio_translations_pk_language_pk": { + "name": "studio_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studios": { + "name": "studios", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "studios_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "studios_id_unique": { + "name": "studios_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "studios_slug_unique": { + "name": "studios_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.entry_video_join": { + "name": "entry_video_join", + "schema": "kyoo", + "columns": { + "entry_pk": { + "name": "entry_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "evj_video_pk": { + "name": "evj_video_pk", + "columns": [ + { + "expression": "video_pk", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entry_video_join_entry_pk_entries_pk_fk": { + "name": "entry_video_join_entry_pk_entries_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_video_join_video_pk_videos_pk_fk": { + "name": "entry_video_join_video_pk_videos_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_video_join_entry_pk_video_pk_pk": { + "name": "entry_video_join_entry_pk_video_pk_pk", + "columns": ["entry_pk", "video_pk"] + } + }, + "uniqueConstraints": { + "entry_video_join_slug_unique": { + "name": "entry_video_join_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.videos": { + "name": "videos", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendering": { + "name": "rendering", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "guess": { + "name": "guess", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "videos_id_unique": { + "name": "videos_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "videos_path_unique": { + "name": "videos_path_unique", + "nullsNotDistinct": false, + "columns": ["path"] + }, + "rendering_unique": { + "name": "rendering_unique", + "nullsNotDistinct": true, + "columns": ["rendering", "part", "version"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"kyoo\".\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"kyoo\".\"videos\".\"version\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.watchlist": { + "name": "watchlist", + "schema": "kyoo", + "columns": { + "profile_pk": { + "name": "profile_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "watchlist_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "seen_count": { + "name": "seen_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_entry": { + "name": "next_entry", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_played_at": { + "name": "last_played_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "watchlist_profile_pk_profiles_pk_fk": { + "name": "watchlist_profile_pk_profiles_pk_fk", + "tableFrom": "watchlist", + "tableTo": "profiles", + "schemaTo": "kyoo", + "columnsFrom": ["profile_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_show_pk_shows_pk_fk": { + "name": "watchlist_show_pk_shows_pk_fk", + "tableFrom": "watchlist", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_next_entry_entries_pk_fk": { + "name": "watchlist_next_entry_entries_pk_fk", + "tableFrom": "watchlist", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["next_entry"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watchlist_profile_pk_show_pk_pk": { + "name": "watchlist_profile_pk_show_pk_pk", + "columns": ["profile_pk", "show_pk"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "score_percent": { + "name": "score_percent", + "value": "\"kyoo\".\"watchlist\".\"score\" between 0 and 100" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": ["episode", "movie", "special", "extra"] + }, + "kyoo.img_status": { + "name": "img_status", + "schema": "kyoo", + "values": ["pending", "link", "ready"] + }, + "kyoo.genres": { + "name": "genres", + "schema": "kyoo", + "values": [ + "action", + "adventure", + "animation", + "comedy", + "crime", + "documentary", + "drama", + "family", + "fantasy", + "history", + "horror", + "music", + "mystery", + "romance", + "science-fiction", + "thriller", + "war", + "western", + "kids", + "reality", + "politics", + "soap", + "talk" + ] + }, + "kyoo.show_kind": { + "name": "show_kind", + "schema": "kyoo", + "values": ["serie", "movie", "collection"] + }, + "kyoo.show_status": { + "name": "show_status", + "schema": "kyoo", + "values": ["unknown", "finished", "airing", "planned"] + }, + "kyoo.role_kind": { + "name": "role_kind", + "schema": "kyoo", + "values": [ + "actor", + "director", + "writter", + "producer", + "music", + "crew", + "other" + ] + }, + "kyoo.watchlist_status": { + "name": "watchlist_status", + "schema": "kyoo", + "values": ["watching", "rewatching", "completed", "dropped", "planned"] + } + }, + "schemas": { + "kyoo": "kyoo" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index 316e183c..67e517a9 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -211,6 +211,13 @@ "when": 1774623568394, "tag": "0029_next_refresh", "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1774974162419, + "tag": "0030_external_hist", + "breakpoints": true } ] } diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index d3520a2b..70913a45 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -62,6 +62,7 @@ export const entryProgressQ = db entryPk: history.entryPk, playedDate: history.playedDate, videoId: videos.id, + external: history.external, }) .from(history) .leftJoin(videos, eq(history.videoPk, videos.pk)) diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index a4f80be4..c8c2155a 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -76,7 +76,7 @@ async function updateHistory( .select({ videoId: videos.id }) .from(history) .for("update", { of: sql`history` as any }) - .leftJoin(videos, eq(videos.pk, history.videoPk)) + .innerJoin(videos, eq(videos.pk, history.videoPk)) .where( and( eq(history.profilePk, userPk), @@ -86,12 +86,16 @@ async function updateHistory( ).map((x) => x.videoId); const toUpdate = traverse( - progress.filter((x) => existing.includes(x.videoId)), + progress.filter((x) => x.videoId && existing.includes(x.videoId)), ); const newEntries = traverse( progress - .filter((x) => !existing.includes(x.videoId)) - .map((x) => ({ ...x, entryUseid: isUuid(x.entry) })), + .filter((x) => !x.videoId || !existing.includes(x.videoId)) + .map((x) => ({ + ...x, + external: x.external ?? false, + entryUseid: isUuid(x.entry), + })), ); const updated = @@ -140,6 +144,7 @@ async function updateHistory( playedDate: coalesce(sql`hist.played_date`, sql`now()`).as( "playedDate", ), + external: sql`hist.external`.as("external"), }) .from(sql`unnest( ${sqlarr(newEntries.entry)}::text[], @@ -147,8 +152,9 @@ async function updateHistory( ${sqlarr(newEntries.videoId)}::uuid[], ${sqlarr(newEntries.time)}::integer[], ${sqlarr(newEntries.percent)}::integer[], - ${sqlarr(newEntries.playedDate)}::timestamptz[] - ) as hist(entry, entry_use_id, video_id, ts, percent, played_date)`) + ${sqlarr(newEntries.playedDate)}::timestamptz[], + ${sqlarr(newEntries.external)}::boolean[] + ) as hist(entry, entry_use_id, video_id, ts, percent, played_date, external)`) .innerJoin( entries, sql` @@ -244,7 +250,7 @@ async function updateWatchlist( seenCount: sql` case when ${entries.kind} = 'movie' then hist.percent - when hist.percent >= 95 then 1 + when hist.percent >= 95 then 100 else 0 end `.as("seen_count"), @@ -315,6 +321,7 @@ const historyProgressQ: typeof entryProgressQ = db entryPk: history.entryPk, playedDate: history.playedDate, videoId: videos.id, + external: history.external, }) .from(history) .leftJoin(videos, eq(history.videoPk, videos.pk)) @@ -360,6 +367,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) sort, filter: and( isNotNull(entryProgressQ.playedDate), + eq(entryProgressQ.external, false), ne(entries.kind, "extra"), filter, ), @@ -406,6 +414,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) sort, filter: and( isNotNull(entryProgressQ.playedDate), + eq(entryProgressQ.external, false), ne(entries.kind, "extra"), filter, ), diff --git a/api/src/db/schema/history.ts b/api/src/db/schema/history.ts index 304b969d..4e7267f1 100644 --- a/api/src/db/schema/history.ts +++ b/api/src/db/schema/history.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { check, index, integer, timestamp } from "drizzle-orm/pg-core"; +import { boolean, check, index, integer, timestamp } from "drizzle-orm/pg-core"; import { entries } from "./entries"; import { profiles } from "./profiles"; import { schema } from "./utils"; @@ -23,6 +23,8 @@ export const history = schema.table( playedDate: timestamp({ withTimezone: true, precision: 3 }) .notNull() .defaultNow(), + // true if the user only marked the entry has seen and has not seen it on kyoo + external: boolean().notNull(), }, (t) => [ index("history_play_date").on(t.playedDate.desc()), diff --git a/api/src/models/history.ts b/api/src/models/history.ts index c7b0223e..bc14f719 100644 --- a/api/src/models/history.ts +++ b/api/src/models/history.ts @@ -33,6 +33,18 @@ export const SeedHistory = t.Intersect([ entry: t.String({ description: "Id or slug of the entry/movie you watched", }), + external: t.Optional( + t.Boolean({ + description: comment` + Set this to true if the user marked the entry as watched + without actually watching it on kyoo. + + If true, it will not add it to the history but still mark it as + seen. + `, + default: false, + }), + ), }), ]); export type SeedHistory = typeof SeedHistory.static; diff --git a/front/src/components/items/context-menus.tsx b/front/src/components/items/context-menus.tsx index c102e7c6..0208dad6 100644 --- a/front/src/components/items/context-menus.tsx +++ b/front/src/components/items/context-menus.tsx @@ -29,8 +29,25 @@ export const EntryContext = ({ } & Partial> & Partial>) => { // const downloader = useDownloader(); + const account = useAccount(); const { t } = useTranslation(); + const markAsSeenMutation = useMutation({ + method: "POST", + path: ["api", "profiles", "me", "history"], + body: [ + { + percent: 100, + entry: slug, + videoId: null, + time: 0, + playedDate: null, + external: true, + }, + ], + invalidate: null, + }); + return ( + {account && ( + markAsSeenMutation.mutate()} + /> + )} {serieSlug && ( Date: Tue, 31 Mar 2026 19:01:15 +0200 Subject: [PATCH 10/10] Fix language preference being forgotten --- front/src/ui/player/language-preference.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/front/src/ui/player/language-preference.ts b/front/src/ui/player/language-preference.ts index 66d39ba4..d3c06449 100644 --- a/front/src/ui/player/language-preference.ts +++ b/front/src/ui/player/language-preference.ts @@ -15,13 +15,14 @@ export const useLanguagePreference = (player: VideoPlayer, slug: string) => { lang: account?.claims.settings.audioLanguage ?? null, }); useEvent(player, "onAudioTrackChange", () => { + if (!audios?.length) return; const selected = audios?.[player.getAvailableTextTracks().findIndex((x) => x.selected)]; if (!selected) return; aud.current = { idx: selected.index, lang: selected.language }; }); useEffect(() => { - if (!audios) return; + if (!audios?.length) return; let audRet = audios.findIndex( aud.current.lang === "default" ? (x) => x.isDefault @@ -47,7 +48,7 @@ export const useLanguagePreference = (player: VideoPlayer, slug: string) => { sub.current = { idx: null, lang: null, forced: false }; return; } - if (!subtitles) return; + if (!subtitles?.length) return; const idx = player.getAvailableTextTracks().findIndex((x) => x.selected); sub.current = { idx: idx, @@ -56,7 +57,7 @@ export const useLanguagePreference = (player: VideoPlayer, slug: string) => { }; }); useEffect(() => { - if (!subtitles || sub.current.idx === null) return; + if (!subtitles?.length || sub.current.idx === null) return; let subRet = subtitles.findIndex( sub.current.lang === "default" ? (x) => x.isDefault