diff --git a/README.md b/README.md index e88e8957..a71ecf0b 100644 --- a/README.md +++ b/README.md @@ -769,23 +769,36 @@ The library makes it easy to interact with YouTube programmatically. However, do * Playlists: ```js - // Create a playlist: - await youtube.playlist.create('NAME', 'VIDEO_ID'); - - // Delete a playlist: + const videos = [ + 'VIDEO_ID1', + 'VIDEO_ID2', + 'VIDEO_ID3' + //... + ]; + + // Create and delete a playlist: + await youtube.playlist.create('My Playlist', videos); await youtube.playlist.delete('PLAYLIST_ID'); - // Add videos to a playlist: - await youtube.playlist.addVideos('PLAYLIST_ID', [ 'VIDEO_ID1', 'VIDEO_ID2' ]); + // Add and remove videos from a playlist: + await youtube.playlist.addVideos('PLAYLIST_ID', videos); + await youtube.playlist.removeVideos('PLAYLIST_ID', videos); ``` - + * Change notification preferences: ```js // Options: ALL | NONE | PERSONALIZED await youtube.interact.setNotificationPreferences('CHANNEL_ID', 'ALL'); ``` -These methods will always return ```{ success: true, status_code: 200 }``` if successful. +Response schema: +```js +{ + success: boolean, + status_code: number, + playlist_id?: string +} +``` ### Account Settings It is also possible to manage an account's settings: @@ -1117,4 +1130,4 @@ Should you have any questions or concerns please contact me directly via email. ## License Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License. -

(back to top)

+

(back to top)

\ No newline at end of file diff --git a/lib/Innertube.js b/lib/Innertube.js index 154df216..89fdc2ae 100644 --- a/lib/Innertube.js +++ b/lib/Innertube.js @@ -222,33 +222,96 @@ class Innertube { */ setNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }), }; - + this.playlist = { /** * Creates a playlist. * * @param {string} title - * @param {string} video_id - Note that a video must be supplied, empty playlists cannot be created. - * @returns {Promise.<{ success: boolean; status_code: string; }>} + * @param {string} video_ids + * @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>} */ - create: (title, video_id) => Actions.engage(this, 'playlist/create', { title, video_id }), - + create: async (title, video_ids) => { + const response = await Actions.playlist(this, 'playlist/create', { title, ids: video_ids }); + if (!response.success) return response; + + return { + success: true, + status_code: response.status_code, + playlist_id: response.data.playlistId + } + }, + /** * Deletes a given playlist. * * @param {string} playlist_id - * @returns {Promise.<{ success: boolean; status_code: string; }>} + * @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>} */ - delete: (playlist_id) => Actions.engage(this, 'playlist/delete', { playlist_id }), - + delete: async (playlist_id) => { + const response = await Actions.playlist(this, 'playlist/delete', { playlist_id }); + if (!response.success) return response; + + return { + success: true, + status_code: response.status_code, + playlist_id + } + }, + /** * Adds an array of videos to a given playlist. * * @param {string} playlist_id * @param {Array.} video_ids - * @returns {Promise.<{ success: boolean; status_code: string; }>} + * @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>} */ - addVideos: (playlist_id, video_ids) => Actions.engage(this, 'browse/edit_playlist', { action: 'ACTION_ADD_VIDEO', playlist_id, video_ids }) + addVideos: async (playlist_id, video_ids) => { + const response = await Actions.playlist(this, 'browse/edit_playlist', { + action: 'ACTION_ADD_VIDEO', + playlist_id, + ids: video_ids + }); + + if (!response.success) return response; + + return { + success: true, + status_code: response.status_code, + playlist_id + } + }, + + /** + * Removes videos from a given playlist. + * + * @param {string} playlist_id + * @param {Array.} video_ids + * @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>} + */ + removeVideos: async (playlist_id, video_ids) => { + const plinfo = await Actions.browse(this, 'playlist', { browse_id: `VL${playlist_id}` }); + const list = Utils.findNode(plinfo.data, 'contents', 'contents', 13, false); + + if (!list.isEditable) throw new Utils.InnertubeError('This playlist cannot be edited.', playlist_id); + + const videos = list.contents.filter((item) => video_ids.includes(item.playlistVideoRenderer.videoId)); + const set_video_ids = videos.map((video) => video.playlistVideoRenderer.setVideoId); + + const response = await Actions.playlist(this, 'browse/edit_playlist', { + action: 'ACTION_REMOVE_VIDEO', + playlist_id, + ids: set_video_ids + }); + + if (!response.success) return response; + + return { + success: true, + status_code: response.status_code, + playlist_id + } + } }; } @@ -510,7 +573,7 @@ class Innertube { return trending; } - + /** * Retrieves your subscriptions feed. * @returns {Promise.<{ items: [{ date: string; videos: [] }] }>} @@ -565,7 +628,7 @@ class Innertube { const lyrics_tab = Utils.findNode(continuation, 'contents', 'Lyrics', 8, false); - const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.endpoint?.browseEndpoint.browseId }); + const response = await Actions.browse(this, 'lyrics', { browse_id: lyrics_tab.endpoint?.browseEndpoint.browseId }); if (!response.success || !response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id }); const lyrics = Utils.findNode(response.data, 'contents', 'runs', 6, false); @@ -583,7 +646,7 @@ class Innertube { * { title: string; description: string; total_items: number; duration: string; year: string; items: [] }>} */ async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) { - const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { ytmusic: options.client == 'YTMUSIC', browse_id: `VL${playlist_id}` }); + const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { browse_id: `VL${playlist_id}` }); if (!response.success) throw new Utils.InnertubeError('Could not get playlist', response); const playlist = new Parser(this, response.data, { @@ -836,4 +899,4 @@ class Innertube { } } -module.exports = Innertube; \ No newline at end of file +module.exports = Innertube; diff --git a/lib/core/Actions.js b/lib/core/Actions.js index abe696d4..e72c7f3e 100644 --- a/lib/core/Actions.js +++ b/lib/core/Actions.js @@ -46,20 +46,6 @@ async function engage(session, engagement_type, args = {}) { })[args.comment_action](); data.actions = [ action ]; break; - case 'playlist/create': - data.title = args.title; - data.videoIds = [args.video_id]; - break; - case 'playlist/delete': - data.playlistId = args.playlist_id; - break; - case 'browse/edit_playlist': - data.playlistId = args.playlist_id; - data.actions = args.video_ids.map((id) => ({ - action: args.action, - addedVideoId: id - })); - break; default: throw new Utils.InnertubeError('Invalid action', engagement_type); } @@ -100,6 +86,9 @@ async function browse(session, action, args = {}) { case 'home_feed': data.browseId = 'FEwhat_to_watch'; break; + case 'library': + data.browseId = 'FElibrary'; + break; case 'trending': data.browseId = 'FEtrending'; args.params && (data.params = args.params); @@ -107,6 +96,10 @@ async function browse(session, action, args = {}) { case 'subscriptions_feed': data.browseId = 'FEsubscriptions'; break; + case 'channel': + case 'playlist': + data.browseId = args.browse_id; + break; case 'lyrics': case 'music_playlist': const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it @@ -118,10 +111,6 @@ async function browse(session, action, args = {}) { data.context = context; data.browseId = args.browse_id; break; - case 'channel': - case 'playlist': - data.browseId = args.browse_id; - break; case 'continuation': data.continuation = args.ctoken; break; @@ -172,6 +161,44 @@ async function flag(session, action, args = {}) { }; } +async function playlist(session, action, args = {}) { + const data = { context: session.context }; + + switch (action) { + case 'playlist/create': + data.title = args.title; + data.videoIds = args.ids; + break; + case 'playlist/delete': + data.playlistId = args.playlist_id; + break; + case 'browse/edit_playlist': + data.playlistId = args.playlist_id; + data.actions = args.ids.map((id) => ({ + 'ACTION_ADD_VIDEO': { + action: args.action, + addedVideoId: id + }, + 'ACTION_REMOVE_VIDEO': { + action: args.action, + setVideoId: id + } + })[args.action]); + break; + default: + throw new Utils.InnertubeError('Invalid action', action); + } + + const response = await session.request.post(`/${action}`, JSON.stringify(data)).catch((error) => error); + if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message }; + + return { + success: true, + status_code: response.status, + data: response.data + }; +} + /** * Account settings endpoints. * @@ -458,4 +485,4 @@ async function getSearchSuggestions(session, client, input) { return response; } -module.exports = { engage, browse, account, flag, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions }; \ No newline at end of file +module.exports = { engage, browse, account, playlist, flag, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions }; \ No newline at end of file diff --git a/lib/parser/index.js b/lib/parser/index.js index 463c6098..d416ee53 100644 --- a/lib/parser/index.js +++ b/lib/parser/index.js @@ -28,6 +28,7 @@ class Parser { PLAYLIST: () => this.#processPlaylist(), SUBSFEED: () => this.#processSubscriptionFeed(), HOMEFEED: () => this.#processHomeFeed(), + //LIBRARY: () => this.#processLibrary(), WIP TRENDING: () => this.#processTrending(), HISTORY: () => this.#processHistory(), COMMENTS: () => this.#processComments(), @@ -130,7 +131,7 @@ class Parser { #processPlaylist() { const details = this.data.sidebar.playlistSidebarRenderer.items[0]; - + const metadata = { title: this.data.metadata.playlistMetadataRenderer.title, description: details.playlistSidebarPrimaryInfoRenderer?.description?.simpleText || 'N/A', @@ -138,7 +139,7 @@ class Parser { last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1]?.text || 'N/A', views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText } - + const list = Utils.findNode(this.data, 'contents', 'contents', 13, false); const items = YTDataItems.PlaylistItem.parse(list.contents); @@ -315,6 +316,8 @@ class Parser { } }).filter((c) => c); + response.comment = (text) => this.session.interact.comment(text, this.args.video_id); + response.getContinuation = async () => { const continuation_item = items.find((item) => item.continuationItemRenderer); if (!continuation_item) throw new Utils.InnertubeError('You\'ve reached the end'); @@ -354,6 +357,23 @@ class Parser { return parseItems(contents); } + // WIP + #processLibrary() { + const profile_data = Utils.findNode(this.data, 'contents', 'profileColumnRenderer', 3); + + const stats = profile_data.profileColumnRenderer.items.find((item) => item.profileColumnStatsRenderer); + const userinfo = profile_data.profileColumnRenderer.items.find((item) => item.profileColumnUserInfoRenderer); + + const profile = { + name: userinfo.profileColumnUserInfoRenderer?.title?.simpleText, + thumbnails: userinfo.profileColumnUserInfoRenderer?.thumbnail.thumbnails + } + + return { + profile + } + } + #processSubscriptionFeed() { const contents = Utils.findNode(this.data, 'contents', 'contents', 9, false);