From dbf80b492902ecc55fc5b207ae5aa600be4a3bd0 Mon Sep 17 00:00:00 2001 From: Ajay Date: Mon, 2 Sep 2024 03:22:33 -0400 Subject: [PATCH] Add pre-fetching on hover with a small segment data cache --- maze-utils | 2 +- src/content.ts | 56 +++++---------------- src/utils/requests.ts | 2 - src/utils/segmentData.ts | 104 +++++++++++++++++++++++++++++++++++++++ src/utils/thumbnails.ts | 48 +++++++++++++++--- 5 files changed, 157 insertions(+), 55 deletions(-) create mode 100644 src/utils/segmentData.ts diff --git a/maze-utils b/maze-utils index ab431ec8..e735dcab 160000 --- a/maze-utils +++ b/maze-utils @@ -1 +1 @@ -Subproject commit ab431ec8ba764b4a7a07d2debc91b5903c65db5e +Subproject commit e735dcabe8f378d49b64b04097dbdf6dc7525228 diff --git a/src/content.ts b/src/content.ts index c8cd4879..398102aa 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,7 +1,6 @@ import Config from "./config"; import { ActionType, - ActionTypes, Category, CategorySkipOption, ChannelIDInfo, @@ -18,7 +17,6 @@ import { VideoInfo, } from "./types"; import Utils from "./utils"; -import * as CompileConfig from "../config.json"; import PreviewBar, { PreviewBarSegment } from "./js-components/previewBar"; import SkipNotice from "./render/SkipNotice"; import SkipNoticeComponent from "./components/SkipNoticeComponent"; @@ -52,6 +50,7 @@ import { asyncRequestToServer } from "./utils/requests"; import { isMobileControlsOpen } from "./utils/mobileUtils"; import { defaultPreviewTime } from "./utils/constants"; import { onVideoPage } from "../maze-utils/src/pageInfo"; +import { getSegmentsForVideo } from "./utils/segmentData"; cleanPage(); @@ -269,7 +268,7 @@ function messageListener(request: Message, sender: unknown, sendResponse: (respo sendResponse({ hasVideo: getVideoID() != null }); // fetch segments if (getVideoID()) { - sponsorsLookup(false); + sponsorsLookup(false, true); } break; @@ -364,7 +363,7 @@ function contentConfigUpdateListener(changes: StorageChangesObject) { updateVisibilityOfPlayerControlsButton() break; case "categorySelections": - sponsorsLookup(); + sponsorsLookup(true, true); break; case "barTypes": setCategoryColorCSSVariables(); @@ -1133,42 +1132,20 @@ function setupCategoryPill() { categoryPill.attachToPage(isOnMobileYouTube(), isOnInvidious(), voteAsync); } -async function sponsorsLookup(keepOldSubmissions = true) { - const categories: string[] = Config.config.categorySelections.map((category) => category.name); - - const extraRequestData: Record = {}; - const hashParams = getHashParams(); - if (hashParams.requiredSegment) extraRequestData.requiredSegment = hashParams.requiredSegment; - +async function sponsorsLookup(keepOldSubmissions = true, ignoreCache = false) { const videoID = getVideoID() if (!videoID) { console.error("[SponsorBlock] Attempted to fetch segments with a null/undefined videoID."); return; } - const hashPrefix = (await getHash(videoID, 1)).slice(0, 4) as VideoID & HashedValue; - const response = await asyncRequestToServer('GET', "/api/skipSegments/" + hashPrefix, { - categories: CompileConfig.categoryList, - actionTypes: ActionTypes, - ...extraRequestData - }, { - "X-CLIENT-NAME": `${chrome.runtime.id}/v${chrome.runtime.getManifest().version}` - }); + + const segmentData = await getSegmentsForVideo(videoID, ignoreCache); // store last response status - lastResponseStatus = response?.status; + lastResponseStatus = segmentData.status; + if (segmentData.status === 200) { + const receivedSegments = segmentData.segments; - if (response?.ok) { - const enabledActionTypes = getEnabledActionTypes(); - - const receivedSegments: SponsorTime[] = JSON.parse(response.responseText) - ?.filter((video) => video.videoID === getVideoID()) - ?.map((video) => video.segments)?.[0] - ?.filter((segment) => enabledActionTypes.includes(segment.actionType) && categories.includes(segment.category)) - ?.map((segment) => ({ - ...segment, - source: SponsorSourceType.Server - })) - ?.sort((a, b) => a.segment[0] - b.segment[0]); if (receivedSegments && receivedSegments.length) { sponsorDataFound = true; @@ -1208,6 +1185,7 @@ async function sponsorsLookup(keepOldSubmissions = true) { } // See if some segments should be hidden + const hashPrefix = (await getHash(videoID, 1)).slice(0, 4) as VideoID & HashedValue; const downvotedData = Config.local.downvotedSegments[hashPrefix]; if (downvotedData) { for (const segment of sponsorTimes) { @@ -1280,18 +1258,6 @@ function importExistingChapters(wait: boolean) { } } -function getEnabledActionTypes(forceFullVideo = false): ActionType[] { - const actionTypes = [ActionType.Skip, ActionType.Poi, ActionType.Chapter]; - if (Config.config.muteSegments) { - actionTypes.push(ActionType.Mute); - } - if (Config.config.fullVideoSegments || forceFullVideo) { - actionTypes.push(ActionType.Full); - } - - return actionTypes; -} - async function lockedCategoriesLookup(): Promise { const hashPrefix = (await getHash(getVideoID(), 1)).slice(0, 4); const response = await asyncRequestToServer("GET", "/api/lockCategories/" + hashPrefix); @@ -2016,7 +1982,7 @@ function startOrEndTimingNewSegment() { Config.forceLocalUpdate("unsubmittedSegments"); // Make sure they know if someone has already submitted something it while they were watching - sponsorsLookup(); + sponsorsLookup(true, true); updateEditButtonsOnPlayer(); updateSponsorTimesSubmitting(false); diff --git a/src/utils/requests.ts b/src/utils/requests.ts index acbde374..8ce80601 100644 --- a/src/utils/requests.ts +++ b/src/utils/requests.ts @@ -23,8 +23,6 @@ export function asyncRequestToCustomServer(type: string, url: string, data = {}, export async function asyncRequestToServer(type: string, address: string, data = {}, headers = {}): Promise { const serverAddress = Config.config.testingServer ? CompileConfig.testingServerAddress : Config.config.serverAddress; - console.log(address, headers) - return await (asyncRequestToCustomServer(type, serverAddress + address, data, headers)); } diff --git a/src/utils/segmentData.ts b/src/utils/segmentData.ts new file mode 100644 index 00000000..1c2e631a --- /dev/null +++ b/src/utils/segmentData.ts @@ -0,0 +1,104 @@ +import { DataCache } from "../../maze-utils/src/cache"; +import { getHash, HashedValue } from "../../maze-utils/src/hash"; +import Config from "../config"; +import * as CompileConfig from "../../config.json"; +import { ActionType, ActionTypes, SponsorSourceType, SponsorTime, VideoID } from "../types"; +import { getHashParams } from "./pageUtils"; +import { asyncRequestToServer } from "./requests"; + +const segmentDataCache = new DataCache(() => { + return { + segments: null, + status: 200 + }; +}, 5); + +const pendingList: Record> = {}; + +export interface SegmentResponse { + segments: SponsorTime[] | null; + status: number; +} + +export async function getSegmentsForVideo(videoID: VideoID, ignoreCache: boolean): Promise { + if (!ignoreCache) { + const cachedData = segmentDataCache.getFromCache(videoID); + if (cachedData) { + segmentDataCache.cacheUsed(videoID); + return cachedData; + } + } + + if (pendingList[videoID]) { + return await pendingList[videoID]; + } + + const pendingData = fetchSegmentsForVideo(videoID); + pendingList[videoID] = pendingData; + + const result = await pendingData; + delete pendingList[videoID]; + + return result; +} + +async function fetchSegmentsForVideo(videoID: VideoID): Promise { + const categories: string[] = Config.config.categorySelections.map((category) => category.name); + + const extraRequestData: Record = {}; + const hashParams = getHashParams(); + if (hashParams.requiredSegment) extraRequestData.requiredSegment = hashParams.requiredSegment; + + const hashPrefix = (await getHash(videoID, 1)).slice(0, 4) as VideoID & HashedValue; + const response = await asyncRequestToServer('GET', "/api/skipSegments/" + hashPrefix, { + categories: CompileConfig.categoryList, + actionTypes: ActionTypes, + ...extraRequestData + }, { + "X-CLIENT-NAME": `${chrome.runtime.id}/v${chrome.runtime.getManifest().version}` + }); + + if (response.ok) { + const enabledActionTypes = getEnabledActionTypes(); + + const receivedSegments: SponsorTime[] = JSON.parse(response.responseText) + ?.filter((video) => video.videoID === videoID) + ?.map((video) => video.segments)?.[0] + ?.filter((segment) => enabledActionTypes.includes(segment.actionType) && categories.includes(segment.category)) + ?.map((segment) => ({ + ...segment, + source: SponsorSourceType.Server + })) + ?.sort((a, b) => a.segment[0] - b.segment[0]); + + if (receivedSegments && receivedSegments.length) { + const result = { + segments: receivedSegments, + status: response.status + }; + + segmentDataCache.setupCache(videoID).segments = result.segments; + return result; + } else { + // Setup with null data + segmentDataCache.setupCache(videoID); + } + } + + return { + segments: null, + status: response.status + }; +} + +function getEnabledActionTypes(forceFullVideo = false): ActionType[] { + const actionTypes = [ActionType.Skip, ActionType.Poi, ActionType.Chapter]; + if (Config.config.muteSegments) { + actionTypes.push(ActionType.Mute); + } + if (Config.config.fullVideoSegments || forceFullVideo) { + actionTypes.push(ActionType.Full); + } + + return actionTypes; +} \ No newline at end of file diff --git a/src/utils/thumbnails.ts b/src/utils/thumbnails.ts index 61d28f18..0fb2579e 100644 --- a/src/utils/thumbnails.ts +++ b/src/utils/thumbnails.ts @@ -1,10 +1,15 @@ import { isOnInvidious, parseYouTubeVideoIDFromURL } from "../../maze-utils/src/video"; import Config from "../config"; import { getVideoLabel } from "./videoLabels"; -import { setThumbnailListener } from "../../maze-utils/src/thumbnailManagement"; +import { getThumbnailSelector, setThumbnailListener } from "../../maze-utils/src/thumbnailManagement"; +import { VideoID } from "../types"; +import { getSegmentsForVideo } from "./segmentData"; -export async function labelThumbnails(thumbnails: HTMLImageElement[]): Promise { - await Promise.all(thumbnails.map((t) => labelThumbnail(t))); +export async function handleThumbnails(thumbnails: HTMLImageElement[]): Promise { + await Promise.all(thumbnails.map((t) => { + labelThumbnail(t); + setupThumbnailHover(t); + })); } export async function labelThumbnail(thumbnail: HTMLImageElement): Promise { @@ -13,9 +18,7 @@ export async function labelThumbnail(thumbnail: HTMLImageElement): Promise { + // Cache would be reset every load due to no SPA + if (isOnInvidious()) return; + + const mainElement = thumbnail.closest("#dismissible") as HTMLElement; + if (mainElement) { + mainElement.removeEventListener("mouseenter", thumbnailHoverListener); + mainElement.addEventListener("mouseenter", thumbnailHoverListener); + } +} + +function thumbnailHoverListener(e: MouseEvent) { + if (!chrome.runtime?.id) return; + + const thumbnail = (e.target as HTMLElement).querySelector(getThumbnailSelector()) as HTMLImageElement; + if (!thumbnail) return; + + // Pre-fetch data for this video + const videoID = extractVideoID(thumbnail); + if (videoID) { + void getSegmentsForVideo(videoID, false); + } +} + +function extractVideoID(thumbnail: HTMLImageElement): VideoID | null { + const link = (isOnInvidious() ? thumbnail.parentElement : thumbnail.querySelector("#thumbnail")) as HTMLAnchorElement + if (!link || link.nodeName !== "A" || !link.href) return null; // no link found + + return parseYouTubeVideoIDFromURL(link.href)?.videoID; +} + function getOldThumbnailLabel(thumbnail: HTMLImageElement): HTMLElement | null { return thumbnail.querySelector(".sponsorThumbnailLabel") as HTMLElement | null; } @@ -109,7 +143,7 @@ function insertSBIconDefinition() { } export function setupThumbnailListener(): void { - setThumbnailListener(labelThumbnails, () => { + setThumbnailListener(handleThumbnails, () => { insertSBIconDefinition(); }, () => Config.isReady()); } \ No newline at end of file