Add album to playlist for youtube
This commit is contained in:
81
src/lib/server/playlist/youtubeResponse.ts
Normal file
81
src/lib/server/playlist/youtubeResponse.ts
Normal file
@ -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
|
@ -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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user