diff --git a/README.md b/README.md index 53445a65..ca20a5e7 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,9 @@ Retrieves video info, including playback data and even layout elements such as m - `#getWatchNextContinuation()` - Retrieves the next batch of items for the watch next feed. +- `#addToWatchHistory()` + - Adds the video to the watch history. + - `#page` - Returns original InnerTube response (sanitized). diff --git a/src/core/Actions.ts b/src/core/Actions.ts index 3ef3e19a..74579611 100644 --- a/src/core/Actions.ts +++ b/src/core/Actions.ts @@ -681,6 +681,26 @@ class Actions { return this.#wrap(response); } + /** + * Makes calls to the playback tracking API. + */ + async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }) { + const s_url = new URL(url); + + s_url.searchParams.set('ver', '2'); + s_url.searchParams.set('c', client.client_name.toLowerCase()); + s_url.searchParams.set('cbrver', client.client_version); + s_url.searchParams.set('cver', client.client_version); + + for (const key of Object.keys(params)) { + s_url.searchParams.set(key, params[key]); + } + + const response = await this.#session.http.fetch(s_url); + + return response; + } + /** * Executes an API call. * @param action - endpoint diff --git a/src/core/Music.ts b/src/core/Music.ts index 6ce88a3d..189a6bc6 100644 --- a/src/core/Music.ts +++ b/src/core/Music.ts @@ -46,7 +46,7 @@ class Music { const continuation = this.#actions.execute('/next', { client: 'YTMUSIC', videoId: video_id }); const response = await Promise.all([ initial_info, continuation ]); - return new TrackInfo(response, this.#actions); + return new TrackInfo(response, this.#actions, cpn); } /** diff --git a/src/parser/index.ts b/src/parser/index.ts index d891a207..bd1b7d52 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -252,6 +252,10 @@ export default class Parser { refinements: data.refinements || null, estimated_results: data.estimatedResults ? parseInt(data.estimatedResults) : null, player_overlays: Parser.parse(data.playerOverlays), + playback_tracking: data.playbackTracking ? { + videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl, + videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl + } : null, playability_status: data.playabilityStatus ? { status: data.playabilityStatus.status as string, error_screen: Parser.parse(data.playabilityStatus.errorScreen), diff --git a/src/parser/youtube/VideoInfo.ts b/src/parser/youtube/VideoInfo.ts index 756115ab..0c0bc3a5 100644 --- a/src/parser/youtube/VideoInfo.ts +++ b/src/parser/youtube/VideoInfo.ts @@ -75,6 +75,9 @@ class VideoInfo { endscreen; captions; cards; + + #playback_tracking; + primary_info; secondary_info; merchandise; @@ -130,6 +133,8 @@ class VideoInfo { this.captions = info.captions; this.cards = info.cards; + this.#playback_tracking = info.playback_tracking; + const two_col = next?.contents.item().as(TwoColumnWatchNextResults); const results = two_col?.results; @@ -177,6 +182,28 @@ class VideoInfo { return this; } + /** + * Adds the video to the watch history. + */ + async addToWatchHistory() { + if (!this.#playback_tracking) + throw new InnertubeError('Playback tracking not available'); + + const url_params = { + cpn: this.#cpn, + fmt: 251, + rtn: 0, + rt: 0 + }; + + const response = await this.#actions.stats(this.#playback_tracking.videostats_playback_url, { + client_name: Constants.CLIENTS.WEB.NAME, + client_version: Constants.CLIENTS.WEB.VERSION + }, url_params); + + return response; + } + /** * Retrieves watch next feed continuation. */ @@ -258,6 +285,10 @@ class VideoInfo { return this.#actions; } + get cpn() { + return this.#cpn; + } + get page() { return this.#page; } diff --git a/src/parser/ytmusic/Recap.ts b/src/parser/ytmusic/Recap.ts index 7e6e86ed..721e6f88 100644 --- a/src/parser/ytmusic/Recap.ts +++ b/src/parser/ytmusic/Recap.ts @@ -2,6 +2,7 @@ import Parser, { ParsedResponse } from '../index'; import Actions, { AxioslikeResponse } from '../../core/Actions'; import Playlist from './Playlist'; +import MusicHeader from '../classes/MusicHeader'; import MusicCarouselShelf from '../classes/MusicCarouselShelf'; import MusicElementHeader from '../classes/MusicElementHeader'; import HighlightsCarousel from '../classes/HighlightsCarousel'; @@ -10,6 +11,7 @@ import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults'; import Tab from '../classes/Tab'; import ItemSection from '../classes/ItemSection'; import SectionList from '../classes/SectionList'; +import Message from '../classes/Message'; import { InnertubeError } from '../../utils/Utils'; @@ -24,14 +26,18 @@ class Recap { this.#page = Parser.parseResponse(response.data); this.#actions = actions; - this.header = this.#page.header.item().as(MusicElementHeader).element?.model?.item().as(HighlightsCarousel); + const header = this.#page.header.item(); + + this.header = header.is(MusicElementHeader) ? + this.#page.header.item().as(MusicElementHeader).element?.model?.item().as(HighlightsCarousel) : + this.#page.header.item().as(MusicHeader); const tab = this.#page.contents.item().as(SingleColumnBrowseResults).tabs.firstOfType(Tab); if (!tab) throw new InnertubeError('Target tab not found'); - this.sections = tab.content?.as(SectionList).contents.array().as(ItemSection, MusicCarouselShelf); + this.sections = tab.content?.as(SectionList).contents.array().as(ItemSection, MusicCarouselShelf, Message); } /** @@ -41,6 +47,9 @@ class Recap { if (!this.header) throw new InnertubeError('Header not found'); + if (!this.header.is(HighlightsCarousel)) + throw new InnertubeError('Recap playlist not available, check back later.'); + const endpoint = this.header.panels[0].text_on_tap_endpoint; const response = await endpoint.callTest(this.#actions, { client: 'YTMUSIC' }); diff --git a/src/parser/ytmusic/TrackInfo.ts b/src/parser/ytmusic/TrackInfo.ts index 4d4acdac..d3e1ea39 100644 --- a/src/parser/ytmusic/TrackInfo.ts +++ b/src/parser/ytmusic/TrackInfo.ts @@ -1,5 +1,6 @@ import Parser, { ParsedResponse } from '..'; import Actions, { AxioslikeResponse } from '../../core/Actions'; +import Constants from '../../utils/Constants'; import { InnertubeError } from '../../utils/Utils'; import Tab from '../classes/Tab'; @@ -13,6 +14,7 @@ import PlayerOverlay from '../classes/PlayerOverlay'; class TrackInfo { #page: [ ParsedResponse, ParsedResponse? ]; #actions: Actions; + #cpn; basic_info; streaming_data; @@ -20,17 +22,20 @@ class TrackInfo { storyboards; endscreen; + #playback_tracking; + tabs; current_video_endpoint; player_overlays; - constructor(data: [AxioslikeResponse, AxioslikeResponse?], actions: Actions) { + constructor(data: [AxioslikeResponse, AxioslikeResponse?], actions: Actions, cpn: string) { this.#actions = actions; const info = Parser.parseResponse(data[0].data); const next = data?.[1]?.data ? Parser.parseResponse(data[1].data) : undefined; this.#page = [ info, next ]; + this.#cpn = cpn; if (info.playability_status?.status === 'ERROR') throw new InnertubeError('This video is unavailable', info.playability_status); @@ -54,6 +59,8 @@ class TrackInfo { this.storyboards = info.storyboards; this.endscreen = info.endscreen; + this.#playback_tracking = info.playback_tracking; + if (next) { const single_col = next.contents.item().as(SingleColumnMusicWatchNextResults); const tabbed_results = single_col.contents.item().as(Tabbed).contents.item().as(WatchNextTabbedResults); @@ -66,6 +73,28 @@ class TrackInfo { } } + /** + * Adds the song to the watch history. + */ + async addToWatchHistory() { + if (!this.#playback_tracking) + throw new InnertubeError('Playback tracking not available'); + + const url_params = { + cpn: this.#cpn, + fmt: 251, + rtn: 0, + rt: 0 + }; + + const response = await this.#actions.stats(this.#playback_tracking.videostats_playback_url, { + client_name: Constants.CLIENTS.YTMUSIC.NAME, + client_version: Constants.CLIENTS.YTMUSIC.VERSION + }, url_params); + + return response; + } + get page() { return this.#page; } diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index c9db7023..a44df15a 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -33,7 +33,8 @@ export const OAUTH = Object.freeze({ }); export const CLIENTS = Object.freeze({ WEB: { - NAME: 'WEB' + NAME: 'WEB', + VERSION: '2.20220902.01.00' }, YTMUSIC: { NAME: 'WEB_REMIX',