Started Materialious watch history

This commit is contained in:
WardPearce
2026-02-23 19:15:15 +13:00
parent b759ab318c
commit 739a3f00a1
5 changed files with 262 additions and 8 deletions
+47 -6
View File
@@ -4,11 +4,14 @@ import { get } from 'svelte/store';
import { parseChannelRSS } from './youtubejs/subscriptions';
import { getChannelYTjs } from './youtubejs/channel';
import type { ChannelSubscriptions } from '$lib/dexie';
import type { VideoPlay } from './model';
import { vi } from 'zod/v4/locales';
import { getBestThumbnail } from '$lib/images';
async function getInternalAuthorId(authorId: string, rawKey: Uint8Array): Promise<string> {
async function getSecureHash(toHash: string, rawKey: Uint8Array): Promise<string> {
await sodium.ready;
return sodium.to_base64(
sodium.crypto_generichash(sodium.crypto_generichash_BYTES, authorId, rawKey)
sodium.crypto_generichash(sodium.crypto_generichash_BYTES, toHash, rawKey)
);
}
@@ -48,7 +51,7 @@ export async function updateRSSLastUpdated(authorId: string) {
const rawKey = await getRawKey();
if (!rawKey) return false;
const internalAuthorId = await getInternalAuthorId(authorId, rawKey);
const internalAuthorId = await getSecureHash(authorId, rawKey);
await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'PATCH',
@@ -60,7 +63,7 @@ export async function amSubscribedBackend(authorId: string): Promise<boolean> {
const rawKey = await getRawKey();
if (!rawKey) return false;
const internalAuthorId = await getInternalAuthorId(authorId, rawKey);
const internalAuthorId = await getSecureHash(authorId, rawKey);
const resp = await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'GET',
@@ -77,7 +80,7 @@ export async function deleteUnsubscribeBackend(authorId: string) {
const rawKey = await getRawKey();
if (!rawKey) return false;
const internalAuthorId = await getInternalAuthorId(authorId, rawKey);
const internalAuthorId = await getSecureHash(authorId, rawKey);
await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'DELETE',
@@ -92,7 +95,7 @@ export async function postSubscribeBackend(
const rawKey = await getRawKey();
if (!rawKey) return;
const internalAuthorId = await getInternalAuthorId(authorId, rawKey);
const internalAuthorId = await getSecureHash(authorId, rawKey);
if (!authorName) {
const channel = await getChannelYTjs(authorId);
@@ -260,6 +263,44 @@ export async function getKeyValue(key: string): Promise<KeyValue | null> {
return (await decryptWithMasterKey(respJson.valueNonce, respJson.valueCipher)) ?? null;
}
export async function saveHistoryToBackend(video: VideoPlay, progress: number = 0) {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
const videoHash = await getSecureHash(video.videoId, rawKey);
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());
await fetch('/api/user/history', {
method: 'POST',
body: JSON.stringify({
id: videoHash,
watched: new Date(),
progress: progress,
title: {
cipher: title?.cipher,
nonce: title?.nonce
},
author: {
cipher: author?.cipher,
nonce: author?.nonce
},
thumbnail: {
cipher: thumbnail?.cipher,
nonce: thumbnail?.nonce
},
duration: {
cipher: duration?.cipher,
nonce: duration?.nonce
}
})
});
}
async function encryptWithMasterKey(
text: string
): Promise<{ nonce: string; cipher: string } | undefined> {
+55 -2
View File
@@ -5,6 +5,7 @@ let UserTable: ModelCtor<Model<any, any>>;
let ChannelSubscriptionTable: ModelCtor<Model<any, any>>;
let CaptchaTable: ModelCtor<Model<any, any>>;
let UserKeyValueTable: ModelCtor<Model<any, any>>;
let UserHistoryTable: ModelCtor<Model<any, any>>;
let sequelizeInstance: Sequelize | null = null;
@@ -15,6 +16,7 @@ export function getSequelize(): {
ChannelSubscriptionTable: ModelCtor<Model<any, any>>;
CaptchaTable: ModelCtor<Model<any, any>>;
UserKeyValueTable: ModelCtor<Model<any, any>>;
UserHistoryTable: ModelCtor<Model<any, any>>;
} {
if (sequelizeInstance) {
return {
@@ -22,7 +24,8 @@ export function getSequelize(): {
UserTable,
ChannelSubscriptionTable,
CaptchaTable,
UserKeyValueTable
UserKeyValueTable,
UserHistoryTable
};
}
@@ -107,6 +110,54 @@ export function getSequelize(): {
}
);
UserHistoryTable = sequelizeInstance.define('UserHistory', {
id: {
type: DataTypes.STRING,
allowNull: false,
primaryKey: true
},
watched: {
type: DataTypes.DATE,
allowNull: false
},
progress: {
type: DataTypes.INTEGER,
allowNull: false
},
titleCipher: {
type: DataTypes.STRING,
allowNull: false
},
titleNonce: {
type: DataTypes.STRING,
allowNull: false
},
authorCipher: {
type: DataTypes.STRING,
allowNull: false
},
authorNonce: {
type: DataTypes.STRING,
allowNull: false
},
thumbnailCipher: {
type: DataTypes.STRING,
allowNull: false
},
thumbnailNonce: {
type: DataTypes.STRING,
allowNull: false
},
durationCipher: {
type: DataTypes.STRING,
allowNull: false
},
durationNonce: {
type: DataTypes.STRING,
allowNull: false
}
});
ChannelSubscriptionTable = sequelizeInstance.define('Subscriptions', {
id: {
type: DataTypes.STRING,
@@ -149,13 +200,15 @@ export function getSequelize(): {
UserTable.hasMany(ChannelSubscriptionTable);
UserTable.hasMany(UserKeyValueTable);
UserTable.hasMany(UserHistoryTable);
return {
sequelize: sequelizeInstance,
UserTable,
ChannelSubscriptionTable,
CaptchaTable,
UserKeyValueTable
UserKeyValueTable,
UserHistoryTable
};
}
+7
View File
@@ -10,6 +10,7 @@ import {
invidiousAuthStore,
playerProxyVideosStore,
playerState,
rawMasterKeyStore,
returnYTDislikesInstanceStore,
returnYtDislikesStore
} from '$lib/store';
@@ -17,6 +18,8 @@ import { parseDescription } from '$lib/description';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
import { _ } from './i18n';
import { isOwnBackend } from './shared';
import { saveHistoryToBackend } from './api/backend';
export async function getWatchDetails(videoId: string, url: URL) {
const playerStateRetrieved = get(playerState);
@@ -45,6 +48,10 @@ export async function getWatchDetails(videoId: string, url: URL) {
personalPlaylists = null;
}
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
saveHistoryToBackend(video);
}
let comments;
try {
comments = video.liveNow ? null : getComments(videoId, { sort_by: 'top' }, { priority: 'low' });
@@ -0,0 +1,106 @@
import { getSequelize } from '$lib/server/database.js';
import { error, json } from '@sveltejs/kit';
import z from 'zod';
import { Op } from 'sequelize';
const zUserHistory = z.object({
id: z.string(),
watched: z.coerce.date(),
progress: z.number(),
title: z.object({
cipher: z.string().max(255),
nonce: z.string().max(255)
}),
author: z.object({
cipher: z.string().max(255),
nonce: z.string().max(255)
}),
thumbnail: z.object({
cipher: z.string().max(255),
nonce: z.string().max(255)
}),
duration: z.object({
cipher: z.string().max(255),
nonce: z.string().max(255)
})
});
export async function POST({ locals, request }) {
if (!locals.userId) throw error(401, 'Unauthorized');
const data = zUserHistory.safeParse(await request.json());
if (!data.success) throw error(400, data.error.message);
const history = await getSequelize().UserHistoryTable.findOne({
where: {
UserId: locals.userId,
id: data.data.id
}
});
const toStore = {
progress: data.data.progress,
watched: data.data.watched,
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
};
if (history) {
await history.update(toStore);
} else {
await getSequelize().UserHistoryTable.create({
UserId: locals.userId,
id: data.data.id,
...toStore
});
}
return new Response();
}
export async function GET({ locals, url }) {
if (!locals.userId) throw error(401, 'Unauthorized');
const limit = 100;
const page = Number(url.searchParams.get('page') ?? 0);
const videoHashes = url.searchParams.get('videoHashes');
let videoHashesList: string[] = [];
if (videoHashes) {
videoHashesList = videoHashes.split(',');
}
const whereClause: any = {
UserId: locals.userId
};
if (videoHashesList.length > 0) {
whereClause[Op.or] = [{ id: { [Op.in]: videoHashesList } }];
}
const history = await getSequelize().UserHistoryTable.findAll({
where: whereClause,
limit,
offset: limit * page
});
return json(history);
}
export async function DELETE({ locals }) {
await getSequelize().UserHistoryTable.destroy({
where: {
UserId: locals.userId
}
});
return new Response();
}
@@ -0,0 +1,47 @@
import { getSequelize } from '$lib/server/database.js';
import { error, json } from '@sveltejs/kit';
import z from 'zod';
const zUserHistoryUpdate = z
.object({
watched: z.date().optional(),
progress: z.number().optional()
})
.transform((data) => {
return Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));
});
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);
await getSequelize().UserHistoryTable.update(data.data, {
where: { UserId: locals.userId, VideoHash: params.videoHash }
});
return new Response();
}
export async function DELETE({ locals, params }) {
if (!locals.userId) throw error(401, 'Unauthorized');
await getSequelize().UserHistoryTable.destroy({
where: { UserId: locals.userId, VideoHash: params.videoHash }
});
return new Response();
}
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 }
});
if (!history) throw error(404);
return json(history);
}