diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index b4f48db48c..dbb6bc7259 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -20,6 +20,11 @@ import { Effect, Option } from "effect"; import { Hono } from "hono"; import { z } from "zod"; import { runPromise } from "@/lib/server"; +import { + isAtLeastSemver, + isFromDesktopSemver, + UPLOAD_PROGRESS_VERSION, +} from "@/utils/desktop"; import { stringOrNumberOptional } from "@/utils/zod"; import { withAuth } from "../../utils"; @@ -186,10 +191,10 @@ app.get( fps, }); - const xCapVersion = c.req.header("X-Cap-Desktop-Version"); - const clientSupportsUploadProgress = xCapVersion - ? isAtLeastSemver(xCapVersion, 0, 3, 68) - : false; + const clientSupportsUploadProgress = isFromDesktopSemver( + c.req, + UPLOAD_PROGRESS_VERSION, + ); if (clientSupportsUploadProgress) await db().insert(videoUploads).values({ @@ -334,41 +339,44 @@ app.post( try { const [video] = await db() - .select({ id: videos.id }) + .select({ id: videos.id, upload: videoUploads }) .from(videos) - .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))); + .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)); if (!video) return c.json( { error: true, message: "Video not found" }, { status: 404 }, ); - const [result] = await db() - .update(videoUploads) - .set({ - uploaded, - total, - updatedAt, - }) - .where( - and( - eq(videoUploads.videoId, videoId), - lte(videoUploads.updatedAt, updatedAt), - ), - ); - - if (result.affectedRows === 0) + if (video.upload) { + if (uploaded === total && video.upload.mode === "singlepart") { + await db() + .delete(videoUploads) + .where(eq(videoUploads.videoId, videoId)); + } else { + await db() + .update(videoUploads) + .set({ + uploaded, + total, + updatedAt, + }) + .where( + and( + eq(videoUploads.videoId, videoId), + lte(videoUploads.updatedAt, updatedAt), + ), + ); + } + } else { await db().insert(videoUploads).values({ videoId, uploaded, total, updatedAt, }); - - // if (uploaded === total) - // await db() - // .delete(videoUploads) - // .where(eq(videoUploads.videoId, videoId)); + } return c.json(true); } catch (error) { @@ -377,27 +385,3 @@ app.post( } }, ); - -function isAtLeastSemver( - versionString: string, - major: number, - minor: number, - patch: number, -): boolean { - const match = versionString - .replace(/^v/, "") - .match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?/); - if (!match) return false; - const [, vMajor, vMinor, vPatch, prerelease] = match; - const M = vMajor ? parseInt(vMajor, 10) || 0 : 0; - const m = vMinor ? parseInt(vMinor, 10) || 0 : 0; - const p = vPatch ? parseInt(vPatch, 10) || 0 : 0; - if (M > major) return true; - if (M < major) return false; - if (m > minor) return true; - if (m < minor) return false; - if (p > patch) return true; - if (p < patch) return false; - // Equal triplet: accept only non-prerelease - return !prerelease; -} diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index 05e16727dd..45194169d8 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -46,6 +46,38 @@ app.post( subpath: "result.mp4", }); + const videoIdFromFileKey = fileKey.split("/")[1]; + const videoId = "videoId" in body ? body.videoId : videoIdFromFileKey; + if (!videoId) throw new Error("Video ID is required"); + + const resp = await Effect.gen(function* () { + const videos = yield* Videos; + const db = yield* Database; + + const video = yield* videos.getById(Video.VideoId.make(videoId)); + if (Option.isNone(video)) return yield* new Video.NotFoundError(); + + yield* db.use((db) => + db + .update(Db.videoUploads) + .set({ mode: "multipart" }) + .where(eq(Db.videoUploads.videoId, video.value[0].id)), + ); + }).pipe( + provideOptionalAuth, + Effect.tapError(Effect.logError), + Effect.catchAll((e) => { + if (e._tag === "VideoNotFoundError") + return Effect.succeed(c.text("Video not found", 404)); + + return Effect.succeed( + c.json({ error: "Error initiating multipart upload" }, 500), + ); + }), + runPromise, + ); + if (resp) return resp; + try { try { const uploadId = await Effect.gen(function* () { diff --git a/apps/web/app/api/upload/[...route]/signed.ts b/apps/web/app/api/upload/[...route]/signed.ts index 7fa094f10d..9dd656c51d 100644 --- a/apps/web/app/api/upload/[...route]/signed.ts +++ b/apps/web/app/api/upload/[...route]/signed.ts @@ -4,7 +4,7 @@ import { } from "@aws-sdk/client-cloudfront"; import type { PresignedPost } from "@aws-sdk/s3-presigned-post"; import { db, updateIfDefined } from "@cap/database"; -import { s3Buckets, videos } from "@cap/database/schema"; +import * as Db from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import { AwsCredentials, S3Buckets } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; @@ -15,6 +15,11 @@ import { Hono } from "hono"; import { z } from "zod"; import { runPromise } from "@/lib/server"; +import { + isAtLeastSemver, + isFromDesktopSemver, + UPLOAD_PROGRESS_VERSION, +} from "@/utils/desktop"; import { stringOrNumberOptional } from "@/utils/zod"; import { withAuth } from "../../utils"; import { parseVideoIdOrFileKey } from "../utils"; @@ -51,8 +56,8 @@ app.post( try { const [customBucket] = await db() .select() - .from(s3Buckets) - .where(eq(s3Buckets.ownerId, user.id)); + .from(Db.s3Buckets) + .where(eq(Db.s3Buckets.ownerId, user.id)); const s3Config = customBucket ? { @@ -156,22 +161,32 @@ app.post( const videoIdFromKey = fileKey.split("/")[1]; // Assuming fileKey format is userId/videoId/... const videoIdToUse = "videoId" in body ? body.videoId : videoIdFromKey; - if (videoIdToUse) + if (videoIdToUse) { + const videoId = Video.VideoId.make(videoIdToUse); await db() - .update(videos) + .update(Db.videos) .set({ - duration: updateIfDefined(durationInSecs, videos.duration), - width: updateIfDefined(width, videos.width), - height: updateIfDefined(height, videos.height), - fps: updateIfDefined(fps, videos.fps), + duration: updateIfDefined(durationInSecs, Db.videos.duration), + width: updateIfDefined(width, Db.videos.width), + height: updateIfDefined(height, Db.videos.height), + fps: updateIfDefined(fps, Db.videos.fps), }) .where( - and( - eq(videos.id, Video.VideoId.make(videoIdToUse)), - eq(videos.ownerId, user.id), - ), + and(eq(Db.videos.id, videoId), eq(Db.videos.ownerId, user.id)), ); + // i hate this but it'll have to do + const clientSupportsUploadProgress = isFromDesktopSemver( + c.req, + UPLOAD_PROGRESS_VERSION, + ); + if (fileKey.endsWith("result.mp4") && clientSupportsUploadProgress) + await db() + .update(Db.videoUploads) + .set({ mode: "singlepart" }) + .where(eq(Db.videoUploads.videoId, videoId)); + } + if (method === "post") return c.json({ presignedPostData: data! }); else return c.json({ presignedPutData: data! }); } catch (s3Error) { diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index 1c069040b9..732bcba60e 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -288,7 +288,7 @@ export function CapVideoPlayer({ useEffect(() => { const video = videoRef.current; - if (!video || resolvedSrc.data?.url) return; + if (!video || resolvedSrc.isPending) return; const handleLoadedData = () => { setVideoLoaded(true); @@ -458,7 +458,7 @@ export function CapVideoPlayer({ captionTrack.removeEventListener("cuechange", handleCueChange); } }; - }, [hasPlayedOnce, resolvedSrc.data?.url]); + }, [hasPlayedOnce, resolvedSrc.isPending]); const generateVideoFrameThumbnail = useCallback((time: number): string => { const video = videoRef.current; diff --git a/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx b/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx index 2e6e5155f5..0148e20306 100644 --- a/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx +++ b/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx @@ -51,7 +51,7 @@ export function useUploadProgress( const lastUpdated = new Date(query.data.updatedAt); - return query.data.uploaded >= query.data.total + return query.data.total > 0 && query.data.uploaded >= query.data.total ? null : Date.now() - lastUpdated.getTime() > 5 * MINUTE ? { diff --git a/apps/web/utils/desktop.ts b/apps/web/utils/desktop.ts new file mode 100644 index 0000000000..431d760dfb --- /dev/null +++ b/apps/web/utils/desktop.ts @@ -0,0 +1,36 @@ +import type { HonoRequest } from "hono"; + +export function isFromDesktopSemver( + request: HonoRequest, + semver: readonly [number, number, number], +) { + const xCapVersion = request.header("X-Cap-Desktop-Version"); + + return xCapVersion ? isAtLeastSemver(xCapVersion, ...semver) : false; +} + +export const UPLOAD_PROGRESS_VERSION = [0, 3, 68] as const; + +export function isAtLeastSemver( + versionString: string, + major: number, + minor: number, + patch: number, +): boolean { + const match = versionString + .replace(/^v/, "") + .match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?/); + if (!match) return false; + const [, vMajor, vMinor, vPatch, prerelease] = match; + const M = vMajor ? parseInt(vMajor, 10) || 0 : 0; + const m = vMinor ? parseInt(vMinor, 10) || 0 : 0; + const p = vPatch ? parseInt(vPatch, 10) || 0 : 0; + if (M > major) return true; + if (M < major) return false; + if (m > minor) return true; + if (m < minor) return false; + if (p > patch) return true; + if (p < patch) return false; + // Equal triplet: accept only non-prerelease + return !prerelease; +} diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 11d29c3e9b..9c80af165b 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -713,6 +713,7 @@ export const videoUploads = mysqlTable("video_uploads", { total: int("total").notNull().default(0), startedAt: timestamp("started_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), + mode: varchar("mode", { length: 255, enum: ["singlepart", "multipart"] }), }); export const importedVideos = mysqlTable(