Migrate to expo only (no next) PART 1 (#763)

This commit is contained in:
Zoe Roux 2025-07-14 03:10:41 +02:00 committed by GitHub
commit 1ea83848f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
347 changed files with 8310 additions and 23769 deletions

View File

@ -1,6 +1,3 @@
{
"extends": ["../biome.json"],
"formatter": {
"lineWidth": 80
}
"extends": "//"
}

View File

@ -6,17 +6,18 @@
"dependencies": {
"@elysiajs/swagger": "zoriya/elysia-swagger#build",
"blurhash": "^2.0.5",
"drizzle-kit": "^0.31.0",
"drizzle-kit": "^0.31.1",
"drizzle-orm": "0.43.1",
"elysia": "^1.3.0",
"jose": "^6.0.10",
"elysia": "^1.3.1",
"jose": "^6.0.11",
"parjs": "^1.3.9",
"pg": "^8.15.6",
"sharp": "^0.34.1",
"pg": "^8.16.0",
"sharp": "^0.34.2",
},
"devDependencies": {
"@types/pg": "^8.11.14",
"bun-types": "^1.2.11",
"@biomejs/biome": "2.0.0",
"@types/pg": "^8.15.2",
"bun-types": "^1.2.14",
"node-addon-api": "^8.3.1",
},
},
@ -25,6 +26,24 @@
"drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch",
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.0.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.0.0", "@biomejs/cli-darwin-x64": "2.0.0", "@biomejs/cli-linux-arm64": "2.0.0", "@biomejs/cli-linux-arm64-musl": "2.0.0", "@biomejs/cli-linux-x64": "2.0.0", "@biomejs/cli-linux-x64-musl": "2.0.0", "@biomejs/cli-win32-arm64": "2.0.0", "@biomejs/cli-win32-x64": "2.0.0" }, "bin": { "biome": "bin/biome" } }, "sha512-BlUoXEOI/UQTDEj/pVfnkMo8SrZw3oOWBDrXYFT43V7HTkIUDkBRY53IC5Jx1QkZbaB+0ai1wJIfYwp9+qaJTQ=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.0.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QvqWYtFFhhxdf8jMAdJzXW+Frc7X8XsnHQLY+TBM1fnT1TfeV/v9vsFI5L2J7GH6qN1+QEEJ19jHibCY2Ypplw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.0.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-5JFhls1EfmuIH4QGFPlNpxJQFC6ic3X1ltcoLN+eSRRIPr6H/lUS1ttuD0Fj7rPgPhZqopK/jfH8UVj/1hIsQw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-BAH4QVi06TzAbVchXdJPsL0Z/P87jOfes15rI+p3EX9/EGTfIjaQ9lBVlHunxcmoptaA5y1Hdb9UYojIhmnjIw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bxsz8ki8+b3PytMnS5SgrGV+mbAWwIxI3ydChb/d1rURlJTMdxTTq5LTebUnlsUWAX6OvJuFeiVq9Gjn1YbCyA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-09PcOGYTtkopWRm6mZ/B6Mr6UHdkniUgIG/jLBv+2J8Z61ezRE+xQmpi3yNgUrFIAU4lPA9atg7mhvE/5Bo7Wg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tiQ0ABxMJb9I6GlfNp0ulrTiQSFacJRJO8245FFwE3ty3bfsfxlU/miblzDIi+qNrgGsLq5wIZcVYGp4c+HXZA=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.0.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vrTtuGu91xNTEQ5ZcMJBZuDlqr32DWU1r14UfePIGndF//s2WUAmer4FmgoPgruo76rprk37e8S2A2c0psXdxw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.0.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2USVQ0hklNsph/KIR72ZdeptyXNnQ3JdzPn3NbjI4Sna34CnxeiYAaZcZzXPDl5PYNFBivV4xmvT3Z3rTmyDBg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@elysiajs/swagger": ["@elysiajs/swagger@github:zoriya/elysia-swagger#f88fbc7", { "dependencies": { "@scalar/themes": "^0.9.81", "@scalar/types": "^0.1.3", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "zoriya-elysia-swagger-f88fbc7"],

View File

@ -20,9 +20,10 @@
"sharp": "^0.34.2"
},
"devDependencies": {
"@biomejs/biome": "2.0.0",
"@types/pg": "^8.15.2",
"node-addon-api": "^8.3.1",
"bun-types": "^1.2.14"
"bun-types": "^1.2.14",
"node-addon-api": "^8.3.1"
},
"module": "src/index.js",
"patchedDependencies": {

View File

@ -1,4 +1,4 @@
import { type SQL, and, desc, eq, isNotNull, ne, sql } from "drizzle-orm";
import { and, desc, eq, isNotNull, ne, type SQL, sql } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { auth } from "~/auth";
import { db } from "~/db";
@ -31,14 +31,14 @@ import { KError } from "~/models/error";
import { madeInAbyss } from "~/models/examples";
import {
AcceptLanguage,
createPage,
Filter,
type FilterDef,
Page,
Sort,
createPage,
isUuid,
keysetPaginate,
Page,
processLanguages,
Sort,
sortToSql,
} from "~/models/utils";
import { desc as description } from "~/models/utils/descriptions";
@ -146,7 +146,7 @@ export const mapProgress = ({ aliased }: { aliased: boolean }) => {
const ret = {
time: coalesce(time, sql<number>`0`),
percent: coalesce(percent, sql<number>`0`),
playedDate: sql<string>`${playedDate}`,
playedDate: sql`to_char(${playedDate}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
videoId: sql<string>`${videoId}`,
};
if (!aliased) return ret;

View File

@ -1,28 +1,24 @@
import type { Stats } from "node:fs";
import type { BunFile, S3File, S3Stats } from "bun";
import { type SQL, and, eq, sql } from "drizzle-orm";
import type { S3Stats } from "bun";
import { and, eq, type SQL, sql } from "drizzle-orm";
import Elysia, { type Context, t } from "elysia";
import { prefix } from "~/base";
import { db } from "~/db";
import {
showTranslations,
shows,
showTranslations,
staff,
studioTranslations,
studios,
studioTranslations,
} from "~/db/schema";
import { sqlarr } from "~/db/utils";
import { KError } from "~/models/error";
import { bubble } from "~/models/examples";
import { AcceptLanguage, isUuid, processLanguages } from "~/models/utils";
import { getFile } from "~/utils";
import { comment, getFile } from "~/utils";
import { imageDir } from "./seed/images";
function getRedirectToImageHandler({
filter,
}: {
filter?: SQL;
}) {
function getRedirectToImageHandler({ filter }: { filter?: SQL }) {
return async function Handler({
params: { id, image },
headers: { "accept-language": languages },
@ -97,11 +93,30 @@ function getRedirectToImageHandler({
export const imagesH = new Elysia({ tags: ["images"] })
.get(
"/images/:id",
async ({ params: { id }, query: { quality }, headers: reqHeaders }) => {
async ({
params: { id },
query: { quality },
headers: reqHeaders,
status,
}) => {
const path = `${imageDir}/${id}.${quality}.jpg`;
const file = getFile(path);
const etag = await generateETag(file);
const stat = await file.stat().catch(() => undefined);
if (!stat) {
return status(404, {
status: 404,
message: comment`
No image available with this ID.
Either the id is invalid or the image has not been downloaded yet.
`,
});
}
const etag =
"etag" in stat
? stat.etag
: Buffer.from(stat.mtime.toISOString(), "utf8").toString("base64");
if (await isCached(reqHeaders, etag, path))
return new Response(null, { status: 304 });
@ -387,10 +402,3 @@ export async function isCached(
return false;
}
export async function generateETag(file: BunFile | S3File) {
const hash = new Bun.CryptoHasher("md5");
hash.update(await file.arrayBuffer());
return hash.digest("base64");
}

View File

@ -11,10 +11,10 @@ import { KError } from "~/models/error";
import { SeedHistory } from "~/models/history";
import {
AcceptLanguage,
Filter,
Page,
createPage,
Filter,
isUuid,
Page,
processLanguages,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";

View File

@ -8,13 +8,13 @@ import { getColumns, sqlarr } from "~/db/utils";
import { Entry } from "~/models/entry";
import {
AcceptLanguage,
createPage,
Filter,
type FilterDef,
Page,
Sort,
createPage,
keysetPaginate,
Page,
processLanguages,
Sort,
sortToSql,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";

View File

@ -18,11 +18,11 @@ import { Movie } from "~/models/movie";
import { Serie } from "~/models/serie";
import {
AcceptLanguage,
createPage,
DbMetadata,
Filter,
Page,
createPage,
isUuid,
Page,
processLanguages,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";

View File

@ -1,20 +1,20 @@
import { and, eq, sql } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { db } from "~/db";
import { seasonTranslations, seasons, shows } from "~/db/schema";
import { seasons, seasonTranslations, shows } from "~/db/schema";
import { getColumns, sqlarr } from "~/db/utils";
import { KError } from "~/models/error";
import { madeInAbyss } from "~/models/examples";
import {
AcceptLanguage,
createPage,
Filter,
type FilterDef,
Page,
Sort,
createPage,
isUuid,
keysetPaginate,
Page,
processLanguages,
Sort,
sortToSql,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";

View File

@ -1,11 +1,11 @@
import path from "node:path";
import { encode } from "blurhash";
import { type SQL, and, eq, is, lt, sql } from "drizzle-orm";
import { and, eq, is, lt, type SQL, sql } from "drizzle-orm";
import { PgColumn, type PgTable } from "drizzle-orm/pg-core";
import { version } from "package.json";
import type { PoolClient } from "pg";
import sharp from "sharp";
import { type Transaction, db } from "~/db";
import { db, type Transaction } from "~/db";
import { mqueue } from "~/db/schema/mqueue";
import type { Image } from "~/models/utils";
import { getFile } from "~/utils";

View File

@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
import { db } from "~/db";
import { showTranslations, shows } from "~/db/schema";
import { shows, showTranslations } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils";
import type { SeedCollection } from "~/models/collections";
import type { SeedMovie } from "~/models/movie";

View File

@ -1,4 +1,4 @@
import { type Column, type SQL, eq, sql } from "drizzle-orm";
import { type Column, eq, type SQL, sql } from "drizzle-orm";
import { db } from "~/db";
import {
entries,

View File

@ -1,5 +1,5 @@
import { db } from "~/db";
import { seasonTranslations, seasons } from "~/db/schema";
import { seasons, seasonTranslations } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils";
import type { SeedSeason } from "~/models/season";
import { enqueueOptImage } from "../images";

View File

@ -1,15 +1,15 @@
import {
type SQLWrapper,
and,
count,
eq,
exists,
isNull,
ne,
type SQLWrapper,
sql,
} from "drizzle-orm";
import { type Transaction, db } from "~/db";
import { entries, entryVideoJoin, showTranslations, shows } from "~/db/schema";
import { db, type Transaction } from "~/db";
import { entries, entryVideoJoin, shows, showTranslations } from "~/db/schema";
import { conflictUpdateAllExcept, sqlarr } from "~/db/utils";
import type { SeedCollection } from "~/models/collections";
import type { SeedMovie } from "~/models/movie";

View File

@ -1,5 +1,5 @@
import { db } from "~/db";
import { showStudioJoin, studioTranslations, studios } from "~/db/schema";
import { showStudioJoin, studios, studioTranslations } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils";
import type { SeedStudio } from "~/models/studio";
import { enqueueOptImage } from "../images";

View File

@ -1,7 +1,7 @@
// oh i hate js dates so much.
export const guessNextRefresh = (airDate: Date | string) => {
if (typeof airDate === "string") airDate = new Date(airDate);
const diff = new Date().getTime() - airDate.getTime();
const diff = Date.now() - airDate.getTime();
const days = diff / (24 * 60 * 60 * 1000);
const ret = new Date();

View File

@ -16,10 +16,10 @@ import { Serie } from "~/models/serie";
import { Show } from "~/models/show";
import {
AcceptLanguage,
Filter,
Page,
createPage,
Filter,
isUuid,
Page,
processLanguages,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";

View File

@ -1,4 +1,4 @@
import { type SQL, and, eq, exists, gt, ne, sql } from "drizzle-orm";
import { and, eq, exists, ne, type SQL, sql } from "drizzle-orm";
import { db } from "~/db";
import {
entries,
@ -6,10 +6,10 @@ import {
entryVideoJoin,
profiles,
showStudioJoin,
showTranslations,
shows,
studioTranslations,
showTranslations,
studios,
studioTranslations,
videos,
} from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist";
@ -26,12 +26,12 @@ import type { MovieStatus } from "~/models/movie";
import { SerieStatus, type SerieTranslation } from "~/models/serie";
import type { Studio } from "~/models/studio";
import {
buildRelations,
type FilterDef,
Genre,
type Image,
Sort,
buildRelations,
keysetPaginate,
Sort,
sortToSql,
} from "~/models/utils";
import type { EmbeddedVideo } from "~/models/video";
@ -49,6 +49,11 @@ export const watchStatusQ = db
.as("watchstatus");
export const showFilters: FilterDef = {
kind: {
column: shows.kind,
type: "enum",
values: ["serie", "movie", "collection"],
},
genres: {
column: shows.genres,
type: "enum",
@ -81,6 +86,11 @@ export const showFilters: FilterDef = {
export const showSort = Sort(
{
slug: shows.slug,
name: {
sql: sql.raw(`t.${showTranslations.name.name}`),
isNullable: false,
accessor: (x) => x.name,
},
rating: shows.rating,
airDate: shows.startAir,
startAir: shows.startAir,
@ -111,7 +121,7 @@ const showRelations = {
.as("translations");
},
studios: ({ languages }: { languages: string[] }) => {
const { pk: _, ...studioCol } = getColumns(studios);
const { pk: _, createdAt, updatedAt, ...studioCol } = getColumns(studios);
const studioTransQ = db
.selectDistinctOn([studioTranslations.pk])
.from(studioTranslations)
@ -125,7 +135,14 @@ const showRelations = {
return db
.select({
json: coalesce(
jsonbAgg(jsonbBuildObject<Studio>({ ...studioTrans, ...studioCol })),
jsonbAgg(
jsonbBuildObject<Studio>({
...studioTrans,
...studioCol,
createdAt: sql`to_char(${createdAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
updatedAt: sql`to_char(${updatedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
}),
),
sql`'[]'::jsonb`,
).as("json"),
})
@ -261,6 +278,8 @@ export async function getShows({
.orderBy(
showTranslations.pk,
sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`,
// ensure a stable sort to prevent future pages to contains the same element again
showTranslations.language,
)
.as("t");

View File

@ -9,10 +9,10 @@ import { bubble } from "~/models/examples";
import { FullMovie, Movie, MovieTranslation } from "~/models/movie";
import {
AcceptLanguage,
Filter,
Page,
createPage,
Filter,
isUuid,
Page,
processLanguages,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";

View File

@ -9,10 +9,10 @@ import { madeInAbyss } from "~/models/examples";
import { FullSerie, Serie, SerieTranslation } from "~/models/serie";
import {
AcceptLanguage,
Filter,
Page,
createPage,
Filter,
isUuid,
Page,
processLanguages,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";

View File

@ -8,9 +8,9 @@ import { KError } from "~/models/error";
import { Show } from "~/models/show";
import {
AcceptLanguage,
createPage,
Filter,
Page,
createPage,
processLanguages,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";

View File

@ -1,9 +1,9 @@
import { type SQL, and, eq, sql } from "drizzle-orm";
import { and, eq, type SQL, sql } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { auth } from "~/auth";
import { prefix } from "~/base";
import { db } from "~/db";
import { profiles, showTranslations, shows } from "~/db/schema";
import { profiles, shows, showTranslations } from "~/db/schema";
import { roles, staff } from "~/db/schema/staff";
import { watchlist } from "~/db/schema/watchlist";
import { getColumns, jsonbBuildObject, sqlarr } from "~/db/utils";
@ -13,15 +13,15 @@ import { Role, Staff } from "~/models/staff";
import { RoleWShow, RoleWStaff } from "~/models/staff-roles";
import {
AcceptLanguage,
createPage,
Filter,
type FilterDef,
type Image,
Page,
Sort,
createPage,
isUuid,
keysetPaginate,
Page,
processLanguages,
Sort,
sortToSql,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";

View File

@ -1,4 +1,4 @@
import { type SQL, and, eq, exists, sql } from "drizzle-orm";
import { and, eq, exists, type SQL, sql } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { auth } from "~/auth";
import { prefix } from "~/base";
@ -6,8 +6,8 @@ import { db } from "~/db";
import {
showStudioJoin,
shows,
studioTranslations,
studios,
studioTranslations,
} from "~/db/schema";
import {
getColumns,
@ -22,14 +22,14 @@ import { Show } from "~/models/show";
import { Studio, StudioTranslation } from "~/models/studio";
import {
AcceptLanguage,
Filter,
Page,
Sort,
buildRelations,
createPage,
Filter,
isUuid,
keysetPaginate,
Page,
processLanguages,
Sort,
sortToSql,
} from "~/models/utils";
import { desc } from "~/models/utils/descriptions";

View File

@ -1,6 +1,6 @@
import { and, eq, notExists, or, sql } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { type Transaction, db } from "~/db";
import { db, type Transaction } from "~/db";
import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
import {
conflictUpdateAllExcept,
@ -13,12 +13,12 @@ import {
import { KError } from "~/models/error";
import { bubbleVideo } from "~/models/examples";
import {
Page,
type Resource,
Sort,
createPage,
isUuid,
keysetPaginate,
Page,
type Resource,
Sort,
sortToSql,
} from "~/models/utils";
import { desc as description } from "~/models/utils/descriptions";

View File

@ -8,11 +8,11 @@ import {
primaryKey,
real,
text,
timestamp,
unique,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { shows } from "./shows";
import { image, language, schema } from "./utils";
import { entryVideoJoin } from "./videos";
@ -67,14 +67,14 @@ export const entries = schema.table(
externalId: entry_extid(),
createdAt: timestamp({ withTimezone: true, mode: "string" })
createdAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.default(sql`now()`),
updatedAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.$onUpdate(() => sql`now()`),
availableSince: timestamp({ withTimezone: true, mode: "string" }),
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
availableSince: timestamp({ withTimezone: true, mode: "iso" }),
nextRefresh: timestamp({ withTimezone: true, mode: "iso" }).notNull(),
},
(t) => [
unique().on(t.showPk, t.seasonNumber, t.episodeNumber),

View File

@ -1,5 +1,6 @@
import { sql } from "drizzle-orm";
import { check, index, integer, timestamp } from "drizzle-orm/pg-core";
import { check, index, integer } from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { entries } from "./entries";
import { profiles } from "./profiles";
import { schema } from "./utils";
@ -18,9 +19,9 @@ export const history = schema.table(
videoPk: integer().references(() => videos.pk, { onDelete: "set null" }),
percent: integer().notNull().default(0),
time: integer(),
playedDate: timestamp({ withTimezone: true, mode: "string" })
playedDate: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.defaultNow(),
.default(sql`now()`),
},
(t) => [
index("history_play_date").on(t.playedDate.desc()),

View File

@ -1,9 +1,9 @@
export * from "./entries";
export * from "./seasons";
export * from "./shows";
export * from "./studios";
export * from "./staff";
export * from "./videos";
export * from "./profiles";
export * from "./history";
export * from "./mqueue";
export * from "./profiles";
export * from "./seasons";
export * from "./shows";
export * from "./staff";
export * from "./studios";
export * from "./videos";

View File

@ -1,11 +1,6 @@
import {
index,
integer,
jsonb,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { index, integer, jsonb, uuid, varchar } from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { schema } from "./utils";
export const mqueue = schema.table(
@ -15,9 +10,9 @@ export const mqueue = schema.table(
kind: varchar({ length: 255 }).notNull(),
message: jsonb().notNull(),
attempt: integer().notNull().default(0),
createdAt: timestamp({ withTimezone: true, mode: "string" })
createdAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.defaultNow(),
.default(sql`now()`),
},
(t) => [index("mqueue_created").on(t.createdAt)],
);

View File

@ -6,11 +6,11 @@ import {
jsonb,
primaryKey,
text,
timestamp,
unique,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { shows } from "./shows";
import { image, language, schema } from "./utils";
@ -42,13 +42,13 @@ export const seasons = schema.table(
externalId: season_extid(),
createdAt: timestamp({ withTimezone: true, mode: "string" })
createdAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.default(sql`now()`),
updatedAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.$onUpdate(() => sql`now()`),
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
nextRefresh: timestamp({ withTimezone: true, mode: "iso" }).notNull(),
},
(t) => [
unique().on(t.showPk, t.seasonNumber),

View File

@ -9,11 +9,11 @@ import {
primaryKey,
smallint,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import type { Image, Original } from "~/models/utils";
import { timestamp } from "../utils";
import { entries } from "./entries";
import { seasons } from "./seasons";
import { roles } from "./staff";
@ -87,13 +87,13 @@ export const shows = schema.table(
externalId: externalid(),
createdAt: timestamp({ withTimezone: true, mode: "string" })
createdAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.default(sql`now()`),
updatedAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.$onUpdate(() => sql`now()`),
nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(),
nextRefresh: timestamp({ withTimezone: true, mode: "iso" }).notNull(),
},
(t) => [
check("rating_valid", sql`${t.rating} between 0 and 100`),

View File

@ -3,13 +3,12 @@ import {
index,
integer,
jsonb,
primaryKey,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import type { Character } from "~/models/staff";
import { timestamp } from "../utils";
import { shows } from "./shows";
import { externalid, image, schema } from "./utils";
@ -32,10 +31,10 @@ export const staff = schema.table("staff", {
image: image(),
externalId: externalid(),
createdAt: timestamp({ withTimezone: true, mode: "string" })
createdAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.default(sql`now()`),
updatedAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.$onUpdate(() => sql`now()`),
});

View File

@ -4,10 +4,10 @@ import {
integer,
primaryKey,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { shows } from "./shows";
import { externalid, image, language, schema } from "./utils";
@ -17,10 +17,10 @@ export const studios = schema.table("studios", {
slug: varchar({ length: 255 }).notNull().unique(),
externalId: externalid(),
createdAt: timestamp({ withTimezone: true, mode: "string" })
createdAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.default(sql`now()`),
updatedAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.$onUpdate(() => sql`now()`),
});

View File

@ -5,12 +5,12 @@ import {
jsonb,
primaryKey,
text,
timestamp,
unique,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import type { Guess } from "~/models/video";
import { timestamp } from "../utils";
import { entries } from "./entries";
import { schema } from "./utils";
@ -25,10 +25,10 @@ export const videos = schema.table(
version: integer().notNull().default(1),
guess: jsonb().$type<Guess>().notNull(),
createdAt: timestamp({ withTimezone: true, mode: "string" })
createdAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.default(sql`now()`),
updatedAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.$onUpdate(() => sql`now()`),
},

View File

@ -1,5 +1,6 @@
import { sql } from "drizzle-orm";
import { check, integer, primaryKey, timestamp } from "drizzle-orm/pg-core";
import { check, integer, primaryKey } from "drizzle-orm/pg-core";
import { timestamp } from "../utils";
import { entries } from "./entries";
import { profiles } from "./profiles";
import { shows } from "./shows";
@ -29,14 +30,14 @@ export const watchlist = schema.table(
score: integer(),
startedAt: timestamp({ withTimezone: true, mode: "string" }),
lastPlayedAt: timestamp({ withTimezone: true, mode: "string" }),
completedAt: timestamp({ withTimezone: true, mode: "string" }),
startedAt: timestamp({ withTimezone: true, mode: "iso" }),
lastPlayedAt: timestamp({ withTimezone: true, mode: "iso" }),
completedAt: timestamp({ withTimezone: true, mode: "iso" }),
createdAt: timestamp({ withTimezone: true, mode: "string" })
createdAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.defaultNow(),
updatedAt: timestamp({ withTimezone: true, mode: "string" })
.default(sql`now()`),
updatedAt: timestamp({ withTimezone: true, mode: "iso" })
.notNull()
.$onUpdate(() => sql`now()`),
},

View File

@ -1,19 +1,23 @@
import {
type Column,
type ColumnsSelection,
getTableColumns,
is,
type SQL,
type SQLWrapper,
type Subquery,
sql,
Table,
View,
ViewBaseConfig,
getTableColumns,
is,
sql,
} from "drizzle-orm";
import type { CasingCache } from "drizzle-orm/casing";
import type { AnyMySqlSelect } from "drizzle-orm/mysql-core";
import type { AnyPgSelect, SelectedFieldsFlat } from "drizzle-orm/pg-core";
import {
type AnyPgSelect,
customType,
type SelectedFieldsFlat,
} from "drizzle-orm/pg-core";
import type { AnySQLiteSelect } from "drizzle-orm/sqlite-core";
import type { WithSubquery } from "drizzle-orm/subquery";
import { db } from "./index";
@ -148,3 +152,19 @@ export const isUniqueConstraint = (e: unknown): boolean => {
typeof e === "object" && e != null && "code" in e && e.code === "23505"
);
};
export const timestamp = customType<{
data: string;
driverData: string;
config: { withTimezone: boolean; precision?: number; mode: "iso" };
}>({
dataType(config) {
const precision = config?.precision ? ` (${config.precision})` : "";
return `timestamp${precision}${config?.withTimezone ? " with time zone" : ""}`;
},
fromDriver(value: string): string {
// postgres format: 2025-06-22 16:13:37.489301+00
// what we want: 2025-06-22T16:13:37Z
return `${value.substring(0, 10)}T${value.substring(11, 19)}Z`;
},
});

View File

@ -1,5 +1,5 @@
import { t } from "elysia";
import { type Prettify, comment } from "~/utils";
import { comment, type Prettify } from "~/utils";
import { madeInAbyss, registerExamples } from "../examples";
import { Progress } from "../history";
import { DbMetadata, SeedImage } from "../utils";

View File

@ -13,6 +13,6 @@ export type SeedEntry = SeedEpisode | SeedMovieEntry | SeedSpecial;
export type EntryKind = Entry["kind"] | Extra["kind"];
export * from "./episode";
export * from "./extra";
export * from "./movie-entry";
export * from "./special";
export * from "./extra";

View File

@ -1,5 +1,5 @@
import { t } from "elysia";
import { type Prettify, comment } from "~/utils";
import { comment, type Prettify } from "~/utils";
import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
import { Progress } from "../history";
import {

View File

@ -1,5 +1,5 @@
import { t } from "elysia";
import { type Prettify, comment } from "~/utils";
import { comment, type Prettify } from "~/utils";
import { bubbleImages, madeInAbyss, registerExamples } from "../examples";
import { Progress } from "../history";
import {

View File

@ -31,7 +31,7 @@ export const registerExamples = <T extends TSchema>(
};
export * from "./bubble";
export * from "./made-in-abyss";
export * from "./dune-1984";
export * from "./dune-2021";
export * from "./dune-collection";
export * from "./made-in-abyss";

View File

@ -23,7 +23,10 @@ export type FilterDef = {
export const Filter = ({
def,
description = "Filters to apply to the query.",
}: { def: FilterDef; description?: string }) =>
}: {
def: FilterDef;
description?: string;
}) =>
t
.Transform(
t.String({

View File

@ -1,11 +1,11 @@
import {
type Parjser,
anyStringOf,
digit,
float,
int,
letter,
noCharOf,
type Parjser,
string,
} from "parjs";
import {

View File

@ -1,7 +1,6 @@
import {
type BinaryOperator,
type SQL,
and,
type BinaryOperator,
eq,
gt,
gte,
@ -10,6 +9,7 @@ import {
ne,
not,
or,
type SQL,
sql,
} from "drizzle-orm";
import { KErrorT } from "~/models/error";

View File

@ -1,12 +1,12 @@
export * from "./db-metadata";
export * from "./external-id";
export * from "./filters";
export * from "./genres";
export * from "./image";
export * from "./language";
export * from "./resource";
export * from "./filters";
export * from "./page";
export * from "./sort";
export * from "./keyset-paginate";
export * from "./db-metadata";
export * from "./language";
export * from "./original";
export * from "./page";
export * from "./relations";
export * from "./resource";
export * from "./sort";

View File

@ -4,9 +4,7 @@ import {
type TSchema,
type TString,
} from "@sinclair/typebox";
import { type Column, type Table, eq, sql } from "drizzle-orm";
import { t } from "elysia";
import { sqlarr } from "~/db/utils";
import { comment } from "../../utils";
import { KErrorT } from "../error";
@ -74,7 +72,7 @@ export const TranslationRecord = <T extends TSchema>(
.Encode((x) => x);
export const processLanguages = (languages?: string) => {
if (!languages) return ["*"];
if (!languages) return ["en", "*"];
return languages
.split(",")
.map((x) => {
@ -91,9 +89,11 @@ export const processLanguages = (languages?: string) => {
export const AcceptLanguage = ({
autoFallback = false,
}: { autoFallback?: boolean } = {}) =>
}: {
autoFallback?: boolean;
} = {}) =>
t.String({
default: "*",
default: "en, *",
example: "en-us, ja;q=0.5",
description:
comment`

View File

@ -1,6 +1,6 @@
import { PatternStringExact, type TSchema } from "@sinclair/typebox";
import { t } from "elysia";
import { type Prettify, comment } from "~/utils";
import { comment, type Prettify } from "~/utils";
import { ExtraType } from "./entry/extra";
import { bubble, bubbleVideo, registerExamples } from "./examples";
import { DbMetadata, EpisodeId, ExternalId, Resource } from "./utils";

View File

@ -1,8 +1,7 @@
export * from "~/base";
export * from "./movies-helper";
export * from "./series-helper";
export * from "./shows-helper";
export * from "./studio-helper";
export * from "./staff-helper";
export * from "./studio-helper";
export * from "./videos-helper";
export * from "~/base";

View File

@ -45,7 +45,11 @@ export const getSerie = async (
export const getSeries = async ({
langs,
...query
}: { langs?: string; preferOriginal?: boolean; with?: string[] }) => {
}: {
langs?: string;
preferOriginal?: boolean;
with?: string[];
}) => {
const resp = await handlers.handle(
new Request(buildUrl("series", query), {
method: "GET",

View File

@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test";
import { eq } from "drizzle-orm";
import { expectStatus } from "tests/utils";
import { db } from "~/db";
import { showTranslations, shows, videos } from "~/db/schema";
import { shows, showTranslations, videos } from "~/db/schema";
import { bubble } from "~/models/examples";
import { dune, duneVideo } from "~/models/examples/dune-2021";
import { createMovie, createVideo } from "../helpers";

View File

@ -36,7 +36,7 @@ describe("Set & get watch status", () => {
expect(body.items[0].slug).toBe(bubble.slug);
expect(body.items[0].watchStatus).toMatchObject({
status: "completed",
completedAt: "2024-12-21 00:00:00+00",
completedAt: "2024-12-21T00:00:00Z",
score: 85,
percent: 100,
});
@ -61,7 +61,7 @@ describe("Set & get watch status", () => {
expect(body.items[0].slug).toBe(bubble.slug);
expect(body.items[0].watchStatus).toMatchObject({
status: "rewatching",
completedAt: "2024-12-21 00:00:00+00",
completedAt: "2024-12-21T00:00:00Z",
score: 85,
percent: 0,
});
@ -89,7 +89,7 @@ describe("Set & get watch status", () => {
expect(body.items[0].slug).toBe(bubble.slug);
expect(body.items[0].watchStatus).toMatchObject({
status: "rewatching",
completedAt: "2024-12-21 00:00:00+00",
completedAt: "2024-12-21T00:00:00Z",
score: 85,
percent: 0,
});
@ -109,7 +109,7 @@ describe("Set & get watch status", () => {
expect(body.slug).toBe(bubble.slug);
expect(body.watchStatus).toMatchObject({
status: "rewatching",
completedAt: "2024-12-21 00:00:00+00",
completedAt: "2024-12-21T00:00:00Z",
score: 85,
percent: 0,
});

View File

@ -109,7 +109,7 @@ describe("Set & get history", () => {
percent: 100,
time: 38 * 60,
videoId: madeInAbyssVideo.id,
playedDate: "2025-02-03 00:00:00+00",
playedDate: "2025-02-03T00:00:00Z",
});
});
@ -122,7 +122,7 @@ describe("Set & get history", () => {
percent: 100,
time: 38 * 60,
videoId: madeInAbyssVideo.id,
playedDate: "2025-02-03 00:00:00+00",
playedDate: "2025-02-03T00:00:00Z",
});
});
@ -137,13 +137,13 @@ describe("Set & get history", () => {
expect(body.items[0].watchStatus).toMatchObject({
status: "watching",
seenCount: 1,
startedAt: "2025-02-01 00:00:00+00",
startedAt: "2025-02-01T00:00:00Z",
});
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",
completedAt: "2025-02-02T00:00:00Z",
});
});
});

View File

@ -113,7 +113,7 @@ describe("nextup", () => {
percent: 58,
time: 28 * 60 + 12,
videoId: madeInAbyssVideo.id,
playedDate: "2025-02-01T00:00:00+00:00",
playedDate: "2025-02-01T00:00:00Z",
});
[resp, body] = await getMovie(bubble.slug, {});
@ -121,7 +121,7 @@ describe("nextup", () => {
expect(body.watchStatus).toMatchObject({
percent: 100,
status: "completed",
completedAt: "2025-02-02 00:00:00+00",
completedAt: "2025-02-02T00:00:00Z",
});
[resp, body] = await getNextup("me", {});
@ -132,7 +132,7 @@ describe("nextup", () => {
percent: 58,
time: 28 * 60 + 12,
videoId: madeInAbyssVideo.id,
playedDate: "2025-02-01 00:00:00+00",
playedDate: "2025-02-01T00:00:00Z",
});
});

View File

@ -10,7 +10,7 @@ import {
} from "tests/helpers";
import { expectStatus } from "tests/utils";
import { db } from "~/db";
import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
import { entries, shows, videos } from "~/db/schema";
import { bubble, madeInAbyss } from "~/models/examples";
beforeAll(async () => {

View File

@ -10,6 +10,6 @@ pkgs.mkShell {
postgresql_15
pgformatter
# to run tests
hurl
# hurl
];
}

View File

@ -1,18 +1,15 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.1/schema.json",
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"formatter": {
"enabled": true,
"formatWithErrors": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto",
"ignore": ["**/.yarn/**", "**/.next/**", "**/.expo/**", "**/next-env.d.ts", "**/back/**"]
},
"organizeImports": {
"enabled": true
"lineWidth": 80,
"attributePosition": "auto"
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
@ -24,16 +21,20 @@
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off"
"noArrayIndexKey": "off",
"noTemplateCurlyInString": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"complexity": {
"noBannedTypes": "off"
"noBannedTypes": "off",
"noUselessUndefinedInitialization": "off"
},
"correctness": {
"noUnusedVariables": "off"
}
},
"ignore": ["**/.yarn/**", "**/.next/**", "**/.expo/**", "**/next-env.d.ts", "**/back/**"]
}
},
"javascript": {
"formatter": {

View File

@ -25,26 +25,25 @@ x-transcoder: &transcoder-base
target: /app
services:
# front:
# build:
# context: ./front
# dockerfile: Dockerfile.dev
# volumes:
# - ./front:/app
# - /app/.yarn
# - /app/node_modules
# - /app/apps/mobile/node_modules
# - /app/apps/web/.next/
# - /app/apps/mobile/.expo/
# ports:
# - "3000:3000"
# - "8081:8081"
# restart: unless-stopped
# environment:
# - KYOO_URL=${KYOO_URL:-http://api:5000/api}
# labels:
# - "traefik.enable=true"
# - "traefik.http.routers.front.rule=PathPrefix(`/`)"
front:
build:
context: ./front
dockerfile: Dockerfile.dev
restart: unless-stopped
ports:
- "8081:8081"
environment:
- KYOO_URL=${KYOO_URL:-http://api:5000/api}
labels:
- "traefik.enable=true"
- "traefik.http.routers.front.rule=PathPrefix(`/`)"
develop:
watch:
- action: sync
path: ./front
target: /app
- action: rebuild
path: ./front/bun.lock
auth:
build:
@ -82,6 +81,8 @@ services:
- JWT_ISSUER=${PUBLIC_URL}
env_file:
- ./.env
volumes:
- images:/app/images
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=PathPrefix(`/api/`) || PathPrefix(`/swagger`)"
@ -95,7 +96,7 @@ services:
path: ./api
target: /app
- action: rebuild
path: ./api/packages.json
path: ./api/bun.lock
scanner:
build: ./scanner
@ -206,4 +207,5 @@ services:
volumes:
db:
images:
transcoder_metadata:

View File

@ -1,14 +1,9 @@
Dockerfile
Dockerfile.dev
.dockerignore
.eslintrc.json
.gitignore
node_modules
npm-debug.log
README.md
.next
.expo
.git
.yarn
!.yarn/releases
!.yarn/plugins
**
!/package.json
!/bun.lock
!/tsconfig.json
!/metro.config.js
!/app.config.ts
!/src
!/app
!/public

View File

@ -1,2 +0,0 @@
/.yarn/releases/** binary
/.yarn/plugins/** binary

8
front/.gitignore vendored
View File

@ -1,5 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
dist/
# dependencies
node_modules
.pnp
@ -45,3 +45,9 @@ yarn-error.log*
apps/web/next-env.d.ts
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/bin/eslint.js
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/bin/eslint.js your application uses
module.exports = absRequire(`eslint/bin/eslint.js`);

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint your application uses
module.exports = absRequire(`eslint`);

View File

@ -1,6 +0,0 @@
{
"name": "eslint",
"version": "8.19.0-sdk",
"main": "./lib/api.js",
"type": "commonjs"
}

View File

@ -1,3 +0,0 @@
# This file is automatically generated by @yarnpkg/sdks.
# Manual changes might be lost!

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/index.js
require(absPnpApiPath).setup();
}
}
// Defer to the real prettier/index.js your application uses
module.exports = absRequire(`prettier/index.js`);

View File

@ -1,6 +0,0 @@
{
"name": "prettier",
"version": "2.7.1-sdk",
"main": "./index.js",
"type": "commonjs"
}

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsc
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsc your application uses
module.exports = absRequire(`typescript/bin/tsc`);

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsserver
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsserver your application uses
module.exports = absRequire(`typescript/bin/tsserver`);

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = absRequire(`typescript/lib/tsc.js`);

View File

@ -1,223 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View File

@ -1,223 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/typescript.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/typescript.js your application uses
module.exports = absRequire(`typescript/lib/typescript.js`);

View File

@ -1,6 +0,0 @@
{
"name": "typescript",
"version": "4.7.4-sdk",
"main": "./lib/typescript.js",
"type": "commonjs"
}

View File

@ -1,24 +0,0 @@
nodeLinker: node-modules
packageExtensions:
"@expo/cli@*":
dependencies:
expo-modules-autolinking: "*"
babel-preset-expo@*:
dependencies:
"@babel/core": "*"
expo-asset@*:
dependencies:
expo: "*"
react-native-codegen@*:
peerDependenciesMeta:
"@babel/preset-env":
optional: true
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
yarnPath: .yarn/releases/yarn-3.2.4.cjs

View File

@ -1,32 +1,12 @@
FROM node:18-alpine AS builder
FROM oven/bun AS builder
WORKDIR /app
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY package.json yarn.lock ./
COPY apps/web/package.json apps/web/package.json
COPY apps/mobile/package.json apps/mobile/package.json
COPY packages/ui/package.json packages/ui/package.json
COPY packages/primitives/package.json packages/primitives/package.json
COPY packages/models/package.json packages/models/package.json
RUN yarn --immutable
COPY package.json bun.lock .
RUN bun install --production
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN yarn build:web
EXPOSE 8081
CMD ["bun", "dev"]
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone/apps/web .
COPY --from=builder /app/apps/web/.next/standalone/node_modules ./node_modules
COPY --from=builder /app/apps/web/.next/static ./.next/static/
COPY --from=builder /app/apps/web/public ./public
EXPOSE 8901
ENV PORT=8901
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
CMD ["node", "server.js"]
# TODO: Actually do something there, either nginx or preferably an ssr bun serv

View File

@ -1,17 +1,10 @@
FROM node:18-alpine
RUN apk add git bash
FROM oven/bun
WORKDIR /app
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY package.json yarn.lock ./
COPY apps/web/package.json apps/web/package.json
COPY apps/mobile/package.json apps/mobile/package.json
COPY packages/ui/package.json packages/ui/package.json
COPY packages/primitives/package.json packages/primitives/package.json
COPY packages/models/package.json packages/models/package.json
RUN yarn --immutable
ENV NEXT_TELEMETRY_DISABLED=1
EXPOSE 3000
COPY package.json bun.lock .
RUN bun install --frozen-lockfile
COPY . .
EXPOSE 8081
ENTRYPOINT ["yarn", "dev"]
CMD ["bun", "dev"]

76
front/app.config.ts Normal file
View File

@ -0,0 +1,76 @@
import type { ExpoConfig } from "expo/config";
const IS_DEV = process.env.APP_VARIANT === "development";
export const expo: ExpoConfig = {
name: IS_DEV ? "Kyoo Development" : "Kyoo",
slug: "kyoo",
scheme: "kyoo",
version: "1.0.0",
newArchEnabled: true,
platforms: ["web", "ios", "android"],
orientation: "default",
icon: "./public/icon-256x256.png",
userInterfaceStyle: "automatic",
ios: {
supportsTablet: true,
},
android: {
package: IS_DEV ? "dev.zoriya.kyoo.dev" : "dev.zoriya.kyoo",
adaptiveIcon: {
foregroundImage: "./public/icon-256x256.png",
backgroundColor: "#eff1f5",
},
edgeToEdgeEnabled: true,
},
web: {
favicon: "./public/icon-256x256.png",
bundler: "metro",
},
updates: {
url: "https://u.expo.dev/55de6b52-c649-4a15-9a45-569ff5ed036c",
fallbackToCacheTimeout: 0,
},
runtimeVersion: {
policy: "sdkVersion",
},
extra: {
eas: {
projectId: "55de6b52-c649-4a15-9a45-569ff5ed036c",
},
},
plugins: [
"expo-router",
[
"expo-build-properties",
{
android: {
usesCleartextTraffic: true,
},
},
],
"expo-localization",
[
"expo-splash-screen",
{
image: "./public/icon-256x256.png",
resizeMode: "contain",
backgroundColor: "#eff1f5",
dark: {
image: "./public/icon-256x256.png",
resizeMode: "contain",
backgroundColor: "#1e1e2e",
},
},
],
[
"react-native-video",
{
enableNotificationControls: true,
},
],
],
experiments: {
typedRoutes: true,
},
};

View File

@ -1,91 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
// import "ts-node/register"; // Add this to import TypeScript files
import type { ExpoConfig } from "expo/config";
import { withAndroidManifest } from "expo/config-plugins";
const IS_DEV = process.env.APP_VARIANT === "development";
// Defined outside the config because dark splashscreen needs to be platform specific.
const splash = {
image: "./assets/icon.png",
resizeMode: "contain",
backgroundColor: "#eff1f5",
dark: {
image: "./assets/icon.png",
resizeMode: "contain",
backgroundColor: "#1e1e2e",
},
} as const;
const config: ExpoConfig = {
name: IS_DEV ? "Kyoo Development" : "Kyoo",
slug: "kyoo",
scheme: "kyoo",
version: "1.0.0",
orientation: "default",
icon: "./assets/icon.png",
userInterfaceStyle: "automatic",
splash,
assetBundlePatterns: ["**/*"],
ios: {
supportsTablet: true,
},
android: {
package: IS_DEV ? "dev.zoriya.kyoo.dev" : "dev.zoriya.kyoo",
adaptiveIcon: {
foregroundImage: "./assets/icon.png",
backgroundColor: "#eff1f5",
},
splash,
},
updates: {
url: "https://u.expo.dev/55de6b52-c649-4a15-9a45-569ff5ed036c",
fallbackToCacheTimeout: 0,
},
runtimeVersion: {
policy: "sdkVersion",
},
extra: {
eas: {
projectId: "55de6b52-c649-4a15-9a45-569ff5ed036c",
},
},
plugins: [
[
"expo-build-properties",
{
android: {
usesCleartextTraffic: true,
},
},
],
"expo-localization",
[
"react-native-video",
{
enableNotificationControls: true,
},
],
],
};
export default config;

View File

@ -1,60 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Icon } from "@kyoo/primitives";
import Browse from "@material-symbols/svg-400/rounded/browse-fill.svg";
import Downloading from "@material-symbols/svg-400/rounded/downloading-fill.svg";
import Home from "@material-symbols/svg-400/rounded/home-fill.svg";
import { Tabs } from "expo-router";
import { useTranslation } from "react-i18next";
export default function TabsLayout() {
const { t } = useTranslation();
return (
<Tabs
screenOptions={{
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
tabBarLabel: t("navbar.home"),
tabBarIcon: ({ color, size }) => <Icon icon={Home} color={color} size={size} />,
}}
/>
<Tabs.Screen
name="browse"
options={{
tabBarLabel: t("navbar.browse"),
tabBarIcon: ({ color, size }) => <Icon icon={Browse} color={color} size={size} />,
}}
/>
<Tabs.Screen
name="downloads"
options={{
tabBarLabel: t("navbar.download"),
tabBarIcon: ({ color, size }) => <Icon icon={Downloading} color={color} size={size} />,
}}
/>
</Tabs>
);
}

View File

@ -1,23 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { BrowsePage } from "@kyoo/ui";
export default BrowsePage;

View File

@ -1,59 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { ConnectionErrorContext, useAccount } from "@kyoo/models";
import { NavbarRight, NavbarTitle } from "@kyoo/ui";
import { useNetInfo } from "@react-native-community/netinfo";
import { Redirect, Stack } from "expo-router";
import { useContext } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTheme } from "yoshiki/native";
export default function SignGuard() {
const insets = useSafeAreaInsets();
const theme = useTheme();
// TODO: support guest accounts on mobile too.
const account = useAccount();
const { error } = useContext(ConnectionErrorContext);
const netInfo = useNetInfo();
if (error && netInfo.isConnected) return <Redirect href={"/connection-error"} />;
if (!account) return <Redirect href="/server-url" />;
return (
<Stack
screenOptions={{
navigationBarColor: "transparent",
// @ts-expect-error Not yet available. Waiting for expo-router update.
navigationBarTranslucent: true,
headerTitle: () => <NavbarTitle />,
headerRight: () => <NavbarRight />,
contentStyle: {
backgroundColor: theme.background,
paddingLeft: insets.left,
paddingRight: insets.right,
},
headerStyle: {
backgroundColor: theme.accent,
},
headerTintColor: theme.colors.white,
}}
/>
);
}

View File

@ -63,59 +63,6 @@ import { withTranslations } from "../i18n";
const font = Poppins({ weight: ["300", "400", "900"], subsets: ["latin"], display: "swap" });
const GlobalCssTheme = () => {
const theme = useTheme();
return (
<>
<style jsx global>{`
body {
margin: 0px;
padding: 0px;
overflow: "hidden";
background-color: ${theme.background};
font-family: ${font.style.fontFamily};
}
*::-webkit-scrollbar {
height: 6px;
width: 6px;
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: #999;
border-radius: 90px;
}
*:hover::-webkit-scrollbar-thumb {
background-color: rgb(134, 127, 127);
}
#__next {
height: 100vh;
}
.infinite-scroll-component__outerdiv {
width: 100%;
height: 100%;
}
::cue {
background-color: transparent;
text-shadow:
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
1px 1px 0 #000;
}
`}</style>
<WebTooltip theme={theme} />
<SkeletonCss />
<TouchOnlyCss />
<HiddenIfNoJs />
</>
);
};
const YoshikiDebug = ({ children }: { children: JSX.Element }) => {
if (typeof window === "undefined") return children;
const registry = useStyleRegistry();

View File

@ -1,3 +1,6 @@
{
"extends": ["../biome.json"]
"extends": "//",
"files": {
"includes": ["src/**"]
}
}

2269
front/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"cli": {
"version": ">= 3.0.0",
"version": ">= 15.0.10",
"appVersionSource": "remote"
},
"build": {
@ -20,7 +20,8 @@
}
},
"production": {
"channel": "prod"
"channel": "prod",
"autoIncrement": true
}
},
"submit": {

18
front/metro.config.js Normal file
View File

@ -0,0 +1,18 @@
const { getDefaultConfig } = require("expo/metro-config");
module.exports = (() => {
const config = getDefaultConfig(__dirname);
const { transformer, resolver } = config;
config.transformer = {
...transformer,
babelTransformerPath: require.resolve("react-native-svg-transformer/expo"),
};
config.resolver = {
...resolver,
assetExts: resolver.assetExts.filter((ext) => ext !== "svg"),
sourceExts: [...resolver.sourceExts, "svg"],
};
return config;
})();

View File

@ -1,24 +1,68 @@
{
"name": "kyoo",
"private": true,
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"dev": "yarn workspaces foreach -pi run dev",
"web": "yarn workspace web dev",
"mobile": "yarn workspace mobile dev",
"build:web": "yarn workspace web build",
"build:mobile": "yarn workspace mobile build",
"build:mobile:apk": "yarn workspace mobile build:apk",
"build:mobile:dev": "yarn workspace mobile build:dev",
"update": "yarn workspace mobile update",
"dev": "expo start",
"apk": "eas build --profile preview --platform android --non-interactive --json",
"apk:dev": "eas build --profile development --platform android --non-interactive",
"lint": "biome lint .",
"lint:fix": "biome lint . --write",
"format": "biome format .",
"format:fix": "biome format . --write"
},
"workspaces": ["apps/*", "packages/*"],
"devDependencies": {
"@biomejs/biome": "1.8.3",
"typescript": "5.5.4"
"dependencies": {
"@expo/html-elements": "^0.12.5",
"@gorhom/portal": "^1.0.14",
"@legendapp/list": "^1.0.20",
"@material-symbols/svg-400": "^0.31.6",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6",
"@tanstack/react-query": "^5.80.6",
"expo": "~53.0.10",
"expo-build-properties": "^0.14.6",
"expo-image": "^2.3.0",
"expo-linear-gradient": "^14.1.5",
"expo-linking": "~7.1.5",
"expo-localization": "^16.1.5",
"expo-router": "~5.1.0",
"expo-splash-screen": "^0.30.9",
"expo-status-bar": "~2.2.3",
"expo-updates": "~0.28.14",
"i18next-http-backend": "^3.0.2",
"jotai": "^2.12.5",
"react": "19.0.0",
"react-i18next": "^15.5.2",
"react-native": "0.79.3",
"react-native-mmkv": "^3.2.0",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-svg": "15.11.2",
"react-native-video": "^6.15.0",
"react-native-web": "^0.20.0",
"react-tooltip": "^5.29.1",
"sweetalert2": "^11.22.0",
"yoshiki": "1.2.14",
"zod": "^3.25.56"
},
"packageManager": "yarn@3.2.4"
"devDependencies": {
"@biomejs/biome": "2.0.0",
"@tanstack/react-query-devtools": "^5.80.6",
"@types/react": "~19.0.10",
"@types/react-dom": "^19.1.6",
"expo-dev-client": "^5.2.0",
"react-native-svg-transformer": "^1.5.1",
"typescript": "5.8.3"
},
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"listUnknownPackages": false
}
}
}
}

View File

@ -17,108 +17,3 @@
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Platform } from "react-native";
import { MMKV } from "react-native-mmkv";
import { type ZodTypeAny, z } from "zod";
import { type Account, AccountP } from "./accounts";
export const storage = new MMKV();
const readAccounts = () => {
const acc = storage.getString("accounts");
if (!acc) return [];
return z.array(AccountP).parse(JSON.parse(acc));
};
const writeAccounts = (accounts: Account[]) => {
storage.set("accounts", JSON.stringify(accounts));
if (Platform.OS === "web") {
const selected = accounts.find((x) => x.selected);
if (!selected) return;
setCookie("account", selected);
// cookie used for images and videos since we can't add Authorization headers in img or video tags.
setCookie("X-Bearer", selected?.token.access_token);
}
};
export const setCookie = (key: string, val?: unknown) => {
let value = typeof val !== "string" ? JSON.stringify(val) : val;
// Remove illegal values from json. There should not be one in the account anyways.
value = value?.replaceAll(";", "");
const d = new Date();
// A year
d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000);
const expires = value ? `expires=${d.toUTCString()}` : "expires=Thu, 01 Jan 1970 00:00:01 GMT";
document.cookie = `${key}=${value};${expires};path=/;samesite=strict`;
return null;
};
export const readCookie = <T extends ZodTypeAny>(
cookies: string | undefined,
key: string,
parser?: T,
) => {
if (!cookies) return null;
const name = `${key}=`;
const decodedCookie = decodeURIComponent(cookies);
const ca = decodedCookie.split(";");
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === " ") {
c = c.substring(1);
}
if (c.indexOf(name) === 0) {
const str = c.substring(name.length, c.length);
return parser ? parser.parse(JSON.parse(str)) : str;
}
}
return null;
};
export const getCurrentAccount = () => {
const accounts = readAccounts();
return accounts.find((x) => x.selected);
};
export const addAccount = (account: Account) => {
const accounts = readAccounts();
// Prevent the user from adding the same account twice.
if (accounts.find((x) => x.id === account.id)) {
updateAccount(account.id, account);
return;
}
for (const acc of accounts) acc.selected = false;
accounts.push(account);
writeAccounts(accounts);
};
export const removeAccounts = (filter: (acc: Account) => boolean) => {
let accounts = readAccounts();
accounts = accounts.filter((x) => !filter(x));
if (!accounts.find((x) => x.selected) && accounts.length > 0) {
accounts[0].selected = true;
}
writeAccounts(accounts);
};
export const updateAccount = (id: string, account: Account) => {
const accounts = readAccounts();
const idx = accounts.findIndex((x) => x.id === id);
if (idx === -1) return;
const selected = account.selected;
if (selected) {
for (const acc of accounts) acc.selected = false;
// if account was already on the accounts list, we keep it selected.
account.selected = selected;
} else if (accounts[idx].selected) {
// we just unselected the current account, focus another one.
if (accounts.length > 0) accounts[0].selected = true;
}
accounts[idx] = account;
writeAccounts(accounts);
};

View File

@ -38,27 +38,6 @@ import { useFetch } from "./query";
import { ServerInfoP, type User, UserP } from "./resources";
import { zdate } from "./utils";
export const TokenP = z.object({
token_type: z.literal("Bearer"),
access_token: z.string(),
refresh_token: z.string(),
expire_in: z.string(),
expire_at: zdate(),
});
export type Token = z.infer<typeof TokenP>;
export const AccountP = UserP.and(
z.object({
// set it optional for accounts logged in before the kind was present
kind: z.literal("user").optional(),
token: TokenP,
apiUrl: z.string(),
selected: z.boolean(),
}),
);
export type Account = z.infer<typeof AccountP>;
const defaultApiUrl = Platform.OS === "web" ? "/api" : null;
const currentApiUrl = atom<string | null>(defaultApiUrl);
export const getCurrentApiUrl = () => {
const store = getDefaultStore();
@ -72,138 +51,6 @@ export const setSsrApiUrl = () => {
store.set(currentApiUrl, process.env.KYOO_URL ?? "http://localhost:5000");
};
const AccountContext = createContext<(Account & { select: () => void; remove: () => void })[]>([]);
export const ConnectionErrorContext = createContext<{
error: KyooErrors | null;
loading: boolean;
retry?: () => void;
setError: (error: KyooErrors) => void;
}>({ error: null, loading: true, setError: () => {} });
export const AccountProvider = ({
children,
ssrAccount,
ssrError,
}: {
children: ReactNode;
ssrAccount?: Account;
ssrError?: KyooErrors;
}) => {
const setApiUrl = useSetAtom(currentApiUrl);
if (Platform.OS === "web" && typeof window === "undefined") {
const accs = ssrAccount
? [{ ...ssrAccount, selected: true, select: () => {}, remove: () => {} }]
: [];
return (
<AccountContext.Provider value={accs}>
<ConnectionErrorContext.Provider
value={{
error: ssrError || null,
loading: false,
retry: () => {
queryClient.resetQueries({ queryKey: ["auth", "me"] });
},
setError: () => {},
}}
>
{children}
</ConnectionErrorContext.Provider>
</AccountContext.Provider>
);
}
const initialSsrError = useRef(ssrError);
const [accStr] = useMMKVString("accounts");
const acc = accStr ? z.array(AccountP).parse(JSON.parse(accStr)) : null;
const accounts = useMemo(
() =>
acc?.map((account) => ({
...account,
select: () => updateAccount(account.id, { ...account, selected: true }),
remove: () => removeAccounts((x) => x.id === account.id),
})) ?? [],
[acc],
);
// update user's data from kyoo un startup, it could have changed.
const selected = useMemo(() => accounts.find((x) => x.selected), [accounts]);
useEffect(() => {
setApiUrl(selected?.apiUrl ?? defaultApiUrl);
}, [selected, setApiUrl]);
const {
isSuccess: userIsSuccess,
isError: userIsError,
isLoading: userIsLoading,
isPlaceholderData: userIsPlaceholder,
data: user,
error: userError,
} = useFetch({
path: ["auth", "me"],
parser: UserP,
placeholderData: selected as User,
enabled: !!selected,
});
// Use a ref here because we don't want the effect to trigger when the selected
// value has changed, only when the fetch result changed
// If we trigger the effect when the selected value change, we enter an infinite render loop
const selectedRef = useRef(selected);
selectedRef.current = selected;
useEffect(() => {
if (!selectedRef.current || !userIsSuccess || userIsPlaceholder) return;
// The id is different when user is stale data, we need to wait for the use effect to invalidate the query.
if (user.id !== selectedRef.current.id) return;
const nUser = { ...selectedRef.current, ...user };
updateAccount(nUser.id, nUser);
}, [user, userIsSuccess, userIsPlaceholder]);
const queryClient = useQueryClient();
const oldSelected = useRef<{ id: string; token: string } | null>(
selected ? { id: selected.id, token: selected.token.access_token } : null,
);
const [permissionError, setPermissionError] = useState<KyooErrors | null>(null);
useEffect(() => {
// if the user change account (or connect/disconnect), reset query cache.
if (
// biome-ignore lint/suspicious/noDoubleEquals: id can be an id, null or undefined
selected?.id != oldSelected.current?.id ||
(userIsError && selected?.token.access_token !== oldSelected.current?.token)
) {
initialSsrError.current = undefined;
setPermissionError(null);
queryClient.resetQueries();
}
oldSelected.current = selected ? { id: selected.id, token: selected.token.access_token } : null;
// update cookies for ssr (needs to contains token, theme, language...)
if (Platform.OS === "web") {
setCookie("account", selected);
// cookie used for images and videos since we can't add Authorization headers in img or video tags.
setCookie("X-Bearer", selected?.token.access_token);
}
}, [selected, queryClient, userIsError]);
return (
<AccountContext.Provider value={accounts}>
<ConnectionErrorContext.Provider
value={{
error: (selected ? (initialSsrError.current ?? userError) : null) ?? permissionError,
loading: userIsLoading,
retry: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
},
setError: setPermissionError,
}}
>
{children}
</ConnectionErrorContext.Provider>
</AccountContext.Provider>
);
};
export const useAccount = () => {
const acc = useContext(AccountContext);
@ -227,3 +74,4 @@ export const useHasPermission = (perms?: string[]) => {
if (!available) return false;
return perms.every((perm) => available.includes(perm));
};

View File

@ -21,12 +21,6 @@
export * from "./accounts";
export { storage } from "./account-internal";
export * from "./theme";
export * from "./resources";
export * from "./traits";
export * from "./page";
export * from "./kyoo-errors";
export * from "./utils";
export * from "./login";
export * from "./issue";
export * from "./query";

View File

@ -1,33 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* The list of errors that where made in the request.
*/
export interface KyooErrors {
/**
* The list of errors that where made in the request.
*
* @example `["InvalidFilter: no field 'startYear' on a collection"]`
*/
errors: string[];
status?: number | "aborted" | "parse" | "json";
}

View File

@ -1,66 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { z } from "zod";
/**
* A page of resource that contains information about the pagination of resources.
*/
export interface Page<T> {
/**
* The link of the current page.
*
* @format uri
*/
this: string;
/**
* The link of the first page.
*
* @format uri
*/
first: string;
/**
* The link of the next page.
*
* @format uri
*/
next: string | null;
/**
* The number of items in the current page.
*/
count: number;
/**
* The list of items in the page.
*/
items: T[];
}
export const Paged = <Item>(item: z.ZodType<Item>): z.ZodSchema<Page<Item>> =>
z.object({
this: z.string(),
first: z.string(),
next: z.string().nullable(),
count: z.number(),
items: z.array(item),
});

View File

@ -1,282 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import {
QueryClient,
type QueryFunctionContext,
useInfiniteQuery,
useQuery,
} from "@tanstack/react-query";
import type { ComponentType, ReactElement } from "react";
import type { z } from "zod";
import { getCurrentApiUrl } from ".";
import type { KyooErrors } from "./kyoo-errors";
import { getToken, getTokenWJ } from "./login";
import { type Page, Paged } from "./page";
export let lastUsedUrl: string = null!;
const cleanSlash = (str: string | null, keepFirst = false) => {
if (!str) return null;
if (keepFirst) return str.replace(/\/$/g, "");
return str.replace(/^\/|\/$/g, "");
};
export const queryFn = async <Parser extends z.ZodTypeAny>(
context: {
apiUrl?: string | null;
authenticated?: boolean;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
} & (
| QueryFunctionContext
| ({
path: (string | false | undefined | null)[];
body?: object;
formData?: FormData;
plainText?: boolean;
} & Partial<QueryFunctionContext>)
),
type?: Parser,
iToken?: string | null,
): Promise<z.infer<Parser>> => {
const url = context.apiUrl && context.apiUrl.length > 0 ? context.apiUrl : getCurrentApiUrl();
lastUsedUrl = url!;
const token = iToken === undefined && context.authenticated !== false ? await getToken() : iToken;
const path = [cleanSlash(url, true)]
.concat(
"path" in context
? (context.path as string[])
: "pageParam" in context && context.pageParam
? [cleanSlash(context.pageParam as string)]
: (context.queryKey as string[]),
)
.filter((x) => x)
.join("/")
.replace("/?", "?");
let resp: Response;
try {
resp = await fetch(path, {
method: context.method,
body:
"body" in context && context.body
? JSON.stringify(context.body)
: "formData" in context && context.formData
? context.formData
: undefined,
headers: {
...(token ? { Authorization: token } : {}),
...("body" in context ? { "Content-Type": "application/json" } : {}),
},
signal: context.signal,
});
} catch (e) {
if (typeof e === "object" && e && "name" in e && e.name === "AbortError")
throw { errors: ["Aborted"] } as KyooErrors;
console.log("Fetch error", e, path);
throw { errors: ["Could not reach Kyoo's server."], status: "aborted" } as KyooErrors;
}
if (resp.status === 404) {
throw { errors: ["Resource not found."], status: 404 } as KyooErrors;
}
// If we got a forbidden, try to refresh the token
// if we got a token as an argument, it either means we already retried or we go one provided that's fresh
// so we can't retry either ways.
if (resp.status === 403 && iToken === undefined && token) {
const [newToken, _, error] = await getTokenWJ(undefined, true);
if (newToken) return await queryFn(context, type, newToken);
console.error("refresh error while retrying a forbidden", error);
}
if (!resp.ok) {
const error = await resp.text();
let data: Record<string, any>;
try {
data = JSON.parse(error);
} catch (e) {
data = { errors: [error] } as KyooErrors;
}
data.status = resp.status;
console.trace(
`Invalid response (${
"method" in context && context.method ? context.method : "GET"
} ${path}):`,
data,
resp.status,
);
throw data as KyooErrors;
}
if (resp.status === 204) return null;
if ("plainText" in context && context.plainText) return (await resp.text()) as unknown;
let data: Record<string, any>;
try {
data = await resp.json();
} catch (e) {
console.error("Invalid json from kyoo", e);
throw { errors: ["Invalid response from kyoo"], status: "json" };
}
if (!type) return data;
const parsed = await type.safeParseAsync(data);
if (!parsed.success) {
console.log("Path: ", path, " Response: ", resp.status, " Parse error: ", parsed.error);
throw {
errors: [
"Invalid response from kyoo. Possible version mismatch between the server and the application.",
],
status: "parse",
} as KyooErrors;
}
return parsed.data;
};
export type MutationParam = {
params?: Record<string, number | string>;
body?: object;
path: string[];
method: "POST" | "DELETE";
};
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// 5min
staleTime: 300_000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
mutations: {
mutationFn: (({ method, path, body, params }: MutationParam) => {
return queryFn({
method,
path: toQueryKey({ path, params }),
body,
});
}) as any,
},
},
});
export type QueryIdentifier<T = unknown, Ret = T> = {
parser: z.ZodType<T, z.ZodTypeDef, any>;
path: (string | undefined)[];
params?: { [query: string]: boolean | number | string | string[] | undefined };
infinite?: boolean | { value: true; map?: (x: any[]) => Ret[] };
placeholderData?: T | (() => T);
enabled?: boolean;
options?: Partial<Parameters<typeof queryFn>[0]>;
};
export type QueryPage<Props = {}, Items = unknown> = ComponentType<
Props & { randomItems: Items[] }
> & {
getFetchUrls?: (route: { [key: string]: string }, randomItems: Items[]) => QueryIdentifier<any>[];
getLayout?:
| QueryPage<{ page: ReactElement }>
| { Layout: QueryPage<{ page: ReactElement }>; props: object };
requiredPermissions?: string[];
randomItems?: Items[];
isPublic?: boolean;
};
export const toQueryKey = (query: {
path: (string | undefined)[];
params?: { [query: string]: boolean | number | string | string[] | undefined };
options?: { apiUrl?: string | null };
}) => {
return [
query.options?.apiUrl,
...query.path,
query.params
? `?${Object.entries(query.params)
.filter(([_, v]) => v !== undefined)
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`)
.join("&")}`
: null,
].filter((x) => x);
};
export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
return useQuery<Data, KyooErrors>({
queryKey: toQueryKey(query),
queryFn: (ctx) =>
queryFn(
{
...ctx,
queryKey: toQueryKey({ ...query, options: {} }),
...query.options,
},
query.parser,
),
placeholderData: query.placeholderData as any,
enabled: query.enabled,
});
};
export const useInfiniteFetch = <Data, Ret>(query: QueryIdentifier<Data, Ret>) => {
const ret = useInfiniteQuery<Page<Data>, KyooErrors>({
queryKey: toQueryKey(query),
queryFn: (ctx) =>
queryFn(
{ ...ctx, queryKey: toQueryKey({ ...query, options: {} }), ...query.options },
Paged(query.parser),
),
getNextPageParam: (page: Page<Data>) => page?.next || undefined,
initialPageParam: undefined,
placeholderData: query.placeholderData as any,
enabled: query.enabled,
});
const items = ret.data?.pages.flatMap((x) => x.items);
return {
...ret,
items:
items && typeof query.infinite === "object" && query.infinite.map
? query.infinite.map(items)
: (items as unknown as Ret[] | undefined),
};
};
export const fetchQuery = async (queries: QueryIdentifier[], authToken?: string | null) => {
// we can't put this check in a function because we want build time optimizations
// see https://github.com/vercel/next.js/issues/5354 for details
if (typeof window !== "undefined") return null;
const client = createQueryClient();
await Promise.all(
queries.map((query) => {
if (query.infinite) {
return client.prefetchInfiniteQuery({
queryKey: toQueryKey(query),
queryFn: (ctx) => queryFn(ctx, Paged(query.parser), authToken),
initialPageParam: undefined,
});
}
return client.prefetchQuery({
queryKey: toQueryKey(query),
queryFn: (ctx) => queryFn(ctx, query.parser, authToken),
});
}),
);
return client;
};

View File

@ -1,44 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { z } from "zod";
import { ImagesP, ResourceP } from "../traits";
export const CollectionP = ResourceP("collection")
.merge(ImagesP)
.extend({
/**
* The title of this collection.
*/
name: z.string(),
/**
* The summary of this show.
*/
overview: z.string().nullable(),
})
.transform((x) => ({
...x,
href: `/collection/${x.slug}`,
}));
/**
* A class representing collections of show or movies.
*/
export type Collection = z.infer<typeof CollectionP>;

View File

@ -1,46 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
export enum Genre {
Action = "Action",
Adventure = "Adventure",
Animation = "Animation",
Comedy = "Comedy",
Crime = "Crime",
Documentary = "Documentary",
Drama = "Drama",
Family = "Family",
Fantasy = "Fantasy",
History = "History",
Horror = "Horror",
Music = "Music",
Mystery = "Mystery",
Romance = "Romance",
ScienceFiction = "ScienceFiction",
Thriller = "Thriller",
War = "War",
Western = "Western",
Kids = "Kids",
News = "News",
Reality = "Reality",
Soap = "Soap",
Talk = "Talk",
Politics = "Politics",
}

View File

@ -1,35 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
export * from "./library-item";
export * from "./news";
export * from "./show";
export * from "./movie";
export * from "./collection";
export * from "./genre";
export * from "./person";
export * from "./studio";
export * from "./episode";
export * from "./season";
export * from "./watch-info";
export * from "./watch-status";
export * from "./watchlist";
export * from "./user";
export * from "./server-info";

View File

@ -1,44 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { z } from "zod";
import { CollectionP } from "./collection";
import { MovieP } from "./movie";
import { ShowP } from "./show";
export const LibraryItemP = z.union([
/*
* Either a Show
*/
ShowP,
/*
* Or a Movie
*/
MovieP,
/*
* Or a Collection
*/
CollectionP,
]);
/**
* An item that can be contained by a Library (so a Show, a Movie or a Collection).
*/
export type LibraryItem = z.infer<typeof LibraryItemP>;

View File

@ -1,41 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { z } from "zod";
export const MetadataP = z.preprocess(
(x) =>
typeof x === "object" && x ? Object.fromEntries(Object.entries(x).filter(([_, v]) => v)) : x,
z.record(
z.object({
/*
* The ID of the resource on the external provider.
*/
dataId: z.string(),
/*
* The URL of the resource on the external provider.
*/
link: z.string().nullable(),
}),
),
);
export type Metadata = z.infer<typeof MetadataP>;

Some files were not shown because too many files have changed in this diff Show More