Show watched history on thumbnail and cull any history older then 3

months
This commit is contained in:
WardPearce
2026-02-24 12:59:21 +13:00
parent f4579326d1
commit 4bdae34d88
11 changed files with 125 additions and 38 deletions
+11 -8
View File
@@ -1,6 +1,6 @@
import sodium from 'libsodium-wrappers-sumo';
import { decryptWithMasterKey, encryptWithMasterKey, getRawKey, getSecureHash } from './encryption';
import type { VideoPlay, WatchHistoryItem } from '../model';
import type { VideoPlay, VideoWatchHistory } from '../model';
import { getBestThumbnail } from '$lib/images';
export async function updateWatchHistory(videoId: string, progress: number) {
@@ -22,7 +22,7 @@ export async function updateWatchHistory(videoId: string, progress: number) {
async function decryptWatchHistory(
history: Record<string, string | number>
): Promise<WatchHistoryItem> {
): Promise<VideoWatchHistory> {
const author = (await decryptWithMasterKey(
history.authorNonce as string,
history.authorCipher as string
@@ -46,13 +46,16 @@ async function decryptWatchHistory(
thumbnail,
videoId,
watched: new Date(history.watched),
duration: history.duration as number,
lengthSeconds: history.lengthSeconds as number,
id: history.id as string,
progress: history.progress as number
progress: history.progress as number,
type: 'historyVideo'
};
}
export async function getVideoWatchHistory(videoId: string): Promise<WatchHistoryItem | undefined> {
export async function getVideoWatchHistory(
videoId: string
): Promise<VideoWatchHistory | undefined> {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
@@ -71,7 +74,7 @@ export async function getVideoWatchHistory(videoId: string): Promise<WatchHistor
export async function getWatchHistory(
options: { page?: number; videoIds?: string[] } = { page: 0, videoIds: undefined }
): Promise<WatchHistoryItem[]> {
): Promise<VideoWatchHistory[]> {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return [];
@@ -90,7 +93,7 @@ export async function getWatchHistory(
const rawHistory = await resp.json();
const history: WatchHistoryItem[] = [];
const history: VideoWatchHistory[] = [];
for (const item of rawHistory) {
history.push(await decryptWatchHistory(item));
@@ -134,7 +137,7 @@ export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
cipher: videoId?.cipher,
nonce: videoId?.nonce
},
duration: video.lengthSeconds
lengthSeconds: video.lengthSeconds
})
});
}
@@ -1,8 +1,8 @@
import type { WatchHistoryItem } from '../model';
import type { VideoWatchHistory } from '../model';
import { getWatchHistory } from './history';
const videoIds: string[] = [];
const pendingResolves = new Map<string, (result: WatchHistoryItem | undefined) => void>();
const pendingResolves = new Map<string, (result: VideoWatchHistory | undefined) => void>();
let timeout: ReturnType<typeof setTimeout> | null = null;
const DEBOUNCE_MS = 1000;
@@ -15,10 +15,10 @@ async function processBatches(): Promise<void> {
batches.push(videoIds.splice(0, BATCH_SIZE));
}
const results: WatchHistoryItem[] = [];
const results: VideoWatchHistory[] = [];
for (const batch of batches) {
const res: WatchHistoryItem[] = await getWatchHistory({ videoIds: batch });
const res: VideoWatchHistory[] = await getWatchHistory({ videoIds: batch });
results.push(...res);
// Resolve pending promises for this batch
@@ -33,10 +33,10 @@ async function processBatches(): Promise<void> {
}
}
export function queueGetWatchHistory(videoId: string): Promise<WatchHistoryItem | undefined> {
export function queueGetWatchHistory(videoId: string): Promise<VideoWatchHistory | undefined> {
videoIds.push(videoId);
const promise = new Promise<WatchHistoryItem | undefined>((resolve) => {
const promise = new Promise<VideoWatchHistory | undefined>((resolve) => {
pendingResolves.set(videoId, resolve);
});
+3 -2
View File
@@ -322,13 +322,14 @@ export type ChannelOptions = {
sortBy?: ChannelSortBy;
};
export interface WatchHistoryItem {
export interface VideoWatchHistory {
author: string;
watched: Date;
duration: number;
lengthSeconds: number;
progress: number;
id: string;
title: string;
thumbnail: string;
videoId: string;
type: 'historyVideo';
}
@@ -249,7 +249,7 @@
}
}}
>
{#if item.type === 'video' || item.type === 'shortVideo' || item.type === 'stream'}
{#if item.type === 'video' || item.type === 'shortVideo' || item.type === 'stream' || item.type === 'historyVideo'}
{#key item.videoId}
<Thumbnail video={item} {playlistId} />
{/key}
@@ -7,7 +7,13 @@
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { getDeArrow, getThumbnail } from '$lib/api';
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '$lib/api/model';
import type {
Notification,
PlaylistPageVideo,
Video,
VideoBase,
VideoWatchHistory
} from '$lib/api/model';
import { createVideoUrl, insecureRequestImageHandler } from '$lib/misc';
import type { PlayerEvents } from '$lib/player';
import {
@@ -21,9 +27,10 @@
} from '$lib/store';
import { relativeTimestamp } from '$lib/time';
import { queueGetWatchHistory } from '$lib/api/backend/historyPool';
import { page } from '$app/state';
interface Props {
video: VideoBase | Video | Notification | PlaylistPageVideo;
video: VideoBase | Video | Notification | PlaylistPageVideo | VideoWatchHistory;
playlistId?: string;
sideways?: boolean;
}
@@ -81,7 +88,8 @@
if (get(interfaceLowBandwidthMode)) return;
let imageSrc = getBestThumbnail(video.videoThumbnails) as string;
let imageSrc =
'thumbnail' in video ? video.thumbnail : (getBestThumbnail(video.videoThumbnails) as string);
if (get(deArrowEnabledStore)) {
try {
@@ -168,13 +176,32 @@
onclick={onVideoSelected}
>
{#if !$interfaceLowBandwidthMode}
{#if !thumbnail}
<div class="secondary-container" style="width: 100%;height: {placeholderHeight}px;"></div>
{:else}
<div class:crop={thumbnail.height > 300}>
<img class="responsive" loading="lazy" src={thumbnail.src} alt="Thumbnail for video" />
</div>
{/if}
{@const beenWatched = progress && !page.url.pathname.endsWith('/history')}
<div class="thumbnail-image">
{#if !thumbnail}
<div
class="secondary-container"
style="width: 100%;height: {placeholderHeight}px;"
></div>
{:else}
<div class:crop={thumbnail.height > 300}>
<img
class="responsive"
class:watched={beenWatched}
loading="lazy"
src={thumbnail.src}
alt="Thumbnail for video"
/>
</div>
{/if}
{#if beenWatched}
<div class="chip surface-container-highest">
<i>check</i>
</div>
{/if}
</div>
{/if}
{#if progress}
<progress
@@ -220,7 +247,7 @@
</a>
<div>
{#if video.authorId}
{#if 'authorId' in video && video.authorId}
<a
tabindex="-1"
class:author={!sideways}
@@ -232,7 +259,7 @@
<p>{video.author}</p>
{/if}
{#if video.promotedBy === 'favourited'}
{#if 'promotedBy' in video && video.promotedBy === 'favourited'}
<i>star</i>
{/if}
@@ -326,6 +353,20 @@
line-height: 1.5;
}
.thumbnail-image {
position: relative;
}
.thumbnail-image .chip {
position: absolute;
top: 8px;
right: 8px;
}
.watched {
filter: brightness(40%);
}
@media screen and (max-width: 1499px) {
.sideways-root .thumbnail {
width: 100%;
+4 -2
View File
@@ -24,7 +24,8 @@ import type {
PlaylistPage,
PlaylistPageVideo,
Video,
VideoBase
VideoBase,
VideoWatchHistory
} from './api/model';
import { page } from '$app/state';
import { Capacitor } from '@capacitor/core';
@@ -155,7 +156,8 @@ export type feedItem =
| Video
| Playlist
| HashTag
| PlaylistPage;
| PlaylistPage
| VideoWatchHistory;
export type feedItems = feedItem[];
export function extractUniqueId(item: feedItem): string {
+10 -1
View File
@@ -1,7 +1,8 @@
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { isYTBackend } from './misc';
import { invidiousAuthStore } from './store';
import { invidiousAuthStore, rawMasterKeyStore } from './store';
import { isOwnBackend } from './shared';
export type Pages = { icon: string; href: string; name: string; requiresAuth: boolean }[];
@@ -28,6 +29,14 @@ export function getPages(): Pages {
}
];
if (isOwnBackend() && get(rawMasterKeyStore))
pages.push({
icon: 'history',
href: '/history',
name: get(_)('pages.history'),
requiresAuth: false
});
pages = pages.filter((page) => {
return !page.requiresAuth || (get(invidiousAuthStore) && !isYTBackend());
});
+1 -1
View File
@@ -120,7 +120,7 @@ export function getSequelize(): {
type: DataTypes.DATE,
allowNull: false
},
duration: {
lengthSeconds: {
type: DataTypes.INTEGER,
allowNull: false
},
@@ -0,0 +1,7 @@
<script lang="ts">
import ItemsList from '$lib/components/layout/ItemsList.svelte';
let { data } = $props();
</script>
<ItemsList items={data.videos} />
@@ -0,0 +1,13 @@
import { resolve } from '$app/paths';
import { getWatchHistory } from '$lib/api/backend/history';
import { isOwnBackend } from '$lib/shared';
import { rawMasterKeyStore } from '$lib/store';
import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store';
export async function load() {
if (!isOwnBackend()?.internalAuth || !get(rawMasterKeyStore))
throw redirect(302, resolve('/', {}));
return { videos: await getWatchHistory() };
}
@@ -7,7 +7,7 @@ const zUserHistory = z.object({
id: z.string().max(255),
watched: z.coerce.date(),
progress: z.number().max(115200),
duration: z.number().max(115200),
lengthSeconds: z.number().max(115200),
title: z.object({
cipher: z.string().max(255),
nonce: z.string().max(255)
@@ -43,7 +43,7 @@ export async function POST({ locals, request }) {
const toStore = {
progress: data.data.progress,
watched: data.data.watched,
duration: data.data.duration,
lengthSeconds: data.data.lengthSeconds,
titleCipher: data.data.title.cipher,
titleNonce: data.data.title.nonce,
authorCipher: data.data.author.cipher,
@@ -64,6 +64,18 @@ export async function POST({ locals, request }) {
});
}
// Cull any history older then 3 months.
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
await getSequelize().UserHistoryTable.destroy({
where: {
UserId: locals.userId,
watched: {
[Op.lt]: threeMonthsAgo
}
}
});
return new Response();
}
@@ -80,8 +92,6 @@ export async function GET({ locals, url }) {
videoHashesList = videoHashes.split(',');
}
console.log(videoHashesList);
const whereClause: any = {
UserId: locals.userId
};
@@ -93,7 +103,8 @@ export async function GET({ locals, url }) {
const history = await getSequelize().UserHistoryTable.findAll({
where: whereClause,
limit,
offset: page > 0 ? limit * page : undefined
offset: page > 0 ? limit * page : undefined,
order: [['watched', 'DESC']]
});
return json(history);