diff --git a/src/components/remove-video-enhancer-app.tsx b/src/components/remove-video-enhancer-app.tsx index 920dbe2..ba43f14 100644 --- a/src/components/remove-video-enhancer-app.tsx +++ b/src/components/remove-video-enhancer-app.tsx @@ -2,8 +2,9 @@ import { Component } from 'preact' import LinearProgress from 'preact-material-components/LinearProgress' import RemoveVideoEnhancerContainer from '~components/remove-video-enhancer-container' import { YTConfigData, Playlist } from '~src/youtube' -import { removeVideosFromPlaylist, fetchAllPlaylistContent } from '~src/yt-api' +import { removeWatchHistoryForVideo, removeVideosFromPlaylist, fetchAllPlaylistContent } from '~src/yt-api' import partition from '~lib/partition' +import removeWatchedFromPlaylistUI from '~src/operations/actions/remove-watched-from-playlist-ui' import removeVideosFromPlaylistUI from '~src/operations/actions/remove-videos-from-playlist-ui' interface Properties { @@ -21,6 +22,7 @@ export default class RemoveVideoEnhancerApp extends Component super(properties) this.state = {} this.removeVideoWatchedPercentHandler = this.removeVideoWatchedPercentHandler.bind(this) + this.resetVideoHandler = this.resetVideoHandler.bind(this) this.removeVideoHandler = this.removeVideoHandler.bind(this) } @@ -33,6 +35,15 @@ export default class RemoveVideoEnhancerApp extends Component } } + async resetVideoHandler(videoId: string) { + try { + await removeWatchHistoryForVideo(this.props.config, videoId) + removeWatchedFromPlaylistUI(videoId) + } catch (error) { + this.setState({ ...this.state, errorMessages: [error.message] }) + } + } + async removeVideoWatchedPercentHandler(watchTimeValue: number) { const { playlist } = this.state if (playlist && playlist.continuations[0].videos.length > 0) { @@ -98,6 +109,7 @@ export default class RemoveVideoEnhancerApp extends Component return ( ) diff --git a/src/components/remove-video-enhancer-container.tsx b/src/components/remove-video-enhancer-container.tsx index 7e96d2f..de4a81f 100644 --- a/src/components/remove-video-enhancer-container.tsx +++ b/src/components/remove-video-enhancer-container.tsx @@ -5,10 +5,12 @@ import Slider from 'preact-material-components/Slider' import LinearProgress from 'preact-material-components/LinearProgress' import getElementsByXpath from '~lib/get-elements-by-xpath' import { XPATH } from '~src/selectors' +import VideoItemQuickResetButton from './video-item-quick-reset-button' import VideoItemQuickDeleteButton from './video-item-quick-delete-button' interface Properties { removeVideoWatchedPercentHandler: (watchTimeValue: number) => Promise | void + resetVideoHandler: (videoId: string) => Promise | void removeVideoHandler: (videoId: string) => Promise | void initialValue?: number } @@ -29,6 +31,7 @@ function validate(value: any): boolean { function RemoveVideoEnhancerContainer({ removeVideoWatchedPercentHandler, initialValue = 100, + resetVideoHandler, removeVideoHandler, }: Properties) { const [inputValue, setValue] = useState(initialValue) @@ -46,16 +49,27 @@ function RemoveVideoEnhancerContainer({ await removeVideoHandler(videoId) }, []) + const resetVideo = useCallback(async (videoId: string) => { + await resetVideoHandler(videoId) + }, []) + useEffect(() => { const menus = getElementsByXpath(XPATH.YT_PLAYLIST_VIDEO_MENU) as HTMLElement[] for (const element of menus) { element.style.display = 'inline-flex' render( - h(VideoItemQuickDeleteButton, { - // @ts-ignore element.data does not exists on types - videoId: element.parentElement?.data.videoId, - onClick: removeVideo, - }), + [ + h(VideoItemQuickResetButton, { + // @ts-ignore element.data does not exists on types + videoId: element.parentElement?.data.videoId, + onClick: resetVideo, + }), + h(VideoItemQuickDeleteButton, { + // @ts-ignore element.data does not exists on types + videoId: element.parentElement?.data.videoId, + onClick: removeVideo, + }), + ], element ) } diff --git a/src/components/video-item-quick-delete-button.tsx b/src/components/video-item-quick-delete-button.tsx index 583f076..cd4d8ad 100644 --- a/src/components/video-item-quick-delete-button.tsx +++ b/src/components/video-item-quick-delete-button.tsx @@ -14,6 +14,7 @@ function VideoItemQuickDeleteButton(properties: { videoId: string; onClick: (vid setLoading(false) }} disabled={loading} + title='Remove video' > delete diff --git a/src/components/video-item-quick-reset-button.tsx b/src/components/video-item-quick-reset-button.tsx new file mode 100644 index 0000000..298ad07 --- /dev/null +++ b/src/components/video-item-quick-reset-button.tsx @@ -0,0 +1,24 @@ +import { useState } from 'preact/hooks' + +import IconButton from 'preact-material-components/IconButton' + +function VideoItemQuickResetButton(properties: { videoId: string; onClick: (videoId: string) => Promise }) { + const [loading, setLoading] = useState(false) + return ( + { + setLoading(true) + await properties.onClick(properties.videoId) + setLoading(false) + }} + disabled={loading} + title='Mark as unwatched' + > + refresh + + ) +} + +export default VideoItemQuickResetButton diff --git a/src/operations/actions/remove-watched-from-playlist-ui.ts b/src/operations/actions/remove-watched-from-playlist-ui.ts new file mode 100644 index 0000000..056782d --- /dev/null +++ b/src/operations/actions/remove-watched-from-playlist-ui.ts @@ -0,0 +1,12 @@ +import getElementsByXpath from '~src/lib/get-elements-by-xpath' +import { XPATH } from '~src/selectors' + +export default function removeWatchedFromPlaylistUI(videoId: string) { + const playlistVideoRendererNodes = getElementsByXpath(XPATH.YT_PLAYLIST_VIDEO_RENDERERS) as any[] + + for (const video of playlistVideoRendererNodes) { + if (video.data.videoId === videoId) { + video.querySelector('#overlays ytd-thumbnail-overlay-resume-playback-renderer').remove() + } + } +} diff --git a/src/yt-api.ts b/src/yt-api.ts index 3cc18e3..020c49e 100644 --- a/src/yt-api.ts +++ b/src/yt-api.ts @@ -179,6 +179,88 @@ export async function fetchAllPlaylistContent(config: YTConfigData, playlistName throw PlaylistNotEditableError } +async function getRemoveFromHistoryToken(videoId: string): Promise { + const initDataRegex = /(?:window\["ytInitialData"]|ytInitialData)\W?=\W?({.*?});/ + const result = await fetch('https://www.youtube.com/feed/history', { + credentials: 'include', + method: 'GET', + mode: 'cors', + }) + const body = await result.text() + + try { + const matchedData = body.match(initDataRegex) + if (!matchedData || !matchedData[1]) throw new Error('Failed to parse initData') + const initData = JSON.parse(matchedData[1]) + + const groups = initData?.contents?.twoColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.sectionListRenderer?.contents + .map((group: { itemSectionRenderer: object }) => group.itemSectionRenderer) + .filter(Boolean) + + let matchingVideo + for (const item of groups) { + for (const { videoRenderer } of item.contents) { + if (videoRenderer?.videoId && videoId === videoRenderer?.videoId) { + matchingVideo = videoRenderer + break + } + } + } + + if (!matchingVideo) { + throw new Error('Video not found in watch history') + } + + return matchingVideo?.menu?.menuRenderer?.topLevelButtons?.[0]?.buttonRenderer?.serviceEndpoint?.feedbackEndpoint + ?.feedbackToken + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + throw new Error('Failed to parse initData') + } +} + +const makeFeedbackPayload = (feedbackToken: string) => ({ + context: { + client: { + hl: 'en', + clientName: 'WEB', + clientVersion: '2.20210711.07.00', + }, + user: { + lockedSafetyMode: false, + }, + request: { + useSsl: true, + internalExperimentFlags: [], + consistencyTokenJars: [], + }, + }, + isFeedbackTokenUnencrypted: false, + shouldMerge: false, + feedbackTokens: [feedbackToken], +}) + +async function sendFeedbackRequest(config: YTConfigData, feedbackToken: string) { + const url = `https://www.youtube.com/youtubei/v1/feedback?key=${config.INNERTUBE_API_KEY}` + const rawResponse = await fetch(url, { + method: 'POST', + headers: generateRequestHeaders(config, API_V1_REQUIRED_HEADERS), + body: JSON.stringify(makeFeedbackPayload(feedbackToken)), + }) + const response = await rawResponse.json() + if (!response.feedbackResponses[0].isProcessed) { + throw new Error('Failed to remove video from watch history') + } +} + +export async function removeWatchHistoryForVideo(config: YTConfigData, videoId: string) { + const feedbackToken = await getRemoveFromHistoryToken(videoId) + if (feedbackToken) { + await sendFeedbackRequest(config, feedbackToken) + } +} + export async function removeVideosFromPlaylist( config: YTConfigData, playlistId: string,