From 58a61e020d4c9c4ed88633b3117f1cde3aed153f Mon Sep 17 00:00:00 2001 From: Visirt Date: Sun, 11 Oct 2026 11:30:32 +0000 Subject: [PATCH 1/4] feat(api): add playtime endpoints server side --- .../v1/client/playtime/[gameid]/index.get.ts | 44 ++++++++++++++ .../v1/client/playtime/[gameid]/index.post.ts | 60 +++++++++++++++++++ .../api/v1/client/playtime/index.get.ts | 23 +++++++ server/server/internal/userlibrary/index.ts | 8 ++- 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 server/server/api/v1/client/playtime/[gameid]/index.get.ts create mode 100644 server/server/api/v1/client/playtime/[gameid]/index.post.ts create mode 100644 server/server/api/v1/client/playtime/index.get.ts diff --git a/server/server/api/v1/client/playtime/[gameid]/index.get.ts b/server/server/api/v1/client/playtime/[gameid]/index.get.ts new file mode 100644 index 000000000..440974170 --- /dev/null +++ b/server/server/api/v1/client/playtime/[gameid]/index.get.ts @@ -0,0 +1,44 @@ +import { ClientCapabilities } from "~/prisma/client/enums"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.TrackPlaytime)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + const user = await fetchUser(); + const gameId = getRouterParam(h3, "gameid"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "No gameID in route params", + }); + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { id: true }, + }); + if (!game) + throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); + + const playtime = await prisma.playtime.findUnique({ + where: { + gameId_userId: { + gameId, + userId: user.id, + }, + }, + }); + if (!playtime) + throw createError({ + statusCode: 404, + statusMessage: "Playtime not found", + }); + + return playtime; + }, +); diff --git a/server/server/api/v1/client/playtime/[gameid]/index.post.ts b/server/server/api/v1/client/playtime/[gameid]/index.post.ts new file mode 100644 index 000000000..eb8eba24a --- /dev/null +++ b/server/server/api/v1/client/playtime/[gameid]/index.post.ts @@ -0,0 +1,60 @@ +import { type } from "arktype"; +import { ClientCapabilities } from "~/prisma/client/enums"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; + +const maxElapsedSeconds = 31536000; +const PlaytimeBody = type({ + seconds: "number.integer", +}).configure(throwingArktype); + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.TrackPlaytime)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + const user = await fetchUser(); + const gameId = getRouterParam(h3, "gameid"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "No gameID in route params", + }); + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { id: true }, + }); + if (!game) + throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); + + const body = await readDropValidatedBody(h3, PlaytimeBody); + const elapsedSeconds = body.seconds; + if (elapsedSeconds <= 0 || elapsedSeconds > maxElapsedSeconds) + throw createError({ + statusCode: 400, + statusMessage: `seconds must be between 1 and ${maxElapsedSeconds}.`, + }); + + return prisma.playtime.upsert({ + where: { + gameId_userId: { + gameId, + userId: user.id, + }, + }, + create: { + gameId, + userId: user.id, + seconds: elapsedSeconds, + }, + update: { + seconds: { increment: elapsedSeconds }, + }, + }); + }, +); diff --git a/server/server/api/v1/client/playtime/index.get.ts b/server/server/api/v1/client/playtime/index.get.ts new file mode 100644 index 000000000..1f7867daa --- /dev/null +++ b/server/server/api/v1/client/playtime/index.get.ts @@ -0,0 +1,23 @@ +import { ClientCapabilities } from "~/prisma/client/enums"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.TrackPlaytime)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + const user = await fetchUser(); + + const playtimes = await prisma.playtime.findMany({ + where: { + userId: user.id, + }, + }); + + return playtimes; + }, +); diff --git a/server/server/internal/userlibrary/index.ts b/server/server/internal/userlibrary/index.ts index 6991b8a77..07aea579a 100644 --- a/server/server/internal/userlibrary/index.ts +++ b/server/server/internal/userlibrary/index.ts @@ -53,7 +53,13 @@ class UserLibraryManager { include: { entries: { where: gameFilter ? { game: gameFilter } : undefined, - include: { game: true }, + include: { + game: { + include: { + playtime: { where: { userId } }, + }, + }, + }, }, }, }); From 2524413062b0220ae156186d1713c06b3b129878 Mon Sep 17 00:00:00 2001 From: Visirt Date: Sun, 11 Oct 2026 18:55:09 +0000 Subject: [PATCH 2/4] feat(frontend): displaying playtime in game library --- server/i18n/locales/en_us.json | 1 + server/pages/library/game/[id]/index.vue | 11 +++++++++++ server/server/api/v1/games/[id]/index.get.ts | 3 +++ 3 files changed, 15 insertions(+) diff --git a/server/i18n/locales/en_us.json b/server/i18n/locales/en_us.json index 22ae8eb89..49d214dc9 100644 --- a/server/i18n/locales/en_us.json +++ b/server/i18n/locales/en_us.json @@ -610,6 +610,7 @@ "launcherOpen": "Open in Launcher", "noGames": "No games in library", "notFound": "Game not found", + "playtime": "Playtime: {time} hours", "search": "Search library…", "subheader": "Organize your games into collections for easy access, and access all your games." }, diff --git a/server/pages/library/game/[id]/index.vue b/server/pages/library/game/[id]/index.vue index 369528f47..d19ea199d 100644 --- a/server/pages/library/game/[id]/index.vue +++ b/server/pages/library/game/[id]/index.vue @@ -35,6 +35,13 @@ > {{ game.mName }} +

+ {{ + $t("library.playtime", { + time: formatPlaytime(game.playtime[0].seconds), + }) + }} +

{{ game.mShortDescription }}

@@ -139,6 +146,10 @@ const descriptionHTML = computed(() => micromark(game.value.mDescription ?? ""), ); +function formatPlaytime(seconds: number): string { + return `${(seconds / 3600).toFixed(1)}`; +} + // const currentImageIndex = ref(0); // function nextImage() { diff --git a/server/server/api/v1/games/[id]/index.get.ts b/server/server/api/v1/games/[id]/index.get.ts index 61293e331..88471491b 100644 --- a/server/server/api/v1/games/[id]/index.get.ts +++ b/server/server/api/v1/games/[id]/index.get.ts @@ -57,6 +57,9 @@ export default defineEventHandler(async (h3) => { }, tags: true, ageRatings: true, + playtime: { + where: { userId: user.id }, + }, }, }); From f7830e29c797795310fc7da2ca8a1fc19ac8947b Mon Sep 17 00:00:00 2001 From: Visirt Date: Sun, 11 Oct 2026 21:08:56 +0000 Subject: [PATCH 3/4] feat(desktop): display Playtime counter on desktop app --- desktop/main/pages/library/[id]/index.vue | 7 +++++++ desktop/main/types.ts | 1 + desktop/src-tauri/games/src/library.rs | 7 +++++++ 3 files changed, 15 insertions(+) diff --git a/desktop/main/pages/library/[id]/index.vue b/desktop/main/pages/library/[id]/index.vue index 498078739..f86cd6282 100644 --- a/desktop/main/pages/library/[id]/index.vue +++ b/desktop/main/pages/library/[id]/index.vue @@ -22,6 +22,9 @@ > {{ game.mName }} +

+ Playtime: {{ formatPlaytime(game.playtime[0].seconds) }} +

>(); const installDirs = ref>(); diff --git a/desktop/main/types.ts b/desktop/main/types.ts index a42238fef..6fc3a20f1 100644 --- a/desktop/main/types.ts +++ b/desktop/main/types.ts @@ -39,6 +39,7 @@ export type Game = { mCoverObjectId: string; mImageLibraryObjectIds: string[]; mImageCarouselObjectIds: string[]; + playtime: Array<{ seconds: number }>; }; export type Collection = { diff --git a/desktop/src-tauri/games/src/library.rs b/desktop/src-tauri/games/src/library.rs index fc581de1a..bb9a94aa4 100644 --- a/desktop/src-tauri/games/src/library.rs +++ b/desktop/src-tauri/games/src/library.rs @@ -51,6 +51,13 @@ pub struct Game { pub m_image_library_object_ids: Vec, pub m_image_carousel_object_ids: Vec, pub library_path: String, + #[serde(default)] + pub playtime: Vec, +} +#[derive(Serialize, Deserialize, Clone, Debug, Default, Encode, Decode)] +#[serde(rename_all = "camelCase")] +pub struct GamePlaytime { + pub seconds: i32, } impl Game { pub fn id(&self) -> &String { From 0cf4f6f9b8ef4e947af50c6498360340f7775d94 Mon Sep 17 00:00:00 2001 From: Visirt Date: Mon, 12 Oct 2026 09:41:10 +0000 Subject: [PATCH 4/4] feat(desktop): sending recorded playtime --- desktop/src-tauri/games/src/library.rs | 18 +++++++++++++++++- .../src-tauri/process/src/process_manager.rs | 14 +++++++++++++- desktop/src-tauri/remote/src/auth.rs | 1 + desktop/src-tauri/remote/src/requests.rs | 9 +++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/games/src/library.rs b/desktop/src-tauri/games/src/library.rs index bb9a94aa4..a20da3086 100644 --- a/desktop/src-tauri/games/src/library.rs +++ b/desktop/src-tauri/games/src/library.rs @@ -6,7 +6,7 @@ use database::{ }; use log::{debug, error, warn}; use remote::{ - auth::generate_authorization_header, error::RemoteAccessError, requests::generate_url, + auth::generate_authorization_header, error::{DropServerError, RemoteAccessError}, requests::{generate_url, make_authenticated_post}, utils::DROP_CLIENT_ASYNC, }; use serde::{Deserialize, Serialize}; @@ -59,6 +59,10 @@ pub struct Game { pub struct GamePlaytime { pub seconds: i32, } +#[derive(Serialize)] +struct PlaytimeUpdate { + seconds: u64, +} impl Game { pub fn id(&self) -> &String { &self.id @@ -275,6 +279,18 @@ pub async fn on_game_complete( Ok(()) } +pub async fn report_playtime(game_id: String, seconds: u64) -> Result<(), RemoteAccessError> { + let endpoint = generate_url(&["/api/v1/client/playtime", &game_id], &[])?; + let response = make_authenticated_post(endpoint, &PlaytimeUpdate { seconds }).await?; + + if !response.status().is_success() { + let error: DropServerError = response.json().await?; + return Err(RemoteAccessError::InvalidResponse(error)); + } + + Ok(()) +} + pub fn push_game_update( app_handle: &AppHandle, game_id: &String, diff --git a/desktop/src-tauri/process/src/process_manager.rs b/desktop/src-tauri/process/src/process_manager.rs index 5822c4711..b72e87781 100644 --- a/desktop/src-tauri/process/src/process_manager.rs +++ b/desktop/src-tauri/process/src/process_manager.rs @@ -16,7 +16,7 @@ use database::{ }; use dynfmt::Format; use dynfmt::SimpleCurlyFormat; -use games::{library::push_game_update, state::GameStatusManager}; +use games::{library::{push_game_update, report_playtime}, state::GameStatusManager}; use log::{debug, info, warn}; use serde::Serialize; use shared_child::SharedChild; @@ -205,6 +205,18 @@ impl ProcessManager<'_> { Some(version_data.clone()), status, ); + + let elapsed_seconds = elapsed.as_secs(); + if elapsed_seconds > 2 { + let playtime_game_id = game_id.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = report_playtime(playtime_game_id.clone(), elapsed_seconds).await + { + warn!("failed to report playtime for game {playtime_game_id}: {error}"); + } + }); + } + Ok(()) } diff --git a/desktop/src-tauri/remote/src/auth.rs b/desktop/src-tauri/remote/src/auth.rs index 6c303f52a..5c985075a 100644 --- a/desktop/src-tauri/remote/src/auth.rs +++ b/desktop/src-tauri/remote/src/auth.rs @@ -129,6 +129,7 @@ pub fn auth_initiate_logic(mode: String) -> Result { capabilities: HashMap::from([ ("peerAPI".to_owned(), CapabilityConfiguration {}), ("cloudSaves".to_owned(), CapabilityConfiguration {}), + ("trackPlaytime".to_owned(), CapabilityConfiguration {}), ]), mode, }; diff --git a/desktop/src-tauri/remote/src/requests.rs b/desktop/src-tauri/remote/src/requests.rs index d2fbdf9aa..d54299f3e 100644 --- a/desktop/src-tauri/remote/src/requests.rs +++ b/desktop/src-tauri/remote/src/requests.rs @@ -28,3 +28,12 @@ pub async fn make_authenticated_get(url: Url) -> Result(url: Url, body: &T) -> Result { + DROP_CLIENT_ASYNC + .post(url) + .header("Authorization", generate_authorization_header()) + .json(body) + .send() + .await +}