mixins/playlists.js

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PlaylistsMixin = void 0;
const helpers_1 = require("../helpers");
const parsers_1 = require("../parsers");
const playlists_1 = require("../parsers/playlists");
const utils_1 = require("../parsers/utils");
/**
 * @module Playlists
 */
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const PlaylistsMixin = (Base) => {
    return class PlaylistsMixin extends Base {
        /**
         * Return a list of playlist items.
         * @param {string} [playlistId ] Playlist id.
         * @param {number} [limit=100] How many songs to return.
         * @example <caption>Each item is in the following format</caption>
         * {
         *   "id": "PLQwVIlKxHM6qv-o99iX9R85og7IzF9YS_",
         *   "privacy": "PUBLIC",
         *   "title": "New EDM This Week 03/13/2020",
         *   "thumbnails": [...]
         *   "description": "Weekly r/EDM new release roundup. Created with github.com/sigma67/spotifyplaylist_to_gmusic",
         *   "author": "sigmatics",
         *   "year": "2020",
         *   "duration": "6+ hours",
         *   "duration_seconds": 52651,
         *   "trackCount": 237,
         *   "tracks": [
         *     {
         *       "videoId": "bjGppZKiuFE",
         *       "title": "Lost",
         *       "artists": [
         *         {
         *           "name": "Guest Who",
         *           "id": "UCkgCRdnnqWnUeIH7EIc3dBg"
         *         },
         *         {
         *           "name": "Kate Wild",
         *           "id": "UCwR2l3JfJbvB6aq0RnnJfWg"
         *         }
         *       ],
         *       "album": {
         *       "name": "Lost",
         *       "id": "MPREb_PxmzvDuqOnC"
         *     },
         *     "duration": "2:58",
         *     "likeStatus": "INDIFFERENT",
         *     "thumbnails": [...],
         *     "isAvailable": True,
         *     "isExplicit": False,
         *     "feedbackTokens": {
         *       "add": "AB9zfpJxtvrU...",
         *       "remove": "AB9zfpKTyZ..."
         *     }
         *   ]
         * }
         */
        async getPlaylist(playlistId, limit = 100) {
            const browseId = !playlistId.startsWith('VL')
                ? `VL${playlistId}`
                : playlistId;
            const body = { browseId: browseId };
            const endpoint = 'browse';
            const response = await this._sendRequest(endpoint, body);
            const results = (0, utils_1.nav)(response, [
                ...parsers_1.SINGLE_COLUMN_TAB,
                ...parsers_1.SECTION_LIST_ITEM,
                'musicPlaylistShelfRenderer',
            ]);
            const playlist = {
                id: results['playlistId'],
            };
            const ownPlaylist = 'musicEditablePlaylistDetailHeaderRenderer' in response['header'];
            let header;
            if (!ownPlaylist) {
                header = response['header']['musicDetailHeaderRenderer'];
                playlist['privacy'] = 'PUBLIC';
            }
            else {
                header =
                    response['header']['musicEditablePlaylistDetailHeaderRenderer'];
                playlist['privacy'] =
                    header['editHeader']['musicPlaylistEditHeaderRenderer']['privacy'];
                header = header['header']['musicDetailHeaderRenderer'];
            }
            playlist['title'] = (0, utils_1.nav)(header, parsers_1.TITLE_TEXT);
            playlist['thumbnails'] = (0, utils_1.nav)(header, parsers_1.THUMBNAIL_CROPPED);
            playlist['description'] = (0, utils_1.nav)(header, parsers_1.DESCRIPTION, true);
            const runCount = header['subtitle']['runs'].length;
            if (runCount > 1) {
                playlist['author'] = {
                    name: (0, utils_1.nav)(header, parsers_1.SUBTITLE2),
                    id: (0, utils_1.nav)(header, ['subtitle', 'runs', 2, ...parsers_1.NAVIGATION_BROWSE_ID], true),
                };
                if (runCount == 5) {
                    playlist['year'] = (0, utils_1.nav)(header, parsers_1.SUBTITLE3);
                }
            }
            const songCount = (0, helpers_1.toInt)(header['secondSubtitle']['runs'][0]['text'].normalize('NFKD'));
            if (header['secondSubtitle']['runs'].length > 1) {
                playlist['duration'] = header['secondSubtitle']['runs'][2]['text'];
            }
            playlist['trackCount'] = songCount;
            playlist['suggestions_token'] = (0, utils_1.nav)(response, [
                ...parsers_1.SINGLE_COLUMN_TAB,
                'sectionListRenderer',
                'contents',
                1,
                ...parsers_1.MUSIC_SHELF,
                ...parsers_1.RELOAD_CONTINUATION,
            ], true);
            playlist['tracks'] = [];
            if (songCount > 0) {
                playlist['tracks'] = [
                    ...playlist['tracks'],
                    ...(0, playlists_1.parsePlaylistItems)(results['contents']),
                ];
                const songsToGet = Math.min(limit, songCount);
                if ('continuations' in results) {
                    const requestFunc = async (additionalParams) => await this._sendRequest(endpoint, body, additionalParams);
                    const parseFunc = (contents) => (0, playlists_1.parsePlaylistItems)(contents);
                    playlist['tracks'] = [
                        ...playlist['tracks'],
                        ...(await (0, utils_1.getContinuations)(results, 'musicPlaylistShelfContinuation', songsToGet - playlist['tracks'].length, requestFunc, parseFunc)),
                    ];
                }
            }
            //For some reason we are able to go over limit, so manually truncate at the end @codyduong TODO
            playlist['tracks'] = playlist['tracks'].slice(0, limit);
            playlist['duration_seconds'] = (0, helpers_1.sumTotalDuration)(playlist);
            return playlist;
        }
        /**
         * Gets suggested tracks to add to a playlist. Suggestions are offered for playlists with less than 100 tracks
         * @param suggestionsToken Token returned by `getPlaylist` or this function
         * @returns Object containing suggested `tracks` and a `refresh_token` to get another set of suggestions.
         * For data format of tracks, check `getPlaylist`
         */
        async getPlaylistSuggestions(suggestionsToken) {
            if (!suggestionsToken) {
                throw new Error('Suggestions token is undefined.\nPlease ensure the playlist is small enough to receive suggestions.');
            }
            const endpoint = 'browse';
            const additionalParams = (0, utils_1.getContinuationString)(suggestionsToken);
            const response = this._sendRequest(endpoint, {}, additionalParams);
            const results = (0, utils_1.nav)(response, [
                'continuationContents',
                'musicShelfContinuation',
            ]);
            const refreshToken = (0, utils_1.nav)(results, parsers_1.RELOAD_CONTINUATION);
            const suggestions = (0, playlists_1.parsePlaylistItems)(results['contents']);
            return { tracks: suggestions, refresh_token: refreshToken };
        }
        /**
         * Creates a new empty playlist and returns its id.
         * @param title Playlist title.
         * @param description Optional. Playlist description.
         * @param {string} [privacyStatus='PRIVATE'] Playlists can be 'PUBLIC', 'PRIVATE', or 'UNLISTED'. Default: 'PRIVATE'.
         * @param options
         * @param {string[]} [options.videoIds] IDs of songs to create the playlist with.
         * @param {string} [options.sourcePlaylist] Another playlist whose songs should be added to the new playlist.
         * @returns ID of the YouTube playlist or full response if there was an error.
         */
        async createPlaylist(title, description, privacyStatus, options) {
            this._checkAuth();
            let actualDescription, actualPrivacyStatus, actualVideoIds, actualSourcePlaylist;
            if (typeof description == 'object') {
                actualDescription = description.description;
                actualPrivacyStatus = description.privacyStatus;
                actualVideoIds = description.videoIds;
                actualSourcePlaylist = description.sourcePlaylist;
            }
            else if (typeof privacyStatus == 'object') {
                actualDescription = description;
                actualPrivacyStatus = privacyStatus.privacyStatus;
                actualVideoIds = privacyStatus.videoIds;
                actualSourcePlaylist = privacyStatus.sourcePlaylist;
            }
            else {
                actualDescription = description;
                actualPrivacyStatus = privacyStatus;
                actualVideoIds = options?.videoIds;
                actualSourcePlaylist = options?.sourcePlaylist;
            }
            const body = {
                title: title,
                description: (0, helpers_1.htmlToText)(actualDescription ?? ''),
                privacyStatus: actualPrivacyStatus,
            };
            if (actualVideoIds) {
                body['videoIds'] = actualVideoIds;
            }
            if (actualSourcePlaylist) {
                {
                    body['sourcePlaylistId'] = actualSourcePlaylist;
                }
            }
            const endpoint = 'playlist/create';
            const response = await this._sendRequest(endpoint, body);
            return 'playlistId' in response ? response['playlistId'] : response;
        }
        /**
         * Edit title, description or privacyStatus of a playlist.
         * You may also move an item within a playlist or append another playlist to this playlist.
         * @param playlistId Playlist id.
         * @param options
         * @param {string} [options.title=] New title for the playlist.
         * @param {string} [options.description=] New description for the playlist.
         * @param {pt.PrivacyStatu} [options.privacyStatus=] New privacy status for the playlist.
         * @param {string} [options.moveItem=] Move one item before another. Items are specified by setVideoId, see `getPlaylist`.
         * @param {string} [options.addPlaylistId=] Id of another playlist to add to this playlist
         * @return Status String or full response
         */
        async editPlaylist(playlistId, options) {
            this._checkAuth();
            const { title, description, privacyStatus, moveItem, addPlaylistId } = options;
            const body = {
                playlistId: (0, utils_1.validatePlaylistId)(playlistId),
            };
            const actions = [];
            if (title) {
                {
                    actions.push({
                        action: 'ACTION_SET_PLAYLIST_NAME',
                        playlistName: title,
                    });
                }
            }
            if (description) {
                actions.push({
                    action: 'ACTION_SET_PLAYLIST_DESCRIPTION',
                    playlistDescription: description,
                });
            }
            if (privacyStatus) {
                actions.push({
                    action: 'ACTION_SET_PLAYLIST_PRIVACY',
                    playlistPrivacy: privacyStatus,
                });
            }
            if (moveItem) {
                actions.push({
                    action: 'ACTION_MOVE_VIDEO_BEFORE',
                    setVideoId: moveItem[0],
                    movedSetVideoIdSuccessor: moveItem[1],
                });
            }
            if (addPlaylistId) {
                actions.push({
                    action: 'ACTION_ADD_PLAYLIST',
                    addedFullListId: addPlaylistId,
                });
            }
            body['actions'] = actions;
            const endpoint = 'browse/edit_playlist';
            const response = await this._sendRequest(endpoint, body);
            return 'status' in response ? response['status'] : response;
        }
        /**
         * Delete a playlist.
         * @param {string} [playlistId] Playlist id.
         * @returns Status String or full response.
         */
        async deletePlaylist(playlistId) {
            this._checkAuth();
            const body = { playlistId: (0, utils_1.validatePlaylistId)(playlistId) };
            const endpoint = 'playlist/delete';
            const response = await this._sendRequest(endpoint, body);
            return 'status' in response ? response['status'] : response;
        }
        /**
         * Add songs to an existing playlist
         * @param playlistId Playlist id.
         * @param {string[]} [options.videoIds] IDs of songs to create the playlist with.
         * @param {string} [options.sourcePlaylist] Another playlist whose songs should be added to the new playlist.
         * @param {boolean} [options.duplicates=false]  If true, duplicates will be added. If false, an error will be returned if there are duplicates (no items are added to the playlist)
         * @returns Status String and an object containing the new setVideoId for each videoId or full response.
         */
        async addPlaylistItems(playlistId, options) {
            this._checkAuth();
            const { videoIds, sourcePlaylist, duplicates } = options;
            const body = {
                playlistId: (0, utils_1.validatePlaylistId)(playlistId),
                actions: [],
            };
            if (!videoIds && !sourcePlaylist) {
                throw new Error('You must provide either videoIds or a source_playlist to add to the playlist');
            }
            if (videoIds) {
                for (const videoId of videoIds) {
                    const action = {
                        action: 'ACTION_ADD_VIDEO',
                        addedVideoId: videoId,
                    };
                    if (duplicates) {
                        action['dedupeOption'] = 'DEDUPE_OPTION_SKIP';
                    }
                    body['actions'].push(action);
                }
            }
            if (sourcePlaylist) {
                body['actions'].push({
                    action: 'ACTION_ADD_PLAYLIST',
                    addedFullListId: sourcePlaylist,
                });
                // add an empty ACTION_ADD_VIDEO because otherwise
                // YTM doesn't return the dict that maps videoIds to their new setVideoIds
                if (!videoIds) {
                    body['actions'].push({
                        action: 'ACTION_ADD_VIDEO',
                        addedVideoId: null,
                    });
                }
            }
            const endpoint = 'browse/edit_playlist';
            const response = await this._sendRequest(endpoint, body);
            if ('status' in response && response['status'].includes('SUCCEEDED')) {
                const resultArray = [
                    (response['playlistEditResults'] ?? []).map((resultData) => resultData['playlistEditVideoAddedResultData']),
                ];
                return { status: response['status'], playlistEditResults: resultArray };
            }
            else {
                return response;
            }
        }
        /**
         * Remove songs from an existing playlist.
         * @param playlistId: Playlist id.
         * @param videos: List of PlaylistItems, see `getPlaylist`.
         *
         */
        async removePlaylistItems(playlistId, videos) {
            this._checkAuth();
            videos = videos.filter((x) => 'videoId' in x && 'setVideoId' in x);
            if (videos.length == 0) {
                throw new Error('Cannot remove songs, because setVideoId is missing. Do you own this playlist?');
            }
            const body = {
                playlistId: (0, utils_1.validatePlaylistId)(playlistId),
                actions: [],
            };
            for (const video of videos) {
                body['actions'].push({
                    setVideoId: video['setVideoId'],
                    removedVideoId: video['videoId'],
                    action: 'ACTION_REMOVE_VIDEO',
                });
            }
            const endpoint = 'browse/edit_playlist';
            const response = await this._sendRequest(endpoint, body);
            return 'status' in response ? response['status'] : response;
        }
    };
};
exports.PlaylistsMixin = PlaylistsMixin;