Implemented watch history pool

This commit is contained in:
WardPearce
2026-02-24 12:26:56 +13:00
parent 11b6f0c19e
commit f4579326d1
9 changed files with 192 additions and 79 deletions
-50
View File
@@ -1,50 +0,0 @@
// const videoIds: string[] = [];
// const pendingResolves = new Map<string, (result: ApiExntendedProgressModel | undefined) => void>();
// let timeout: ReturnType<typeof setTimeout> | null = null;
// const DEBOUNCE_MS = 1000;
// const BATCH_SIZE = 100;
// async function processBatches(): Promise<void> {
// const batches: string[][] = [];
// while (videoIds.length > 0) {
// batches.push(videoIds.splice(0, BATCH_SIZE));
// }
// const results: ApiExntendedProgressModel[] = [];
// for (const batch of batches) {
// const res: ApiExntendedProgressModel[] = await getVideoProgress(batch.join(','));
// results.push(...res);
// // Resolve pending promises for this batch
// for (const videoId of batch) {
// const match = res.find((item) => item.video_id === videoId);
// const resolve = pendingResolves.get(videoId);
// if (resolve) {
// resolve(match);
// pendingResolves.delete(videoId);
// }
// }
// }
// }
// export function queueGetWatchProgress(
// videoId: string
// ): Promise<ApiExntendedProgressModel | undefined> {
// videoIds.push(videoId);
// const promise = new Promise<ApiExntendedProgressModel | undefined>((resolve) => {
// pendingResolves.set(videoId, resolve);
// });
// if (timeout) clearTimeout(timeout);
// timeout = setTimeout(() => {
// processBatches().catch((e) => {
// console.error('Failed to process batches:', e);
// });
// }, DEBOUNCE_MS);
// return promise;
// }
+87 -7
View File
@@ -1,6 +1,6 @@
import sodium from 'libsodium-wrappers-sumo';
import { encryptWithMasterKey, getRawKey, getSecureHash } from './encryption';
import type { VideoPlay } from '../model';
import { decryptWithMasterKey, encryptWithMasterKey, getRawKey, getSecureHash } from './encryption';
import type { VideoPlay, WatchHistoryItem } from '../model';
import { getBestThumbnail } from '$lib/images';
export async function updateWatchHistory(videoId: string, progress: number) {
@@ -20,6 +20,85 @@ export async function updateWatchHistory(videoId: string, progress: number) {
});
}
async function decryptWatchHistory(
history: Record<string, string | number>
): Promise<WatchHistoryItem> {
const author = (await decryptWithMasterKey(
history.authorNonce as string,
history.authorCipher as string
)) as string;
const title = (await decryptWithMasterKey(
history.titleNonce as string,
history.titleCipher as string
)) as string;
const thumbnail = (await decryptWithMasterKey(
history.thumbnailNonce as string,
history.thumbnailCipher as string
)) as string;
const videoId = (await decryptWithMasterKey(
history.videoIdNonce as string,
history.videoIdCipher as string
)) as string;
return {
author,
title,
thumbnail,
videoId,
watched: new Date(history.watched),
duration: history.duration as number,
id: history.id as string,
progress: history.progress as number
};
}
export async function getVideoWatchHistory(videoId: string): Promise<WatchHistoryItem | undefined> {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
const videoHash = await getSecureHash(videoId, rawKey);
const resp = await fetch(`/api/user/history/${videoHash}`, {
method: 'GET',
credentials: 'same-origin'
});
if (!resp.ok) return;
return await decryptWatchHistory(await resp.json());
}
export async function getWatchHistory(
options: { page?: number; videoIds?: string[] } = { page: 0, videoIds: undefined }
): Promise<WatchHistoryItem[]> {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return [];
const videoHashes: string[] = [];
if (options.videoIds) {
for (const videoId of options.videoIds) {
videoHashes.push(await getSecureHash(videoId, rawKey));
}
}
const resp = await fetch(
`/api/user/history?page=${options.page}${videoHashes.length > 0 ? '&videoHashes=' + videoHashes.join(',') : ''}`
);
if (!resp.ok) return [];
const rawHistory = await resp.json();
const history: WatchHistoryItem[] = [];
for (const item of rawHistory) {
history.push(await decryptWatchHistory(item));
}
return history;
}
export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
await sodium.ready;
const rawKey = await getRawKey();
@@ -30,7 +109,7 @@ export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
const title = await encryptWithMasterKey(video.title);
const author = await encryptWithMasterKey(video.author);
const thumbnail = await encryptWithMasterKey(getBestThumbnail(video.videoThumbnails));
const duration = await encryptWithMasterKey(video.lengthSeconds.toString());
const videoId = await encryptWithMasterKey(video.videoId);
await fetch('/api/user/history', {
method: 'POST',
@@ -51,10 +130,11 @@ export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
cipher: thumbnail?.cipher,
nonce: thumbnail?.nonce
},
duration: {
cipher: duration?.cipher,
nonce: duration?.nonce
}
videoId: {
cipher: videoId?.cipher,
nonce: videoId?.nonce
},
duration: video.lengthSeconds
})
});
}
@@ -0,0 +1,51 @@
import type { WatchHistoryItem } from '../model';
import { getWatchHistory } from './history';
const videoIds: string[] = [];
const pendingResolves = new Map<string, (result: WatchHistoryItem | undefined) => void>();
let timeout: ReturnType<typeof setTimeout> | null = null;
const DEBOUNCE_MS = 1000;
const BATCH_SIZE = 100;
async function processBatches(): Promise<void> {
const batches: string[][] = [];
while (videoIds.length > 0) {
batches.push(videoIds.splice(0, BATCH_SIZE));
}
const results: WatchHistoryItem[] = [];
for (const batch of batches) {
const res: WatchHistoryItem[] = await getWatchHistory({ videoIds: batch });
results.push(...res);
// Resolve pending promises for this batch
for (const videoId of batch) {
const match = res.find((item) => item.videoId === videoId);
const resolve = pendingResolves.get(videoId);
if (resolve) {
resolve(match);
pendingResolves.delete(videoId);
}
}
}
}
export function queueGetWatchHistory(videoId: string): Promise<WatchHistoryItem | undefined> {
videoIds.push(videoId);
const promise = new Promise<WatchHistoryItem | undefined>((resolve) => {
pendingResolves.set(videoId, resolve);
});
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
processBatches().catch((e) => {
console.error('Failed to process batches:', e);
});
}, DEBOUNCE_MS);
return promise;
}
+11
View File
@@ -321,3 +321,14 @@ export type ChannelOptions = {
continuation?: string;
sortBy?: ChannelSortBy;
};
export interface WatchHistoryItem {
author: string;
watched: Date;
duration: number;
progress: number;
id: string;
title: string;
thumbnail: string;
videoId: string;
}
@@ -62,6 +62,7 @@
import { Network, type ConnectionStatus } from '@capacitor/network';
import { ScreenOrientation, type ScreenOrientationResult } from '@capacitor/screen-orientation';
import ClosedCaptions from './ClosedCaptions.svelte';
import { getVideoWatchHistory, updateWatchHistory } from '$lib/api/backend/history';
interface Props {
data: { video: VideoPlay; content: ParsedDescription; playlistId: string | null };
@@ -750,6 +751,9 @@
// Continue regardless of error
}
const watchHistory = await getVideoWatchHistory(data.video.videoId);
if (watchHistory && watchHistory.progress > toSetTime) toSetTime = watchHistory.progress;
return toSetTime;
}
@@ -769,6 +773,8 @@
// Continue regardless of error
}
}
updateWatchHistory(data.video.videoId, playerElement.currentTime);
}
onDestroy(async () => {
@@ -20,6 +20,7 @@
syncPartyPeerStore
} from '$lib/store';
import { relativeTimestamp } from '$lib/time';
import { queueGetWatchHistory } from '$lib/api/backend/historyPool';
interface Props {
video: VideoBase | Video | Notification | PlaylistPageVideo;
@@ -72,6 +73,12 @@
calcThumbnailPlaceholderHeight();
});
queueGetWatchHistory(video.videoId).then((watchHistory) => {
if (watchHistory) {
progress = watchHistory.progress.toString();
}
});
if (get(interfaceLowBandwidthMode)) return;
let imageSrc = getBestThumbnail(video.videoThumbnails) as string;
+12 -8
View File
@@ -120,10 +120,22 @@ export function getSequelize(): {
type: DataTypes.DATE,
allowNull: false
},
duration: {
type: DataTypes.INTEGER,
allowNull: false
},
progress: {
type: DataTypes.INTEGER,
allowNull: false
},
videoIdCipher: {
type: DataTypes.STRING,
allowNull: false
},
videoIdNonce: {
type: DataTypes.STRING,
allowNull: false
},
titleCipher: {
type: DataTypes.STRING,
allowNull: false
@@ -147,14 +159,6 @@ export function getSequelize(): {
thumbnailNonce: {
type: DataTypes.STRING,
allowNull: false
},
durationCipher: {
type: DataTypes.STRING,
allowNull: false
},
durationNonce: {
type: DataTypes.STRING,
allowNull: false
}
});
@@ -4,9 +4,10 @@ import z from 'zod';
import { Op } from 'sequelize';
const zUserHistory = z.object({
id: z.string(),
id: z.string().max(255),
watched: z.coerce.date(),
progress: z.number(),
progress: z.number().max(115200),
duration: z.number().max(115200),
title: z.object({
cipher: z.string().max(255),
nonce: z.string().max(255)
@@ -16,10 +17,10 @@ const zUserHistory = z.object({
nonce: z.string().max(255)
}),
thumbnail: z.object({
cipher: z.string().max(255),
cipher: z.string().max(1000),
nonce: z.string().max(255)
}),
duration: z.object({
videoId: z.object({
cipher: z.string().max(255),
nonce: z.string().max(255)
})
@@ -42,14 +43,15 @@ export async function POST({ locals, request }) {
const toStore = {
progress: data.data.progress,
watched: data.data.watched,
duration: data.data.duration,
titleCipher: data.data.title.cipher,
titleNonce: data.data.title.nonce,
authorCipher: data.data.author.cipher,
authorNonce: data.data.author.nonce,
thumbnailCipher: data.data.thumbnail.cipher,
thumbnailNonce: data.data.thumbnail.nonce,
durationCipher: data.data.duration.cipher,
durationNonce: data.data.duration.nonce
videoIdCipher: data.data.videoId.cipher,
videoIdNonce: data.data.videoId.nonce
};
if (history) {
@@ -78,18 +80,20 @@ export async function GET({ locals, url }) {
videoHashesList = videoHashes.split(',');
}
console.log(videoHashesList);
const whereClause: any = {
UserId: locals.userId
};
if (videoHashesList.length > 0) {
whereClause[Op.or] = [{ id: { [Op.in]: videoHashesList } }];
whereClause.id = { [Op.in]: videoHashesList };
}
const history = await getSequelize().UserHistoryTable.findAll({
where: whereClause,
limit,
offset: limit * page
offset: page > 0 ? limit * page : undefined
});
return json(history);
@@ -4,8 +4,8 @@ import z from 'zod';
const zUserHistoryUpdate = z
.object({
watched: z.date().optional(),
progress: z.number().optional()
watched: z.coerce.date().optional(),
progress: z.number().max(115200).optional()
})
.transform((data) => {
return Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));
@@ -15,10 +15,10 @@ export async function POST({ locals, request, params }) {
if (!locals.userId) throw error(401, 'Unauthorized');
const data = zUserHistoryUpdate.safeParse(await request.json());
if (!data.success) throw error(400);
if (!data.success) throw error(400, data.error.message);
await getSequelize().UserHistoryTable.update(data.data, {
where: { UserId: locals.userId, VideoHash: params.videoHash }
where: { UserId: locals.userId, id: params.videoHash }
});
return new Response();
@@ -28,7 +28,7 @@ export async function DELETE({ locals, params }) {
if (!locals.userId) throw error(401, 'Unauthorized');
await getSequelize().UserHistoryTable.destroy({
where: { UserId: locals.userId, VideoHash: params.videoHash }
where: { UserId: locals.userId, id: params.videoHash }
});
return new Response();
@@ -38,7 +38,7 @@ export async function GET({ locals, params }) {
if (!locals.userId) throw error(401, 'Unauthorized');
const history = await getSequelize().UserHistoryTable.findOne({
where: { UserId: locals.userId, VideoHash: params.videoHash }
where: { UserId: locals.userId, id: params.videoHash }
});
if (!history) throw error(404);