Show watched history on thumbnail and cull any history older then 3
months
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user