diff --git a/materialious/src/lib/api/backend/history.ts b/materialious/src/lib/api/backend/history.ts index f917892b..fafb6220 100644 --- a/materialious/src/lib/api/backend/history.ts +++ b/materialious/src/lib/api/backend/history.ts @@ -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 -): Promise { +): Promise { 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 { +export async function getVideoWatchHistory( + videoId: string +): Promise { await sodium.ready; const rawKey = await getRawKey(); if (!rawKey) return; @@ -71,7 +74,7 @@ export async function getVideoWatchHistory(videoId: string): Promise { +): Promise { 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 }) }); } diff --git a/materialious/src/lib/api/backend/historyPool.ts b/materialious/src/lib/api/backend/historyPool.ts index ab60a4de..5c6be48d 100644 --- a/materialious/src/lib/api/backend/historyPool.ts +++ b/materialious/src/lib/api/backend/historyPool.ts @@ -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 void>(); +const pendingResolves = new Map void>(); let timeout: ReturnType | null = null; const DEBOUNCE_MS = 1000; @@ -15,10 +15,10 @@ async function processBatches(): Promise { 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 { } } -export function queueGetWatchHistory(videoId: string): Promise { +export function queueGetWatchHistory(videoId: string): Promise { videoIds.push(videoId); - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { pendingResolves.set(videoId, resolve); }); diff --git a/materialious/src/lib/api/model.ts b/materialious/src/lib/api/model.ts index d2448cf1..46ecc93b 100644 --- a/materialious/src/lib/api/model.ts +++ b/materialious/src/lib/api/model.ts @@ -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'; } diff --git a/materialious/src/lib/components/layout/ItemsList.svelte b/materialious/src/lib/components/layout/ItemsList.svelte index e161549f..4e40f774 100644 --- a/materialious/src/lib/components/layout/ItemsList.svelte +++ b/materialious/src/lib/components/layout/ItemsList.svelte @@ -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} {/key} diff --git a/materialious/src/lib/components/thumbnail/VideoThumbnail.svelte b/materialious/src/lib/components/thumbnail/VideoThumbnail.svelte index 9b5c0f2b..37d83d25 100644 --- a/materialious/src/lib/components/thumbnail/VideoThumbnail.svelte +++ b/materialious/src/lib/components/thumbnail/VideoThumbnail.svelte @@ -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} -
- {:else} -
300}> - Thumbnail for video -
- {/if} + {@const beenWatched = progress && !page.url.pathname.endsWith('/history')} + +
+ {#if !thumbnail} +
+ {:else} +
300}> + Thumbnail for video +
+ {/if} + + {#if beenWatched} +
+ check +
+ {/if} +
{/if} {#if progress}
- {#if video.authorId} + {#if 'authorId' in video && video.authorId} {video.author}

{/if} - {#if video.promotedBy === 'favourited'} + {#if 'promotedBy' in video && video.promotedBy === 'favourited'} star {/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%; diff --git a/materialious/src/lib/misc.ts b/materialious/src/lib/misc.ts index a6aaecd8..f2add37e 100644 --- a/materialious/src/lib/misc.ts +++ b/materialious/src/lib/misc.ts @@ -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 { diff --git a/materialious/src/lib/navPages.ts b/materialious/src/lib/navPages.ts index 6a601597..5a6a2367 100644 --- a/materialious/src/lib/navPages.ts +++ b/materialious/src/lib/navPages.ts @@ -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()); }); diff --git a/materialious/src/lib/server/database.ts b/materialious/src/lib/server/database.ts index a21486e4..7436f300 100644 --- a/materialious/src/lib/server/database.ts +++ b/materialious/src/lib/server/database.ts @@ -120,7 +120,7 @@ export function getSequelize(): { type: DataTypes.DATE, allowNull: false }, - duration: { + lengthSeconds: { type: DataTypes.INTEGER, allowNull: false }, diff --git a/materialious/src/routes/(app)/history/+page.svelte b/materialious/src/routes/(app)/history/+page.svelte new file mode 100644 index 00000000..60b04bdb --- /dev/null +++ b/materialious/src/routes/(app)/history/+page.svelte @@ -0,0 +1,7 @@ + + + diff --git a/materialious/src/routes/(app)/history/+page.ts b/materialious/src/routes/(app)/history/+page.ts new file mode 100644 index 00000000..3e08ee47 --- /dev/null +++ b/materialious/src/routes/(app)/history/+page.ts @@ -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() }; +} diff --git a/materialious/src/routes/api/user/history/+server.ts b/materialious/src/routes/api/user/history/+server.ts index e777b08e..630ee601 100644 --- a/materialious/src/routes/api/user/history/+server.ts +++ b/materialious/src/routes/api/user/history/+server.ts @@ -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);