refactor how youtube API requests are retried and auth errors are handled fix #48

This commit is contained in:
2025-07-17 12:51:01 +02:00
parent d99e840e8d
commit 9a59bfbec4

View File

@ -89,6 +89,63 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
return refreshedToken; return refreshedToken;
} }
private async getVideoUris(song: SongInfo, token: OauthResponse): Promise<string[]> {
this.logger.debug('getVideoUris');
if (!song.youtubeUrl) {
this.logger.info('Skip adding song to YT playlist, no youtube Url', song);
return [];
}
const songUrl = new URL(song.youtubeUrl);
let videoIds: string[] = [];
if (song.type === 'song') {
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);
return [youtubeId];
}
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 albumPlaylistItemRequest = new Request(albumItemsUrl, {
headers: { Authorization: `${token.token_type} ${token.access_token}` }
});
const albumPlaylistItem = await this.fetchWithRetry<YoutubePlaylistItemResponse>(
albumPlaylistItemRequest,
token
);
if (albumPlaylistItem === null) {
this.logger.info('Could not check album tracks');
return [];
}
const albumTracks = 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
);
if (videoIds.length === 0) {
this.logger.debug(
'Skip adding album to YT playlist, is empty',
song.youtubeUrl,
albumPlaylistItem
);
}
return videoIds;
}
public async addToPlaylist(song: SongInfo) { public async addToPlaylist(song: SongInfo) {
this.logger.debug('addToYoutubePlaylist'); this.logger.debug('addToYoutubePlaylist');
let token = await this.refreshToken(); let token = await this.refreshToken();
@ -100,60 +157,8 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
this.logger.debug('no playlist ID configured'); this.logger.debug('no playlist ID configured');
return; return;
} }
if (!song.youtubeUrl) {
this.logger.info('Skip adding song to YT playlist, no youtube Url', song);
return;
}
const songUrl = new URL(song.youtubeUrl); const videoIds = await this.getVideoUris(song, token);
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 albumPlaylistItemRequest = new Request(albumItemsUrl, {
headers: { Authorization: `${token.token_type} ${token.access_token}` }
});
const albumPlaylistItem = await fetch(albumPlaylistItemRequest).then((r) => r.json());
if (albumPlaylistItem.error) {
this.logger.info(
'Could not check album tracks',
albumPlaylistItem.error,
'request',
albumPlaylistItemRequest
);
}
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
);
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) { for (let youtubeId of videoIds) {
this.logger.debug('Adding to playlist', youtubeId); this.logger.debug('Adding to playlist', youtubeId);
@ -165,32 +170,14 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
this.logger.debug('Item not already in playlist', song.youtubeUrl, song.title); this.logger.debug('Item not already in playlist', song.youtubeUrl, song.title);
} }
let retries = 3; const success = await this.addSongToPlaylist(youtubeId, token);
let success = false; if (success) {
while (retries > 0 && !success) { this.logger.info('Added to playlist', youtubeId, song.title);
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> { private async addSongToPlaylist(videoId: string, token: OauthResponse): Promise<boolean> {
const addItemUrl = new URL(this.apiBase + '/playlistItems'); const addItemUrl = new URL(this.apiBase + '/playlistItems');
addItemUrl.searchParams.append('part', 'snippet'); addItemUrl.searchParams.append('part', 'snippet');
const options: RequestInit = { const options: RequestInit = {
@ -207,26 +194,47 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
}) })
}; };
const request = new Request(addItemUrl, options); const request = new Request(addItemUrl, options);
const resp = await fetch(request); const resp = await this.fetchWithRetry(request, token);
let respObj: YoutubeResponse = await resp.json(); return resp !== null;
if (respObj.error) {
this.logger.error(
'Add to playlist failed',
respObj.error.errors,
respObj.error.code,
respObj.error.message,
'request',
request
);
throw new Error(respObj.error.errors[0].reason);
}
} }
private async isVideoInPlaylist( private async fetchWithRetry<T extends YoutubeResponse>(
videoId: string, request: Request,
token: OauthResponse, token: OauthResponse,
retry: boolean = true retries: number = 3
): Promise<boolean> { ): Promise<T | null> {
if (retries <= 0) {
return null;
}
request.headers.set('Authorization', `${token.token_type} ${token.access_token}`);
const resp = await fetch(request);
let respObj: T = await resp.json();
if (!respObj.error) {
return respObj;
}
this.logger.error(
'Request failed',
respObj.error.errors,
respObj.error.code,
respObj.error.message
);
if (
respObj.error.code === 401 ||
respObj.error.code === 403 ||
respObj.error.errors.some((x) => x.reason === 'authError')
) {
this.logger.info('Refreshing auth token');
const newToken = await this.refreshToken(true);
if (newToken == null) {
this.logger.error('Refreshing auth token failed');
return null;
}
return await this.fetchWithRetry(request, newToken, retries - 1);
}
return null;
}
private async isVideoInPlaylist(videoId: string, token: OauthResponse): Promise<boolean> {
const playlistItemsUrl = new URL(this.apiBase + '/playlistItems'); const playlistItemsUrl = new URL(this.apiBase + '/playlistItems');
playlistItemsUrl.searchParams.append('videoId', videoId); playlistItemsUrl.searchParams.append('videoId', videoId);
playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID); playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID);
@ -234,28 +242,14 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
let existingPlaylistItemRequest = new Request(playlistItemsUrl, { let existingPlaylistItemRequest = new Request(playlistItemsUrl, {
headers: { Authorization: `${token.token_type} ${token.access_token}` } headers: { Authorization: `${token.token_type} ${token.access_token}` }
}); });
let existingPlaylistItem: YoutubePlaylistItemResponse = await fetch(
existingPlaylistItemRequest
).then((r) => r.json());
if (existingPlaylistItem.error) {
this.logger.error(
'Could not check if item is already in playlist',
existingPlaylistItem.error,
'request',
existingPlaylistItemRequest
);
if (existingPlaylistItem.error.code === 401) { const existingPlaylistItem = await this.fetchWithRetry<YoutubePlaylistItemResponse>(
const newToken = await this.refreshToken(true); existingPlaylistItemRequest,
if (newToken === null) { token
return false; );
} if (existingPlaylistItem === null) {
if (retry) { this.logger.error('Could not check if item is already in playlist');
return await this.isVideoInPlaylist(videoId, newToken, false); return false;
} else {
return false;
}
}
} }
return existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0; return existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0;
} }