Compare commits
3 Commits
35572a48e7
...
main
Author | SHA1 | Date | |
---|---|---|---|
df35c48e8c
|
|||
44fc2bb621
|
|||
7cdfa00af5
|
@ -151,3 +151,5 @@ Other icons:
|
||||
- [error-warning-fill by remix icon](https://remixicon.com/icon/error-warning-fill)
|
||||
- [git-branch-fill by remix icon](https://remixicon.com/icon/git-branch-fill)
|
||||
- [rss-fill by remix icon](https://remixicon.com/icon/rss-line)
|
||||
- [spotify-fill by remix icon](https://remixicon.com/icon/spotify-fill)
|
||||
- [youtube-fill by remix icon](https://remixicon.com/icon/youtube-fill)
|
||||
|
@ -4,6 +4,7 @@ import type { Handle, HandleServerError } from '@sveltejs/kit';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import fs from 'fs/promises';
|
||||
import { close } from '$lib/server/db';
|
||||
import { version } from '$app/environment';
|
||||
|
||||
const logger = new Logger('App');
|
||||
|
||||
@ -15,7 +16,7 @@ if (process?.pid) {
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('App startup, PID', process?.pid);
|
||||
logger.log('App startup, version', version, 'PID', process?.pid);
|
||||
|
||||
logger.log('Debug log enabled', Logger.isDebugEnabled());
|
||||
TimelineReader.init();
|
||||
|
1
src/lib/assets/spotify-fill.svg
Normal file
1
src/lib/assets/spotify-fill.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12.001 2C6.50098 2 2.00098 6.5 2.00098 12C2.00098 17.5 6.50098 22 12.001 22C17.501 22 22.001 17.5 22.001 12C22.001 6.5 17.551 2 12.001 2ZM15.751 16.65C13.401 15.2 10.451 14.8992 6.95014 15.6992C6.60181 15.8008 6.30098 15.55 6.20098 15.25C6.10098 14.8992 6.35098 14.6 6.65098 14.5C10.451 13.6492 13.751 14 16.351 15.6C16.701 15.75 16.7501 16.1492 16.6018 16.45C16.4018 16.7492 16.0518 16.85 15.751 16.65ZM16.7501 13.95C14.051 12.3 9.95098 11.8 6.80098 12.8C6.40181 12.9 5.95098 12.7 5.85098 12.3C5.75098 11.9 5.95098 11.4492 6.35098 11.3492C10.001 10.25 14.501 10.8008 17.601 12.7C17.9018 12.8508 18.051 13.35 17.8018 13.7C17.551 14.05 17.101 14.2 16.7501 13.95ZM6.30098 9.75083C5.80098 9.9 5.30098 9.6 5.15098 9.15C5.00098 8.64917 5.30098 8.15 5.75098 7.99917C9.30098 6.94917 15.151 7.14917 18.8518 9.35C19.301 9.6 19.451 10.2 19.201 10.65C18.9518 11.0008 18.351 11.1492 17.9018 10.9C14.701 9 9.35098 8.8 6.30098 9.75083Z"></path></svg>
|
After Width: | Height: | Size: 1.0 KiB |
19
src/lib/assets/tidal.svg
Normal file
19
src/lib/assets/tidal.svg
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.98352,0,0,1,0.395532,0)">
|
||||
<rect x="-0.402" y="0" width="24.402" height="24" style="fill-opacity:0;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.755537,0,0,0.755537,0,0.100656)">
|
||||
<path d="M21.177,5.705L15.883,10.999L10.589,5.705L15.883,0.412L21.177,5.705Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.755537,0,0,0.755537,0,0.100656)">
|
||||
<path d="M21.177,16.294L15.883,21.588L10.589,16.294L15.883,10.999L21.177,16.294Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.755537,0,0,0.755537,0,0.100656)">
|
||||
<path d="M10.589,5.705L5.294,11L0,5.705L5.294,0.412L10.589,5.705Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.755537,0,0,0.755537,0,0.100656)">
|
||||
<path d="M31.766,5.705L26.472,11L21.177,5.705L26.472,0.412L31.766,5.705Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
src/lib/assets/youtube-fill.svg
Normal file
1
src/lib/assets/youtube-fill.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12.2439 4C12.778 4.00294 14.1143 4.01586 15.5341 4.07273L16.0375 4.09468C17.467 4.16236 18.8953 4.27798 19.6037 4.4755C20.5486 4.74095 21.2913 5.5155 21.5423 6.49732C21.942 8.05641 21.992 11.0994 21.9982 11.8358L21.9991 11.9884L21.9991 11.9991C21.9991 11.9991 21.9991 12.0028 21.9991 12.0099L21.9982 12.1625C21.992 12.8989 21.942 15.9419 21.5423 17.501C21.2878 18.4864 20.5451 19.261 19.6037 19.5228C18.8953 19.7203 17.467 19.8359 16.0375 19.9036L15.5341 19.9255C14.1143 19.9824 12.778 19.9953 12.2439 19.9983L12.0095 19.9991L11.9991 19.9991C11.9991 19.9991 11.9956 19.9991 11.9887 19.9991L11.7545 19.9983C10.6241 19.9921 5.89772 19.941 4.39451 19.5228C3.4496 19.2573 2.70692 18.4828 2.45587 17.501C2.0562 15.9419 2.00624 12.8989 2 12.1625V11.8358C2.00624 11.0994 2.0562 8.05641 2.45587 6.49732C2.7104 5.51186 3.45308 4.73732 4.39451 4.4755C5.89772 4.05723 10.6241 4.00622 11.7545 4H12.2439ZM9.99911 8.49914V15.4991L15.9991 11.9991L9.99911 8.49914Z"></path></svg>
|
After Width: | Height: | Size: 1.0 KiB |
@ -1,6 +1,10 @@
|
||||
<script>
|
||||
import git from '$lib/assets/git-branch-fill.svg';
|
||||
import rss from '$lib/assets/rss-fill.svg';
|
||||
import spotify from '$lib/assets/spotify-fill.svg';
|
||||
import youtube from '$lib/assets/youtube-fill.svg';
|
||||
import tidal from '$lib/assets/tidal.svg';
|
||||
import { version } from '$app/environment';
|
||||
</script>
|
||||
|
||||
<div class="footer">
|
||||
@ -16,7 +20,7 @@
|
||||
<div>
|
||||
<a href="https://phlaym.net/git/phlaym/moshing-mammut">
|
||||
<img alt="Git branch" src={git} class="icon" />
|
||||
<span class="label">Source Code</span>
|
||||
<span class="label"><span class="feedSuffix">Source Code </span>v{version}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
||||
@ -26,6 +30,30 @@
|
||||
<span class="label">RSS<span class="feedSuffix"> Feed</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
||||
<div>
|
||||
<a href="https://open.spotify.com/playlist/62B8GOmJE3YrASAXSQVRVU" target="_blank">
|
||||
<img alt="Spotify" src={spotify} class="icon" />
|
||||
<span class="label">Spotify</span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
||||
<div>
|
||||
<a
|
||||
href="https://www.youtube.com/playlist?list=PLrSjNPaM6N4S54jT5R-ebKAYLBIEDb8sX"
|
||||
target="_blank"
|
||||
>
|
||||
<img alt="Youtube" src={youtube} class="icon" />
|
||||
<span class="label">Youtube</span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
||||
<div>
|
||||
<a href="https://tidal.com/playlist/9f60278a-7b9b-459b-b7e5-65a7849fe498" target="_blank">
|
||||
<img alt="Tidal" src={tidal} class="icon" />
|
||||
<span class="label">Tidal</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
@ -131,11 +131,10 @@
|
||||
</picture>
|
||||
<a href={song.pageUrl ?? song.postedUrl} target="_blank">
|
||||
<div class="info">
|
||||
<picture>
|
||||
<picture class="cover">
|
||||
{@html getSourceSetHtml(song)}
|
||||
<img
|
||||
src={song.thumbnailUrl}
|
||||
class="cover"
|
||||
alt="Cover for {song.artistName} - {song.title}"
|
||||
loading="lazy"
|
||||
width={song.thumbnailWidth}
|
||||
|
@ -4,6 +4,7 @@ import { isTruthy } from '$lib/truthyString';
|
||||
const { DEV } = import.meta.env;
|
||||
|
||||
export const enableVerboseLog = isTruthy(env.VERBOSE);
|
||||
export const debugLogEnv = isTruthy(DEBUG_LOG);
|
||||
|
||||
/**
|
||||
* @deprecated Use the new {@link Logger} class instead.
|
||||
@ -42,7 +43,7 @@ export class Logger {
|
||||
public constructor(private name: string) {}
|
||||
|
||||
public static isDebugEnabled(): boolean {
|
||||
return !!DEBUG_LOG || DEV || enableVerboseLog;
|
||||
return debugLogEnv || DEV || enableVerboseLog;
|
||||
}
|
||||
public verbose(...params: any[]) {
|
||||
if (!enableVerboseLog) {
|
||||
|
@ -145,7 +145,27 @@ export abstract class OauthPlaylistAdder {
|
||||
redirect_uri?: string,
|
||||
client_secret?: string,
|
||||
customHeader?: HeadersInit
|
||||
) {
|
||||
): Promise<OauthResponse | null> {
|
||||
return (
|
||||
await this.requestRefreshTokenWithHeaders(
|
||||
tokenUrl,
|
||||
clientId,
|
||||
refresh_token,
|
||||
redirect_uri,
|
||||
client_secret,
|
||||
customHeader
|
||||
)
|
||||
).resp;
|
||||
}
|
||||
|
||||
protected async requestRefreshTokenWithHeaders(
|
||||
tokenUrl: URL,
|
||||
clientId: string,
|
||||
refresh_token: string,
|
||||
redirect_uri?: string,
|
||||
client_secret?: string,
|
||||
customHeader?: HeadersInit
|
||||
): Promise<{ resp: OauthResponse | null; headers: Headers }> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('client_id', clientId);
|
||||
params.append('grant_type', 'refresh_token');
|
||||
@ -157,15 +177,20 @@ export abstract class OauthPlaylistAdder {
|
||||
params.append('redirect_uri', redirect_uri);
|
||||
}
|
||||
this.logger.debug('sending token req', params);
|
||||
const resp: OauthResponse = await fetch(tokenUrl, {
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: customHeader
|
||||
}).then((r) => r.json());
|
||||
});
|
||||
|
||||
const resp: OauthResponse = await response.json();
|
||||
this.logger.verbose('received access token', resp);
|
||||
if (resp.error) {
|
||||
this.logger.error('token resp error', resp);
|
||||
return null;
|
||||
return {
|
||||
resp: null,
|
||||
headers: response.headers
|
||||
};
|
||||
}
|
||||
if (!resp.refresh_token) {
|
||||
resp.refresh_token = refresh_token;
|
||||
@ -175,6 +200,9 @@ export abstract class OauthPlaylistAdder {
|
||||
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
|
||||
resp.expires = expiration;
|
||||
await fs.writeFile(this.token_file_name, JSON.stringify(resp));
|
||||
return resp;
|
||||
return {
|
||||
resp: resp,
|
||||
headers: response.headers
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,8 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd
|
||||
super('https://openapi.tidal.com/v2', 'tidal_auth_token');
|
||||
//super('https://api.tidal.com/v2', 'tidal_auth_token');
|
||||
this.logger = new Logger('TidalPlaylistAdder');
|
||||
// Tidal aggressively rate-limits, so reduce the number of refreshing requests
|
||||
this.refresh_time = 3;
|
||||
}
|
||||
|
||||
public constructAuthUrl(redirectUri: URL): URL {
|
||||
@ -67,7 +69,32 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd
|
||||
}
|
||||
|
||||
const tokenUrl = new URL('https://auth.tidal.com/v1/oauth2/token');
|
||||
return await this.requestRefreshToken(tokenUrl, TIDAL_CLIENT_ID, token.refresh_token);
|
||||
const response = await this.requestRefreshTokenWithHeaders(
|
||||
tokenUrl,
|
||||
TIDAL_CLIENT_ID,
|
||||
token.refresh_token
|
||||
);
|
||||
this.processTidalHeaders(response.headers);
|
||||
return response.resp;
|
||||
}
|
||||
|
||||
private processTidalHeaders(headers: Headers) {
|
||||
const remainingTokens = headers.get('x-ratelimit-remaining');
|
||||
const requiredTokens = headers.get('x-ratelimit-requested-tokens');
|
||||
const replenishRate = headers.get('x-ratelimit-replenish-rate');
|
||||
if (remainingTokens !== null && replenishRate !== null) {
|
||||
const remainingTokensValue = parseInt(remainingTokens);
|
||||
const replenishRateValue = parseInt(replenishRate);
|
||||
let requiredTokensValue = parseInt(requiredTokens ?? '-1');
|
||||
this.logger.debug(
|
||||
'Tidal rate limit. Remaining',
|
||||
remainingTokensValue,
|
||||
'reuqired for last request',
|
||||
requiredTokensValue,
|
||||
'replenish rate',
|
||||
replenishRateValue
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async addToPlaylistRetry(song: SongInfo, remaning: number = 3) {
|
||||
@ -112,7 +139,6 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd
|
||||
};
|
||||
const apiUrl = new URL(`${this.apiBase}/playlists/${TIDAL_PLAYLIST_ID}/relationships/items`);
|
||||
const request = new Request(apiUrl, options);
|
||||
this.logger.debug('Adding to playlist request', request);
|
||||
|
||||
// This would be API v1 (or api v2, but *not* the OpenAPI v2),
|
||||
// but that requires r_usr and w_usr permission scopes which are impossible to request
|
||||
@ -150,25 +176,59 @@ export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAd
|
||||
*/
|
||||
|
||||
let resp: Response | null = null;
|
||||
let respTxt: string | null = null;
|
||||
try {
|
||||
resp = await fetch(request);
|
||||
this.processTidalHeaders(resp.headers);
|
||||
let respObj: TidalAddToPlaylistResponse | null = null;
|
||||
// If the request was successful, a 201 with no content is received
|
||||
// Errors will have content and a different status code
|
||||
if (resp.status !== 201) {
|
||||
if (resp.status !== 201 && resp.status !== 429) {
|
||||
respObj = await resp.json();
|
||||
} else {
|
||||
respTxt = await resp.text();
|
||||
}
|
||||
if (respObj !== null && respObj.errors) {
|
||||
this.logger.error('Add to playlist failed', song.tidalUri, resp.status, respObj.errors);
|
||||
if (respObj.errors.some((x) => x.status === 401)) {
|
||||
if (resp.status === 401 || respObj.errors.some((x) => x.code === 'UNAUTHORIZED')) {
|
||||
const token = await this.refreshToken(true);
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
this.addToPlaylistRetry(song, remaning--);
|
||||
}
|
||||
} else if (respObj === null && resp.status === 201) {
|
||||
this.logger.info('Added to playlist', song.tidalUri, song.title);
|
||||
} else if (respObj === null) {
|
||||
switch (resp.status) {
|
||||
case 201:
|
||||
this.logger.info('Added to playlist', song.tidalUri, song.title);
|
||||
break;
|
||||
case 429:
|
||||
const remainingTokens = resp.headers.get('x-ratelimit-remaining');
|
||||
const requiredTokens = resp.headers.get('x-ratelimit-requested-tokens');
|
||||
const replenishRate = resp.headers.get('x-ratelimit-replenish-rate');
|
||||
if (remainingTokens !== null && requiredTokens !== null && replenishRate !== null) {
|
||||
const remainingTokensValue = parseInt(remainingTokens);
|
||||
const requiredTokensValue = parseInt(requiredTokens);
|
||||
const replenishRateValue = parseInt(replenishRate);
|
||||
const needToReplenish = requiredTokensValue - remainingTokensValue;
|
||||
const secondsToWait = 1 + needToReplenish / replenishRateValue;
|
||||
this.logger.warn(
|
||||
'Received HTTP 429 Too Many Requests. Retrying in',
|
||||
secondsToWait,
|
||||
'sec'
|
||||
);
|
||||
// Try again secondsToWait sec later, just to be safe one additional second
|
||||
setTimeout(() => {
|
||||
this.addToPlaylistRetry(song, remaning--);
|
||||
}, secondsToWait * 1000);
|
||||
} else {
|
||||
this.logger.warn('Could not read headers how long to wait', resp.headers);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.logger.warn('Unknown response', resp.status, respTxt);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
this.logger.info(
|
||||
'Add to playlist result is neither 201 nor error',
|
||||
|
@ -25,4 +25,5 @@ export type TidalErrorCode =
|
||||
| 'NOT_ACCEPTABLE'
|
||||
| 'UNSUPPORTED_MEDIA_TYPE'
|
||||
| 'UNAVAILABLE_FOR_LEGAL_REASONS_RESPONSE'
|
||||
| 'INTERNAL_SERVER_ERROR';
|
||||
| 'INTERNAL_SERVER_ERROR'
|
||||
| 'UNAUTHORIZED';
|
||||
|
@ -1,4 +1,7 @@
|
||||
export function isTruthy(value: string | number | boolean | null | undefined): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true' || !!+value; // here we parse to number first
|
||||
}
|
||||
|
@ -24,6 +24,7 @@
|
||||
position: sticky;
|
||||
bottom: 0px;
|
||||
display: inline-block;
|
||||
z-index: 99;
|
||||
}
|
||||
:global(.toast.error) {
|
||||
--toastColor: var(--color-button-text);
|
||||
|
@ -12,7 +12,9 @@ const config = {
|
||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
|
||||
version: {
|
||||
name: process.env.npm_package_version
|
||||
},
|
||||
csp: {
|
||||
directives: {
|
||||
'script-src': ['self', 'unsafe-inline'],
|
||||
|
Reference in New Issue
Block a user