From 896d8f5cd0a4bc1dfd6d4eefb6cd561237c61a01 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 18 Dec 2025 20:37:15 +0100 Subject: [PATCH 1/5] Move websocket auth to keibi --- api/src/auth.ts | 2 +- api/src/websockets.ts | 51 ++++--------------------------------------- auth/jwt.go | 14 +++++++++--- 3 files changed, 16 insertions(+), 51 deletions(-) diff --git a/api/src/auth.ts b/api/src/auth.ts index 101a5dc0..c5c28728 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -33,7 +33,7 @@ const Jwt = t.Object({ type Jwt = typeof Jwt.static; const validator = TypeCompiler.Compile(Jwt); -export async function verifyJwt(bearer: string) { +async function verifyJwt(bearer: string) { // @ts-expect-error ts can't understand that there's two overload idk why const { payload } = await jwtVerify(bearer, jwtSecret ?? jwks, { issuer: process.env.JWT_ISSUER, diff --git a/api/src/websockets.ts b/api/src/websockets.ts index 10da619c..d244b7fb 100644 --- a/api/src/websockets.ts +++ b/api/src/websockets.ts @@ -1,6 +1,6 @@ import type { TObject, TString } from "@sinclair/typebox"; import Elysia, { type TSchema, t } from "elysia"; -import { verifyJwt } from "./auth"; +import { auth } from "./auth"; import { updateProgress } from "./controllers/profiles/history"; import { getOrCreateProfile } from "./controllers/profiles/profile"; import { SeedHistory } from "./models/history"; @@ -8,7 +8,7 @@ import { SeedHistory } from "./models/history"; const actionMap = { ping: handler({ message(ws) { - ws.send({ response: "pong" }); + ws.send({ action: "ping", response: "pong" }); }, }), watch: handler({ @@ -20,55 +20,12 @@ const actionMap = { const ret = await updateProgress(profilePk, [ { ...body, playedDate: null }, ]); - ws.send(ret); + ws.send({ action: "watch", ...ret }); }, }), }; -const baseWs = new Elysia() - .guard({ - headers: t.Object( - { - authorization: t.Optional(t.TemplateLiteral("Bearer ${string}")), - "Sec-WebSocket-Protocol": t.Optional( - t.Array( - t.Union([t.Literal("kyoo"), t.TemplateLiteral("Bearer ${string}")]), - ), - ), - }, - { additionalProperties: true }, - ), - }) - .resolve( - async ({ - headers: { authorization, "Sec-WebSocket-Protocol": wsProtocol }, - status, - }) => { - const auth = - authorization ?? - (wsProtocol?.length === 2 && - wsProtocol[0] === "kyoo" && - wsProtocol[1].startsWith("Bearer ") - ? wsProtocol[1] - : null); - const bearer = auth?.slice(7); - if (!bearer) { - return status(403, { - status: 403, - message: "No authorization header was found.", - }); - } - try { - return await verifyJwt(bearer); - } catch (err) { - return status(403, { - status: 403, - message: "Invalid jwt. Verification vailed", - details: err, - }); - } - }, - ); +const baseWs = new Elysia().use(auth); export const appWs = baseWs.ws("/ws", { body: t.Union( diff --git a/auth/jwt.go b/auth/jwt.go index 4337bca9..ab72be8c 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -44,9 +44,17 @@ func (h *Handler) CreateJwt(c echo.Context) error { var token string if auth == "" { - c, _ := c.Request().Cookie("X-Bearer") - if c != nil { - token = c.Value + cookie, _ := c.Request().Cookie("X-Bearer") + if cookie != nil { + token = cookie.Value + } else { + protocol, ok := c.Request().Header["Sec-Websocket-Protocol"] + if ok && + len(protocol) == 2 && + protocol[0] == "kyoo" && + strings.HasPrefix(protocol[1], "Bearer") { + token = protocol[1][len("Bearer "):] + } } } else if strings.HasPrefix(auth, "Bearer ") { token = auth[len("Bearer "):] From ac6478fee37677da1a822ab699696e0c0f461ac5 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 18 Dec 2025 20:37:44 +0100 Subject: [PATCH 2/5] Rework progress observer to use websockets --- front/bun.lock | 4 + front/package.json | 1 + front/src/query/websockets.ts | 28 ++++++ front/src/ui/player/index.tsx | 6 ++ .../ui/player/old/watch-status-observer.tsx | 86 ------------------- front/src/ui/player/progress-observer.ts | 36 ++++++++ 6 files changed, 75 insertions(+), 86 deletions(-) create mode 100644 front/src/query/websockets.ts delete mode 100644 front/src/ui/player/old/watch-status-observer.tsx create mode 100644 front/src/ui/player/progress-observer.ts diff --git a/front/bun.lock b/front/bun.lock index 4f50bdb6..d45cd5b9 100644 --- a/front/bun.lock +++ b/front/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "kyoo", @@ -49,6 +50,7 @@ "react-native-web": "^0.21.2", "react-native-worklets": "0.5.1", "react-tooltip": "^5.29.1", + "react-use-websocket": "^4.13.0", "sweetalert2": "^11.26.3", "tsx": "^4.20.6", "uuid": "^13.0.0", @@ -1352,6 +1354,8 @@ "react-tooltip": ["react-tooltip@5.30.0", "", { "dependencies": { "@floating-ui/dom": "^1.6.1", "classnames": "^2.3.0" }, "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0" } }, "sha512-Yn8PfbgQ/wmqnL7oBpz1QiDaLKrzZMdSUUdk7nVeGTwzbxCAJiJzR4VSYW+eIO42F1INt57sPUmpgKv0KwJKtg=="], + "react-use-websocket": ["react-use-websocket@4.13.0", "", {}, "sha512-anMuVoV//g2N76Wxqvqjjo1X48r9Np3y1/gMl7arX84tAPXdy5R7sB5lO5hvCzQRYjqXwV8XMAiEBOUbyrZFrw=="], + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], diff --git a/front/package.json b/front/package.json index 271181b8..8e8091a5 100644 --- a/front/package.json +++ b/front/package.json @@ -60,6 +60,7 @@ "react-native-web": "^0.21.2", "react-native-worklets": "0.5.1", "react-tooltip": "^5.29.1", + "react-use-websocket": "^4.13.0", "sweetalert2": "^11.26.3", "tsx": "^4.20.6", "uuid": "^13.0.0", diff --git a/front/src/query/websockets.ts b/front/src/query/websockets.ts new file mode 100644 index 00000000..1c242af0 --- /dev/null +++ b/front/src/query/websockets.ts @@ -0,0 +1,28 @@ +import { useEffect } from "react"; +import useWebSocket from "react-use-websocket"; +import { useToken } from "~/providers/account-context"; + +export const useWebsockets = ({ + filterActions, +}: { + filterActions: string[]; +}) => { + const { apiUrl, authToken } = useToken(); + const ret = useWebSocket(`${apiUrl}/api/ws`, { + protocols: authToken ? ["kyoo", `Bearer ${authToken}`] : undefined, + filter: (msg) => filterActions.includes(msg.data.action), + share: true, + retryOnError: true, + heartbeat: { + message: `{ "action": "ping" }`, + returnMessage: `{ "response": "pong" }`, + interval: 25_000, + }, + }); + + useEffect(() => { + console.log(ret.readyState); + }, [ret.readyState]); + + return ret; +}; diff --git a/front/src/ui/player/index.tsx b/front/src/ui/player/index.tsx index eb703c69..cf0d9058 100644 --- a/front/src/ui/player/index.tsx +++ b/front/src/ui/player/index.tsx @@ -21,6 +21,7 @@ import { toggleFullscreen } from "./controls/misc"; import { PlayModeContext } from "./controls/tracks-menu"; import { useKeyboard } from "./keyboard"; import { enhanceSubtitles } from "./subtitles"; +import { useProgressObserver } from "./progress-observer"; const clientId = uuidv4(); @@ -113,6 +114,11 @@ export const Player = () => { return true; }, [data?.next, setSlug, setStart]); + useProgressObserver( + player, + data && entry ? { videoId: data.id, entryId: entry.id } : null, + ); + useEvent(player, "onEnd", () => { const hasNext = playNext(); if (!hasNext && data?.show) router.navigate(data.show.href); diff --git a/front/src/ui/player/old/watch-status-observer.tsx b/front/src/ui/player/old/watch-status-observer.tsx deleted file mode 100644 index 926b2068..00000000 --- a/front/src/ui/player/old/watch-status-observer.tsx +++ /dev/null @@ -1,86 +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 . - */ - -import { type MutationParam, useAccount, WatchStatusV } from "@kyoo/models"; -import { useMutation } from "@tanstack/react-query"; -import { useAtomValue } from "jotai"; -import { useAtomCallback } from "jotai/utils"; -import { useCallback, useEffect } from "react"; -import { playAtom, progressAtom } from "./old/statee"; - -export const WatchStatusObserver = ({ - type, - slug, - duration, -}: { - type: "episode" | "movie"; - slug: string; - duration: number; -}) => { - const account = useAccount(); - // const queryClient = useQueryClient(); - const { mutate: _mutate } = useMutation({ - mutationKey: [type, slug, "watchStatus"], - // onSettled: async () => - // await queryClient.invalidateQueries({ queryKey: [type === "episode" ? "show" : type, slug] }), - }); - const mutate = useCallback( - (type: string, slug: string, seconds: number) => { - if (seconds < 0 || duration <= 0) return; - _mutate({ - method: "POST", - path: [type, slug, "watchStatus"], - params: { - status: WatchStatusV.Watching, - watchedTime: Math.round(seconds), - percent: Math.round((seconds / duration) * 100), - }, - }); - }, - [_mutate, duration], - ); - const readProgress = useAtomCallback( - useCallback((get) => { - const currCount = get(progressAtom); - return currCount; - }, []), - ); - - // update watch status every 10 seconds and on unmount. - useEffect(() => { - if (!account) return; - const timer = setInterval(() => { - mutate(type, slug, readProgress()); - }, 10_000); - return () => { - clearInterval(timer); - mutate(type, slug, readProgress()); - }; - }, [account, type, slug, readProgress, mutate]); - - // update watch status when play status change (and on mount). - const isPlaying = useAtomValue(playAtom); - // biome-ignore lint/correctness/useExhaustiveDependencies: Include isPlaying - useEffect(() => { - if (!account) return; - mutate(type, slug, readProgress()); - }, [account, type, slug, isPlaying, readProgress, mutate]); - return null; -}; diff --git a/front/src/ui/player/progress-observer.ts b/front/src/ui/player/progress-observer.ts new file mode 100644 index 00000000..e0926956 --- /dev/null +++ b/front/src/ui/player/progress-observer.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect } from "react"; +import { useEvent, type VideoPlayer } from "react-native-video"; +import { useWebsockets } from "~/query/websockets"; + +export const useProgressObserver = ( + player: VideoPlayer, + ids: { videoId: string; entryId: string } | null, +) => { + const { sendJsonMessage } = useWebsockets({ + filterActions: ["watch"], + }); + + const updateProgress = useCallback(() => { + if ( + ids === null || + Number.isNaN(player.currentTime) || + Number.isNaN(player.duration) || + !player.isPlaying + ) + return; + sendJsonMessage({ + action: "watch", + entry: ids.entryId, + videoId: ids.videoId, + percent: Math.round((player.currentTime / player.duration) * 100), + time: Math.round(player.currentTime), + }); + }, [player, ids, sendJsonMessage]); + + useEffect(() => { + const interval = setInterval(updateProgress, 5000); + return () => clearInterval(interval); + }, [updateProgress]); + + useEvent(player, "onPlaybackStateChange", updateProgress); +}; From b03cb757f4bb3a0b4ed108df02c63639475e7de9 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 19 Dec 2025 17:28:06 +0100 Subject: [PATCH 3/5] Fix watch ws api --- api/shell.nix | 1 + api/src/websockets.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/api/shell.nix b/api/shell.nix index 36ee641a..5b0e12ee 100644 --- a/api/shell.nix +++ b/api/shell.nix @@ -3,6 +3,7 @@ pkgs.mkShell { packages = with pkgs; [ bun biome + websocat # for psql to debug from the cli postgresql_18 # to build libvips (for sharp) diff --git a/api/src/websockets.ts b/api/src/websockets.ts index d244b7fb..dbc76a0c 100644 --- a/api/src/websockets.ts +++ b/api/src/websockets.ts @@ -3,7 +3,6 @@ import Elysia, { type TSchema, t } from "elysia"; import { auth } from "./auth"; import { updateProgress } from "./controllers/profiles/history"; import { getOrCreateProfile } from "./controllers/profiles/profile"; -import { SeedHistory } from "./models/history"; const actionMap = { ping: handler({ @@ -12,7 +11,18 @@ const actionMap = { }, }), watch: handler({ - body: t.Omit(SeedHistory, ["playedDate"]), + body: t.Object({ + percent: t.Integer({ minimum: 0, maximum: 100 }), + time: t.Integer({ + minimum: 0, + }), + videoId: t.Nullable( + t.String({ + format: "uuid", + }), + ), + entry: t.String(), + }), permissions: ["core.read"], async message(ws, body) { const profilePk = await getOrCreateProfile(ws.data.jwt.sub); From 452e4f32b426eb7d4997df3a4efe1ec743ad3cfd Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 19 Dec 2025 17:30:35 +0100 Subject: [PATCH 4/5] Fix `/api/videos/:id?with=show`'s watchlist date formats --- api/src/controllers/videos.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index db528571..3fcb323a 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -318,11 +318,16 @@ const videoRelations = { ) .as("t"); + const { startedAt, lastPlayedAt, completedAt, ...watchlistCols } = + getColumns(watchlist); const watchStatusQ = db .select({ watchStatus: jsonbBuildObject({ - ...getColumns(watchlist), + ...watchlistCols, percent: watchlist.seenCount, + startedAt: sql`to_char(${startedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, + lastPlayedAt: sql`to_char(${lastPlayedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, + completedAt: sql`to_char(${completedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, }).as("watchStatus"), }) .from(watchlist) From cf4a592804ba69040b62ca42556aa5b142f1597e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 19 Dec 2025 17:30:55 +0100 Subject: [PATCH 5/5] Fix player menu not disappearing --- front/src/ui/player/controls/index.tsx | 87 +++++++++++++------------- front/src/ui/player/index.tsx | 2 +- 2 files changed, 45 insertions(+), 44 deletions(-) diff --git a/front/src/ui/player/controls/index.tsx b/front/src/ui/player/controls/index.tsx index 366acfa7..dded654d 100644 --- a/front/src/ui/player/controls/index.tsx +++ b/front/src/ui/player/controls/index.tsx @@ -50,50 +50,51 @@ export const Controls = ({ player={player} forceShow={hover || menuOpenned} {...css(StyleSheet.absoluteFillObject)} - /> - theme.darkOverlay, - paddingTop: insets.top, - paddingLeft: insets.left, - paddingRight: insets.right, - }, - hoverControls, + > + theme.darkOverlay, + paddingTop: insets.top, + paddingLeft: insets.left, + paddingRight: insets.right, + }, + hoverControls, + )} + /> + {isTouch && ( + )} - /> - {isTouch && ( - - )} - theme.darkOverlay, - paddingLeft: insets.left, - paddingRight: insets.right, - paddingBottom: insets.bottom, - }, - hoverControls, - )} - /> + theme.darkOverlay, + paddingLeft: insets.left, + paddingRight: insets.right, + paddingBottom: insets.bottom, + }, + hoverControls, + )} + /> + ); }; diff --git a/front/src/ui/player/index.tsx b/front/src/ui/player/index.tsx index cf0d9058..a646b124 100644 --- a/front/src/ui/player/index.tsx +++ b/front/src/ui/player/index.tsx @@ -20,8 +20,8 @@ import { Back } from "./controls/back"; import { toggleFullscreen } from "./controls/misc"; import { PlayModeContext } from "./controls/tracks-menu"; import { useKeyboard } from "./keyboard"; -import { enhanceSubtitles } from "./subtitles"; import { useProgressObserver } from "./progress-observer"; +import { enhanceSubtitles } from "./subtitles"; const clientId = uuidv4();