Skip to content

Commit

Permalink
feat: add mark as unwatched button (#222)
Browse files Browse the repository at this point in the history
  • Loading branch information
dnicolson authored May 6, 2024
1 parent 227640b commit 1ca7250
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 6 deletions.
14 changes: 13 additions & 1 deletion src/components/remove-video-enhancer-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,6 +22,7 @@ export default class RemoveVideoEnhancerApp extends Component<Properties, State>
super(properties)
this.state = {}
this.removeVideoWatchedPercentHandler = this.removeVideoWatchedPercentHandler.bind(this)
this.resetVideoHandler = this.resetVideoHandler.bind(this)
this.removeVideoHandler = this.removeVideoHandler.bind(this)
}

Expand All @@ -33,6 +35,15 @@ export default class RemoveVideoEnhancerApp extends Component<Properties, State>
}
}

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) {
Expand Down Expand Up @@ -98,6 +109,7 @@ export default class RemoveVideoEnhancerApp extends Component<Properties, State>
return (
<RemoveVideoEnhancerContainer
removeVideoWatchedPercentHandler={this.removeVideoWatchedPercentHandler}
resetVideoHandler={this.resetVideoHandler}
removeVideoHandler={this.removeVideoHandler}
/>
)
Expand Down
24 changes: 19 additions & 5 deletions src/components/remove-video-enhancer-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void
resetVideoHandler: (videoId: string) => Promise<void> | void
removeVideoHandler: (videoId: string) => Promise<void> | void
initialValue?: number
}
Expand All @@ -29,6 +31,7 @@ function validate(value: any): boolean {
function RemoveVideoEnhancerContainer({
removeVideoWatchedPercentHandler,
initialValue = 100,
resetVideoHandler,
removeVideoHandler,
}: Properties) {
const [inputValue, setValue] = useState(initialValue)
Expand All @@ -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
)
}
Expand Down
1 change: 1 addition & 0 deletions src/components/video-item-quick-delete-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function VideoItemQuickDeleteButton(properties: { videoId: string; onClick: (vid
setLoading(false)
}}
disabled={loading}
title='Remove video'
>
<IconButton.Icon style={{ color: '#e10000' }}>delete</IconButton.Icon>
</IconButton>
Expand Down
24 changes: 24 additions & 0 deletions src/components/video-item-quick-reset-button.tsx
Original file line number Diff line number Diff line change
@@ -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<void> }) {
const [loading, setLoading] = useState(false)
return (
<IconButton
// we use this order -1 to avoid the button sometimes being render AFTER the row menu when swtiching between playlist
style={{ order: -1 }}
onClick={async () => {
setLoading(true)
await properties.onClick(properties.videoId)
setLoading(false)
}}
disabled={loading}
title='Mark as unwatched'
>
<IconButton.Icon style={{ color: '#e10000' }}>refresh</IconButton.Icon>
</IconButton>
)
}

export default VideoItemQuickResetButton
12 changes: 12 additions & 0 deletions src/operations/actions/remove-watched-from-playlist-ui.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
82 changes: 82 additions & 0 deletions src/yt-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,88 @@ export async function fetchAllPlaylistContent(config: YTConfigData, playlistName
throw PlaylistNotEditableError
}

async function getRemoveFromHistoryToken(videoId: string): Promise<string> {
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,
Expand Down

0 comments on commit 1ca7250

Please sign in to comment.