diff --git a/README.md b/README.md index 89024f99..be01ad17 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Kyoo does not have a plugin system and aim to have every features built-in (see - **Video Preview Thumbnails:** Simply hover the video's progress bar and see a preview of the video. +- **Intro/Credit detection:** Automatically detect intro/credits with audio fingerprinting (or chapter title matching). + - **Enhanced Subtitle Support:** Subtitles are important, Kyoo supports PGS/VODSUB and SSA/ASS and uses the video's embedded fonts when available. - **Anime Name Parsing**: Kyoo will match weird anime names (like `[Some-Stuffs] Jojo's Bizarre Adventure Stone Ocean 24 (1920x1080 Blu-Ray Opus) [2750810F].mkv`) without issue. diff --git a/api/src/controllers/video-metadata.ts b/api/src/controllers/video-metadata.ts index c4837b52..96430bf3 100644 --- a/api/src/controllers/video-metadata.ts +++ b/api/src/controllers/video-metadata.ts @@ -1,12 +1,16 @@ -import { eq } from "drizzle-orm"; +import { getLogger } from "@logtape/logtape"; +import { and, eq } from "drizzle-orm"; import { Elysia, t } from "elysia"; import slugify from "slugify"; import { auth } from "~/auth"; import { db } from "~/db"; -import { entryVideoJoin, videos } from "~/db/schema"; +import { entries, entryVideoJoin, videos } from "~/db/schema"; import { KError } from "~/models/error"; import { isUuid } from "~/models/utils"; import { Video } from "~/models/video"; +import { toQueryStr } from "~/utils"; + +const logger = getLogger(); export const videosMetadata = new Elysia({ prefix: "/videos", @@ -188,4 +192,85 @@ export const videosMetadata = new Elysia({ }, }, }, + ) + .get( + ":id/prepare", + async ({ params: { id }, headers: { authorization }, status }) => { + const ret = await prepareVideo(id, authorization!); + if ("kyoo" in ret) return status(ret.status, ret as any); + return { status: ret.status, body: await ret.json() }; + }, + { + detail: { description: "Prepare a video for playback" }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the video to watch.", + example: "made-in-abyss-s1e13", + }), + }), + response: { + 200: t.Any({ + description: + "Prepare said video for playback (compute everything possible and cache it)", + }), + 404: { + ...KError, + description: "No video found with the given id or slug.", + }, + }, + }, ); + +export const prepareVideo = async (slug: string, auth: string) => { + const [vid] = await db + .select({ path: videos.path, show: entries.showPk, order: entries.order }) + .from(videos) + .innerJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk)) + .leftJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) + .where(eq(entryVideoJoin.slug, slug)) + .limit(1); + + if (!vid) { + return { + kyoo: true, + status: 404, + message: `No video found with slug ${slug}`, + } as const; + } + + const related = vid.show + ? await db + .select({ order: entries.order, path: videos.path }) + .from(entries) + .innerJoin(entryVideoJoin, eq(entries.pk, entryVideoJoin.entryPk)) + .innerJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) + .where(and(eq(entries.showPk, vid.show), eq(entries.kind, "episode"))) + .orderBy(entries.order) + : []; + const idx = related.findIndex((x) => x.order === vid.order); + const prev = related[idx - 1]?.path; + const next = related[idx + 1]?.path; + + logger.info("Preparing next video {slug} (near episodes: {near})", { + slug, + near: [prev, next], + }); + + const path = Buffer.from(vid.path, "utf8").toString("base64url"); + const params = { + prev: prev ? Buffer.from(prev, "utf8").toString("base64url") : null, + next: next ? Buffer.from(next, "utf8").toString("base64url") : null, + }; + return await fetch( + new URL( + `/video/${path}/prepare${toQueryStr(params)}`, + process.env.TRANSCODER_SERVER ?? "http://transcoder:7666", + ), + { + headers: { + authorization: auth, + "content-type": "application/json", + }, + }, + ); +}; diff --git a/api/src/otel.ts b/api/src/otel.ts index 53b56c15..3f2df8de 100644 --- a/api/src/otel.ts +++ b/api/src/otel.ts @@ -19,6 +19,7 @@ import type { LogRecordExporter } from "@opentelemetry/sdk-logs"; import { BatchLogRecordProcessor, LoggerProvider, + type LogRecordProcessor, } from "@opentelemetry/sdk-logs"; import { MeterProvider, @@ -45,6 +46,20 @@ const resource = resourceFromAttributes({ const logger = getLogger(); +// all logs in kyoo are in uppercase by default, also make it uppercase here. +function upperCaseSeverityTextProcessor(): LogRecordProcessor { + return { + onEmit(logRecord) { + const record = logRecord as { severityText?: string }; + if (typeof record.severityText === "string") { + record.severityText = record.severityText.toUpperCase(); + } + }, + forceFlush: async () => {}, + shutdown: async () => {}, + }; +} + export function setupOtel() { logger.info("Configuring OTEL"); const protocol = ( @@ -99,7 +114,10 @@ export function setupOtel() { if (le) { lp = new LoggerProvider({ resource, - processors: [new BatchLogRecordProcessor(le)], + processors: [ + upperCaseSeverityTextProcessor(), + new BatchLogRecordProcessor(le), + ], }); } diff --git a/api/src/websockets.ts b/api/src/websockets.ts index d41792dc..7b45016e 100644 --- a/api/src/websockets.ts +++ b/api/src/websockets.ts @@ -5,6 +5,7 @@ import Elysia, { type TSchema, t } from "elysia"; import { auth } from "./auth"; import { updateProgress } from "./controllers/profiles/history"; import { getOrCreateProfile } from "./controllers/profiles/profile"; +import { prepareVideo } from "./controllers/video-metadata"; import { getVideos } from "./controllers/videos"; import { videos } from "./db/schema"; @@ -61,23 +62,14 @@ const actionMap = { languages: ["*"], userId: ws.data.jwt.sub, }); - if (!vid) return; - - logger.info("Preparing next video {videoId}", { - videoId: vid.id, - }); - const path = Buffer.from(vid.path, "utf8").toString("base64url"); - await fetch( - new URL( - `/video/${path}/prepare`, - process.env.TRANSCODER_SERVER ?? "http://transcoder:7666", - ), - { - headers: { - authorization: ws.data.headers.authorization!, - }, - }, - ); + const next = vid?.next?.video; + if (!next) { + logger.info("No next video to prepare for ${slug}", { + slug: vid.path, + }); + return; + } + await prepareVideo(next, ws.data.headers.authorization!); } }, }), diff --git a/front/public/translations/en.json b/front/public/translations/en.json index ce6de080..ba92ce8b 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -188,6 +188,27 @@ "label": "Subtitle language", "description": "The default subtitle language used", "none": "None" + }, + "chapterSkip": { + "label": "Chapter skip", + "behaviors": { + "autoSkip": "Auto skip", + "autoSkipExceptFirstAppearance": "Auto skip except first appearance", + "showSkipButton": "Show skip button", + "disabled": "Do nothing" + }, + "types": { + "recap": "Recap", + "intro": "Intro", + "credits": "Credits", + "preview": "Preview" + }, + "descriptions": { + "recap": "Control what happens when a recap chapter starts", + "intro": "Control what happens when an intro chapter starts", + "credits": "Control what happens when a credits chapter starts", + "preview": "Control what happens when a preview chapter starts" + } } }, "account": { @@ -256,7 +277,14 @@ "unsupportedError": "Video codec not supported, transcoding in progress...", "not-available": "{{entry}} is not available on kyoo yet, ask your server admins about it", "fatal": "Fatal playback error", - "entry-list": "Entry list" + "entry-list": "Entry list", + "chapters": { + "skip": "Skip {{type}}", + "intro": "intro", + "credits": "credits", + "recap": "recap", + "preview": "preview" + } }, "search": { "empty": "No result found. Try a different query." diff --git a/front/shell.nix b/front/shell.nix index 10ab171c..ac392a0a 100644 --- a/front/shell.nix +++ b/front/shell.nix @@ -3,7 +3,7 @@ pkgs.mkShell { packages = with pkgs; [ bun biome - nodePackages.eas-cli + eas-cli ]; } diff --git a/front/src/models/user.ts b/front/src/models/user.ts index eb3c68f9..daeb1049 100644 --- a/front/src/models/user.ts +++ b/front/src/models/user.ts @@ -1,5 +1,14 @@ import { z } from "zod/v4"; +const ChapterSkipBehavior = z + .enum([ + "autoSkip", + "autoSkipExceptFirstAppearance", + "showSkipButton", + "disabled", + ]) + .catch("showSkipButton"); + export const User = z .object({ id: z.string(), @@ -28,11 +37,30 @@ export const User = z .catch("original"), audioLanguage: z.string().catch("default"), subtitleLanguage: z.string().nullable().catch(null), + chapterSkip: z + .object({ + recap: ChapterSkipBehavior, + intro: ChapterSkipBehavior, + credits: ChapterSkipBehavior, + preview: ChapterSkipBehavior, + }) + .catch({ + recap: "showSkipButton", + intro: "showSkipButton", + credits: "showSkipButton", + preview: "showSkipButton", + }), }) .default({ downloadQuality: "original", audioLanguage: "default", subtitleLanguage: null, + chapterSkip: { + recap: "showSkipButton", + intro: "showSkipButton", + credits: "showSkipButton", + preview: "showSkipButton", + }, }), }), oidc: z diff --git a/front/src/models/video-info.ts b/front/src/models/video-info.ts index 80e0466d..ce7edd03 100644 --- a/front/src/models/video-info.ts +++ b/front/src/models/video-info.ts @@ -65,6 +65,8 @@ export const Chapter = z.object({ endTime: z.number(), name: z.string(), type: z.enum(["content", "recap", "intro", "credits", "preview"]), + firstAppearance: z.boolean().optional(), + matchAccuracy: z.number().optional(), }); export type Chapter = z.infer; diff --git a/front/src/ui/player/controls/index.tsx b/front/src/ui/player/controls/index.tsx index e9cda2b7..6d60e8e4 100644 --- a/front/src/ui/player/controls/index.tsx +++ b/front/src/ui/player/controls/index.tsx @@ -7,6 +7,7 @@ import { useIsTouch } from "~/primitives"; import { Back } from "./back"; import { BottomControls } from "./bottom-controls"; import { MiddleControls } from "./middle-controls"; +import { SkipChapterButton } from "./skip-chapter"; import { TouchControls } from "./touch"; export const Controls = ({ @@ -20,6 +21,7 @@ export const Controls = ({ chapters, playPrev, playNext, + seekEnd, onOpenEntriesMenu, forceShow, }: { @@ -33,6 +35,7 @@ export const Controls = ({ chapters: Chapter[]; playPrev: (() => boolean) | null; playNext: (() => boolean) | null; + seekEnd: () => void; onOpenEntriesMenu?: () => void; forceShow?: boolean; }) => { @@ -40,6 +43,7 @@ export const Controls = ({ const [hover, setHover] = useState(false); const [menuOpened, setMenuOpened] = useState(false); + const [controlsVisible, setControlsVisible] = useState(false); const hoverControls = { onPointerEnter: (e) => { @@ -61,6 +65,7 @@ export const Controls = ({ + ); }; diff --git a/front/src/ui/player/controls/skip-chapter.tsx b/front/src/ui/player/controls/skip-chapter.tsx new file mode 100644 index 00000000..a316f075 --- /dev/null +++ b/front/src/ui/player/controls/skip-chapter.tsx @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useEvent, type VideoPlayer } from "react-native-video"; +import type { Chapter } from "~/models"; +import { Button } from "~/primitives"; +import { useAccount } from "~/providers/account-context"; +import { useFetch } from "~/query"; +import { Info } from "~/ui/info"; +import { cn, useQueryState } from "~/utils"; + +export const SkipChapterButton = ({ + player, + seekEnd, + chapters, + isVisible, +}: { + player: VideoPlayer; + seekEnd: () => void; + chapters: Chapter[]; + isVisible: boolean; +}) => { + const { t } = useTranslation(); + const account = useAccount(); + const [slug] = useQueryState("slug", undefined!); + const { data } = useFetch(Info.infoQuery(slug)); + const lastAutoSkippedChapter = useRef(null); + + const [progress, setProgress] = useState(player.currentTime || 0); + useEvent(player, "onProgress", ({ currentTime }) => { + setProgress(currentTime); + }); + + const chapter = chapters.find( + (chapter) => chapter.startTime <= progress && progress < chapter.endTime, + ); + + const behavior = + (chapter && + chapter.type !== "content" && + account?.claims.settings.chapterSkip[chapter.type]) || + "showSkipButton"; + const shouldAutoSkip = + behavior === "autoSkip" || + (behavior === "autoSkipExceptFirstAppearance" && !chapter!.firstAppearance); + + // delay credits appearance by a few seconds, we want to make sure it doesn't + // show on top of the end of the serie. it's common for the end credits music + // to start playing on top of the episode also. + const start = chapter + ? chapter.startTime + +(chapter.type === "credits") * 4 + : Infinity; + + const skipChapter = useCallback(() => { + if (!chapter) return; + if (data?.durationSeconds && data.durationSeconds <= chapter.endTime + 3) { + return seekEnd(); + } + player.seekTo(chapter.endTime); + }, [player, chapter, data?.durationSeconds, seekEnd]); + + useEffect(() => { + if ( + chapter && + shouldAutoSkip && + progress >= start && + lastAutoSkippedChapter.current !== chapter.startTime + ) { + lastAutoSkippedChapter.current = chapter.startTime; + skipChapter(); + } + }, [chapter, progress, shouldAutoSkip, start, skipChapter]); + + if (!chapter || chapter.type === "content" || behavior === "disabled") + return null; + if (!isVisible && progress >= start + 8) return null; + + return ( +