Add entriesCount, availableCount & isAvailable (#831)

This commit is contained in:
Zoe Roux 2025-03-08 14:56:54 +01:00 committed by GitHub
commit 74a509ec03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1409 additions and 27 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE "kyoo"."shows" ADD COLUMN "entries_count" integer NOT NULL;--> statement-breakpoint
ALTER TABLE "kyoo"."shows" ADD COLUMN "available_count" integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -85,6 +85,13 @@
"when": 1741014917375, "when": 1741014917375,
"tag": "0011_join_rename", "tag": "0011_join_rename",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1741360992371,
"tag": "0012_available_count",
"breakpoints": true
} }
] ]
} }

View File

@ -27,6 +27,7 @@ export const insertCollection = async (
startAir: show.kind === "movie" ? show.airDate : show.startAir, startAir: show.kind === "movie" ? show.airDate : show.startAir,
endAir: show.kind === "movie" ? show.airDate : show.endAir, endAir: show.kind === "movie" ? show.airDate : show.endAir,
nextRefresh: show.nextRefresh, nextRefresh: show.nextRefresh,
entriesCount: 0,
...col, ...col,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({

View File

@ -1,7 +1,7 @@
import { eq, sql } from "drizzle-orm"; import { and, count, eq, exists, ne, sql } from "drizzle-orm";
import { db } from "~/db"; import { db } from "~/db";
import { showTranslations, shows } from "~/db/schema"; import { entries, entryVideoJoin, showTranslations, shows } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils"; import { conflictUpdateAllExcept, sqlarr } from "~/db/utils";
import type { SeedCollection } from "~/models/collections"; import type { SeedCollection } from "~/models/collections";
import type { SeedMovie } from "~/models/movie"; import type { SeedMovie } from "~/models/movie";
import type { SeedSerie } from "~/models/serie"; import type { SeedSerie } from "~/models/serie";
@ -93,3 +93,37 @@ async function insertBaseShow(
slug: show.slug, slug: show.slug,
}; };
} }
export async function updateAvailableCount(
showPks: number[],
updateEntryCount = true,
) {
return await db
.update(shows)
.set({
availableCount: sql`${db
.select({ count: count() })
.from(entries)
.where(
and(
eq(entries.showPk, shows.pk),
ne(entries.kind, "extra"),
exists(
db
.select()
.from(entryVideoJoin)
.where(eq(entryVideoJoin.entryPk, entries.pk)),
),
),
)}`,
...(updateEntryCount && {
entriesCount: sql`${db
.select({ count: count() })
.from(entries)
.where(
and(eq(entries.showPk, shows.pk), ne(entries.kind, "extra")),
)}`,
}),
})
.where(eq(shows.pk, sql`any(${sqlarr(showPks)})`));
}

View File

@ -3,7 +3,7 @@ import type { SeedMovie } from "~/models/movie";
import { getYear } from "~/utils"; import { getYear } from "~/utils";
import { insertCollection } from "./insert/collection"; import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries"; import { insertEntries } from "./insert/entries";
import { insertShow } from "./insert/shows"; import { insertShow, updateAvailableCount } from "./insert/shows";
import { insertStudios } from "./insert/studios"; import { insertStudios } from "./insert/studios";
import { guessNextRefresh } from "./refresh"; import { guessNextRefresh } from "./refresh";
@ -60,6 +60,7 @@ export const seedMovie = async (
startAir: bMovie.airDate, startAir: bMovie.airDate,
nextRefresh, nextRefresh,
collectionPk: col?.pk, collectionPk: col?.pk,
entriesCount: 1,
...bMovie, ...bMovie,
}, },
translations, translations,
@ -80,6 +81,7 @@ export const seedMovie = async (
videos, videos,
}, },
]); ]);
await updateAvailableCount([show.pk], false);
const retStudios = await insertStudios(studios, show.pk); const retStudios = await insertStudios(studios, show.pk);

View File

@ -4,7 +4,7 @@ import { getYear } from "~/utils";
import { insertCollection } from "./insert/collection"; import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries"; import { insertEntries } from "./insert/entries";
import { insertSeasons } from "./insert/seasons"; import { insertSeasons } from "./insert/seasons";
import { insertShow } from "./insert/shows"; import { insertShow, updateAvailableCount } from "./insert/shows";
import { insertStudios } from "./insert/studios"; import { insertStudios } from "./insert/studios";
import { guessNextRefresh } from "./refresh"; import { guessNextRefresh } from "./refresh";
@ -94,6 +94,7 @@ export const seedSerie = async (
kind: "serie", kind: "serie",
nextRefresh, nextRefresh,
collectionPk: col?.pk, collectionPk: col?.pk,
entriesCount: entries.length,
...serie, ...serie,
}, },
translations, translations,
@ -106,6 +107,7 @@ export const seedSerie = async (
show, show,
(extras ?? []).map((x) => ({ ...x, kind: "extra", extraKind: x.kind })), (extras ?? []).map((x) => ({ ...x, kind: "extra", extraKind: x.kind })),
); );
await updateAvailableCount([show.pk]);
const retStudios = await insertStudios(studios, show.pk); const retStudios = await insertStudios(studios, show.pk);

View File

@ -88,6 +88,7 @@ export async function getShows({
status: sql<MovieStatus>`${shows.status}`, status: sql<MovieStatus>`${shows.status}`,
airDate: shows.startAir, airDate: shows.startAir,
kind: sql<any>`${shows.kind}`, kind: sql<any>`${shows.kind}`,
isAvailable: sql<boolean>`${shows.availableCount} != 0`,
poster: sql<Image>`coalesce(${showTranslations.poster}, ${poster})`, poster: sql<Image>`coalesce(${showTranslations.poster}, ${poster})`,
thumbnail: sql<Image>`coalesce(${showTranslations.thumbnail}, ${thumbnail})`, thumbnail: sql<Image>`coalesce(${showTranslations.thumbnail}, ${thumbnail})`,
@ -99,10 +100,9 @@ export async function getShows({
.leftJoin( .leftJoin(
showTranslations, showTranslations,
and( and(
sql`${preferOriginal ?? false}`,
eq(shows.pk, showTranslations.pk), eq(shows.pk, showTranslations.pk),
eq(showTranslations.language, shows.originalLanguage), eq(showTranslations.language, shows.originalLanguage),
// TODO: check user's settings before fallbacking to false.
sql`coalesce(${preferOriginal ?? null}::boolean, false)`,
), ),
) )
.where( .where(
@ -139,10 +139,12 @@ export async function getShow(
extras: { extras: {
airDate: sql<string>`${shows.startAir}`.as("airDate"), airDate: sql<string>`${shows.startAir}`.as("airDate"),
status: sql<MovieStatus>`${shows.status}`.as("status"), status: sql<MovieStatus>`${shows.status}`.as("status"),
isAvailable: sql<boolean>`${shows.availableCount} != 0`.as("isAvailable"),
}, },
where: and(isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), filters), where: and(isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), filters),
with: { with: {
selectedTranslation: selectTranslationQuery(showTranslations, languages), selectedTranslation: selectTranslationQuery(showTranslations, languages),
...(preferOriginal && {
originalTranslation: { originalTranslation: {
columns: { columns: {
poster: true, poster: true,
@ -150,14 +152,8 @@ export async function getShow(
banner: true, banner: true,
logo: true, logo: true,
}, },
extras: {
// TODO: also fallback on user settings (that's why i made a select here)
preferOriginal:
sql<boolean>`(select coalesce(${preferOriginal ?? null}::boolean, false))`.as(
"preferOriginal",
),
},
}, },
}),
...(relations.includes("translations") && { ...(relations.includes("translations") && {
translations: { translations: {
columns: { columns: {
@ -192,7 +188,7 @@ export async function getShow(
...ret, ...ret,
...translation, ...translation,
kind: ret.kind as any, kind: ret.kind as any,
...(ot?.preferOriginal && { ...(ot && {
...(ot.poster && { poster: ot.poster }), ...(ot.poster && { poster: ot.poster }),
...(ot.thumbnail && { thumbnail: ot.thumbnail }), ...(ot.thumbnail && { thumbnail: ot.thumbnail }),
...(ot.banner && { banner: ot.banner }), ...(ot.banner && { banner: ot.banner }),

View File

@ -72,6 +72,8 @@ export const shows = schema.table(
collectionPk: integer().references((): AnyPgColumn => shows.pk, { collectionPk: integer().references((): AnyPgColumn => shows.pk, {
onDelete: "set null", onDelete: "set null",
}), }),
entriesCount: integer().notNull(),
availableCount: integer().notNull().default(0),
externalId: externalid(), externalId: externalid(),

View File

@ -12,7 +12,7 @@ export const madeInAbyssVideo: Video = {
title: "Made in abyss", title: "Made in abyss",
season: [1], season: [1],
episode: [13], episode: [13],
type: "episode", kind: "episode",
from: "guessit", from: "guessit",
}, },
createdAt: "2024-11-23T15:01:24.968Z", createdAt: "2024-11-23T15:01:24.968Z",

View File

@ -59,7 +59,9 @@ export const Movie = t.Intersect([
MovieTranslation, MovieTranslation,
BaseMovie, BaseMovie,
DbMetadata, DbMetadata,
// t.Object({ isAvailable: t.Boolean() }), t.Object({
isAvailable: t.Boolean(),
}),
]); ]);
export type Movie = Prettify<typeof Movie.static>; export type Movie = Prettify<typeof Movie.static>;

View File

@ -45,6 +45,12 @@ const BaseSerie = t.Object({
), ),
nextRefresh: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }),
entriesCount: t.Integer({
description: "The number of episodes in this serie",
}),
availableCount: t.Integer({
description: "The number of episodes that can be played right away",
}),
externalId: ExternalId(), externalId: ExternalId(),
}); });
@ -82,7 +88,7 @@ export const FullSerie = t.Intersect([
export type FullMovie = Prettify<typeof FullSerie.static>; export type FullMovie = Prettify<typeof FullSerie.static>;
export const SeedSerie = t.Intersect([ export const SeedSerie = t.Intersect([
t.Omit(BaseSerie, ["kind", "nextRefresh"]), t.Omit(BaseSerie, ["kind", "nextRefresh", "entriesCount", "availableCount"]),
t.Object({ t.Object({
slug: t.String({ format: "slug" }), slug: t.String({ format: "slug" }),
translations: TranslationRecord( translations: TranslationRecord(

View File

@ -2,14 +2,15 @@ import { beforeAll, describe, expect, it } from "bun:test";
import { expectStatus } from "tests/utils"; import { expectStatus } from "tests/utils";
import { seedMovie } from "~/controllers/seed/movies"; import { seedMovie } from "~/controllers/seed/movies";
import { db } from "~/db"; import { db } from "~/db";
import { shows } from "~/db/schema"; import { shows, videos } from "~/db/schema";
import { bubble } from "~/models/examples"; import { bubble, bubbleVideo } from "~/models/examples";
import { getMovie } from "../helpers"; import { getMovie } from "../helpers";
let bubbleId = ""; let bubbleId = "";
beforeAll(async () => { beforeAll(async () => {
await db.delete(shows); await db.delete(shows);
await db.insert(videos).values(bubbleVideo);
const ret = await seedMovie(bubble); const ret = await seedMovie(bubble);
if (!("status" in ret)) bubbleId = ret.id; if (!("status" in ret)) bubbleId = ret.id;
}); });
@ -116,4 +117,21 @@ describe("Get movie", () => {
}); });
expect(resp.headers.get("Content-Language")).toBe("en"); expect(resp.headers.get("Content-Language")).toBe("en");
}); });
it("With isAvailable", async () => {
const [resp, body] = await getMovie(bubble.slug, {});
expectStatus(resp, body).toBe(200);
expect(body.isAvailable).toBe(true);
});
it("With isAvailable=false", async () => {
await seedMovie({
...bubble,
slug: "no-video",
videos: [],
});
const [resp, body] = await getMovie("no-video", {});
expectStatus(resp, body).toBe(200);
expect(body.isAvailable).toBe(false);
});
}); });

View File

@ -0,0 +1,32 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { createSerie, getSerie } from "tests/helpers";
import { expectStatus } from "tests/utils";
import { db } from "~/db";
import { shows, videos } from "~/db/schema";
import { madeInAbyss, madeInAbyssVideo } from "~/models/examples";
beforeAll(async () => {
await db.delete(videos);
await db.delete(shows);
await db.insert(videos).values(madeInAbyssVideo);
await createSerie(madeInAbyss);
});
describe("aet series", () => {
it("Invalid slug", async () => {
const [resp, body] = await getSerie("sotneuhn", { langs: "en" });
expectStatus(resp, body).toBe(404);
expect(body).toMatchObject({
status: 404,
message: expect.any(String),
});
});
it("With a valid entryCount/availableCount", async () => {
const [resp, body] = await getSerie(madeInAbyss.slug, { langs: "en" });
expectStatus(resp, body).toBe(200);
expect(body.entriesCount).toBe(madeInAbyss.entries.length);
expect(body.availableCount).toBe(1);
});
});