Allow multiple ratings per serie/movie (#1377)

This commit is contained in:
Zoe Roux 2026-03-18 20:58:43 +01:00 committed by GitHub
parent 02ad4dabcd
commit a0ff1c3dfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 171 additions and 69 deletions

View File

@ -0,0 +1,5 @@
DROP INDEX "kyoo"."rating";--> statement-breakpoint
ALTER TABLE "kyoo"."shows" DROP CONSTRAINT "rating_valid";--> statement-breakpoint
ALTER TABLE "kyoo"."shows" ALTER COLUMN "rating" SET DATA TYPE jsonb USING COALESCE(jsonb_build_object('legacy', "rating"), '{}');--> statement-breakpoint
ALTER TABLE "kyoo"."shows" ALTER COLUMN "rating" SET DEFAULT '{}'::jsonb;--> statement-breakpoint
ALTER TABLE "kyoo"."shows" ALTER COLUMN "rating" SET NOT NULL;

View File

@ -197,6 +197,13 @@
"when": 1771505332722,
"tag": "0027_show_slug",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1771600000000,
"tag": "0028_rating_jsonb",
"breakpoints": true
}
]
}

View File

@ -66,7 +66,6 @@ export const showFilters: FilterDef = {
values: Genre.enum,
isArray: true,
},
rating: { column: shows.rating, type: "int" },
status: { column: shows.status, type: "enum", values: SerieStatus.enum },
runtime: { column: shows.runtime, type: "float" },
airDate: { column: shows.startAir, type: "date" },
@ -88,6 +87,10 @@ export const showFilters: FilterDef = {
},
score: { column: watchStatusQ.score, type: "int" },
isAvailable: { column: sql`(${shows.availableCount} > 0)`, type: "bool" },
rating: {
column: (source: string) => sql`(${shows.rating}->>${source})::int`,
type: "int",
},
};
export const showSort = Sort(
{
@ -97,7 +100,6 @@ export const showSort = Sort(
isNullable: false,
accessor: (x) => x.name,
},
rating: shows.rating,
airDate: shows.startAir,
startAir: shows.startAir,
endAir: shows.endAir,
@ -105,6 +107,11 @@ export const showSort = Sort(
nextRefresh: shows.nextRefresh,
watchStatus: watchStatusQ.status,
score: watchStatusQ.score,
rating: (source: string) => ({
sql: sql`(${shows.rating}->>${source})::int`,
isNullable: true,
accessor: (x: any) => x.rating?.[source] ?? null,
}),
},
{
default: ["slug"],

View File

@ -7,7 +7,6 @@ import {
integer,
jsonb,
primaryKey,
smallint,
text,
timestamp,
unique,
@ -73,7 +72,7 @@ export const shows = schema.table(
slug: varchar({ length: 255 }).notNull(),
kind: showKind().notNull(),
genres: genres().array().notNull(),
rating: smallint(),
rating: jsonb().$type<Record<string, number>>().notNull().default({}),
runtime: integer(),
status: showStatus().notNull(),
startAir: date(),
@ -99,12 +98,10 @@ export const shows = schema.table(
(t) => [
unique("kind_slug").on(t.kind, t.slug),
check("rating_valid", sql`${t.rating} between 0 and 100`),
check("runtime_valid", sql`${t.runtime} >= 0`),
index("kind").using("hash", t.kind),
index("slug").on(t.slug),
index("rating").on(t.rating),
index("startAir").on(t.startAir),
],
);

View File

@ -15,7 +15,11 @@ import {
const BaseCollection = t.Object({
genres: t.Array(Genre),
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
rating: t.Record(t.String(), t.Integer({ minimum: 0, maximum: 100 }), {
default: {},
description:
"Rating from various sources (0-100 scale). Keys are source names, values are ratings.",
}),
startAir: t.Nullable(
t.String({
format: "date",

View File

@ -52,7 +52,7 @@ export const bubble: SeedMovie = {
},
},
genres: ["animation", "adventure", "science-fiction", "fantasy"],
rating: 74,
rating: { themoviedatabase: 74 },
status: "finished",
runtime: 101,
airDate: "2022-02-14",

View File

@ -38,7 +38,7 @@ export const dune1984: SeedMovie = {
},
},
genres: ["adventure", "drama", "science-fiction"],
rating: 60,
rating: { themoviedatabase: 60 },
status: "finished",
runtime: 137,
airDate: "1984-12-14",

View File

@ -38,7 +38,7 @@ export const dune: SeedMovie = {
},
},
genres: ["adventure", "drama", "science-fiction", "action"],
rating: 83,
rating: { themoviedatabase: 83 },
status: "finished",
runtime: 155,
airDate: "2021-10-22",

View File

@ -20,7 +20,7 @@ export const duneCollection: SeedCollection = {
},
originalLanguage: "en",
genres: ["adventure", "science-fiction"],
rating: 80,
rating: { themoviedatabase: 80 },
externalId: {
themoviedatabase: [
{

View File

@ -103,7 +103,7 @@ export const madeInAbyss = {
"fantasy",
],
status: "finished",
rating: 84,
rating: { themoviedatabase: 84 },
runtime: 24,
originalLanguage: "ja",
startAir: "2017-07-07",

View File

@ -23,7 +23,11 @@ export type MovieStatus = typeof MovieStatus.static;
const BaseMovie = t.Object({
genres: t.Array(Genre),
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
rating: t.Record(t.String(), t.Integer({ minimum: 0, maximum: 100 }), {
default: {},
description:
"Rating from various sources (0-100 scale). Keys are source names, values are ratings.",
}),
status: MovieStatus,
runtime: t.Nullable(
t.Number({ minimum: 0, description: "Runtime of the movie in minutes." }),

View File

@ -29,7 +29,11 @@ export type SerieStatus = typeof SerieStatus.static;
const BaseSerie = t.Object({
genres: t.Array(Genre),
rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })),
rating: t.Record(t.String(), t.Integer({ minimum: 0, maximum: 100 }), {
default: {},
description:
"Rating from various sources (0-100 scale). Keys are source names, values are ratings.",
}),
status: SerieStatus,
runtime: t.Nullable(
t.Number({

View File

@ -8,7 +8,7 @@ import { toDrizzle } from "./to-sql";
export type FilterDef = {
[key: string]:
| {
column: Column | SQLWrapper;
column: Column | SQLWrapper | ((param: string) => Column | SQLWrapper);
type: "int" | "float" | "date" | "string" | "bool";
isArray?: boolean;
}
@ -34,9 +34,13 @@ export const Filter = ({
${description}
This is based on [odata's filter specification](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter).
Filters available: ${Object.keys(def).join(", ")}.
Filters available: ${Object.keys(def)
.map((x) =>
typeof def[x].column === "function" ? `${x}:param` : x,
)
.join(", ")}.
`,
example: "(rating gt 75 and genres has action) or status eq planned",
example: "(runtime gt 75 and genres has action) or rating:tmdb ge 50",
}),
)
.Decode((filter) => {

View File

@ -17,6 +17,7 @@ import {
many,
many1,
map,
maybe,
or,
qthen,
recover,
@ -25,7 +26,11 @@ import {
thenq,
} from "parjs/combinators";
export type Property = string;
export type Property = {
type: "property";
name: string;
param: string | null;
};
export type Value =
| { type: "int"; value: number }
| { type: "float"; value: number }
@ -57,7 +62,20 @@ const enumP = t(
.expects("an enum value"),
);
const property = t(letter().pipe(many1(), stringify())).expects("a property");
const property = t(
letter().pipe(
many1(),
stringify(),
then(
string(":").pipe(qthen(letter().pipe(many1(), stringify())), maybe(null)),
),
map(([prop, param]) => ({
type: "property" as const,
name: prop,
param: param,
})),
),
).expects("a property");
const intVal = t(int().pipe(map((i) => ({ type: "int" as const, value: i }))));
const floatVal = t(
@ -87,6 +105,7 @@ const strVal = t(noCharOf('"').pipe(many1(), stringify(), between('"'))).pipe(
);
const enumVal = enumP.pipe(map((e) => ({ type: "enum" as const, value: e })));
const value = dateVal
.pipe(
// until we get the `-` character, this could be an int or a float.
recover(() => ({ kind: "Soft" })),

View File

@ -30,19 +30,39 @@ const opMap: Record<Operator, BinaryOperator> = {
export const toDrizzle = (expr: Expression, config: FilterDef): SQL => {
switch (expr.type) {
case "op": {
const where = `${expr.property} ${expr.operator} ${expr.value.value}`;
const prop = config[expr.property];
const where = `${expr.property.name}${expr.property.param ? `:${expr.property.param}` : ""} ${expr.operator} ${expr.value.value}`;
const prop = config[expr.property.name];
if (!prop) {
throw new KErrorT(
comment`
Invalid property: ${expr.property}.
Invalid property: ${expr.property.name}.
Expected one of ${Object.keys(config).join(", ")}.
`,
{ in: where },
);
}
if (typeof prop.column === "function" && !expr.property.param) {
throw new KErrorT(
comment`
Property ${expr.property.name} requires a parameter (e.g., ${expr.property.name}:source).
`,
{ in: where },
);
} else if (typeof prop.column !== "function" && expr.property.param) {
throw new KErrorT(
comment`
Property ${expr.property.name} does not accept a parameter.
`,
{ in: where },
);
}
const column =
typeof prop.column === "function"
? prop.column(expr.property.param!)
: prop.column;
if (expr.value.type === "enum" && prop.type === "string") {
// promote enum to string since this is legal
// but parser doesn't know if an enum should be a string
@ -52,7 +72,7 @@ export const toDrizzle = (expr: Expression, config: FilterDef): SQL => {
if (expr.value.value !== "false" && expr.value.value !== "true") {
throw new KErrorT(
comment`
Invalid value for property ${expr.property}.
Invalid value for property ${expr.property.name}.
Get ${expr.value.value} but expected true or false.
`,
{ in: where },
@ -63,7 +83,7 @@ export const toDrizzle = (expr: Expression, config: FilterDef): SQL => {
if (prop.type !== expr.value.type) {
throw new KErrorT(
comment`
Invalid value for property ${expr.property}.
Invalid value for property ${expr.property.name}.
Got ${expr.value.type} but expected ${prop.type}.
`,
{ in: where },
@ -76,7 +96,7 @@ export const toDrizzle = (expr: Expression, config: FilterDef): SQL => {
) {
throw new KErrorT(
comment`
Invalid value ${expr.value.value} for property ${expr.property}.
Invalid value ${expr.value.value} for property ${expr.property.name}.
Expected one of ${prop.values.join(", ")} but got ${expr.value.value}.
`,
{ in: where },
@ -87,15 +107,15 @@ export const toDrizzle = (expr: Expression, config: FilterDef): SQL => {
if (expr.operator !== "has" && expr.operator !== "eq") {
throw new KErrorT(
comment`
Property ${expr.property} is an array but you wanted to use the
Property ${expr.property.name} is an array but you wanted to use the
operator ${expr.operator}. Only "has" is supported ("eq" is also aliased to "has")
`,
{ in: where },
);
}
return sql`${expr.value.value} = any(${prop.column})`;
return sql`${expr.value.value} = any(${column})`;
}
return opMap[expr.operator](prop.column, expr.value.value);
return opMap[expr.operator](column, expr.value.value);
}
case "and": {
const lhs = toDrizzle(expr.lhs, config);

View File

@ -26,7 +26,7 @@ export type SortVal =
};
export const Sort = (
values: Record<string, SortVal | SortVal[]>,
values: Record<string, SortVal | SortVal[] | ((param: string) => SortVal)>,
{
description = "How to sort the query",
default: def,
@ -36,17 +36,26 @@ export const Sort = (
tablePk: SQLWrapper;
description?: string;
},
) =>
t
) => {
const staticKeys = Object.keys(values).filter(
(k) => typeof values[k] !== "function",
);
const paramKeys = Object.keys(values).filter(
(k) => typeof values[k] === "function",
);
return t
.Transform(
t.Array(
t.Union([
t.UnionEnum([
"random",
...Object.keys(values),
...Object.keys(values).map((x) => `-${x}`),
...staticKeys,
...staticKeys.map((x) => `-${x}`),
]),
t.TemplateLiteral("random:${number}"),
...paramKeys.map((k) => t.TemplateLiteral(`${k}:\${string}`)),
...paramKeys.map((k) => t.TemplateLiteral(`-${k}:\${string}`)),
]),
{
default: def,
@ -67,7 +76,8 @@ export const Sort = (
tablePk,
sort: sort.flatMap((x) => {
const desc = x[0] === "-";
const key = desc ? x.substring(1) : x;
const [key, param] = (desc ? x.substring(1) : x).split(":", 2);
const process = (val: SortVal): Sort["sort"][0] => {
if ("getSQL" in val) {
return {
@ -85,6 +95,16 @@ export const Sort = (
desc,
};
};
if (typeof values[key] === "function") {
if (!param) {
throw new Error(
`Sort key "${key}" requires a parameter (e.g., ${key}:source)`,
);
}
return process(values[key](param));
}
return Array.isArray(values[key])
? values[key].map(process)
: process(values[key]);
@ -94,6 +114,7 @@ export const Sort = (
.Encode(() => {
throw new Error("Encode not supported for sort");
});
};
export const sortToSql = (sort: Sort | undefined) => {
if (!sort) return [];

View File

@ -28,7 +28,7 @@ describe("Parse filter", () => {
value: {
type: "op",
operator: "eq",
property: "status",
property: { name: "status" },
value: { type: "enum", value: "finished" },
},
});
@ -40,7 +40,7 @@ describe("Parse filter", () => {
value: {
type: "op",
operator: "lt",
property: "rating",
property: { name: "rating" },
value: { type: "int", value: 10 },
},
});
@ -52,7 +52,7 @@ describe("Parse filter", () => {
value: {
type: "op",
operator: "ge",
property: "airDate",
property: { name: "airDate" },
value: { type: "date", value: "2022-10-12" },
},
});
@ -66,7 +66,7 @@ describe("Parse filter", () => {
expression: {
type: "op",
operator: "lt",
property: "rating",
property: { name: "rating" },
value: { type: "int", value: 10 },
},
},
@ -79,7 +79,7 @@ describe("Parse filter", () => {
value: {
type: "op",
operator: "lt",
property: "rating",
property: { name: "rating" },
value: { type: "int", value: 10 },
},
});
@ -93,7 +93,7 @@ describe("Parse filter", () => {
expression: {
type: "op",
operator: "lt",
property: "rating",
property: { name: "rating" },
value: { type: "int", value: 10 },
},
},
@ -110,14 +110,14 @@ describe("Parse filter", () => {
expression: {
type: "op",
operator: "lt",
property: "rating",
property: { name: "rating" },
value: { type: "int", value: 10 },
},
},
rhs: {
type: "op",
operator: "lt",
property: "rating",
property: { name: "rating" },
value: { type: "int", value: 20 },
},
},
@ -138,14 +138,14 @@ describe("Parse filter", () => {
expression: {
type: "op",
operator: "lt",
property: "rating",
property: { name: "rating" },
value: { type: "int", value: 10 },
},
},
rhs: {
type: "op",
operator: "lt",
property: "rating",
property: { name: "rating" },
value: { type: "int", value: 20 },
},
},
@ -154,7 +154,7 @@ describe("Parse filter", () => {
lhs: {
type: "op",
operator: "eq",
property: "status",
property: { name: "status" },
value: { type: "enum", value: "finished" },
},
rhs: {
@ -162,7 +162,7 @@ describe("Parse filter", () => {
expression: {
type: "op",
operator: "ne",
property: "status",
property: { name: "status" },
value: { type: "enum", value: "airing" },
},
},
@ -177,7 +177,7 @@ describe("Parse filter", () => {
value: {
type: "op",
operator: "eq",
property: "tags",
property: { name: "tags" },
value: { type: "string", value: "magic armor" },
},
});
@ -189,7 +189,7 @@ describe("Parse filter", () => {
value: {
type: "op",
operator: "eq",
property: "tags",
property: { name: "tags" },
value: { type: "string", value: "magic armor" },
},
});
@ -202,7 +202,7 @@ describe("Parse filter", () => {
value: {
type: "op",
operator: "eq",
property: "tags",
property: { name: "tags" },
// this is parsed as enum but is handled afterwards
value: { type: "enum", value: "magic" },
},

View File

@ -39,7 +39,7 @@ describe("with a null value", () => {
},
genres: [],
status: "unknown",
rating: null,
rating: {},
runtime: null,
airDate: null,
originalLanguage: "en",

View File

@ -10,16 +10,22 @@ export const Rating = ({
iconClassName,
...props
}: {
rating: number | null;
rating: Record<string, number>;
className?: string;
textClassName?: string;
iconClassName?: string;
}) => {
const values = Object.values(rating);
const avg =
values.length > 0
? values.reduce((a, b) => a + b, 0) / values.length
: null;
return (
<View className={cn("flex-row items-center", className)} {...props}>
<Icon icon={Star} className={cn("mr-1", iconClassName)} />
<P className={cn("align-middle", textClassName)}>
{rating ? rating / 10 : "??"} / 10
{avg !== null ? Math.round(avg) / 10 : "??"} / 10
</P>
</View>
);

View File

@ -18,7 +18,7 @@ export const Collection = z
aliases: z.array(z.string()),
tags: z.array(z.string()),
description: z.string().nullable(),
rating: z.number().int().gte(0).lte(100).nullable(),
rating: z.record(z.string(), z.number().int().gte(0).lte(100)),
startAir: zdate().nullable(),
endAir: zdate().nullable(),
genres: z.array(Genre),

View File

@ -21,7 +21,7 @@ export const Movie = z
tags: z.array(z.string()),
description: z.string().nullable(),
status: z.enum(["unknown", "finished", "planned"]),
rating: z.number().int().gte(0).lte(100),
rating: z.record(z.string(), z.number().int().gte(0).lte(100)),
runtime: z.number().int().nullable(),
airDate: zdate().nullable(),
genres: z.array(Genre),

View File

@ -22,7 +22,7 @@ export const Serie = z
tags: z.array(z.string()),
description: z.string().nullable(),
status: z.enum(["unknown", "finished", "airing", "planned"]),
rating: z.number().int().gte(0).lte(100).nullable(),
rating: z.record(z.string(), z.number().int().gte(0).lte(100)),
startAir: zdate().nullable(),
endAir: zdate().nullable(),
genres: z.array(Genre),

View File

@ -56,7 +56,9 @@ BrowsePage.query = ({
path: ["api", "shows"],
infinite: true,
params: {
sort: sortBy ? `${sortOrd === "desc" ? "-" : ""}${sortBy}` : "name",
sort: sortBy
? `${sortOrd === "desc" ? "-" : ""}${sortBy === "rating" ? "rating:themoviedatabase" : sortBy}`
: "name",
filter,
query: search,
},

View File

@ -190,7 +190,7 @@ export const TitleLine = ({
name: string;
tagline: string | null;
date: string | null;
rating: number | null;
rating: Record<string, number>;
runtime: number | null;
poster: KImage | null;
trailerUrl: string | null;
@ -231,7 +231,7 @@ export const TitleLine = ({
watchStatus={watchStatus}
iconsClassName="lg:fill-slate-200 dark:fill-slate-200"
/>
{rating !== null && rating !== 0 && (
{Object.keys(rating).length > 0 && (
<>
<DottedSeparator className="lg:text-slate-200 dark:text-slate-200" />
<Rating

View File

@ -9,7 +9,7 @@ class Collection(Model):
slug: str
original_language: Language | None
genres: list[Genre]
rating: int | None
rating: dict[str, int]
external_id: dict[str, list[MetadataId]]
translations: dict[Language, CollectionTranslation] = {}

View File

@ -21,7 +21,7 @@ class Movie(Model):
slug: str
original_language: Language | None
genres: list[Genre]
rating: int | None
rating: dict[str, int]
status: MovieStatus
runtime: int | None
air_date: date | None

View File

@ -25,7 +25,7 @@ class Serie(Model):
slug: str
original_language: Language | None
genres: list[Genre]
rating: int | None
rating: dict[str, int]
status: SerieStatus
runtime: int | None
start_air: date | None

View File

@ -155,7 +155,7 @@ class TheMovieDatabase(Provider):
slug=to_slug(movie["title"]),
original_language=Language.get(movie["original_language"]),
genres=self._map_genres(x["id"] for x in movie["genres"]),
rating=round(float(movie["vote_average"]) * 10),
rating={self.name: round(float(movie["vote_average"]) * 10)},
status=MovieStatus.FINISHED
if movie["status"] == "Released"
else MovieStatus.PLANNED,
@ -309,7 +309,7 @@ class TheMovieDatabase(Provider):
slug=to_slug(serie["name"]),
original_language=Language.get(serie["original_language"]),
genres=self._map_genres(x["id"] for x in serie["genres"]),
rating=round(float(serie["vote_average"]) * 10),
rating={self.name: round(float(serie["vote_average"]) * 10)},
status=SerieStatus.FINISHED
if serie["status"] == "Released"
else SerieStatus.AIRING
@ -597,9 +597,11 @@ class TheMovieDatabase(Provider):
genres=[
y for x in collection["parts"] for y in self._map_genres(x["genre_ids"])
],
rating=round(
mean(float(x["vote_average"]) * 10 for x in collection["parts"])
),
rating={
self.name: round(
mean(float(x["vote_average"]) * 10 for x in collection["parts"])
)
},
external_id={
self.name: [
MetadataId(

View File

@ -226,7 +226,7 @@ class TVDB(Provider):
for x in ret["genres"]
if self._genre_map[x["slug"]] is not None
],
rating=None, # TODO: maybe use the `score` value.
rating={}, # TODO: maybe use the `score` value.
status=SerieStatus.FINISHED
if ret["status"]["name"] == "Ended"
else SerieStatus.AIRING
@ -423,7 +423,7 @@ class TVDB(Provider):
for x in show["genres"]
if self._genre_map[x["slug"]] is not None
],
rating=None,
rating={},
external_id={
self.name: [
MetadataId(
@ -734,7 +734,7 @@ class TVDB(Provider):
for x in ret["genres"]
if self._genre_map[x["slug"]] is not None
],
rating=None, # TODO: maybe use the `score` value.
rating={}, # TODO: maybe use the `score` value.
status=MovieStatus.FINISHED
if ret["status"]["name"] == "Ended"
else MovieStatus.PLANNED,