mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Create history APIs (#881)
This commit is contained in:
commit
9991da4fe1
5
api/drizzle/0018_history.sql
Normal file
5
api/drizzle/0018_history.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "kyoo"."history" ALTER COLUMN "video_pk" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "kyoo"."watchlist" ALTER COLUMN "status" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
DROP TYPE "kyoo"."watchlist_status";--> statement-breakpoint
|
||||||
|
CREATE TYPE "kyoo"."watchlist_status" AS ENUM('watching', 'rewatching', 'completed', 'dropped', 'planned');--> statement-breakpoint
|
||||||
|
ALTER TABLE "kyoo"."watchlist" ALTER COLUMN "status" SET DATA TYPE "kyoo"."watchlist_status" USING "status"::"kyoo"."watchlist_status";
|
1839
api/drizzle/meta/0018_snapshot.json
Normal file
1839
api/drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -127,6 +127,13 @@
|
|||||||
"when": 1743944773824,
|
"when": 1743944773824,
|
||||||
"tag": "0017_watchlist",
|
"tag": "0017_watchlist",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1744053556621,
|
||||||
|
"tag": "0018_history",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,90 @@
|
|||||||
|
diff --git a/node_modules/drizzle-orm/.bun-tag-3622ae30f31c0d9a b/.bun-tag-3622ae30f31c0d9a
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
diff --git a/node_modules/drizzle-orm/.bun-tag-36446a2521398ee8 b/.bun-tag-36446a2521398ee8
|
diff --git a/node_modules/drizzle-orm/.bun-tag-36446a2521398ee8 b/.bun-tag-36446a2521398ee8
|
||||||
new file mode 100644
|
new file mode 100644
|
||||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/node_modules/drizzle-orm/.bun-tag-844efc51a55b820c b/.bun-tag-844efc51a55b820c
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
diff --git a/node_modules/drizzle-orm/.bun-tag-9fae835e61d5cc75 b/.bun-tag-9fae835e61d5cc75
|
diff --git a/node_modules/drizzle-orm/.bun-tag-9fae835e61d5cc75 b/.bun-tag-9fae835e61d5cc75
|
||||||
new file mode 100644
|
new file mode 100644
|
||||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/node_modules/drizzle-orm/.bun-tag-ce8efc9a806990a3 b/.bun-tag-ce8efc9a806990a3
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/pg-core/dialect.cjs b/pg-core/dialect.cjs
|
||||||
|
index 52acbfb6038fb1bbba4e34115d75a22bb0f9ab1a..1f10884caf05329ab98b06a68c8e7803e5283d32 100644
|
||||||
|
--- a/pg-core/dialect.cjs
|
||||||
|
+++ b/pg-core/dialect.cjs
|
||||||
|
@@ -347,7 +347,14 @@ class PgDialect {
|
||||||
|
buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) {
|
||||||
|
const valuesSqlList = [];
|
||||||
|
const columns = table[import_table2.Table.Symbol.Columns];
|
||||||
|
- const colEntries = Object.entries(columns).filter(([_, col]) => !col.shouldDisableInsert());
|
||||||
|
+ let colEntries = Object.entries(columns);
|
||||||
|
+ colEntries = select && !is(valuesOrSelect, SQL)
|
||||||
|
+ ? Object
|
||||||
|
+ .keys(valuesOrSelect._.selectedFields)
|
||||||
|
+ .map((key) => [key, columns[key]])
|
||||||
|
+ : overridingSystemValue_
|
||||||
|
+ ? colEntries
|
||||||
|
+ : colEntries.filter(([_, col]) => !col.shouldDisableInsert());
|
||||||
|
const insertOrder = colEntries.map(
|
||||||
|
([, column]) => import_sql2.sql.identifier(this.casing.getColumnCasing(column))
|
||||||
|
);
|
||||||
|
diff --git a/pg-core/dialect.js b/pg-core/dialect.js
|
||||||
|
index d7985c81f3d224f7671efe72e79b14153d5ca8ce..91d99ccd2ebda807a7d45c76f7164e571b922159 100644
|
||||||
|
--- a/pg-core/dialect.js
|
||||||
|
+++ b/pg-core/dialect.js
|
||||||
|
@@ -345,7 +345,14 @@ class PgDialect {
|
||||||
|
buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) {
|
||||||
|
const valuesSqlList = [];
|
||||||
|
const columns = table[Table.Symbol.Columns];
|
||||||
|
- const colEntries = Object.entries(columns).filter(([_, col]) => !col.shouldDisableInsert());
|
||||||
|
+ let colEntries = Object.entries(columns);
|
||||||
|
+ colEntries = select && !is(valuesOrSelect, SQL)
|
||||||
|
+ ? Object
|
||||||
|
+ .keys(valuesOrSelect._.selectedFields)
|
||||||
|
+ .map((key) => [key, columns[key]])
|
||||||
|
+ : overridingSystemValue_
|
||||||
|
+ ? colEntries
|
||||||
|
+ : colEntries.filter(([_, col]) => !col.shouldDisableInsert());
|
||||||
|
const insertOrder = colEntries.map(
|
||||||
|
([, column]) => sql.identifier(this.casing.getColumnCasing(column))
|
||||||
|
);
|
||||||
|
diff --git a/pg-core/query-builders/insert.cjs b/pg-core/query-builders/insert.cjs
|
||||||
|
index 08bb0d7485ebf997e3f081e2254ea8fd8bc20f65..341d2513d4377acc33ee0606d05580566fd4b88c 100644
|
||||||
|
--- a/pg-core/query-builders/insert.cjs
|
||||||
|
+++ b/pg-core/query-builders/insert.cjs
|
||||||
|
@@ -75,11 +75,6 @@ class PgInsertBuilder {
|
||||||
|
}
|
||||||
|
select(selectQuery) {
|
||||||
|
const select = typeof selectQuery === "function" ? selectQuery(new import_query_builder.QueryBuilder()) : selectQuery;
|
||||||
|
- if (!(0, import_entity.is)(select, import_sql.SQL) && !(0, import_utils.haveSameKeys)(this.table[import_table.Columns], select._.selectedFields)) {
|
||||||
|
- throw new Error(
|
||||||
|
- "Insert select error: selected fields are not the same or are in a different order compared to the table definition"
|
||||||
|
- );
|
||||||
|
- }
|
||||||
|
return new PgInsertBase(this.table, select, this.session, this.dialect, this.withList, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff --git a/pg-core/query-builders/insert.js b/pg-core/query-builders/insert.js
|
||||||
|
index 0fc8eeb80f4a5512f6c84f3d596832623a33b748..b993f226daf16f423db012dff828d89c522603c3 100644
|
||||||
|
--- a/pg-core/query-builders/insert.js
|
||||||
|
+++ b/pg-core/query-builders/insert.js
|
||||||
|
@@ -51,11 +51,6 @@ class PgInsertBuilder {
|
||||||
|
}
|
||||||
|
select(selectQuery) {
|
||||||
|
const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery;
|
||||||
|
- if (!is(select, SQL) && !haveSameKeys(this.table[Columns], select._.selectedFields)) {
|
||||||
|
- throw new Error(
|
||||||
|
- "Insert select error: selected fields are not the same or are in a different order compared to the table definition"
|
||||||
|
- );
|
||||||
|
- }
|
||||||
|
return new PgInsertBase(this.table, select, this.session, this.dialect, this.withList, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
diff --git a/pg-core/query-builders/select.d.cts b/pg-core/query-builders/select.d.cts
|
diff --git a/pg-core/query-builders/select.d.cts b/pg-core/query-builders/select.d.cts
|
||||||
index b968ebb3f563f37c8c36221dd17cc6f3603270ec..3fda6d0a97997f6bd07ec6a0c83397c0fdd2e97e 100644
|
index b968ebb3f563f37c8c36221dd17cc6f3603270ec..3fda6d0a97997f6bd07ec6a0c83397c0fdd2e97e 100644
|
||||||
--- a/pg-core/query-builders/select.d.cts
|
--- a/pg-core/query-builders/select.d.cts
|
||||||
|
@ -2,6 +2,8 @@ import { Elysia, t } from "elysia";
|
|||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { entriesH } from "./controllers/entries";
|
import { entriesH } from "./controllers/entries";
|
||||||
import { imagesH } from "./controllers/images";
|
import { imagesH } from "./controllers/images";
|
||||||
|
import { historyH } from "./controllers/profiles/history";
|
||||||
|
import { watchlistH } from "./controllers/profiles/watchlist";
|
||||||
import { seasonsH } from "./controllers/seasons";
|
import { seasonsH } from "./controllers/seasons";
|
||||||
import { seed } from "./controllers/seed";
|
import { seed } from "./controllers/seed";
|
||||||
import { collections } from "./controllers/shows/collections";
|
import { collections } from "./controllers/shows/collections";
|
||||||
@ -11,7 +13,6 @@ import { showsH } from "./controllers/shows/shows";
|
|||||||
import { staffH } from "./controllers/staff";
|
import { staffH } from "./controllers/staff";
|
||||||
import { studiosH } from "./controllers/studios";
|
import { studiosH } from "./controllers/studios";
|
||||||
import { videosH } from "./controllers/videos";
|
import { videosH } from "./controllers/videos";
|
||||||
import { watchlistH } from "./controllers/watchlist";
|
|
||||||
import type { KError } from "./models/error";
|
import type { KError } from "./models/error";
|
||||||
|
|
||||||
export const base = new Elysia({ name: "base" })
|
export const base = new Elysia({ name: "base" })
|
||||||
@ -95,4 +96,5 @@ export const app = new Elysia({ prefix })
|
|||||||
},
|
},
|
||||||
(app) => app.use(videosH).use(seed),
|
(app) => app.use(videosH).use(seed),
|
||||||
)
|
)
|
||||||
.use(watchlistH);
|
.use(watchlistH)
|
||||||
|
.use(historyH);
|
||||||
|
@ -45,7 +45,22 @@ import {
|
|||||||
import { desc as description } from "~/models/utils/descriptions";
|
import { desc as description } from "~/models/utils/descriptions";
|
||||||
import type { EmbeddedVideo } from "~/models/video";
|
import type { EmbeddedVideo } from "~/models/video";
|
||||||
|
|
||||||
const entryFilters: FilterDef = {
|
export const entryProgressQ = db
|
||||||
|
.selectDistinctOn([history.entryPk], {
|
||||||
|
percent: history.percent,
|
||||||
|
time: history.time,
|
||||||
|
entryPk: history.entryPk,
|
||||||
|
playedDate: history.playedDate,
|
||||||
|
videoId: videos.id,
|
||||||
|
})
|
||||||
|
.from(history)
|
||||||
|
.leftJoin(videos, eq(history.videoPk, videos.pk))
|
||||||
|
.leftJoin(profiles, eq(history.profilePk, profiles.pk))
|
||||||
|
.where(eq(profiles.id, sql.placeholder("userId")))
|
||||||
|
.orderBy(history.entryPk, desc(history.playedDate))
|
||||||
|
.as("progress");
|
||||||
|
|
||||||
|
export const entryFilters: FilterDef = {
|
||||||
kind: {
|
kind: {
|
||||||
column: entries.kind,
|
column: entries.kind,
|
||||||
type: "enum",
|
type: "enum",
|
||||||
@ -57,18 +72,21 @@ const entryFilters: FilterDef = {
|
|||||||
order: { column: entries.order, type: "float" },
|
order: { column: entries.order, type: "float" },
|
||||||
runtime: { column: entries.runtime, type: "float" },
|
runtime: { column: entries.runtime, type: "float" },
|
||||||
airDate: { column: entries.airDate, type: "date" },
|
airDate: { column: entries.airDate, type: "date" },
|
||||||
|
playedDate: { column: entryProgressQ.playedDate, type: "date" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const extraFilters: FilterDef = {
|
const extraFilters: FilterDef = {
|
||||||
kind: { column: entries.extraKind, type: "enum", values: ExtraType.enum },
|
kind: { column: entries.extraKind, type: "enum", values: ExtraType.enum },
|
||||||
runtime: { column: entries.runtime, type: "float" },
|
runtime: { column: entries.runtime, type: "float" },
|
||||||
|
playedDate: { column: entryProgressQ.playedDate, type: "date" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const unknownFilters: FilterDef = {
|
const unknownFilters: FilterDef = {
|
||||||
runtime: { column: entries.runtime, type: "float" },
|
runtime: { column: entries.runtime, type: "float" },
|
||||||
|
playedDate: { column: entryProgressQ.playedDate, type: "date" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const entrySort = Sort(
|
export const entrySort = Sort(
|
||||||
{
|
{
|
||||||
order: entries.order,
|
order: entries.order,
|
||||||
seasonNumber: entries.seasonNumber,
|
seasonNumber: entries.seasonNumber,
|
||||||
@ -76,6 +94,7 @@ const entrySort = Sort(
|
|||||||
number: entries.episodeNumber,
|
number: entries.episodeNumber,
|
||||||
airDate: entries.airDate,
|
airDate: entries.airDate,
|
||||||
nextRefresh: entries.nextRefresh,
|
nextRefresh: entries.nextRefresh,
|
||||||
|
playedDate: entryProgressQ.playedDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: ["order"],
|
default: ["order"],
|
||||||
@ -89,6 +108,7 @@ const extraSort = Sort(
|
|||||||
name: entryTranslations.name,
|
name: entryTranslations.name,
|
||||||
runtime: entries.runtime,
|
runtime: entries.runtime,
|
||||||
createdAt: entries.createdAt,
|
createdAt: entries.createdAt,
|
||||||
|
playedDate: entryProgressQ.playedDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: ["slug"],
|
default: ["slug"],
|
||||||
@ -126,36 +146,19 @@ export const entryVideosQ = db
|
|||||||
.leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk))
|
.leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk))
|
||||||
.as("videos");
|
.as("videos");
|
||||||
|
|
||||||
export const getEntryProgressQ = (userId: string) =>
|
export const mapProgress = ({ aliased }: { aliased: boolean }) => {
|
||||||
db
|
const { time, percent, playedDate, videoId } = getColumns(entryProgressQ);
|
||||||
.selectDistinctOn([history.entryPk], {
|
|
||||||
percent: history.percent,
|
|
||||||
time: history.time,
|
|
||||||
entryPk: history.entryPk,
|
|
||||||
videoId: videos.id,
|
|
||||||
})
|
|
||||||
.from(history)
|
|
||||||
.leftJoin(videos, eq(history.videoPk, videos.pk))
|
|
||||||
.leftJoin(profiles, eq(history.profilePk, profiles.pk))
|
|
||||||
.where(eq(profiles.id, userId))
|
|
||||||
.orderBy(history.entryPk, desc(history.playedDate))
|
|
||||||
.as("progress");
|
|
||||||
|
|
||||||
export const mapProgress = (
|
|
||||||
progressQ: ReturnType<typeof getEntryProgressQ>,
|
|
||||||
{ aliased }: { aliased: boolean } = { aliased: false },
|
|
||||||
) => {
|
|
||||||
const { time, percent, videoId } = getColumns(progressQ);
|
|
||||||
const ret = {
|
const ret = {
|
||||||
time: coalesce(time, sql`0`),
|
time: coalesce(time, sql`0`),
|
||||||
percent: coalesce(percent, sql`0`),
|
percent: coalesce(percent, sql`0`),
|
||||||
|
playedDate: sql`${playedDate}`,
|
||||||
videoId: sql`${videoId}`,
|
videoId: sql`${videoId}`,
|
||||||
};
|
};
|
||||||
if (!aliased) return ret;
|
if (!aliased) return ret;
|
||||||
return Object.fromEntries(Object.entries(ret).map(([k, v]) => [k, v.as(k)]));
|
return Object.fromEntries(Object.entries(ret).map(([k, v]) => [k, v.as(k)]));
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getEntries({
|
export async function getEntries({
|
||||||
after,
|
after,
|
||||||
limit,
|
limit,
|
||||||
query,
|
query,
|
||||||
@ -163,6 +166,7 @@ async function getEntries({
|
|||||||
filter,
|
filter,
|
||||||
languages,
|
languages,
|
||||||
userId,
|
userId,
|
||||||
|
progressQ = entryProgressQ,
|
||||||
}: {
|
}: {
|
||||||
after: string | undefined;
|
after: string | undefined;
|
||||||
limit: number;
|
limit: number;
|
||||||
@ -171,6 +175,7 @@ async function getEntries({
|
|||||||
filter: SQL | undefined;
|
filter: SQL | undefined;
|
||||||
languages: string[];
|
languages: string[];
|
||||||
userId: string;
|
userId: string;
|
||||||
|
progressQ?: typeof entryProgressQ;
|
||||||
}): Promise<(Entry | Extra | UnknownEntry)[]> {
|
}): Promise<(Entry | Extra | UnknownEntry)[]> {
|
||||||
const transQ = db
|
const transQ = db
|
||||||
.selectDistinctOn([entryTranslations.pk])
|
.selectDistinctOn([entryTranslations.pk])
|
||||||
@ -182,8 +187,6 @@ async function getEntries({
|
|||||||
.as("t");
|
.as("t");
|
||||||
const { pk, name, ...transCol } = getColumns(transQ);
|
const { pk, name, ...transCol } = getColumns(transQ);
|
||||||
|
|
||||||
const entryProgressQ = getEntryProgressQ(userId);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
kind,
|
kind,
|
||||||
externalId,
|
externalId,
|
||||||
@ -198,7 +201,7 @@ async function getEntries({
|
|||||||
...entryCol,
|
...entryCol,
|
||||||
...transCol,
|
...transCol,
|
||||||
videos: entryVideosQ.videos,
|
videos: entryVideosQ.videos,
|
||||||
progress: mapProgress(entryProgressQ, { aliased: true }),
|
progress: mapProgress({ aliased: true }),
|
||||||
// specials don't have an `episodeNumber` but a `number` field.
|
// specials don't have an `episodeNumber` but a `number` field.
|
||||||
number: episodeNumber,
|
number: episodeNumber,
|
||||||
|
|
||||||
@ -217,7 +220,7 @@ async function getEntries({
|
|||||||
.from(entries)
|
.from(entries)
|
||||||
.innerJoin(transQ, eq(entries.pk, transQ.pk))
|
.innerJoin(transQ, eq(entries.pk, transQ.pk))
|
||||||
.leftJoinLateral(entryVideosQ, sql`true`)
|
.leftJoinLateral(entryVideosQ, sql`true`)
|
||||||
.leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk))
|
.leftJoin(progressQ, eq(entries.pk, progressQ.entryPk))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
filter,
|
filter,
|
||||||
@ -231,7 +234,8 @@ async function getEntries({
|
|||||||
: sortToSql(sort)),
|
: sortToSql(sort)),
|
||||||
entries.pk,
|
entries.pk,
|
||||||
)
|
)
|
||||||
.limit(limit);
|
.limit(limit)
|
||||||
|
.execute({ userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const entriesH = new Elysia({ tags: ["series"] })
|
export const entriesH = new Elysia({ tags: ["series"] })
|
||||||
|
347
api/src/controllers/profiles/history.ts
Normal file
347
api/src/controllers/profiles/history.ts
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
count,
|
||||||
|
eq,
|
||||||
|
exists,
|
||||||
|
gt,
|
||||||
|
isNotNull,
|
||||||
|
ne,
|
||||||
|
not,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { alias } from "drizzle-orm/pg-core";
|
||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import { auth, getUserInfo } from "~/auth";
|
||||||
|
import { db } from "~/db";
|
||||||
|
import { entries, history, profiles, shows, videos } from "~/db/schema";
|
||||||
|
import { watchlist } from "~/db/schema/watchlist";
|
||||||
|
import { coalesce, values } from "~/db/utils";
|
||||||
|
import { Entry } from "~/models/entry";
|
||||||
|
import { KError } from "~/models/error";
|
||||||
|
import { SeedHistory } from "~/models/history";
|
||||||
|
import {
|
||||||
|
AcceptLanguage,
|
||||||
|
Filter,
|
||||||
|
Page,
|
||||||
|
createPage,
|
||||||
|
isUuid,
|
||||||
|
processLanguages,
|
||||||
|
} from "~/models/utils";
|
||||||
|
import { desc } from "~/models/utils/descriptions";
|
||||||
|
import {
|
||||||
|
entryFilters,
|
||||||
|
entryProgressQ,
|
||||||
|
entrySort,
|
||||||
|
getEntries,
|
||||||
|
} from "../entries";
|
||||||
|
import { getOrCreateProfile } from "./profile";
|
||||||
|
|
||||||
|
const historyProgressQ: typeof entryProgressQ = db
|
||||||
|
.select({
|
||||||
|
percent: history.percent,
|
||||||
|
time: history.time,
|
||||||
|
entryPk: history.entryPk,
|
||||||
|
playedDate: history.playedDate,
|
||||||
|
videoId: videos.id,
|
||||||
|
})
|
||||||
|
.from(history)
|
||||||
|
.leftJoin(videos, eq(history.videoPk, videos.pk))
|
||||||
|
.leftJoin(profiles, eq(history.profilePk, profiles.pk))
|
||||||
|
.where(eq(profiles.id, sql.placeholder("userId")))
|
||||||
|
.as("progress");
|
||||||
|
|
||||||
|
export const historyH = new Elysia({ tags: ["profiles"] })
|
||||||
|
.use(auth)
|
||||||
|
.guard(
|
||||||
|
{
|
||||||
|
query: t.Object({
|
||||||
|
sort: {
|
||||||
|
...entrySort,
|
||||||
|
default: ["-playedDate"],
|
||||||
|
},
|
||||||
|
filter: t.Optional(Filter({ def: entryFilters })),
|
||||||
|
query: t.Optional(t.String({ description: desc.query })),
|
||||||
|
limit: t.Integer({
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 250,
|
||||||
|
default: 50,
|
||||||
|
description: "Max page size.",
|
||||||
|
}),
|
||||||
|
after: t.Optional(t.String({ description: desc.after })),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
(app) =>
|
||||||
|
app
|
||||||
|
.get(
|
||||||
|
"/profiles/me/history",
|
||||||
|
async ({
|
||||||
|
query: { sort, filter, query, limit, after },
|
||||||
|
headers: { "accept-language": languages },
|
||||||
|
request: { url },
|
||||||
|
jwt: { sub },
|
||||||
|
}) => {
|
||||||
|
const langs = processLanguages(languages);
|
||||||
|
const items = (await getEntries({
|
||||||
|
limit,
|
||||||
|
after,
|
||||||
|
query,
|
||||||
|
sort,
|
||||||
|
filter: and(
|
||||||
|
isNotNull(entryProgressQ.playedDate),
|
||||||
|
ne(entries.kind, "extra"),
|
||||||
|
ne(entries.kind, "unknown"),
|
||||||
|
filter,
|
||||||
|
),
|
||||||
|
languages: langs,
|
||||||
|
userId: sub,
|
||||||
|
progressQ: historyProgressQ,
|
||||||
|
})) as Entry[];
|
||||||
|
|
||||||
|
return createPage(items, { url, sort, limit });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
detail: {
|
||||||
|
description: "List your watch history (episodes/movies seen)",
|
||||||
|
},
|
||||||
|
headers: t.Object(
|
||||||
|
{
|
||||||
|
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: true },
|
||||||
|
),
|
||||||
|
response: {
|
||||||
|
200: Page(Entry),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/profiles/:id/history",
|
||||||
|
async ({
|
||||||
|
params: { id },
|
||||||
|
query: { sort, filter, query, limit, after },
|
||||||
|
headers: { "accept-language": languages, authorization },
|
||||||
|
request: { url },
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
const uInfo = await getUserInfo(id, { authorization });
|
||||||
|
if ("status" in uInfo) return error(uInfo.status as 404, uInfo);
|
||||||
|
|
||||||
|
const langs = processLanguages(languages);
|
||||||
|
const items = (await getEntries({
|
||||||
|
limit,
|
||||||
|
after,
|
||||||
|
query,
|
||||||
|
sort,
|
||||||
|
filter: and(
|
||||||
|
isNotNull(entryProgressQ.playedDate),
|
||||||
|
ne(entries.kind, "extra"),
|
||||||
|
ne(entries.kind, "unknown"),
|
||||||
|
filter,
|
||||||
|
),
|
||||||
|
languages: langs,
|
||||||
|
userId: uInfo.id,
|
||||||
|
progressQ: historyProgressQ,
|
||||||
|
})) as Entry[];
|
||||||
|
|
||||||
|
return createPage(items, { url, sort, limit });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
detail: {
|
||||||
|
description: "List your watch history (episodes/movies seen)",
|
||||||
|
},
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String({
|
||||||
|
description:
|
||||||
|
"The id or username of the user to read the watchlist of",
|
||||||
|
example: "zoriya",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
headers: t.Object({
|
||||||
|
authorization: t.TemplateLiteral("Bearer ${string}"),
|
||||||
|
"accept-language": AcceptLanguage({ autoFallback: true }),
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: Page(Entry),
|
||||||
|
403: KError,
|
||||||
|
404: {
|
||||||
|
...KError,
|
||||||
|
description: "No user found with the specified id/username.",
|
||||||
|
},
|
||||||
|
422: KError,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/profiles/me/history",
|
||||||
|
async ({ body, jwt: { sub }, error }) => {
|
||||||
|
const profilePk = await getOrCreateProfile(sub);
|
||||||
|
|
||||||
|
const vals = values(
|
||||||
|
body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })),
|
||||||
|
).as("hist");
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.insert(history)
|
||||||
|
.select(
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
profilePk: sql`${profilePk}`,
|
||||||
|
entryPk: entries.pk,
|
||||||
|
videoPk: videos.pk,
|
||||||
|
percent: sql`hist.percent::integer`,
|
||||||
|
time: sql`hist.time::integer`,
|
||||||
|
playedDate: sql`hist.playedDate::timestamptz`,
|
||||||
|
})
|
||||||
|
.from(vals)
|
||||||
|
.innerJoin(
|
||||||
|
entries,
|
||||||
|
or(
|
||||||
|
and(
|
||||||
|
sql`hist.entryUseId::boolean`,
|
||||||
|
eq(entries.id, sql`hist.entry::uuid`),
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
not(sql`hist.entryUseId::boolean`),
|
||||||
|
eq(entries.slug, sql`hist.entry`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.leftJoin(videos, eq(videos.id, sql`hist.videoId::uuid`)),
|
||||||
|
)
|
||||||
|
.returning({ pk: history.pk });
|
||||||
|
|
||||||
|
// automatically update watchlist with this new info
|
||||||
|
|
||||||
|
const nextEntry = alias(entries, "next_entry");
|
||||||
|
const nextEntryQ = db
|
||||||
|
.select({
|
||||||
|
pk: nextEntry.pk,
|
||||||
|
})
|
||||||
|
.from(nextEntry)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(nextEntry.showPk, entries.showPk),
|
||||||
|
gt(nextEntry.order, entries.order),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(nextEntry.showPk, entries.order)
|
||||||
|
.limit(1)
|
||||||
|
.as("nextEntryQ");
|
||||||
|
|
||||||
|
const seenCountQ = db
|
||||||
|
.select({ c: count() })
|
||||||
|
.from(entries)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(entries.showPk, sql`excluded.show_pk`),
|
||||||
|
exists(
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(history)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(history.profilePk, profilePk),
|
||||||
|
eq(history.entryPk, entries.pk),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(watchlist)
|
||||||
|
.select(
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
profilePk: sql`${profilePk}`,
|
||||||
|
showPk: entries.showPk,
|
||||||
|
status: sql`
|
||||||
|
case
|
||||||
|
when
|
||||||
|
hist.percent::integer >= 95
|
||||||
|
and ${nextEntryQ.pk} is null
|
||||||
|
then 'completed'::watchlist_status
|
||||||
|
else 'watching'::watchlist_status
|
||||||
|
end
|
||||||
|
`,
|
||||||
|
seenCount: sql`
|
||||||
|
case
|
||||||
|
when ${entries.kind} = 'movie' then hist.percent::integer
|
||||||
|
when hist.percent::integer >= 95 then 1
|
||||||
|
else 0
|
||||||
|
end
|
||||||
|
`,
|
||||||
|
nextEntry: nextEntryQ.pk,
|
||||||
|
score: sql`null`,
|
||||||
|
startedAt: sql`hist.playedDate::timestamptz`,
|
||||||
|
completedAt: sql`
|
||||||
|
case
|
||||||
|
when ${nextEntryQ.pk} is null then hist.playedDate::timestamptz
|
||||||
|
else null
|
||||||
|
end
|
||||||
|
`,
|
||||||
|
// see https://github.com/drizzle-team/drizzle-orm/issues/3608
|
||||||
|
updatedAt: sql`now()`,
|
||||||
|
})
|
||||||
|
.from(vals)
|
||||||
|
.leftJoin(
|
||||||
|
entries,
|
||||||
|
or(
|
||||||
|
and(
|
||||||
|
sql`hist.entryUseId::boolean`,
|
||||||
|
eq(entries.id, sql`hist.entry::uuid`),
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
not(sql`hist.entryUseId::boolean`),
|
||||||
|
eq(entries.slug, sql`hist.entry`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.leftJoinLateral(nextEntryQ, sql`true`),
|
||||||
|
)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [watchlist.profilePk, watchlist.showPk],
|
||||||
|
set: {
|
||||||
|
status: sql`
|
||||||
|
case
|
||||||
|
when excluded.status = 'completed' then excluded.status
|
||||||
|
when
|
||||||
|
${watchlist.status} != 'completed'
|
||||||
|
and ${watchlist.status} != 'rewatching'
|
||||||
|
then excluded.status
|
||||||
|
else ${watchlist.status}
|
||||||
|
end
|
||||||
|
`,
|
||||||
|
seenCount: sql`${seenCountQ}`,
|
||||||
|
nextEntry: sql`
|
||||||
|
case
|
||||||
|
when ${watchlist.status} = 'completed' then null
|
||||||
|
else excluded.next_entry
|
||||||
|
end
|
||||||
|
`,
|
||||||
|
completedAt: coalesce(
|
||||||
|
watchlist.completedAt,
|
||||||
|
sql`excluded.completed_at`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return error(201, { status: 201, inserted: rows.length });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
detail: { description: "Bulk add entries/movies to your watch history." },
|
||||||
|
body: t.Array(SeedHistory),
|
||||||
|
permissions: ["core.read"],
|
||||||
|
response: {
|
||||||
|
201: t.Object({
|
||||||
|
status: t.Literal(201),
|
||||||
|
inserted: t.Integer({
|
||||||
|
description: "The number of history entry inserted",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
422: KError,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
24
api/src/controllers/profiles/profile.ts
Normal file
24
api/src/controllers/profiles/profile.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { db } from "~/db";
|
||||||
|
import { profiles } from "~/db/schema";
|
||||||
|
|
||||||
|
export async function getOrCreateProfile(userId: string) {
|
||||||
|
let [profile] = await db
|
||||||
|
.select({ pk: profiles.pk })
|
||||||
|
.from(profiles)
|
||||||
|
.where(eq(profiles.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
if (profile) return profile.pk;
|
||||||
|
|
||||||
|
[profile] = await db
|
||||||
|
.insert(profiles)
|
||||||
|
.values({ id: userId })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
// we can't do `onConflictDoNothing` because on race conditions
|
||||||
|
// we still want the profile to be returned.
|
||||||
|
target: [profiles.id],
|
||||||
|
set: { id: sql`excluded.id` },
|
||||||
|
})
|
||||||
|
.returning({ pk: profiles.pk });
|
||||||
|
return profile.pk;
|
||||||
|
}
|
@ -1,8 +1,14 @@
|
|||||||
import { type SQL, and, eq, isNotNull, isNull, sql } from "drizzle-orm";
|
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm";
|
||||||
import Elysia, { t } from "elysia";
|
import Elysia, { t } from "elysia";
|
||||||
import { auth, getUserInfo } from "~/auth";
|
import { auth, getUserInfo } from "~/auth";
|
||||||
|
import {
|
||||||
|
getShows,
|
||||||
|
showFilters,
|
||||||
|
showSort,
|
||||||
|
watchStatusQ,
|
||||||
|
} from "~/controllers/shows/logic";
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import { profiles, shows } from "~/db/schema";
|
import { shows } from "~/db/schema";
|
||||||
import { watchlist } from "~/db/schema/watchlist";
|
import { watchlist } from "~/db/schema/watchlist";
|
||||||
import { conflictUpdateAllExcept, getColumns } from "~/db/utils";
|
import { conflictUpdateAllExcept, getColumns } from "~/db/utils";
|
||||||
import { KError } from "~/models/error";
|
import { KError } from "~/models/error";
|
||||||
@ -19,7 +25,7 @@ import {
|
|||||||
} from "~/models/utils";
|
} from "~/models/utils";
|
||||||
import { desc } from "~/models/utils/descriptions";
|
import { desc } from "~/models/utils/descriptions";
|
||||||
import { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
|
import { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
|
||||||
import { getShows, showFilters, showSort, watchStatusQ } from "./shows/logic";
|
import { getOrCreateProfile } from "./profile";
|
||||||
|
|
||||||
async function setWatchStatus({
|
async function setWatchStatus({
|
||||||
show,
|
show,
|
||||||
@ -30,29 +36,13 @@ async function setWatchStatus({
|
|||||||
status: SerieWatchStatus;
|
status: SerieWatchStatus;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
let [profile] = await db
|
const profilePk = await getOrCreateProfile(userId);
|
||||||
.select({ pk: profiles.pk })
|
|
||||||
.from(profiles)
|
|
||||||
.where(eq(profiles.id, userId))
|
|
||||||
.limit(1);
|
|
||||||
if (!profile) {
|
|
||||||
[profile] = await db
|
|
||||||
.insert(profiles)
|
|
||||||
.values({ id: userId })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
// we can't do `onConflictDoNothing` because on race conditions
|
|
||||||
// we still want the profile to be returned.
|
|
||||||
target: [profiles.id],
|
|
||||||
set: { id: sql`excluded.id` },
|
|
||||||
})
|
|
||||||
.returning({ pk: profiles.pk });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [ret] = await db
|
const [ret] = await db
|
||||||
.insert(watchlist)
|
.insert(watchlist)
|
||||||
.values({
|
.values({
|
||||||
...status,
|
...status,
|
||||||
profilePk: profile.pk,
|
profilePk: profilePk,
|
||||||
showPk: show.pk,
|
showPk: show.pk,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
@ -72,7 +62,7 @@ async function setWatchStatus({
|
|||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
...getColumns(watchlist),
|
...getColumns(watchlist),
|
||||||
percent: sql`${watchlist.seenCount}`.as("percent"),
|
percent: sql<number>`${watchlist.seenCount}`.as("percent"),
|
||||||
});
|
});
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
@ -82,7 +72,10 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
.guard(
|
.guard(
|
||||||
{
|
{
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
sort: showSort,
|
sort: {
|
||||||
|
...showSort,
|
||||||
|
default: ["watchStatus", ...showSort.default],
|
||||||
|
},
|
||||||
filter: t.Optional(Filter({ def: showFilters })),
|
filter: t.Optional(Filter({ def: showFilters })),
|
||||||
query: t.Optional(t.String({ description: desc.query })),
|
query: t.Optional(t.String({ description: desc.query })),
|
||||||
limit: t.Integer({
|
limit: t.Integer({
|
||||||
@ -150,7 +143,6 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
error,
|
error,
|
||||||
}) => {
|
}) => {
|
||||||
const uInfo = await getUserInfo(id, { authorization });
|
const uInfo = await getUserInfo(id, { authorization });
|
||||||
|
|
||||||
if ("status" in uInfo) return error(uInfo.status as 404, uInfo);
|
if ("status" in uInfo) return error(uInfo.status as 404, uInfo);
|
||||||
|
|
||||||
const langs = processLanguages(languages);
|
const langs = processLanguages(languages);
|
||||||
@ -233,7 +225,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
}),
|
}),
|
||||||
body: SerieWatchStatus,
|
body: SerieWatchStatus,
|
||||||
response: {
|
response: {
|
||||||
200: t.Union([SerieWatchStatus, DbMetadata]),
|
200: t.Intersect([SerieWatchStatus, DbMetadata]),
|
||||||
404: KError,
|
404: KError,
|
||||||
},
|
},
|
||||||
permissions: ["core.read"],
|
permissions: ["core.read"],
|
||||||
@ -280,7 +272,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
|
|||||||
}),
|
}),
|
||||||
body: t.Omit(MovieWatchStatus, ["percent"]),
|
body: t.Omit(MovieWatchStatus, ["percent"]),
|
||||||
response: {
|
response: {
|
||||||
200: t.Union([MovieWatchStatus, DbMetadata]),
|
200: t.Intersect([MovieWatchStatus, DbMetadata]),
|
||||||
404: KError,
|
404: KError,
|
||||||
},
|
},
|
||||||
permissions: ["core.read"],
|
permissions: ["core.read"],
|
@ -36,7 +36,7 @@ import {
|
|||||||
} from "~/models/utils";
|
} from "~/models/utils";
|
||||||
import type { EmbeddedVideo } from "~/models/video";
|
import type { EmbeddedVideo } from "~/models/video";
|
||||||
import { WatchlistStatus } from "~/models/watchlist";
|
import { WatchlistStatus } from "~/models/watchlist";
|
||||||
import { entryVideosQ, getEntryProgressQ, mapProgress } from "../entries";
|
import { entryProgressQ, entryVideosQ, mapProgress } from "../entries";
|
||||||
|
|
||||||
export const watchStatusQ = db
|
export const watchStatusQ = db
|
||||||
.select({
|
.select({
|
||||||
@ -75,6 +75,7 @@ export const showFilters: FilterDef = {
|
|||||||
type: "enum",
|
type: "enum",
|
||||||
values: WatchlistStatus.enum,
|
values: WatchlistStatus.enum,
|
||||||
},
|
},
|
||||||
|
score: { column: watchStatusQ.score, type: "int" },
|
||||||
};
|
};
|
||||||
export const showSort = Sort(
|
export const showSort = Sort(
|
||||||
{
|
{
|
||||||
@ -86,6 +87,7 @@ export const showSort = Sort(
|
|||||||
createdAt: shows.createdAt,
|
createdAt: shows.createdAt,
|
||||||
nextRefresh: shows.nextRefresh,
|
nextRefresh: shows.nextRefresh,
|
||||||
watchStatus: watchStatusQ.status,
|
watchStatus: watchStatusQ.status,
|
||||||
|
score: watchStatusQ.score,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: ["slug"],
|
default: ["slug"],
|
||||||
@ -164,10 +166,7 @@ const showRelations = {
|
|||||||
.leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk))
|
.leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk))
|
||||||
.as("videos");
|
.as("videos");
|
||||||
},
|
},
|
||||||
firstEntry: ({
|
firstEntry: ({ languages }: { languages: string[] }) => {
|
||||||
languages,
|
|
||||||
userId,
|
|
||||||
}: { languages: string[]; userId: string }) => {
|
|
||||||
const transQ = db
|
const transQ = db
|
||||||
.selectDistinctOn([entryTranslations.pk])
|
.selectDistinctOn([entryTranslations.pk])
|
||||||
.from(entryTranslations)
|
.from(entryTranslations)
|
||||||
@ -178,8 +177,6 @@ const showRelations = {
|
|||||||
.as("t");
|
.as("t");
|
||||||
const { pk, ...transCol } = getColumns(transQ);
|
const { pk, ...transCol } = getColumns(transQ);
|
||||||
|
|
||||||
const progressQ = getEntryProgressQ(userId);
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
firstEntry: jsonbBuildObject<Entry>({
|
firstEntry: jsonbBuildObject<Entry>({
|
||||||
@ -187,12 +184,12 @@ const showRelations = {
|
|||||||
...transCol,
|
...transCol,
|
||||||
number: entries.episodeNumber,
|
number: entries.episodeNumber,
|
||||||
videos: entryVideosQ.videos,
|
videos: entryVideosQ.videos,
|
||||||
progress: mapProgress(progressQ),
|
progress: mapProgress({ aliased: false }),
|
||||||
}).as("firstEntry"),
|
}).as("firstEntry"),
|
||||||
})
|
})
|
||||||
.from(entries)
|
.from(entries)
|
||||||
.innerJoin(transQ, eq(entries.pk, transQ.pk))
|
.innerJoin(transQ, eq(entries.pk, transQ.pk))
|
||||||
.leftJoin(progressQ, eq(entries.pk, progressQ.entryPk))
|
.leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk))
|
||||||
.leftJoinLateral(entryVideosQ, sql`true`)
|
.leftJoinLateral(entryVideosQ, sql`true`)
|
||||||
.where(and(eq(entries.showPk, shows.pk), ne(entries.kind, "extra")))
|
.where(and(eq(entries.showPk, shows.pk), ne(entries.kind, "extra")))
|
||||||
.orderBy(entries.order)
|
.orderBy(entries.order)
|
||||||
@ -201,10 +198,8 @@ const showRelations = {
|
|||||||
},
|
},
|
||||||
nextEntry: ({
|
nextEntry: ({
|
||||||
languages,
|
languages,
|
||||||
userId,
|
|
||||||
}: {
|
}: {
|
||||||
languages: string[];
|
languages: string[];
|
||||||
userId: string;
|
|
||||||
}) => {
|
}) => {
|
||||||
const transQ = db
|
const transQ = db
|
||||||
.selectDistinctOn([entryTranslations.pk])
|
.selectDistinctOn([entryTranslations.pk])
|
||||||
@ -216,8 +211,6 @@ const showRelations = {
|
|||||||
.as("t");
|
.as("t");
|
||||||
const { pk, ...transCol } = getColumns(transQ);
|
const { pk, ...transCol } = getColumns(transQ);
|
||||||
|
|
||||||
const progressQ = getEntryProgressQ(userId);
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
nextEntry: jsonbBuildObject<Entry>({
|
nextEntry: jsonbBuildObject<Entry>({
|
||||||
@ -225,12 +218,12 @@ const showRelations = {
|
|||||||
...transCol,
|
...transCol,
|
||||||
number: entries.episodeNumber,
|
number: entries.episodeNumber,
|
||||||
videos: entryVideosQ.videos,
|
videos: entryVideosQ.videos,
|
||||||
progress: mapProgress(progressQ),
|
progress: mapProgress({ aliased: false }),
|
||||||
}).as("nextEntry"),
|
}).as("nextEntry"),
|
||||||
})
|
})
|
||||||
.from(entries)
|
.from(entries)
|
||||||
.innerJoin(transQ, eq(entries.pk, transQ.pk))
|
.innerJoin(transQ, eq(entries.pk, transQ.pk))
|
||||||
.leftJoin(progressQ, eq(entries.pk, progressQ.entryPk))
|
.leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk))
|
||||||
.leftJoinLateral(entryVideosQ, sql`true`)
|
.leftJoinLateral(entryVideosQ, sql`true`)
|
||||||
.where(eq(watchStatusQ.nextEntry, entries.pk))
|
.where(eq(watchStatusQ.nextEntry, entries.pk))
|
||||||
.as("nextEntry");
|
.as("nextEntry");
|
||||||
@ -294,7 +287,7 @@ export async function getShows({
|
|||||||
|
|
||||||
watchStatus: getColumns(watchStatusQ),
|
watchStatus: getColumns(watchStatusQ),
|
||||||
|
|
||||||
...buildRelations(relations, showRelations, { languages, userId }),
|
...buildRelations(relations, showRelations, { languages }),
|
||||||
})
|
})
|
||||||
.from(shows)
|
.from(shows)
|
||||||
.leftJoin(watchStatusQ, eq(shows.pk, watchStatusQ.showPk))
|
.leftJoin(watchStatusQ, eq(shows.pk, watchStatusQ.showPk))
|
||||||
|
@ -15,9 +15,7 @@ export const history = schema.table(
|
|||||||
entryPk: integer()
|
entryPk: integer()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => entries.pk, { onDelete: "cascade" }),
|
.references(() => entries.pk, { onDelete: "cascade" }),
|
||||||
videoPk: integer()
|
videoPk: integer().references(() => videos.pk, { onDelete: "set null" }),
|
||||||
.notNull()
|
|
||||||
.references(() => videos.pk, { onDelete: "set null" }),
|
|
||||||
percent: integer().notNull().default(0),
|
percent: integer().notNull().default(0),
|
||||||
time: integer(),
|
time: integer(),
|
||||||
playedDate: timestamp({ withTimezone: true, mode: "string" })
|
playedDate: timestamp({ withTimezone: true, mode: "string" })
|
||||||
|
@ -95,10 +95,14 @@ export function values(items: Record<string, unknown>[]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const coalesce = <T>(val: SQL<T> | Column, def: SQL<T>) => {
|
export const coalesce = <T>(val: SQL<T> | Column, def: SQL<T> | Column) => {
|
||||||
return sql<T>`coalesce(${val}, ${def})`;
|
return sql<T>`coalesce(${val}, ${def})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const nullif = <T>(val: SQL<T> | Column, eq: SQL<T>) => {
|
||||||
|
return sql<T>`nullif(${val}, ${eq})`;
|
||||||
|
};
|
||||||
|
|
||||||
export const jsonbObjectAgg = <T>(key: SQLWrapper, value: SQL<T>) => {
|
export const jsonbObjectAgg = <T>(key: SQLWrapper, value: SQL<T>) => {
|
||||||
return sql<
|
return sql<
|
||||||
Record<string, T>
|
Record<string, T>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import type { Prettify } from "~/utils";
|
import type { Prettify } from "~/utils";
|
||||||
import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
|
import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
|
||||||
|
import { Progress } from "../history";
|
||||||
import {
|
import {
|
||||||
DbMetadata,
|
DbMetadata,
|
||||||
EpisodeId,
|
EpisodeId,
|
||||||
@ -9,7 +10,6 @@ import {
|
|||||||
TranslationRecord,
|
TranslationRecord,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { EmbeddedVideo } from "../video";
|
import { EmbeddedVideo } from "../video";
|
||||||
import { Progress } from "../watchlist";
|
|
||||||
import { BaseEntry, EntryTranslation } from "./base-entry";
|
import { BaseEntry, EntryTranslation } from "./base-entry";
|
||||||
|
|
||||||
export const BaseEpisode = t.Intersect([
|
export const BaseEpisode = t.Intersect([
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { type Prettify, comment } from "~/utils";
|
import { type Prettify, comment } from "~/utils";
|
||||||
import { madeInAbyss, registerExamples } from "../examples";
|
import { madeInAbyss, registerExamples } from "../examples";
|
||||||
|
import { Progress } from "../history";
|
||||||
import { DbMetadata, SeedImage } from "../utils";
|
import { DbMetadata, SeedImage } from "../utils";
|
||||||
import { Resource } from "../utils/resource";
|
import { Resource } from "../utils/resource";
|
||||||
import { Progress } from "../watchlist";
|
|
||||||
import { BaseEntry } from "./base-entry";
|
import { BaseEntry } from "./base-entry";
|
||||||
|
|
||||||
export const ExtraType = t.UnionEnum([
|
export const ExtraType = t.UnionEnum([
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { type Prettify, comment } from "~/utils";
|
import { type Prettify, comment } from "~/utils";
|
||||||
import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
|
import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
|
||||||
|
import { Progress } from "../history";
|
||||||
import {
|
import {
|
||||||
DbMetadata,
|
DbMetadata,
|
||||||
ExternalId,
|
ExternalId,
|
||||||
@ -10,7 +11,6 @@ import {
|
|||||||
TranslationRecord,
|
TranslationRecord,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { EmbeddedVideo } from "../video";
|
import { EmbeddedVideo } from "../video";
|
||||||
import { Progress } from "../watchlist";
|
|
||||||
import { BaseEntry, EntryTranslation } from "./base-entry";
|
import { BaseEntry, EntryTranslation } from "./base-entry";
|
||||||
|
|
||||||
export const BaseMovieEntry = t.Intersect(
|
export const BaseMovieEntry = t.Intersect(
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { type Prettify, comment } from "~/utils";
|
import { type Prettify, comment } from "~/utils";
|
||||||
import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
|
import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
|
||||||
|
import { Progress } from "../history";
|
||||||
import {
|
import {
|
||||||
DbMetadata,
|
DbMetadata,
|
||||||
EpisodeId,
|
EpisodeId,
|
||||||
@ -9,7 +10,6 @@ import {
|
|||||||
TranslationRecord,
|
TranslationRecord,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { EmbeddedVideo } from "../video";
|
import { EmbeddedVideo } from "../video";
|
||||||
import { Progress } from "../watchlist";
|
|
||||||
import { BaseEntry, EntryTranslation } from "./base-entry";
|
import { BaseEntry, EntryTranslation } from "./base-entry";
|
||||||
|
|
||||||
export const BaseSpecial = t.Intersect(
|
export const BaseSpecial = t.Intersect(
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { type Prettify, comment } from "~/utils";
|
import { type Prettify, comment } from "~/utils";
|
||||||
import { bubbleImages, registerExamples, youtubeExample } from "../examples";
|
import { bubbleImages, registerExamples, youtubeExample } from "../examples";
|
||||||
|
import { Progress } from "../history";
|
||||||
import { DbMetadata, Resource } from "../utils";
|
import { DbMetadata, Resource } from "../utils";
|
||||||
import { Progress } from "../watchlist";
|
|
||||||
import { BaseEntry, EntryTranslation } from "./base-entry";
|
import { BaseEntry, EntryTranslation } from "./base-entry";
|
||||||
|
|
||||||
export const BaseUnknownEntry = t.Intersect(
|
export const BaseUnknownEntry = t.Intersect(
|
||||||
|
40
api/src/models/history.ts
Normal file
40
api/src/models/history.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { t } from "elysia";
|
||||||
|
import { comment } from "~/utils";
|
||||||
|
|
||||||
|
export const Progress = t.Object({
|
||||||
|
percent: t.Integer({ minimum: 0, maximum: 100 }),
|
||||||
|
time: t.Nullable(
|
||||||
|
t.Integer({
|
||||||
|
minimum: 0,
|
||||||
|
description: comment`
|
||||||
|
When this episode was stopped (in seconds since the start).
|
||||||
|
This value is null if the entry was never watched or is finished.
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
playedDate: t.Nullable(t.String({ format: "date-time" })),
|
||||||
|
videoId: t.Nullable(
|
||||||
|
t.String({
|
||||||
|
format: "uuid",
|
||||||
|
description: comment`
|
||||||
|
Id of the video the user watched.
|
||||||
|
This can be used to resume playback in the correct video file
|
||||||
|
without asking the user what video to play.
|
||||||
|
|
||||||
|
This will be null if the user did not watch the entry or
|
||||||
|
if the video was deleted since.
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
export type Progress = typeof Progress.static;
|
||||||
|
|
||||||
|
export const SeedHistory = t.Intersect([
|
||||||
|
t.Object({
|
||||||
|
entry: t.String({
|
||||||
|
description: "Id or slug of the entry/movie you watched",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Progress,
|
||||||
|
]);
|
||||||
|
export type SeedHistory = typeof SeedHistory.static;
|
@ -1,32 +1,4 @@
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { comment } from "~/utils";
|
|
||||||
|
|
||||||
export const Progress = t.Object({
|
|
||||||
percent: t.Integer({ minimum: 0, maximum: 100 }),
|
|
||||||
time: t.Nullable(
|
|
||||||
t.Integer({
|
|
||||||
minimum: 0,
|
|
||||||
description: comment`
|
|
||||||
When this episode was stopped (in seconds since the start).
|
|
||||||
This value is null if the entry was never watched or is finished.
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
videoId: t.Nullable(
|
|
||||||
t.String({
|
|
||||||
format: "uuid",
|
|
||||||
description: comment`
|
|
||||||
Id of the video the user watched.
|
|
||||||
This can be used to resume playback in the correct video file
|
|
||||||
without asking the user what video to play.
|
|
||||||
|
|
||||||
This will be null if the user did not watch the entry or
|
|
||||||
if the video was deleted since.
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
export type Progress = typeof Progress.static;
|
|
||||||
|
|
||||||
export const WatchlistStatus = t.UnionEnum([
|
export const WatchlistStatus = t.UnionEnum([
|
||||||
"completed",
|
"completed",
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { buildUrl } from "tests/utils";
|
import { buildUrl } from "tests/utils";
|
||||||
import { app } from "~/base";
|
import { app } from "~/base";
|
||||||
|
import type { SeedHistory } from "~/models/history";
|
||||||
import type { SeedSerie } from "~/models/serie";
|
import type { SeedSerie } from "~/models/serie";
|
||||||
import type { SerieWatchStatus } from "~/models/watchlist";
|
import type { SerieWatchStatus } from "~/models/watchlist";
|
||||||
import { getJwtHeaders } from "./jwt";
|
import { getJwtHeaders } from "./jwt";
|
||||||
@ -197,3 +198,47 @@ export const setSerieStatus = async (id: string, status: SerieWatchStatus) => {
|
|||||||
const body = await resp.json();
|
const body = await resp.json();
|
||||||
return [resp, body] as const;
|
return [resp, body] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getHistory = async (
|
||||||
|
profile: string,
|
||||||
|
{
|
||||||
|
langs,
|
||||||
|
...opts
|
||||||
|
}: {
|
||||||
|
filter?: string;
|
||||||
|
limit?: number;
|
||||||
|
after?: string;
|
||||||
|
query?: string;
|
||||||
|
langs?: string;
|
||||||
|
preferOriginal?: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const resp = await app.handle(
|
||||||
|
new Request(buildUrl(`profiles/${profile}/history`, opts), {
|
||||||
|
method: "GET",
|
||||||
|
headers: langs
|
||||||
|
? {
|
||||||
|
"Accept-Language": langs,
|
||||||
|
...(await getJwtHeaders()),
|
||||||
|
}
|
||||||
|
: await getJwtHeaders(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const body = await resp.json();
|
||||||
|
return [resp, body] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addToHistory = async (profile: string, seed: SeedHistory[]) => {
|
||||||
|
const resp = await app.handle(
|
||||||
|
new Request(buildUrl(`profiles/${profile}/history`), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(seed),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(await getJwtHeaders()),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const body = await resp.json();
|
||||||
|
return [resp, body] as const;
|
||||||
|
};
|
||||||
|
@ -67,6 +67,21 @@ describe("Set & get watch status", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Can filter watchlist", async () => {
|
||||||
|
let [resp, body] = await getWatchlist("me", {
|
||||||
|
filter: "watchStatus eq rewatching",
|
||||||
|
});
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
expect(body.items).toBeArrayOfSize(1);
|
||||||
|
expect(body.items[0].slug).toBe(bubble.slug);
|
||||||
|
|
||||||
|
[resp, body] = await getWatchlist("me", {
|
||||||
|
filter: "watchStatus eq completed",
|
||||||
|
});
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
expect(body.items).toBeArrayOfSize(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("Return watchstatus in /shows", async () => {
|
it("Return watchstatus in /shows", async () => {
|
||||||
const [resp, body] = await getShows({});
|
const [resp, body] = await getShows({});
|
||||||
expectStatus(resp, body).toBe(200);
|
expectStatus(resp, body).toBe(200);
|
||||||
|
149
api/tests/series/history.test.ts
Normal file
149
api/tests/series/history.test.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { beforeAll, describe, expect, it } from "bun:test";
|
||||||
|
import {
|
||||||
|
addToHistory,
|
||||||
|
createMovie,
|
||||||
|
createSerie,
|
||||||
|
getEntries,
|
||||||
|
getHistory,
|
||||||
|
getNews,
|
||||||
|
getWatchlist,
|
||||||
|
} from "tests/helpers";
|
||||||
|
import { expectStatus } from "tests/utils";
|
||||||
|
import { db } from "~/db";
|
||||||
|
import { entries, shows, videos } from "~/db/schema";
|
||||||
|
import { bubble, madeInAbyss, madeInAbyssVideo } from "~/models/examples";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await db.delete(shows);
|
||||||
|
await db.delete(entries);
|
||||||
|
await db.delete(videos);
|
||||||
|
// create video beforehand to test linking
|
||||||
|
await db.insert(videos).values(madeInAbyssVideo);
|
||||||
|
let [ret, body] = await createSerie(madeInAbyss);
|
||||||
|
expectStatus(ret, body).toBe(201);
|
||||||
|
[ret, body] = await createMovie(bubble);
|
||||||
|
expectStatus(ret, body).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
const miaEntrySlug = `${madeInAbyss.slug}-s1e13`;
|
||||||
|
|
||||||
|
describe("Set & get history", () => {
|
||||||
|
it("Add episodes & movie to history", async () => {
|
||||||
|
let [resp, body] = await getHistory("me", {});
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
expect(body.items).toBeArrayOfSize(0);
|
||||||
|
|
||||||
|
const [r, b] = await addToHistory("me", [
|
||||||
|
{
|
||||||
|
entry: miaEntrySlug,
|
||||||
|
videoId: madeInAbyssVideo.id,
|
||||||
|
percent: 58,
|
||||||
|
time: 28 * 60 + 12,
|
||||||
|
playedDate: "2025-02-01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry: bubble.slug,
|
||||||
|
videoId: null,
|
||||||
|
percent: 100,
|
||||||
|
time: 2 * 60,
|
||||||
|
playedDate: "2025-02-02",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expectStatus(r, b).toBe(201);
|
||||||
|
expect(b.inserted).toBe(2);
|
||||||
|
|
||||||
|
[resp, body] = await getHistory("me", {});
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
expect(body.items).toBeArrayOfSize(2);
|
||||||
|
expect(body.items[0].slug).toBe(bubble.slug);
|
||||||
|
expect(body.items[0].progress).toMatchObject({
|
||||||
|
percent: 100,
|
||||||
|
time: 2 * 60,
|
||||||
|
});
|
||||||
|
expect(body.items[1].slug).toBe(miaEntrySlug);
|
||||||
|
expect(body.items[1].progress).toMatchObject({
|
||||||
|
percent: 58,
|
||||||
|
videoId: madeInAbyssVideo.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Create duplicated history entry", async () => {
|
||||||
|
const [r, b] = await addToHistory("me", [
|
||||||
|
{
|
||||||
|
entry: miaEntrySlug!,
|
||||||
|
videoId: madeInAbyssVideo.id,
|
||||||
|
percent: 100,
|
||||||
|
time: 38 * 60,
|
||||||
|
playedDate: "2025-02-03",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expectStatus(r, b).toBe(201);
|
||||||
|
expect(b.inserted).toBe(1);
|
||||||
|
|
||||||
|
const [resp, body] = await getHistory("me", {});
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
expect(body.items).toBeArrayOfSize(3);
|
||||||
|
expect(body.items[0].slug).toBe(miaEntrySlug);
|
||||||
|
expect(body.items[0].progress).toMatchObject({
|
||||||
|
percent: 100,
|
||||||
|
videoId: madeInAbyssVideo.id,
|
||||||
|
});
|
||||||
|
expect(body.items[1].slug).toBe(bubble.slug);
|
||||||
|
expect(body.items[1].progress).toMatchObject({
|
||||||
|
percent: 100,
|
||||||
|
time: 2 * 60,
|
||||||
|
});
|
||||||
|
expect(body.items[2].slug).toBe(miaEntrySlug);
|
||||||
|
expect(body.items[2].progress).toMatchObject({
|
||||||
|
percent: 58,
|
||||||
|
videoId: madeInAbyssVideo.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Return progress in /shows/:id/entries", async () => {
|
||||||
|
const [resp, body] = await getEntries(madeInAbyss.slug, { langs: "en" });
|
||||||
|
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
expect(body.items).toBeArrayOfSize(madeInAbyss.entries.length);
|
||||||
|
expect(body.items[0].progress).toMatchObject({
|
||||||
|
percent: 100,
|
||||||
|
time: 38 * 60,
|
||||||
|
videoId: madeInAbyssVideo.id,
|
||||||
|
playedDate: "2025-02-03 00:00:00+00",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Return progress in /news", async () => {
|
||||||
|
const [resp, body] = await getNews({ langs: "en" });
|
||||||
|
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
const entry = body.items.find((x: any) => x.slug === miaEntrySlug);
|
||||||
|
expect(entry.progress).toMatchObject({
|
||||||
|
percent: 100,
|
||||||
|
time: 38 * 60,
|
||||||
|
videoId: madeInAbyssVideo.id,
|
||||||
|
playedDate: "2025-02-03 00:00:00+00",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// extras, unknowns
|
||||||
|
|
||||||
|
it("Update watchlist", async () => {
|
||||||
|
const [resp, body] = await getWatchlist("me", {});
|
||||||
|
expectStatus(resp, body).toBe(200);
|
||||||
|
expect(body.items).toBeArrayOfSize(2);
|
||||||
|
// watching items before completed ones
|
||||||
|
expect(body.items[0].slug).toBe(madeInAbyss.slug);
|
||||||
|
expect(body.items[0].watchStatus).toMatchObject({
|
||||||
|
status: "watching",
|
||||||
|
seenCount: 1,
|
||||||
|
startedAt: "2025-02-01 00:00:00+00",
|
||||||
|
});
|
||||||
|
expect(body.items[1].slug).toBe(bubble.slug);
|
||||||
|
expect(body.items[1].watchStatus).toMatchObject({
|
||||||
|
status: "completed",
|
||||||
|
percent: 100,
|
||||||
|
completedAt: "2025-02-02 00:00:00+00",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user