From bad6072d7085678e79a3e7d23b8abf46501183ad Mon Sep 17 00:00:00 2001 From: Max Nuding Date: Tue, 15 Jul 2025 14:23:36 +0200 Subject: [PATCH] Add album to playlist for youtube --- src/lib/server/playlist/youtubeResponse.ts | 81 +++++++++++ src/lib/server/playlist/ytPlaylistAdder.ts | 157 +++++++++++++++------ 2 files changed, 196 insertions(+), 42 deletions(-) create mode 100644 src/lib/server/playlist/youtubeResponse.ts diff --git a/src/lib/server/playlist/youtubeResponse.ts b/src/lib/server/playlist/youtubeResponse.ts new file mode 100644 index 0000000..7883bef --- /dev/null +++ b/src/lib/server/playlist/youtubeResponse.ts @@ -0,0 +1,81 @@ +export type YoutubePlaylistItem = { + kind: 'youtube#playlistItem'; + etag: string; + id: string; + snippet: { + resourceId: { + kind: string; + videoId: string; + }; + }; +}; +export type YoutubePlaylistItemResponse = YoutubeResponse & { + kind: 'youtube#playlistItemListResponse'; + etag: string; + nextPageToken: string; + prevPageToken: string; + pageInfo: { + totalResults: number; + resultsPerPage: number; + }; + items: YoutubePlaylistItem[]; +}; +export type YoutubeResponse = { + error?: YoutubeErrorResponse; +}; +export type YoutubeErrorResponse = { + errors: YoutubeError[]; + code: number; + message: string; +}; + +export type YoutubeError = { + domain: 'global' | string; + reason: YoutubeErrorReason; + message: string; + locationType: string; + location: string; +}; + +export type YoutubeErrorReason = + | 'movedPermanently' + | 'seeOther' + | 'mediaDownloadRedirect' + | 'notModified' + | 'temporaryRedirect' + | 'badRequest' + | 'badBinaryDomainRequest' + | 'badContent' + | 'badLockedDomainRequest' + | 'corsRequestWithXOrigin' + | 'endpointConstraintMismatch' + | 'invalid' + | 'invalidAltValue' + | 'invalidHeader' + | 'invalidParameter' + | 'invalidQuery' + | 'keyExpired' + | 'keyInvalid' + | 'lockedDomainCreationFailure' + | 'notDownload' + | 'notUpload' + | 'parseError' + | 'required' + | 'tooManyParts' + | 'unknownApi' + | 'unsupportedMediaProtocol' + | 'unsupportedOutputFormat' + | 'wrongUrlForUpload' + | 'unauthorized' + | 'authError' + | 'expired' + | 'lockedDomainExpired' + | 'required' + | 'dailyLimitExceeded402' + | 'quotaExceeded402' + | 'user402' + | 'quotaExceeded' + | 'rateLimitExceeded' + | 'limitExceeded' + | 'unknownAuth' + | string; // many more diff --git a/src/lib/server/playlist/ytPlaylistAdder.ts b/src/lib/server/playlist/ytPlaylistAdder.ts index 102c2be..76d40c9 100644 --- a/src/lib/server/playlist/ytPlaylistAdder.ts +++ b/src/lib/server/playlist/ytPlaylistAdder.ts @@ -1,6 +1,10 @@ import { YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, YOUTUBE_PLAYLIST_ID } from '$env/static/private'; import { Logger } from '$lib/log'; import type { OauthResponse } from '$lib/mastodon/response'; +import type { + YoutubePlaylistItemResponse, + YoutubeResponse +} from '$lib/server/playlist/youtubeResponse'; import type { SongInfo } from '$lib/odesliResponse'; import { OauthPlaylistAdder } from './oauthPlaylistAdder'; import type { PlaylistAdder } from './playlistAdder'; @@ -42,6 +46,9 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist if (token == null) { return; } + } + + private async checkAuthorizedToken(token: OauthResponse) { try { this.logger.debug('Checking authorized token'); const res = await fetch(this.apiBase + '/channels?part=id&mine=true', { @@ -50,7 +57,7 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist }).then((r) => r.json()); this.logger.debug('Checked authorized token', res); } catch (e) { - this.logger.debug('Error checking authorized token', e); + this.logger.error('Error checking authorized token', e); } } @@ -69,25 +76,22 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist } const tokenUrl = new URL('https://oauth2.googleapis.com/token'); - return await this.requestRefreshToken( + const refreshedToken = await this.requestRefreshToken( tokenUrl, YOUTUBE_CLIENT_ID, token.refresh_token, this.getRedirectUri('ytauth').toString(), YOUTUBE_CLIENT_SECRET ); + if (refreshedToken !== null) { + await this.checkAuthorizedToken(refreshedToken); + } + return refreshedToken; } public async addToPlaylist(song: SongInfo) { - await this.addToPlaylistRetry(song); - } - - private async addToPlaylistRetry(song: SongInfo, remaning: number = 3) { - if (remaning < 0) { - this.logger.error('max retries reached, song will not be added to spotify playlist'); - } this.logger.debug('addToYoutubePlaylist'); - const token = await this.refreshToken(); + let token = await this.refreshToken(); if (token == null) { return; } @@ -102,28 +106,82 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist } const songUrl = new URL(song.youtubeUrl); - const youtubeId = songUrl.searchParams.get('v'); - if (!youtubeId) { - this.logger.debug( - 'Skip adding song to YT playlist, could not extract YT id from URL', - song.youtubeUrl + let videoIds: string[] = []; + if (song.type === 'album') { + const albumPlaylistId = songUrl.searchParams.get('list') ?? ''; + const albumItemsUrl = new URL(this.apiBase + '/playlistItems'); + albumItemsUrl.searchParams.append('maxResults', '50'); + albumItemsUrl.searchParams.append('playlistId', albumPlaylistId); + albumItemsUrl.searchParams.append('part', 'snippet'); + const albumPlaylistItem = await fetch(albumItemsUrl, { + headers: { Authorization: `${token.token_type} ${token.access_token}` } + }).then((r) => r.json()); + const albumTracks: any[] = albumPlaylistItem.items ?? []; + videoIds = albumTracks.map((x) => x.snippet?.resourceId?.videoId).filter((x) => x); + this.logger.info( + 'Found', + albumPlaylistItem.pageInfo?.totalResults, + 'songs in album, received', + albumTracks.length ); - return; - } - this.logger.debug('Found YT id from URL', song.youtubeUrl, youtubeId); - - const playlistItemsUrl = new URL(this.apiBase + '/playlistItems'); - playlistItemsUrl.searchParams.append('videoId', youtubeId); - playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID); - playlistItemsUrl.searchParams.append('part', 'id'); - const existingPlaylistItem = await fetch(playlistItemsUrl, { - headers: { Authorization: `${token.token_type} ${token.access_token}` } - }).then((r) => r.json()); - if (existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0) { - this.logger.info('Item already in playlist', existingPlaylistItem); - return; + this.logger.debug(videoIds); + if (videoIds.length === 0) { + this.logger.debug( + 'Skip adding album to YT playlist, is empty', + song.youtubeUrl, + albumPlaylistItem + ); + return; + } + } else { + const youtubeId = songUrl.searchParams.get('v'); + if (!youtubeId) { + this.logger.debug( + 'Skip adding song to YT playlist, could not extract YT id from URL', + song.youtubeUrl + ); + return; + } + this.logger.debug('Found YT id from URL', song.youtubeUrl, youtubeId); + videoIds.push(youtubeId); } + for (let youtubeId of videoIds) { + this.logger.debug('Adding to playlist', youtubeId); + const alreadyAdded = await this.isVideoInPlaylist(youtubeId, token); + if (alreadyAdded) { + this.logger.info('Item already in playlist', song.youtubeUrl, song.title); + continue; + } else { + this.logger.debug('Item not already in playlist', song.youtubeUrl, song.title); + } + + let retries = 3; + let success = false; + while (retries > 0 && !success) { + try { + this.logger.debug('Retries', retries); + await this.addSongToPlaylist(youtubeId, token); + success = true; + this.logger.info('Added to playlist', youtubeId, song.title); + } catch (e) { + retries--; + if (e instanceof Error && e.message === 'authError') { + this.logger.info('Refreshing auth token'); + token = await this.refreshToken(true); + if (token == null) { + this.logger.error('Refreshing auth token failed'); + return; + } + } else { + this.logger.error('Add to playlist failed', e); + } + } + } + } + } + + private async addSongToPlaylist(videoId: string, token: OauthResponse): Promise { const addItemUrl = new URL(this.apiBase + '/playlistItems'); addItemUrl.searchParams.append('part', 'snippet'); const options: RequestInit = { @@ -133,25 +191,40 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist snippet: { playlistId: YOUTUBE_PLAYLIST_ID, resourceId: { - videoId: youtubeId, + videoId: videoId, kind: 'youtube#video' } } }) }; - const resp = await fetch(addItemUrl, options); - const respObj = await resp.json(); - this.logger.info('Added to playlist', youtubeId, song.title); + const request = new Request(addItemUrl, options); + const resp = await fetch(request); + let respObj: YoutubeResponse = await resp.json(); if (respObj.error) { - this.logger.error('Add to playlist failed', respObj.error.errors); - if (respObj.error.errors && respObj.error.errors[0].reason === 'authError') { - this.logger.info('Refreshing auth token'); - const token = await this.refreshToken(true); - if (token == null) { - return; - } - this.addToPlaylistRetry(song, remaning--); - } + this.logger.error( + 'Add to playlist failed', + respObj.error.errors, + respObj.error.code, + respObj.error.message + ); + throw new Error(respObj.error.errors[0].reason); } } + + private async isVideoInPlaylist(videoId: string, token: OauthResponse): Promise { + const playlistItemsUrl = new URL(this.apiBase + '/playlistItems'); + playlistItemsUrl.searchParams.append('videoId', videoId); + playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID); + playlistItemsUrl.searchParams.append('part', 'id'); + const existingPlaylistItem: YoutubePlaylistItemResponse = await fetch(playlistItemsUrl, { + headers: { Authorization: `${token.token_type} ${token.access_token}` } + }).then((r) => r.json()); + if (existingPlaylistItem.error) { + this.logger.error( + 'Could not check if item is already in playlist', + existingPlaylistItem.error + ); + } + return existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0; + } }