Compare commits
46 Commits
fix-ismusi
...
main
Author | SHA1 | Date | |
---|---|---|---|
4c3689016f
|
|||
68a139f287
|
|||
53ee5fabbe
|
|||
df35c48e8c
|
|||
44fc2bb621
|
|||
7cdfa00af5
|
|||
35572a48e7
|
|||
3186f375e1
|
|||
3c1b7dba0e
|
|||
5591070979
|
|||
38e8b4c2ba
|
|||
260cef7b73
|
|||
c57f9ec3ea
|
|||
6874804703
|
|||
8cb5ab8340
|
|||
270cd9ad05
|
|||
dfd6d559bf | |||
7f616b4c7d | |||
2e7d2004af
|
|||
b2e6d20d27 | |||
64d7538ff4
|
|||
77e483d637
|
|||
b0465a020d
|
|||
a0757ea3ff
|
|||
a8b6a309f0
|
|||
317f4d7fba
|
|||
b7a930c69a
|
|||
3c6e742e43
|
|||
7296582b0d
|
|||
66f09cf5a3
|
|||
d39ccba927
|
|||
498b1d82d9
|
|||
79405cd08c
|
|||
39c9689af4
|
|||
ad7c8af9de
|
|||
f1cb0b2159
|
|||
049cd86ae0
|
|||
aab4433a55
|
|||
d3b599738e
|
|||
ba89182791
|
|||
5b6dbd327d
|
|||
b960d35a58
|
|||
87b8317c90
|
|||
e103bef84c
|
|||
6d13aed0f0
|
|||
185d28c295
|
@ -1,11 +1,17 @@
|
||||
HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening
|
||||
YOUTUBE_API_KEY = CHANGE_ME
|
||||
YOUTUBE_PLAYLIST_ID = CHANGE_ME
|
||||
YOUTUBE_CLIENT_ID = CHANGE_ME
|
||||
YOUTUBE_CLIENT_SECRET = CHANGE_ME
|
||||
ODESLI_API_KEY = CHANGE_ME
|
||||
MASTODON_INSTANCE = 'metalhead.club'
|
||||
MASTODON_ACCESS_TOKEN = 'YOUR_ACCESS_TOKEN_HERE'
|
||||
BASE_URL = 'https://moshingmammut.phlaym.net'
|
||||
VERBOSE = false
|
||||
DEBUG_LOG = false
|
||||
IGNORE_USERS = @moshhead@metalhead.club
|
||||
WEBSUB_HUB = 'http://pubsubhubbub.superfeedr.com'
|
||||
|
||||
PUBLIC_REFRESH_INTERVAL = 10000
|
||||
PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME = 'Metalhead.club'
|
||||
PORT = 3001
|
@ -1,16 +1,25 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['svelte3', '@typescript-eslint'],
|
||||
extends: ['plugin:svelte/recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
ignorePatterns: ['*.cjs'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.svelte'],
|
||||
parser: 'svelte-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
}
|
||||
],
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
ecmaVersion: 2020,
|
||||
extraFileExtensions: ['.svelte']
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,3 +1,7 @@
|
||||
moshing-mammut.pid
|
||||
yt_auth_token
|
||||
spotify_auth_token
|
||||
tidal_auth_token
|
||||
*.db
|
||||
feed.xml
|
||||
playbook.yml
|
||||
|
@ -1,6 +1,5 @@
|
||||
{
|
||||
"apexskier.eslint.config.eslintConfigPath" : ".eslintrc.cjs",
|
||||
"apexskier.eslint.config.eslintPath" : "node_modules\/@eslint\/eslintrc\/dist\/eslintrc.cjs",
|
||||
"apexskier.eslint.config.eslintConfigPath" : ".eslint.cjs",
|
||||
"apexskier.eslint.config.fixOnSave" : "Enable",
|
||||
"apexskier.typescript.config.formatDocumentOnSave" : "false",
|
||||
"apexskier.typescript.config.isEnabledForJavascript" : "Enable",
|
||||
|
@ -5,6 +5,5 @@
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
|
23
README.md
23
README.md
@ -65,7 +65,7 @@ Set up NVM:
|
||||
|
||||
```
|
||||
$ cd
|
||||
$ curl https://raw.github.com/creationix/nvm/master/install.sh | sh
|
||||
$ curl https://raw.githubusercontent.com/nvm-sh/nvm/refs/heads/master/install.sh | bash
|
||||
$ source ~/.nvm/nvm.sh
|
||||
$ nvm install --lts
|
||||
```
|
||||
@ -88,6 +88,8 @@ Copy `apache2.conf.EXAMPLE` to `/etc/apache2/sites-available/moshingmammut.conf`
|
||||
Domain. If you do not need or want SSL support, remove the whole `<IfModule mod_ssl.c>` block.
|
||||
If you do, add the path to your SSLCertificateFile and SSLCertificateKeyFile.
|
||||
|
||||
Modify DocumentRoot and the two Alias and Directory statements, so that thumbnails and avatars are served directly by apache.
|
||||
|
||||
Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service`
|
||||
and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
|
||||
|
||||
@ -96,11 +98,23 @@ and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
|
||||
Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY` and `ODESLI_API_KEY`.
|
||||
To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an
|
||||
_API key_.
|
||||
If `YOUTUBE_API_KEY` is unset, no playlist will be updated. Also, _all_ YouTube links will be treated as music videos,
|
||||
If `YOUTUBE_API_KEY` is unset _all_ YouTube links will be treated as music videos,
|
||||
because the API is the only way to check if a YouTube link leads to music or something else.
|
||||
|
||||
If `ODESLI_API_KEY` is unset, your rate limit to the song.link API will be lower.
|
||||
|
||||
Add `MASTODON_ACCESS_TOKEN` as well, see [Creating our application
|
||||
|
||||
](https://docs.joinmastodon.org/client/token/#app) in the Mastodon documentation.
|
||||
`read:statuses` and `read:search` the only required scope. An access token will be displayed in your settings. Use that!
|
||||
|
||||
There are currently no plans to implement an actual authentication flow.
|
||||
|
||||
If you want the app to save the songs it encounters into a playlist, YouTube requires OAuth 2.0 credentials.
|
||||
Once again, follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) and the OAuth 2.0 described there
|
||||
to obtain a clientId and clientSecret. Add the values as `YOUTUBE_CLIENT_ID` and `YOUTUBE_CLIENT_SECRET`.
|
||||
Create a playlist and configure its ID as `YOUTUBE_PLAYLIST_ID`.
|
||||
|
||||
Run `npm run build` and copy the output folder, usually `build` to `$APP_DIR` on your server.
|
||||
|
||||
#### On your server again
|
||||
@ -116,6 +130,9 @@ Verify that everything is okay with `service moshing-mammut status`.
|
||||
|
||||
The app should now be reachable on http://localhost:3000 or whatever you configured your domain to be!
|
||||
|
||||
If you want to add the songs available on YouTube to a playlist and have configured the environment variables to do so,
|
||||
you now need to visit `/ytauth`, e.g. `http://localhost:3000/ytauth`. This will obtain the necessary access tokens from Google.
|
||||
|
||||
# Icons
|
||||
|
||||
Favicon is a combination of [speaker-line by remix icon](https://remixicon.com/icon/speaker-line)
|
||||
@ -134,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)
|
||||
|
@ -15,6 +15,23 @@
|
||||
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
DocumentRoot /home/moshing-mammut/app/
|
||||
|
||||
ProxyPass /avatars/ !
|
||||
ProxyPass /thumbnails/ !
|
||||
Alias /avatars/ /home/moshing-mammut/app/avatars/
|
||||
Alias /thumbnails/ /home/moshing-mammut/app/thumbnails/
|
||||
|
||||
<Directory "/home/moshing-mammut/app/avatars/">
|
||||
Require all granted
|
||||
Header set Cache-Control "public,max-age=31536000,immutable"
|
||||
</Directory>
|
||||
|
||||
<Directory "/home/moshing-mammut/app/thumbnails/">
|
||||
Require all granted
|
||||
Header set Cache-Control "public,max-age=31536000,immutable"
|
||||
</Directory>
|
||||
|
||||
ProxyPass / http://localhost:3000/
|
||||
ProxyPassReverse / http://localhost:3000/
|
||||
|
||||
|
73
eslint.config.cjs
Normal file
73
eslint.config.cjs
Normal file
@ -0,0 +1,73 @@
|
||||
const { defineConfig, globalIgnores } = require('eslint/config');
|
||||
|
||||
const tsParser = require('@typescript-eslint/parser');
|
||||
const typescriptEslint = require('@typescript-eslint/eslint-plugin');
|
||||
const parser = require('svelte-eslint-parser');
|
||||
const globals = require('globals');
|
||||
const js = require('@eslint/js');
|
||||
|
||||
const { FlatCompat } = require('@eslint/eslintrc');
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
});
|
||||
|
||||
module.exports = defineConfig([
|
||||
{
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
|
||||
parserOptions: {
|
||||
extraFileExtensions: ['.svelte']
|
||||
},
|
||||
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
},
|
||||
|
||||
extends: compat.extends(
|
||||
'plugin:svelte/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier'
|
||||
),
|
||||
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint
|
||||
},
|
||||
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
}
|
||||
},
|
||||
globalIgnores(['**/*.cjs']),
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
|
||||
languageOptions: {
|
||||
parser: parser,
|
||||
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
}
|
||||
},
|
||||
globalIgnores([
|
||||
'**/.DS_Store',
|
||||
'**/node_modules',
|
||||
'build',
|
||||
'.svelte-kit',
|
||||
'package',
|
||||
'**/.env',
|
||||
'**/.env.*',
|
||||
'!**/.env.example',
|
||||
'**/pnpm-lock.yaml',
|
||||
'**/package-lock.json',
|
||||
'**/yarn.lock'
|
||||
])
|
||||
]);
|
@ -1,14 +1,17 @@
|
||||
[Unit]
|
||||
Description=Moshing Mammut
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/home/moshing-mammut/app/start.sh
|
||||
Restart=always
|
||||
Restart=on-failure
|
||||
User=moshing-mammut
|
||||
Group=moshing-mammut
|
||||
Environment=PATH=/usr/bin:/usr/local/bin
|
||||
Environment=NODE_ENV=production
|
||||
WorkingDirectory=/home/moshing-mammut/app
|
||||
Type=forking
|
||||
PIDFile=/home/moshing-mammut/app/moshing-mammut.pid
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
5644
package-lock.json
generated
5644
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moshing-mammut",
|
||||
"version": "1.3.1",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
@ -14,34 +14,37 @@
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.2.3",
|
||||
"@sveltejs/kit": "^1.5.0",
|
||||
"@types/node": "^18.16.3",
|
||||
"@types/sqlite3": "^3.1.8",
|
||||
"@types/ws": "^8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/kit": "^2.22.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/sqlite3": "^3.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@zerodevx/svelte-toast": "^0.9.3",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-svelte": "^2.8.1",
|
||||
"svelte": "^3.54.0",
|
||||
"svelte-check": "^3.0.1",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^4.0.0"
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^16.3.0",
|
||||
"prettier": "^3.1.0",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"svelte": "^5",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tslib": "^2.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.0.3",
|
||||
"feed": "^4.2.2",
|
||||
"sharp": "^0.32.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"ws": "^8.13.0"
|
||||
"dotenv": "^17.0.0",
|
||||
"feed": "^5.1.0",
|
||||
"sharp": "^0.34.2",
|
||||
"sqlite3": "^5.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
|
25
src/app.html
25
src/app.html
@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@ -10,6 +10,10 @@
|
||||
<meta name="apple-mobile-web-app-title" content="Moshing Mammut" />
|
||||
<meta name="application-name" content="Moshing Mammut" />
|
||||
<meta name="msapplication-TileColor" content="#2e0b78" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A collection of music recommendations and now-listenings by the users of metalhead.club"
|
||||
/>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#17063b" media="(prefers-color-scheme: dark)" />
|
||||
@ -50,9 +54,22 @@
|
||||
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans,
|
||||
Ubuntu, Cantarell, 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen-Sans,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Helvetica Neue',
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Segoe UI Symbol';
|
||||
}
|
||||
|
||||
a {
|
||||
|
@ -1,14 +1,35 @@
|
||||
import { log } from '$lib/log';
|
||||
import { Logger } from '$lib/log';
|
||||
import { TimelineReader } from '$lib/server/timeline';
|
||||
import type { HandleServerError } from '@sveltejs/kit';
|
||||
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');
|
||||
|
||||
if (process?.pid) {
|
||||
try {
|
||||
await fs.writeFile('moshing-mammut.pid', process.pid.toString());
|
||||
} catch (e) {
|
||||
logger.error('Could not write PID to file', e);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('App startup, version', version, 'PID', process?.pid);
|
||||
|
||||
logger.log('Debug log enabled', Logger.isDebugEnabled());
|
||||
TimelineReader.init();
|
||||
|
||||
export const handleError = (({ error }) => {
|
||||
if (error instanceof Error) {
|
||||
log.error('Something went wrong: ', error.name, error.message);
|
||||
process.on('sveltekit:shutdown', (reason) => {
|
||||
close();
|
||||
logger.log('Shutting down', reason);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
export const handleError = (({ error, status }) => {
|
||||
if (error instanceof Error && status !== 404) {
|
||||
logger.error('Something went wrong:', error.name, error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -16,8 +37,6 @@ export const handleError = (({ error }) => {
|
||||
};
|
||||
}) satisfies HandleServerError;
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle = (async ({ event, resolve }) => {
|
||||
// Reeder *insists* on checking /feed instead of /feed.xml
|
||||
if (event.url.pathname === '/feed') {
|
||||
@ -40,7 +59,7 @@ export const handle = (async ({ event, resolve }) => {
|
||||
const readStream = fd
|
||||
.readableWebStream()
|
||||
.getReader({ mode: 'byob' }) as ReadableStream<Uint8Array>;
|
||||
log.info('sending. size: ', stat.size);
|
||||
logger.info('sending. size: ', stat.size);
|
||||
return new Response(readStream, {
|
||||
headers: [
|
||||
['Content-Type', 'image/' + suffix],
|
||||
@ -52,8 +71,8 @@ export const handle = (async ({ event, resolve }) => {
|
||||
const f = await fs.readFile('avatars/' + fileName);
|
||||
return new Response(f, { headers: [['Content-Type', 'image/' + suffix]] });
|
||||
} catch (e) {
|
||||
log.error('no stream', e);
|
||||
throw error(404);
|
||||
logger.error('no stream', e);
|
||||
error(404);
|
||||
}
|
||||
}
|
||||
|
||||
|
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,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { Account } from '$lib/mastodon/response';
|
||||
|
||||
export let account: Account;
|
||||
interface Props {
|
||||
account: Account;
|
||||
}
|
||||
|
||||
let { account }: Props = $props();
|
||||
</script>
|
||||
|
||||
<a href={account.url} target="_blank">{account.display_name} @{account.acct}</a>
|
||||
|
@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Account } from '$lib/mastodon/response';
|
||||
|
||||
export let account: Account;
|
||||
let avatarDescription: string;
|
||||
let sourceSetHtml: string;
|
||||
$: avatarDescription = `Avatar for ${account.acct}`;
|
||||
$: {
|
||||
interface Props {
|
||||
account: Account;
|
||||
}
|
||||
|
||||
let { account }: Props = $props();
|
||||
let avatarDescription: string = $derived(`Avatar for ${account.acct}`);
|
||||
let sourceSetHtml: string = $derived.by(() => {
|
||||
// Sort thumbnails by file type. This is important, because the order of the srcset entries matter.
|
||||
// We need the best format to be first
|
||||
const formatPriority = new Map<string, number>([
|
||||
@ -14,7 +16,7 @@
|
||||
['jpg', 99],
|
||||
['jpeg', 99]
|
||||
]);
|
||||
const resizedAvatars = (account.resizedAvatars ?? []).sort((a, b) => {
|
||||
const resizedAvatars = (account.resizedAvatars ?? []).toSorted((a, b) => {
|
||||
const extensionA = a.file.split('.').pop() ?? '';
|
||||
const extensionB = b.file.split('.').pop() ?? '';
|
||||
const prioA = formatPriority.get(extensionA) ?? 3;
|
||||
@ -33,13 +35,13 @@
|
||||
const srcset = entry[1].join(', ');
|
||||
html += `<source srcset="${srcset}" type="${entry[0]}" />`;
|
||||
}
|
||||
sourceSetHtml = html;
|
||||
}
|
||||
return html;
|
||||
});
|
||||
</script>
|
||||
|
||||
<picture>
|
||||
{@html sourceSetHtml}
|
||||
<img src={account.avatar} alt={avatarDescription} loading="lazy" />
|
||||
<img src={account.avatar} alt={avatarDescription} loading="lazy" width="50" height="50" />
|
||||
</picture>
|
||||
|
||||
<style>
|
||||
|
@ -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>
|
||||
|
@ -1,31 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import LoadingSpinnerComponent from '$lib/components/LoadingSpinnerComponent.svelte';
|
||||
|
||||
export let moreAvailable = false;
|
||||
export let isLoading = false;
|
||||
let displayText = '';
|
||||
let title = '';
|
||||
let disabled: boolean;
|
||||
|
||||
$: if (isLoading) {
|
||||
displayText = 'Loading...';
|
||||
} else if (!moreAvailable) {
|
||||
displayText = 'You reached the end';
|
||||
} else {
|
||||
displayText = 'Load More';
|
||||
interface Props {
|
||||
moreAvailable?: boolean;
|
||||
isLoading?: boolean;
|
||||
loadOlderPosts: any;
|
||||
}
|
||||
$: disabled = !moreAvailable || isLoading;
|
||||
$: title = moreAvailable ? 'Load More' : 'There be dragons!';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let { moreAvailable = false, isLoading = false, loadOlderPosts }: Props = $props();
|
||||
let displayText = $derived.by(() => {
|
||||
if (isLoading) {
|
||||
return 'Loading...';
|
||||
} else if (!moreAvailable) {
|
||||
return 'You reached the end';
|
||||
}
|
||||
return 'Load More';
|
||||
});
|
||||
let title = $derived(moreAvailable ? 'Load More' : 'There be dragons!');
|
||||
let disabled: boolean = $derived(!moreAvailable || isLoading);
|
||||
|
||||
/*const dispatch = createEventDispatcher<{
|
||||
loadOlderPosts: string;
|
||||
}>();
|
||||
|
||||
function loadOlderPosts() {
|
||||
dispatch('loadOlderPosts');
|
||||
}
|
||||
}*/
|
||||
</script>
|
||||
|
||||
<button on:click={loadOlderPosts} {disabled} {title}>
|
||||
<button onclick={() => loadOlderPosts()} {disabled} {title}>
|
||||
<div class="loading" class:collapsed={!isLoading}>
|
||||
<LoadingSpinnerComponent size="0.5em" thickness="6px" />
|
||||
</div>
|
||||
|
@ -1,9 +1,13 @@
|
||||
<script lang="ts">
|
||||
export let size = '64px';
|
||||
export let thickness = '6px';
|
||||
interface Props {
|
||||
size?: string;
|
||||
thickness?: string;
|
||||
}
|
||||
|
||||
let { size = '64px', thickness = '6px' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="lds-dual-ring" style="--size: {size}; --thickness: {thickness}" />
|
||||
<div class="lds-dual-ring" style="--size: {size}; --thickness: {thickness}"></div>
|
||||
|
||||
<style>
|
||||
.lds-dual-ring {
|
||||
|
@ -6,20 +6,54 @@
|
||||
import { secondsSince, relativeTime } from '$lib/relativeTime';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let post: Post;
|
||||
let displayRelativeTime = false;
|
||||
interface Props {
|
||||
post: Post;
|
||||
}
|
||||
|
||||
let { post }: Props = $props();
|
||||
let displayRelativeTime = $state(false);
|
||||
const absoluteDate = new Date(post.created_at).toLocaleString();
|
||||
let dateCreated = absoluteDate;
|
||||
const timePassed = secondsSince(new Date(post.created_at));
|
||||
$: if (displayRelativeTime) {
|
||||
dateCreated = relativeTime($timePassed) ?? absoluteDate;
|
||||
let dateCreated = $derived.by(() => {
|
||||
if (displayRelativeTime) {
|
||||
return relativeTime($timePassed) ?? absoluteDate;
|
||||
}
|
||||
return absoluteDate;
|
||||
});
|
||||
|
||||
const songs = filterDuplicates(post.songs ?? []);
|
||||
|
||||
function filterDuplicates(songs: SongInfo[]): SongInfo[] {
|
||||
return songs.filter((obj, index, arr) => {
|
||||
return arr.map((mapObj) => mapObj.pageUrl).indexOf(obj.pageUrl) === index;
|
||||
});
|
||||
}
|
||||
|
||||
function getThumbnailSize(song: SongInfo): {
|
||||
width?: number;
|
||||
height?: number;
|
||||
widthSmall?: number;
|
||||
heightSmall?: number;
|
||||
} {
|
||||
if (song.thumbnailWidth === undefined || song.thumbnailHeight === undefined) {
|
||||
return { width: undefined, height: undefined, widthSmall: undefined, heightSmall: undefined };
|
||||
}
|
||||
const factor = 200 / song.thumbnailWidth;
|
||||
const smallFactor = 60 / song.thumbnailHeight;
|
||||
const height = song.thumbnailHeight * factor;
|
||||
return {
|
||||
width: 200,
|
||||
height: height,
|
||||
widthSmall: smallFactor * song.thumbnailWidth,
|
||||
heightSmall: 60
|
||||
};
|
||||
}
|
||||
|
||||
// Blurred thumbs aren't generated (yet, unclear of they ever will)
|
||||
// So blurred forces using the small one, by skipping the others and removing its media query.
|
||||
// This is technically unnecessary - the blurred one will only show if it matches the small media query,
|
||||
// but this makes it more explicit
|
||||
function getSourceSetHtml(song: SongInfo, isBlurred: boolean = false): string {
|
||||
function getSourceSetHtml(song: SongInfo, isBlurred = false): string {
|
||||
const small = new Map<string, string[]>();
|
||||
const large = new Map<string, string[]>();
|
||||
|
||||
@ -31,7 +65,7 @@
|
||||
['jpg', 99],
|
||||
['jpeg', 99]
|
||||
]);
|
||||
const thumbs = (song.resizedThumbnails ?? []).sort((a, b) => {
|
||||
const thumbs = (song.resizedThumbnails ?? []).toSorted((a, b) => {
|
||||
const extensionA = a.file.split('.').pop() ?? '';
|
||||
const extensionB = b.file.split('.').pop() ?? '';
|
||||
const prioA = formatPriority.get(extensionA) ?? 3;
|
||||
@ -58,15 +92,16 @@
|
||||
}
|
||||
}
|
||||
let html = '';
|
||||
const { width, height, widthSmall, heightSmall } = getThumbnailSize(song);
|
||||
const mediaAttribute = isBlurred ? '' : 'media="(max-width: 650px)"';
|
||||
for (const entry of small.entries()) {
|
||||
const srcset = entry[1].join(', ');
|
||||
html += `<source srcset="${srcset}" type="${entry[0]}" ${mediaAttribute} />`;
|
||||
html += `<source srcset="${srcset}" type="${entry[0]}" ${mediaAttribute} width="${widthSmall}" height="${heightSmall}" />`;
|
||||
}
|
||||
html += '\n';
|
||||
for (const entry of large.entries()) {
|
||||
const srcset = entry[1].join(', ');
|
||||
html += `<source srcset="${srcset}" type="${entry[0]}" />`;
|
||||
html += `<source srcset="${srcset}" type="${entry[0]}" width="${width}" height="${height}"/>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
@ -88,7 +123,7 @@
|
||||
<div class="content">{@html post.content}</div>
|
||||
<div class="song">
|
||||
{#if post.songs}
|
||||
{#each post.songs as song (song.pageUrl)}
|
||||
{#each songs as song (song.pageUrl)}
|
||||
<div class="info-wrapper">
|
||||
<picture>
|
||||
{@html getSourceSetHtml(song)}
|
||||
@ -96,13 +131,14 @@
|
||||
</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}
|
||||
height={song.thumbnailHeight}
|
||||
/>
|
||||
</picture>
|
||||
<span class="text">{song.artistName} - {song.title}</span>
|
||||
|
@ -1,9 +1,14 @@
|
||||
import { DEBUG_LOG } from '$env/static/private';
|
||||
import { env } from '$env/dynamic/private';
|
||||
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.
|
||||
*/
|
||||
export const log = {
|
||||
verbose: (...params: any[]) => {
|
||||
if (!enableVerboseLog) {
|
||||
@ -12,7 +17,7 @@ export const log = {
|
||||
console.debug(new Date().toISOString(), ...params);
|
||||
},
|
||||
debug: (...params: any[]) => {
|
||||
if (!DEV) {
|
||||
if (!log.isDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
console.debug(new Date().toISOString(), ...params);
|
||||
@ -28,5 +33,59 @@ export const log = {
|
||||
},
|
||||
error: (...params: any[]) => {
|
||||
console.error(new Date().toISOString(), ...params);
|
||||
},
|
||||
isDebugEnabled: (): boolean => {
|
||||
return DEV;
|
||||
}
|
||||
};
|
||||
|
||||
export class Logger {
|
||||
public constructor(private name: string) {}
|
||||
|
||||
public static isDebugEnabled(): boolean {
|
||||
return debugLogEnv || DEV || enableVerboseLog;
|
||||
}
|
||||
public verbose(...params: any[]) {
|
||||
if (!enableVerboseLog) {
|
||||
return;
|
||||
}
|
||||
console.debug(new Date().toISOString(), '- [VRBSE]', `- ${this.name} -`, ...params);
|
||||
}
|
||||
public debug(...params: any[]) {
|
||||
if (!Logger.isDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
console.debug(new Date().toISOString(), '- [DEBUG]', `- ${this.name} -`, ...params);
|
||||
}
|
||||
public log(...params: any[]) {
|
||||
console.log(new Date().toISOString(), '- [ LOG ]', `- ${this.name} -`, ...params);
|
||||
}
|
||||
public info(...params: any[]) {
|
||||
console.info(new Date().toISOString(), '- [INFO ]', `- ${this.name} -`, ...params);
|
||||
}
|
||||
public warn(...params: any[]) {
|
||||
console.warn(new Date().toISOString(), '- [WARN ]', `- ${this.name} -`, ...params);
|
||||
}
|
||||
public error(...params: any[]) {
|
||||
console.error(new Date().toISOString(), '- [ERROR]', `- ${this.name} -`, ...params);
|
||||
}
|
||||
|
||||
public static error(...params: any[]) {
|
||||
console.error(new Date().toISOString(), ...params);
|
||||
}
|
||||
public static debug(...params: any[]) {
|
||||
if (!Logger.isDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
console.debug(new Date().toISOString(), ...params);
|
||||
}
|
||||
public static log(...params: any[]) {
|
||||
console.log(new Date().toISOString(), ...params);
|
||||
}
|
||||
public static info(...params: any[]) {
|
||||
console.info(new Date().toISOString(), ...params);
|
||||
}
|
||||
public static warn(...params: any[]) {
|
||||
console.warn(new Date().toISOString(), ...params);
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,17 @@ export interface Post {
|
||||
songs?: SongInfo[];
|
||||
}
|
||||
|
||||
export interface OauthResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
expires?: Date;
|
||||
refresh_token?: string;
|
||||
refresh_token_expires_in?: number;
|
||||
scope: string;
|
||||
token_type: string;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
export interface PreviewCard {
|
||||
url: string;
|
||||
title: string;
|
||||
|
@ -3,12 +3,17 @@ import type { SongThumbnailImage } from '$lib/mastodon/response';
|
||||
export type SongInfo = {
|
||||
pageUrl: string;
|
||||
youtubeUrl?: string;
|
||||
spotifyUrl?: string;
|
||||
spotifyUri?: string;
|
||||
tidalUri?: string;
|
||||
type: 'song' | 'album';
|
||||
title?: string;
|
||||
artistName?: string;
|
||||
thumbnailUrl?: string;
|
||||
postedUrl: string;
|
||||
resizedThumbnails?: SongThumbnailImage[];
|
||||
thumbnailWidth?: number;
|
||||
thumbnailHeight?: number;
|
||||
};
|
||||
|
||||
export type SongwhipReponse = {
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { IGNORE_USERS, MASTODON_INSTANCE } from '$env/static/private';
|
||||
import { enableVerboseLog, log } from '$lib/log';
|
||||
import { enableVerboseLog, Logger } from '$lib/log';
|
||||
import type { Account, AccountAvatar, Post, SongThumbnailImage, Tag } from '$lib/mastodon/response';
|
||||
import type { SongInfo } from '$lib/odesliResponse';
|
||||
import { TimelineReader } from '$lib/server/timeline';
|
||||
import sqlite3 from 'sqlite3';
|
||||
|
||||
const logger = new Logger('Database');
|
||||
|
||||
type FilterParameter = {
|
||||
$limit?: number | undefined | null;
|
||||
$since?: string | undefined | null;
|
||||
@ -37,9 +39,14 @@ type SongRow = {
|
||||
overviewUrl?: string;
|
||||
type: 'album' | 'song';
|
||||
youtubeUrl?: string;
|
||||
spotifyUrl?: string;
|
||||
spotifyUri?: string;
|
||||
title?: string;
|
||||
artistName?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailWidth?: number;
|
||||
thumbnailHeight?: number;
|
||||
tidalId: string;
|
||||
};
|
||||
|
||||
type AccountAvatarRow = {
|
||||
@ -62,6 +69,15 @@ type Migration = {
|
||||
};
|
||||
|
||||
const db: sqlite3.Database = new sqlite3.Database('moshingmammut.db');
|
||||
|
||||
export function close() {
|
||||
try {
|
||||
db.close();
|
||||
} catch (e) {
|
||||
logger.error('Could not close DB');
|
||||
}
|
||||
}
|
||||
|
||||
// for the local masto instance, the instance name is *not* saved
|
||||
// as part of the username or acct, so it needs to be stripped
|
||||
const ignoredUsers: string[] =
|
||||
@ -79,23 +95,23 @@ let databaseReady = false;
|
||||
if (enableVerboseLog) {
|
||||
sqlite3.verbose();
|
||||
db.on('change', (t, d, table, rowid) => {
|
||||
log.verbose('DB change event', t, d, table, rowid);
|
||||
logger.verbose('DB change event', t, d, table, rowid);
|
||||
});
|
||||
|
||||
db.on('trace', (sql) => {
|
||||
log.verbose('Running', sql);
|
||||
logger.verbose('Running', sql);
|
||||
});
|
||||
|
||||
db.on('profile', (sql) => {
|
||||
log.verbose('Finished', sql);
|
||||
logger.verbose('Finished', sql);
|
||||
});
|
||||
}
|
||||
|
||||
async function applyDbMigration(migration: Migration): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
function applyDbMigration(migration: Migration): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.exec(migration.statement, (err) => {
|
||||
if (err !== null) {
|
||||
log.error(`Failed to apply migration ${migration.name}`, err);
|
||||
logger.error(`Failed to apply migration ${migration.name}`, err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
@ -110,37 +126,37 @@ async function applyMigration(migration: Migration) {
|
||||
// so filtering won't help
|
||||
const posts = await getPostsInternal(null, null, 10000);
|
||||
let current = 0;
|
||||
let total = posts.length.toString().padStart(4, '0');
|
||||
const total = posts.length.toString().padStart(4, '0');
|
||||
for (const post of posts) {
|
||||
current++;
|
||||
if (post.songs && post.songs.length) {
|
||||
continue;
|
||||
}
|
||||
log.info(
|
||||
logger.info(
|
||||
`Fetching songs for existing post ${current.toString().padStart(4, '0')} of ${total}`,
|
||||
post.url
|
||||
);
|
||||
const songs = await TimelineReader.getSongInfoInPost(post);
|
||||
const songs = await TimelineReader.instance.getSongInfoInPost(post);
|
||||
await saveSongInfoData(post.url, songs);
|
||||
log.debug(`Fetched ${songs.length} songs for existing post`, post.url);
|
||||
logger.debug(`Fetched ${songs.length} songs for existing post`, post.url);
|
||||
}
|
||||
log.debug(`Finished fetching songs`);
|
||||
logger.debug(`Finished fetching songs`);
|
||||
} else {
|
||||
await applyDbMigration(migration);
|
||||
}
|
||||
}
|
||||
|
||||
db.on('open', () => {
|
||||
log.info('Opened database');
|
||||
logger.info('Opened database');
|
||||
db.serialize();
|
||||
db.run('CREATE TABLE IF NOT EXISTS "migrations" ("id" integer,"name" TEXT, PRIMARY KEY (id))');
|
||||
db.all('SELECT id FROM migrations', (err, rows: Migration[]) => {
|
||||
if (err !== null) {
|
||||
log.error('Could not fetch existing migrations', err);
|
||||
logger.error('Could not fetch existing migrations', err);
|
||||
databaseReady = true;
|
||||
return;
|
||||
}
|
||||
log.debug('Already applied migrations', rows);
|
||||
logger.debug('Already applied migrations', rows);
|
||||
const appliedMigrations: Set<number> = new Set(rows.map((row) => row['id']));
|
||||
const toApply = getMigrations().filter((m) => !appliedMigrations.has(m.id));
|
||||
let remaining = toApply.length;
|
||||
@ -157,7 +173,7 @@ db.on('open', () => {
|
||||
databaseReady = true;
|
||||
}
|
||||
if (err !== null) {
|
||||
log.error(`Failed to apply migration ${migration.name}`, err);
|
||||
logger.error(`Failed to apply migration ${migration.name}`, err);
|
||||
return;
|
||||
}
|
||||
db.run(
|
||||
@ -165,10 +181,10 @@ db.on('open', () => {
|
||||
[migration.id, migration.name],
|
||||
(e: Error) => {
|
||||
if (e !== null) {
|
||||
log.error(`Failed to mark migration ${migration.name} as applied`, e);
|
||||
logger.error(`Failed to mark migration ${migration.name} as applied`, e);
|
||||
return;
|
||||
}
|
||||
log.info(`Applied migration ${migration.name}`);
|
||||
logger.info(`Applied migration ${migration.name}`);
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -176,7 +192,7 @@ db.on('open', () => {
|
||||
});
|
||||
});
|
||||
db.on('error', (err) => {
|
||||
log.error('Error opening database', err);
|
||||
logger.error('Error opening database', err);
|
||||
});
|
||||
|
||||
function getMigrations(): Migration[] {
|
||||
@ -304,6 +320,26 @@ function getMigrations(): Migration[] {
|
||||
kind INTEGER NOT NULL,
|
||||
FOREIGN KEY (song_thumbnailUrl) REFERENCES songs(thumbnailUrl)
|
||||
);`
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'song thumbnail size',
|
||||
statement: `
|
||||
ALTER TABLE songs ADD COLUMN thumbnailWidth INTEGER NULL;
|
||||
ALTER TABLE songs ADD COLUMN thumbnailHeight INTEGER NULL;`
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'song spotify url/uri',
|
||||
statement: `
|
||||
ALTER TABLE songs ADD COLUMN spotifyUrl TEXT NULL;
|
||||
ALTER TABLE songs ADD COLUMN spotifyUri TEXT NULL;`
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'song tidal id',
|
||||
statement: `
|
||||
ALTER TABLE songs ADD COLUMN tidalId TEXT NULL;`
|
||||
}
|
||||
];
|
||||
}
|
||||
@ -312,9 +348,9 @@ async function waitReady(): Promise<void> {
|
||||
// Simpler than a semaphore and is really only needed on startup
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
log.verbose('Waiting for database to be ready');
|
||||
logger.verbose('Waiting for database to be ready');
|
||||
if (databaseReady) {
|
||||
log.verbose('DB is ready');
|
||||
logger.verbose('DB is ready');
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
@ -345,7 +381,7 @@ function saveAccountData(account: Account): Promise<void> {
|
||||
],
|
||||
(err) => {
|
||||
if (err !== null) {
|
||||
log.error(`Could not insert/update account ${account.id}`, err);
|
||||
logger.error(`Could not insert/update account ${account.id}`, err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
@ -368,7 +404,7 @@ function savePostData(post: Post): Promise<void> {
|
||||
[post.id, post.content, post.created_at, post.url, post.account.url],
|
||||
(postErr) => {
|
||||
if (postErr !== null) {
|
||||
log.error(`Could not insert post ${post.url}`, postErr);
|
||||
logger.error(`Could not insert post ${post.url}`, postErr);
|
||||
reject(postErr);
|
||||
return;
|
||||
}
|
||||
@ -396,7 +432,7 @@ function savePostTagData(post: Post): Promise<void> {
|
||||
[tag.url, tag.name],
|
||||
(tagErr) => {
|
||||
if (tagErr !== null) {
|
||||
log.error(`Could not insert/update tag ${tag.url}`, tagErr);
|
||||
logger.error(`Could not insert/update tag ${tag.url}`, tagErr);
|
||||
reject(tagErr);
|
||||
return;
|
||||
}
|
||||
@ -405,7 +441,7 @@ function savePostTagData(post: Post): Promise<void> {
|
||||
[post.url, tag.url],
|
||||
(posttagserr) => {
|
||||
if (posttagserr !== null) {
|
||||
log.error(`Could not insert poststags ${tag.url}, ${post.url}`, posttagserr);
|
||||
logger.error(`Could not insert poststags ${tag.url}, ${post.url}`, posttagserr);
|
||||
reject(posttagserr);
|
||||
return;
|
||||
}
|
||||
@ -435,22 +471,28 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
|
||||
for (const song of songs) {
|
||||
db.run(
|
||||
`
|
||||
INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, title, artistName, thumbnailUrl, post_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, spotifyUrl, spotifyUri, tidalId,
|
||||
title, artistName, thumbnailUrl, post_url, thumbnailWidth, thumbnailHeight)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
song.postedUrl,
|
||||
song.pageUrl,
|
||||
song.type,
|
||||
song.youtubeUrl,
|
||||
song.spotifyUrl,
|
||||
song.spotifyUri,
|
||||
song.tidalUri,
|
||||
song.title,
|
||||
song.artistName,
|
||||
song.thumbnailUrl,
|
||||
postUrl
|
||||
postUrl,
|
||||
song.thumbnailWidth,
|
||||
song.thumbnailHeight
|
||||
],
|
||||
(songErr) => {
|
||||
if (songErr !== null) {
|
||||
log.error(`Could not insert song ${song.postedUrl}`, songErr);
|
||||
logger.error(`Could not insert song ${song.postedUrl}`, songErr);
|
||||
reject(songErr);
|
||||
return;
|
||||
}
|
||||
@ -468,20 +510,23 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
|
||||
}
|
||||
|
||||
export async function savePost(post: Post, songs: SongInfo[]) {
|
||||
logger.debug(`Saving post ${post.url}`);
|
||||
if (!databaseReady) {
|
||||
await waitReady();
|
||||
}
|
||||
|
||||
log.debug(`Saving post ${post.url}`);
|
||||
const account = post.account;
|
||||
await saveAccountData(account);
|
||||
log.debug(`Saved account data ${post.url}`);
|
||||
logger.debug(`Saved account data ${post.url}`);
|
||||
await savePostData(post);
|
||||
log.debug(`Saved post data ${post.url}`);
|
||||
logger.debug(`Saved post data ${post.url}`);
|
||||
await savePostTagData(post);
|
||||
log.debug(`Saved ${post.tags.length} tag data ${post.url}`);
|
||||
logger.debug(`Saved ${post.tags.length} tag data ${post.url}`);
|
||||
await saveSongInfoData(post.url, songs);
|
||||
log.debug(`Saved ${songs.length} song info data ${post.url}`);
|
||||
logger.debug(
|
||||
`Saved ${songs.length} song info data ${post.url}`,
|
||||
songs.map((s) => s.thumbnailHeight)
|
||||
);
|
||||
}
|
||||
|
||||
function getPostData(filterQuery: string, params: FilterParameter): Promise<PostRow[]> {
|
||||
@ -497,7 +542,7 @@ function getPostData(filterQuery: string, params: FilterParameter): Promise<Post
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows: PostRow[]) => {
|
||||
if (err != null) {
|
||||
log.error('Error loading posts', err);
|
||||
logger.error('Error loading posts', err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
@ -516,7 +561,7 @@ function getTagData(postIdsParams: string, postIds: string[]): Promise<Map<strin
|
||||
postIds,
|
||||
(tagErr, tagRows: PostTagRow[]) => {
|
||||
if (tagErr != null) {
|
||||
log.error('Error loading post tags', tagErr);
|
||||
logger.error('Error loading post tags', tagErr);
|
||||
reject(tagErr);
|
||||
return;
|
||||
}
|
||||
@ -534,17 +579,17 @@ function getTagData(postIdsParams: string, postIds: string[]): Promise<Map<strin
|
||||
});
|
||||
}
|
||||
|
||||
function getSongData(postIdsParams: String, postIds: string[]): Promise<Map<string, SongInfo[]>> {
|
||||
function getSongData(postIdsParams: string, postIds: string[]): Promise<Map<string, SongInfo[]>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT post_url, songs.postedUrl, songs.overviewUrl, songs.type, songs.youtubeUrl,
|
||||
songs.title, songs.artistName, songs.thumbnailUrl, songs.post_url
|
||||
`SELECT post_url, songs.postedUrl, songs.overviewUrl, songs.type, songs.youtubeUrl, songs.spotifyUri, songs.spotifyUri,
|
||||
songs.tidalId, songs.title, songs.artistName, songs.thumbnailUrl, songs.post_url, songs.thumbnailWidth, songs.thumbnailHeight
|
||||
FROM songs
|
||||
WHERE post_url IN (${postIdsParams});`,
|
||||
postIds,
|
||||
(tagErr, tagRows: SongRow[]) => {
|
||||
if (tagErr != null) {
|
||||
log.error('Error loading post tags', tagErr);
|
||||
logger.error('Error loading post songs', tagErr);
|
||||
reject(tagErr);
|
||||
return;
|
||||
}
|
||||
@ -553,17 +598,23 @@ function getSongData(postIdsParams: String, postIds: string[]): Promise<Map<stri
|
||||
const info = {
|
||||
pageUrl: item.overviewUrl,
|
||||
youtubeUrl: item.youtubeUrl,
|
||||
spotifyUrl: item.spotifyUrl,
|
||||
spotifyUri: item.spotifyUri,
|
||||
tidalUri: item.tidalId,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
artistName: item.artistName,
|
||||
thumbnailUrl: item.thumbnailUrl,
|
||||
postedUrl: item.postedUrl
|
||||
postedUrl: item.postedUrl,
|
||||
thumbnailHeight: item.thumbnailHeight,
|
||||
thumbnailWidth: item.thumbnailWidth
|
||||
} as SongInfo;
|
||||
result.set(item.post_url, [...(result.get(item.post_url) || []), info]);
|
||||
return result;
|
||||
},
|
||||
new Map()
|
||||
);
|
||||
logger.verbose('songMap', songMap);
|
||||
resolve(songMap);
|
||||
}
|
||||
);
|
||||
@ -582,7 +633,7 @@ function getAvatarData(
|
||||
accountUrls,
|
||||
(err, rows: AccountAvatarRow[]) => {
|
||||
if (err != null) {
|
||||
log.error('Error loading avatars', err);
|
||||
logger.error('Error loading avatars', err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
@ -616,7 +667,7 @@ function getSongThumbnailData(
|
||||
thumbUrls,
|
||||
(err, rows: SongThumbnailAvatarRow[]) => {
|
||||
if (err != null) {
|
||||
log.error('Error loading avatars', err);
|
||||
logger.error('Error loading avatars', err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
@ -642,6 +693,38 @@ function getSongThumbnailData(
|
||||
});
|
||||
}
|
||||
|
||||
export async function doesTidalSongExist(song: SongInfo): Promise<boolean> {
|
||||
if (!databaseReady) {
|
||||
await waitReady();
|
||||
}
|
||||
if (!song.tidalUri) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sql = `SELECT songs.title, songs.artistName, songs.tidalId
|
||||
FROM songs
|
||||
WHERE songs.tidalId = $tidalId
|
||||
LIMIT $limit`;
|
||||
|
||||
// If only one exists: This is the one that has just been added
|
||||
// If more exits: It has been added before
|
||||
const params = {
|
||||
$tidalId: song.tidalUri,
|
||||
$limit: 2
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err != null) {
|
||||
logger.error('Error loading songs', err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
logger.verbose('doesTidalSongExist', song.tidalUri, rows, rows.length > 1);
|
||||
resolve(rows.length > 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPosts(
|
||||
since: string | null,
|
||||
before: string | null,
|
||||
|
208
src/lib/server/playlist/oauthPlaylistAdder.ts
Normal file
208
src/lib/server/playlist/oauthPlaylistAdder.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { BASE_URL } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import type { OauthResponse } from '$lib/mastodon/response';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
export abstract class OauthPlaylistAdder {
|
||||
/// How many minutes before expiry the token will be refreshed
|
||||
protected refresh_time: number = 15;
|
||||
protected logger: Logger = new Logger('OauthPlaylistAdder');
|
||||
protected redirectUri?: URL;
|
||||
|
||||
protected constructor(
|
||||
protected apiBase: string,
|
||||
protected token_file_name: string
|
||||
) {}
|
||||
|
||||
public async authCodeExists(): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.auth();
|
||||
return token !== null && !token.error;
|
||||
} catch {
|
||||
this.logger.info('No auth token yet, authorizing...');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected getRedirectUri(suffix: string): URL {
|
||||
const uri = this.redirectUri ?? new URL(`${BASE_URL}/${suffix}`);
|
||||
this.logger.debug('getRedirectUri', uri);
|
||||
return uri;
|
||||
}
|
||||
|
||||
protected constructAuthUrlInternal(
|
||||
endpointUrl: string,
|
||||
clientId: string,
|
||||
scope: string,
|
||||
redirectUri: URL,
|
||||
additionalParameters: Map<string, string> = new Map()
|
||||
): URL {
|
||||
const authUrl = new URL(endpointUrl);
|
||||
authUrl.searchParams.append('client_id', clientId);
|
||||
authUrl.searchParams.append('redirect_uri', redirectUri.toString());
|
||||
authUrl.searchParams.append('response_type', 'code');
|
||||
authUrl.searchParams.append('scope', scope);
|
||||
for (let p of additionalParameters.entries()) {
|
||||
authUrl.searchParams.append(p[0], p[1]);
|
||||
}
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
public async receivedAuthCodeInternal(
|
||||
tokenUrl: URL,
|
||||
clientId: string,
|
||||
code: string,
|
||||
redirectUri: URL,
|
||||
client_secret?: string,
|
||||
customHeader?: HeadersInit,
|
||||
code_verifier?: string
|
||||
) {
|
||||
this.logger.debug('received code');
|
||||
const params = new URLSearchParams();
|
||||
params.append('client_id', clientId);
|
||||
params.append('code', code);
|
||||
params.append('grant_type', 'authorization_code');
|
||||
params.append('redirect_uri', redirectUri.toString());
|
||||
if (client_secret) {
|
||||
params.append('client_secret', client_secret);
|
||||
}
|
||||
if (code_verifier) {
|
||||
params.append('code_verifier', code_verifier);
|
||||
}
|
||||
this.logger.debug('sending token req', params);
|
||||
const resp: OauthResponse = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: customHeader
|
||||
}).then((r) => r.json());
|
||||
this.logger.debug('received access token', resp);
|
||||
let expiration = new Date();
|
||||
expiration.setTime(expiration.getTime() + resp.expires_in * 1000);
|
||||
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
|
||||
resp.expires = expiration;
|
||||
await fs.writeFile(this.token_file_name, JSON.stringify(resp));
|
||||
}
|
||||
|
||||
protected async auth(): Promise<OauthResponse | null> {
|
||||
try {
|
||||
const token_file = await fs.readFile(this.token_file_name, { encoding: 'utf8' });
|
||||
let token = JSON.parse(token_file);
|
||||
if (token.expires) {
|
||||
if (typeof token.expires === typeof '') {
|
||||
token.expires = new Date(token.expires);
|
||||
}
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
this.logger.error('Could not read access token', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected async shouldRefreshToken(): Promise<{ token: OauthResponse; refresh: boolean } | null> {
|
||||
const token = await this.auth();
|
||||
if (token == null || !token?.expires) {
|
||||
this.logger.warn('Cannot check if token should be refreshed. Token expiry is unreadablle');
|
||||
return null;
|
||||
}
|
||||
if (token.error) {
|
||||
this.logger.error('Access token is invalid, should refresh');
|
||||
return {
|
||||
token: token,
|
||||
refresh: true
|
||||
};
|
||||
}
|
||||
let refreshAt = new Date(token.expires);
|
||||
// Refresh token this.refresh_time minutes before it expires
|
||||
refreshAt.setTime(refreshAt.getTime() - this.refresh_time * 60 * 1000);
|
||||
|
||||
this.logger.info('refresh @', refreshAt, 'token expires', token.expires);
|
||||
if (refreshAt.getTime() > new Date().getTime()) {
|
||||
return {
|
||||
token: token,
|
||||
refresh: false
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
'Token expires',
|
||||
token.expires,
|
||||
token.expires.getTime(),
|
||||
`so it should be refreshed before or at`,
|
||||
refreshAt,
|
||||
refreshAt.getTime()
|
||||
);
|
||||
return {
|
||||
token: token,
|
||||
refresh: true
|
||||
};
|
||||
}
|
||||
|
||||
protected async requestRefreshToken(
|
||||
tokenUrl: URL,
|
||||
clientId: string,
|
||||
refresh_token: string,
|
||||
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');
|
||||
params.append('refresh_token', refresh_token);
|
||||
if (client_secret) {
|
||||
params.append('client_secret', client_secret);
|
||||
}
|
||||
if (redirect_uri) {
|
||||
params.append('redirect_uri', redirect_uri);
|
||||
}
|
||||
this.logger.debug('sending token req', params);
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: customHeader
|
||||
});
|
||||
|
||||
const resp: OauthResponse = await response.json();
|
||||
this.logger.verbose('received access token', resp);
|
||||
if (resp.error) {
|
||||
this.logger.error('token resp error', resp);
|
||||
return {
|
||||
resp: null,
|
||||
headers: response.headers
|
||||
};
|
||||
}
|
||||
if (!resp.refresh_token) {
|
||||
resp.refresh_token = refresh_token;
|
||||
}
|
||||
let expiration = new Date();
|
||||
expiration.setTime(expiration.getTime() + resp.expires_in * 1000);
|
||||
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
|
||||
resp.expires = expiration;
|
||||
await fs.writeFile(this.token_file_name, JSON.stringify(resp));
|
||||
return {
|
||||
resp: resp,
|
||||
headers: response.headers
|
||||
};
|
||||
}
|
||||
}
|
5
src/lib/server/playlist/playlistAdder.ts
Normal file
5
src/lib/server/playlist/playlistAdder.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { SongInfo } from '$lib/odesliResponse';
|
||||
|
||||
export interface PlaylistAdder {
|
||||
addToPlaylist(song: SongInfo): Promise<void>;
|
||||
}
|
124
src/lib/server/playlist/spotifyPlaylistAdder.ts
Normal file
124
src/lib/server/playlist/spotifyPlaylistAdder.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_PLAYLIST_ID } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import type { OauthResponse } from '$lib/mastodon/response';
|
||||
import type { SongInfo } from '$lib/odesliResponse';
|
||||
import { OauthPlaylistAdder } from './oauthPlaylistAdder';
|
||||
import type { PlaylistAdder } from './playlistAdder';
|
||||
|
||||
export class SpotifyPlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder {
|
||||
public constructor() {
|
||||
super('https://api.spotify.com/v1', 'spotify_auth_token');
|
||||
this.logger = new Logger('SpotifyPlaylistAdder');
|
||||
}
|
||||
|
||||
public constructAuthUrl(redirectUri: URL): URL {
|
||||
const endpoint = 'https://accounts.spotify.com/authorize';
|
||||
return this.constructAuthUrlInternal(
|
||||
endpoint,
|
||||
SPOTIFY_CLIENT_ID,
|
||||
'playlist-modify-private playlist-modify-public',
|
||||
redirectUri
|
||||
);
|
||||
}
|
||||
|
||||
public async receivedAuthCode(code: string, url: URL) {
|
||||
this.logger.debug('received code');
|
||||
const authHeader =
|
||||
'Basic ' + Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET).toString('base64');
|
||||
|
||||
const tokenUrl = new URL('https://accounts.spotify.com/api/token');
|
||||
await this.receivedAuthCodeInternal(tokenUrl, SPOTIFY_CLIENT_ID, code, url, undefined, {
|
||||
Authorization: authHeader
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshToken(force: boolean = false): Promise<OauthResponse | null> {
|
||||
const tokenInfo = await this.shouldRefreshToken();
|
||||
if (tokenInfo == null) {
|
||||
return null;
|
||||
}
|
||||
let token = tokenInfo.token;
|
||||
if (!tokenInfo.refresh && !force) {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (!token.refresh_token) {
|
||||
this.logger.error('Need to refresh access token, but no refresh token provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
const authHeader =
|
||||
'Basic ' + Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET).toString('base64');
|
||||
const tokenUrl = new URL('https://accounts.spotify.com/api/token');
|
||||
return await this.requestRefreshToken(
|
||||
tokenUrl,
|
||||
SPOTIFY_CLIENT_ID,
|
||||
token.refresh_token,
|
||||
undefined,
|
||||
undefined,
|
||||
{ Authorization: authHeader }
|
||||
);
|
||||
}
|
||||
|
||||
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('addToSpotifyPlaylist', remaning);
|
||||
const token = await this.refreshToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SPOTIFY_PLAYLIST_ID || SPOTIFY_PLAYLIST_ID === 'CHANGE_ME') {
|
||||
this.logger.debug('no spotify playlist ID configured');
|
||||
return;
|
||||
}
|
||||
if (!song.spotifyUri) {
|
||||
this.logger.info('Skip adding song to spotify playlist, no Uri', song);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO. Spotify's check for "is this song already in the playlist" is... ugh
|
||||
/*
|
||||
const playlistItemsUrl = new URL(`${this.apiBase}/playlists/${SPOTIFY_PLAYLIST_ID}/tracks`);
|
||||
playlistItemsUrl.searchParams.append('videoId', youtubeId);
|
||||
playlistItemsUrl.searchParams.append('playlistId', SPOTIFY_PLAYLIST_ID);
|
||||
playlistItemsUrl.searchParams.append('part', 'id');*/
|
||||
/*const existingPlaylistItem = await fetch(this.apiBase + '/playlistItems', {
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` }
|
||||
}).then((r) => r.json());
|
||||
logger.debug('existingPlaylistItem', existingPlaylistItem);
|
||||
if (existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0) {
|
||||
logger.info('Item already in playlist');
|
||||
return;
|
||||
}*/
|
||||
|
||||
//const searchParams = new URLSearchParams([['part', 'snippet']]);
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
body: JSON.stringify({
|
||||
uris: [song.spotifyUri]
|
||||
})
|
||||
};
|
||||
const apiUrl = new URL(`${this.apiBase}/playlists/${SPOTIFY_PLAYLIST_ID}/tracks`);
|
||||
const resp = await fetch(apiUrl, options);
|
||||
const respObj = await resp.json();
|
||||
if (respObj.error) {
|
||||
this.logger.debug('Add to playlist failed', song.spotifyUri, respObj.error);
|
||||
if (respObj.error.status === 401) {
|
||||
const token = await this.refreshToken(true);
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
this.addToPlaylistRetry(song, remaning--);
|
||||
}
|
||||
} else {
|
||||
this.logger.info('Added to playlist', song.spotifyUri, song.title);
|
||||
}
|
||||
}
|
||||
public async addToPlaylist(song: SongInfo) {
|
||||
await this.addToPlaylistRetry(song);
|
||||
}
|
||||
}
|
328
src/lib/server/playlist/tidalPlaylistAdder.ts
Normal file
328
src/lib/server/playlist/tidalPlaylistAdder.ts
Normal file
@ -0,0 +1,328 @@
|
||||
import { TIDAL_PLAYLIST_ID, TIDAL_CLIENT_ID, TIDAL_CLIENT_SECRET } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import type { OauthResponse } from '$lib/mastodon/response';
|
||||
import type { SongInfo } from '$lib/odesliResponse';
|
||||
import { createHash } from 'crypto';
|
||||
import { OauthPlaylistAdder } from './oauthPlaylistAdder';
|
||||
import type { PlaylistAdder } from './playlistAdder';
|
||||
import type { TidalAddToPlaylistResponse, TidalAlbumResponse } from './tidalResponse';
|
||||
import { doesTidalSongExist } from '$lib/server/db';
|
||||
import { sleep } from '$lib/sleep';
|
||||
|
||||
export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder {
|
||||
private static code_verifier?: string;
|
||||
|
||||
public constructor() {
|
||||
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 {
|
||||
const endpoint = 'https://login.tidal.com/authorize';
|
||||
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(100)).toString(), 'ascii')
|
||||
.toString('base64url')
|
||||
.slice(0, 128);
|
||||
const code_challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
TidalPlaylistAdder.code_verifier = verifier;
|
||||
|
||||
let additionalParameters = new Map([
|
||||
['code_challenge_method', 'S256'],
|
||||
['code_challenge', code_challenge]
|
||||
]);
|
||||
return this.constructAuthUrlInternal(
|
||||
endpoint,
|
||||
TIDAL_CLIENT_ID,
|
||||
'playlists.write playlists.read user.read', //r_usr w_usr
|
||||
redirectUri,
|
||||
additionalParameters
|
||||
);
|
||||
}
|
||||
|
||||
public async receivedAuthCode(code: string, url: URL) {
|
||||
this.logger.debug('received code');
|
||||
const tokenUrl = new URL('https://auth.tidal.com/v1/oauth2/token');
|
||||
await this.receivedAuthCodeInternal(
|
||||
tokenUrl,
|
||||
TIDAL_CLIENT_ID,
|
||||
code,
|
||||
url,
|
||||
TIDAL_CLIENT_SECRET,
|
||||
undefined,
|
||||
TidalPlaylistAdder.code_verifier
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshToken(force: boolean = false): Promise<OauthResponse | null> {
|
||||
const tokenInfo = await this.shouldRefreshToken();
|
||||
if (tokenInfo == null) {
|
||||
return null;
|
||||
}
|
||||
let token = tokenInfo.token;
|
||||
if (!tokenInfo.refresh && !force) {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (!token.refresh_token) {
|
||||
this.logger.error('Need to refresh access token, but no refresh token provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenUrl = new URL('https://auth.tidal.com/v1/oauth2/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 getAlbumItems(
|
||||
albumId: string,
|
||||
remaning: number = 3,
|
||||
nextLink?: string
|
||||
): Promise<{ id: string; type: string }[]> {
|
||||
this.logger.debug('getAlbumItems', albumId, remaning, nextLink);
|
||||
if (remaning <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const token = await this.refreshToken();
|
||||
if (token == null) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
let albumUrl: URL;
|
||||
if (nextLink) {
|
||||
albumUrl = new URL(nextLink);
|
||||
} else {
|
||||
albumUrl = new URL(`${this.apiBase}/albums/${albumId}/relationships/items`);
|
||||
albumUrl.searchParams.append('countryCode', 'DE');
|
||||
}
|
||||
|
||||
const options: RequestInit = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `${token.token_type} ${token.access_token}`,
|
||||
Accept: 'application/vnd.api+json'
|
||||
}
|
||||
};
|
||||
const request = new Request(albumUrl, options);
|
||||
const resp = await fetch(request);
|
||||
this.processTidalHeaders(resp.headers);
|
||||
if (resp.ok) {
|
||||
const respData: TidalAlbumResponse = await resp.json();
|
||||
if (respData.data !== undefined) {
|
||||
let tracks = respData.data.map((x) => {
|
||||
return { id: x.id, type: x.type };
|
||||
});
|
||||
if (respData.links?.next) {
|
||||
const nextPage = await this.getAlbumItems(albumId, remaning, respData.links.next);
|
||||
tracks = tracks.concat(nextPage);
|
||||
}
|
||||
return tracks;
|
||||
} else {
|
||||
this.logger.error('Error response for album', respData.errors, respData);
|
||||
return [];
|
||||
}
|
||||
} else if (resp.status === 429) {
|
||||
// Tidal docs say, this endpoint uses Retry-After header,
|
||||
// but other endpoints use x-rate-limit headers. Check both
|
||||
let secondsToWait = 0;
|
||||
const retryAfterHeader = resp.headers.get('Retry-After');
|
||||
if (retryAfterHeader) {
|
||||
secondsToWait = parseInt(retryAfterHeader);
|
||||
} else {
|
||||
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;
|
||||
secondsToWait = 1 + needToReplenish / replenishRateValue;
|
||||
}
|
||||
}
|
||||
if (secondsToWait === 0) {
|
||||
// Try again secondsToWait sec later, just to be safe one additional second
|
||||
this.logger.warn(
|
||||
'Received HTTP 429 Too Many Requests. Retrying in',
|
||||
secondsToWait,
|
||||
'sec'
|
||||
);
|
||||
await sleep(secondsToWait * 1000);
|
||||
return await this.getAlbumItems(albumId, remaning - 1, nextLink);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
'Received HTTP 429 Too Many Requests, but no instructions on how long to wait. Aborting',
|
||||
secondsToWait,
|
||||
'sec'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
const respText = await resp.text();
|
||||
this.logger.error('Cannot check album contents', resp.status, respText);
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Error checking album contents', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async addToPlaylistRetry(song: SongInfo, remaning: number = 3) {
|
||||
if (remaning < 0) {
|
||||
this.logger.error('max retries reached, song will not be added to playlist');
|
||||
}
|
||||
this.logger.debug('addToTidalPlaylist', remaning);
|
||||
const token = await this.refreshToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('token check successful');
|
||||
|
||||
if (!TIDAL_PLAYLIST_ID || TIDAL_PLAYLIST_ID === 'CHANGE_ME') {
|
||||
this.logger.debug('no playlist ID configured');
|
||||
return;
|
||||
}
|
||||
if (!song.tidalUri) {
|
||||
this.logger.info('Skip adding song to playlist, no Uri', song);
|
||||
return;
|
||||
}
|
||||
|
||||
let tracks = [
|
||||
{
|
||||
id: song.tidalUri,
|
||||
type: 'tracks'
|
||||
}
|
||||
];
|
||||
if (song.type === 'album') {
|
||||
tracks = await this.getAlbumItems(song.tidalUri);
|
||||
this.logger.debug('received tracks', tracks);
|
||||
if (tracks.length === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const alreadyExists = await doesTidalSongExist(song);
|
||||
try {
|
||||
if (alreadyExists) {
|
||||
this.logger.info('Skip adding song to playlist, has already been added', song);
|
||||
return;
|
||||
}
|
||||
} catch (dbe) {
|
||||
this.logger.error('Could not check for tidal dupes', dbe);
|
||||
}
|
||||
|
||||
// This would be API v2, but that's still in beta and only allows adding an item *before* another one
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `${token.token_type} ${token.access_token}`,
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
Accept: 'application/vnd.api+json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: tracks,
|
||||
meta: {
|
||||
positionBefore: 'ffb6286e-237a-4dfc-bbf1-2fb0eb004ed5' // Hardcoded last element of list
|
||||
}
|
||||
})
|
||||
};
|
||||
const apiUrl = new URL(`${this.apiBase}/playlists/${TIDAL_PLAYLIST_ID}/relationships/items`);
|
||||
const request = new Request(apiUrl, options);
|
||||
|
||||
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 && 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 (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) {
|
||||
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',
|
||||
song.tidalUri,
|
||||
song.title,
|
||||
respObj
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Add to playlist request failed', resp?.status, e);
|
||||
}
|
||||
}
|
||||
public async addToPlaylist(song: SongInfo) {
|
||||
await this.addToPlaylistRetry(song);
|
||||
}
|
||||
}
|
50
src/lib/server/playlist/tidalResponse.ts
Normal file
50
src/lib/server/playlist/tidalResponse.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export type TidalAddToPlaylistResponse = {
|
||||
errors: TidalAddToPlaylistError[];
|
||||
};
|
||||
|
||||
export type TidalAddToPlaylistError = TidalError & {
|
||||
id: string;
|
||||
status: number;
|
||||
source: TidalAddToPlaylistErrorSource;
|
||||
};
|
||||
|
||||
export type TidalAddToPlaylistErrorSource = {
|
||||
parameter: string;
|
||||
};
|
||||
export type TidalErrorMeta = {
|
||||
category: string;
|
||||
};
|
||||
export type TidalError = {
|
||||
code: string;
|
||||
detail: string;
|
||||
meta: TidalErrorMeta;
|
||||
};
|
||||
export type TidalErrorCode =
|
||||
| 'INVALID_ENUM_VALUE'
|
||||
| 'VALUE_REGEX_MISMATCH'
|
||||
| 'NOT_FOUND'
|
||||
| 'METHOD_NOT_SUPPORTED'
|
||||
| 'NOT_ACCEPTABLE'
|
||||
| 'UNSUPPORTED_MEDIA_TYPE'
|
||||
| 'UNAVAILABLE_FOR_LEGAL_REASONS_RESPONSE'
|
||||
| 'INTERNAL_SERVER_ERROR'
|
||||
| 'UNAUTHORIZED';
|
||||
|
||||
export type TidalAlbumResponse = {
|
||||
data?: TidalAlbumTrack[];
|
||||
links?: {
|
||||
next?: string;
|
||||
self: string;
|
||||
};
|
||||
errors?: TidalError[];
|
||||
};
|
||||
export type TidalAlbumTrack = {
|
||||
id: string;
|
||||
type: 'tracks' | string;
|
||||
meta: TidalAlbumTrackMeta;
|
||||
};
|
||||
|
||||
export type TidalAlbumTrackMeta = {
|
||||
trackNumber: number;
|
||||
volumeNumber: number;
|
||||
};
|
157
src/lib/server/playlist/ytPlaylistAdder.ts
Normal file
157
src/lib/server/playlist/ytPlaylistAdder.ts
Normal file
@ -0,0 +1,157 @@
|
||||
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 { SongInfo } from '$lib/odesliResponse';
|
||||
import { OauthPlaylistAdder } from './oauthPlaylistAdder';
|
||||
import type { PlaylistAdder } from './playlistAdder';
|
||||
|
||||
export class YoutubePlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder {
|
||||
public constructor() {
|
||||
super('https://www.googleapis.com/youtube/v3', 'yt_auth_token');
|
||||
this.logger = new Logger('YoutubePlaylistAdder');
|
||||
}
|
||||
|
||||
public constructAuthUrl(redirectUri: URL): URL {
|
||||
this.redirectUri = redirectUri;
|
||||
let additionalParameters = new Map([
|
||||
['access_type', 'offline'],
|
||||
['include_granted_scopes', 'false']
|
||||
]);
|
||||
const endpoint = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
return this.constructAuthUrlInternal(
|
||||
endpoint,
|
||||
YOUTUBE_CLIENT_ID,
|
||||
'https://www.googleapis.com/auth/youtube',
|
||||
redirectUri,
|
||||
additionalParameters
|
||||
);
|
||||
}
|
||||
|
||||
public async receivedAuthCode(code: string, url: URL) {
|
||||
this.logger.debug('received code');
|
||||
this.redirectUri = url;
|
||||
const tokenUrl = new URL('https://oauth2.googleapis.com/token');
|
||||
await this.receivedAuthCodeInternal(
|
||||
tokenUrl,
|
||||
YOUTUBE_CLIENT_ID,
|
||||
code,
|
||||
url,
|
||||
YOUTUBE_CLIENT_SECRET
|
||||
);
|
||||
const token = await this.refreshToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.logger.debug('Checking authorized token');
|
||||
const res = await fetch(this.apiBase + '/channels?part=id&mine=true', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` }
|
||||
}).then((r) => r.json());
|
||||
this.logger.debug('Checked authorized token', res);
|
||||
} catch (e) {
|
||||
this.logger.debug('Error checking authorized token', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshToken(force: boolean = false): Promise<OauthResponse | null> {
|
||||
const tokenInfo = await this.shouldRefreshToken();
|
||||
if (tokenInfo == null) {
|
||||
return null;
|
||||
}
|
||||
let token = tokenInfo.token;
|
||||
if (!tokenInfo.refresh && !force) {
|
||||
return token;
|
||||
}
|
||||
if (!token.refresh_token) {
|
||||
this.logger.error('Need to refresh access token, but no refresh token provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenUrl = new URL('https://oauth2.googleapis.com/token');
|
||||
return await this.requestRefreshToken(
|
||||
tokenUrl,
|
||||
YOUTUBE_CLIENT_ID,
|
||||
token.refresh_token,
|
||||
this.getRedirectUri('ytauth').toString(),
|
||||
YOUTUBE_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!YOUTUBE_PLAYLIST_ID || YOUTUBE_PLAYLIST_ID === 'CHANGE_ME') {
|
||||
this.logger.debug('no playlist ID configured');
|
||||
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 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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const addItemUrl = new URL(this.apiBase + '/playlistItems');
|
||||
addItemUrl.searchParams.append('part', 'snippet');
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
body: JSON.stringify({
|
||||
snippet: {
|
||||
playlistId: YOUTUBE_PLAYLIST_ID,
|
||||
resourceId: {
|
||||
videoId: youtubeId,
|
||||
kind: 'youtube#video'
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
const resp = await fetch(addItemUrl, options);
|
||||
const respObj = await resp.json();
|
||||
this.logger.info('Added to playlist', youtubeId, song.title);
|
||||
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--);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
import { BASE_URL, WEBSUB_HUB } from '$env/static/private';
|
||||
import { PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public';
|
||||
import type { Post } from '$lib//mastodon/response';
|
||||
import { log } from '$lib/log';
|
||||
import { Logger } from '$lib/log';
|
||||
import { Feed } from 'feed';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
const logger = new Logger('RSS');
|
||||
|
||||
export function createFeed(posts: Post[]): Feed {
|
||||
const baseUrl = BASE_URL.endsWith('/') ? BASE_URL : BASE_URL + '/';
|
||||
const hub = WEBSUB_HUB ? WEBSUB_HUB : undefined;
|
||||
@ -52,14 +54,18 @@ export async function saveAtomFeed(feed: Feed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('hub.mode', 'publish');
|
||||
params.append('hub.url', `${BASE_URL}/feed.xml`);
|
||||
const param = new FormData();
|
||||
param.append('hub.mode', 'publish');
|
||||
param.append('hub.url', `${BASE_URL}/feed.xml`);
|
||||
//const params = new URLSearchParams();
|
||||
//params.append('hub.mode', 'publish');
|
||||
//params.append('hub.url', `${BASE_URL}/feed.xml`);
|
||||
await fetch(WEBSUB_HUB, {
|
||||
method: 'POST',
|
||||
body: params
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: param
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('Failed to update WebSub hub', e);
|
||||
logger.error('Failed to update WebSub hub', e);
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,12 @@
|
||||
import {
|
||||
HASHTAG_FILTER,
|
||||
MASTODON_ACCESS_TOKEN,
|
||||
MASTODON_INSTANCE,
|
||||
IGNORE_USERS,
|
||||
ODESLI_API_KEY,
|
||||
YOUTUBE_API_KEY
|
||||
} from '$env/static/private';
|
||||
import { enableVerboseLog, log } from '$lib/log';
|
||||
import { Logger } from '$lib/log';
|
||||
import type {
|
||||
Account,
|
||||
AccountAvatar,
|
||||
@ -24,12 +26,18 @@ import {
|
||||
savePost,
|
||||
saveSongThumbnail
|
||||
} from '$lib/server/db';
|
||||
import { SpotifyPlaylistAdder } from '$lib/server/playlist/spotifyPlaylistAdder';
|
||||
import { YoutubePlaylistAdder } from '$lib/server/playlist/ytPlaylistAdder';
|
||||
import { createFeed, saveAtomFeed } from '$lib/server/rss';
|
||||
import { sleep } from '$lib/sleep';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs/promises';
|
||||
import { console } from 'inspector/promises';
|
||||
import sharp from 'sharp';
|
||||
import { URL, URLSearchParams } from 'url';
|
||||
import { WebSocket } from 'ws';
|
||||
import type { PlaylistAdder } from './playlist/playlistAdder';
|
||||
import { TidalPlaylistAdder } from './playlist/tidalPlaylistAdder';
|
||||
|
||||
const URL_REGEX = new RegExp(/href="(?<postUrl>[^>]+?)" target="_blank"/gm);
|
||||
const INVIDIOUS_REGEX = new RegExp(/invidious.*?watch.*?v=(?<videoId>[a-zA-Z_0-9-]+)/gm);
|
||||
@ -39,10 +47,15 @@ const YOUTUBE_REGEX = new RegExp(
|
||||
|
||||
export class TimelineReader {
|
||||
private static _instance: TimelineReader;
|
||||
private lastPosts: string[] = [];
|
||||
private playlistAdders: PlaylistAdder[];
|
||||
private logger: Logger;
|
||||
private ignoredUsers: string[];
|
||||
|
||||
private static async isMusicVideo(videoId: string) {
|
||||
private async isMusicVideo(videoId: string) {
|
||||
if (!YOUTUBE_API_KEY || YOUTUBE_API_KEY === 'CHANGE_ME') {
|
||||
// Assume that it *is* a music link when no YT API key is provided
|
||||
this.logger.debug('YT API not configured');
|
||||
return true;
|
||||
}
|
||||
const searchParams = new URLSearchParams([
|
||||
@ -54,13 +67,13 @@ export class TimelineReader {
|
||||
const resp = await fetch(youtubeVideoUrl);
|
||||
const respObj = await resp.json();
|
||||
if (!respObj.items.length) {
|
||||
console.warn('Could not find video with id', videoId);
|
||||
this.logger.warn('Could not find video with id', videoId);
|
||||
return false;
|
||||
}
|
||||
|
||||
const item = respObj.items[0];
|
||||
if (!item.snippet) {
|
||||
console.warn('Could not load snippet for video', videoId, item);
|
||||
this.logger.warn('Could not load snippet for video', videoId, item);
|
||||
return false;
|
||||
}
|
||||
if (item.snippet.tags?.includes('music')) {
|
||||
@ -78,33 +91,19 @@ export class TimelineReader {
|
||||
const categoryTitle: string = await fetch(youtubeCategoryUrl)
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.items[0]?.snippet?.title);
|
||||
if (enableVerboseLog) {
|
||||
log.verbose(
|
||||
'Video',
|
||||
videoId,
|
||||
'category',
|
||||
categoryTitle,
|
||||
'tags',
|
||||
item.snippet.tags,
|
||||
'category id',
|
||||
item.snippet.categoryId,
|
||||
'response',
|
||||
respObj,
|
||||
'snippet',
|
||||
item.snippet
|
||||
);
|
||||
} else {
|
||||
log.debug('Video', videoId, 'category', categoryTitle);
|
||||
}
|
||||
this.logger.debug('YT category', categoryTitle);
|
||||
return categoryTitle === 'Music';
|
||||
}
|
||||
|
||||
public static async getSongInfoInPost(post: Post): Promise<SongInfo[]> {
|
||||
public async getSongInfoInPost(post: Post): Promise<SongInfo[]> {
|
||||
const urlMatches = post.content.matchAll(URL_REGEX);
|
||||
const songs: SongInfo[] = [];
|
||||
for (const match of urlMatches) {
|
||||
if (match === undefined || match.groups === undefined) {
|
||||
log.warn('Match listed in allMatches, but either it or its groups are undefined', match);
|
||||
this.logger.warn(
|
||||
'Match listed in allMatches, but either it or its groups are undefined',
|
||||
match
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const urlMatch = match.groups.postUrl.toString();
|
||||
@ -112,14 +111,14 @@ export class TimelineReader {
|
||||
try {
|
||||
url = new URL(urlMatch);
|
||||
} catch (e) {
|
||||
log.error('URL found via Regex does not seem to be a valud url', urlMatch, e);
|
||||
this.logger.error('URL found via Regex does not seem to be a valud url', urlMatch, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check *all* found url and let odesli determine if it is music or not
|
||||
log.debug(`Checking ${url} if it contains song data`);
|
||||
const info = await TimelineReader.getSongInfo(url);
|
||||
log.debug(`Found song info for ${url}?`, info);
|
||||
this.logger.debug(`Checking ${url} if it contains song data`);
|
||||
const info = await this.getSongInfo(url);
|
||||
//this.logger.debug(`Found song info for ${url}?`, info);
|
||||
if (info) {
|
||||
songs.push(info);
|
||||
}
|
||||
@ -127,9 +126,9 @@ export class TimelineReader {
|
||||
return songs;
|
||||
}
|
||||
|
||||
private static async getSongInfo(url: URL, remainingTries = 6): Promise<SongInfo | null> {
|
||||
private async getSongInfo(url: URL, remainingTries = 6): Promise<SongInfo | null> {
|
||||
if (remainingTries === 0) {
|
||||
log.error('No tries remaining. Lookup failed!');
|
||||
this.logger.error('No tries remaining. Lookup failed!');
|
||||
return null;
|
||||
}
|
||||
if (url.hostname === 'songwhip.com') {
|
||||
@ -153,7 +152,6 @@ export class TimelineReader {
|
||||
const odesliApiUrl = `https://api.song.link/v1-alpha.1/links?${odesliParams}`;
|
||||
try {
|
||||
const response = await fetch(odesliApiUrl);
|
||||
log.debug('received odesli response', response.status);
|
||||
if (response.status === 429) {
|
||||
throw new Error('Rate limit reached', { cause: 429 });
|
||||
}
|
||||
@ -162,41 +160,64 @@ export class TimelineReader {
|
||||
return null;
|
||||
}
|
||||
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
|
||||
//this.logger.debug('odesli response', info);
|
||||
const platform: Platform = 'youtube';
|
||||
log.debug(url, 'odesli response', info, 'YT URL', odesliInfo.linksByPlatform[platform]?.url);
|
||||
if (info.platforms.includes(platform)) {
|
||||
let youtubeId =
|
||||
const youtubeId =
|
||||
videoId ??
|
||||
YOUTUBE_REGEX.exec(url.href)?.groups?.videoId ??
|
||||
new URL(odesliInfo.pageUrl).pathname.split('/y/').pop();
|
||||
if (youtubeId === undefined) {
|
||||
log.warn('Looks like a youtube video, but could not extract a video id', url, odesliInfo);
|
||||
this.logger.warn(
|
||||
'Looks like a youtube video, but could not extract a video id',
|
||||
url,
|
||||
odesliInfo
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const isMusic = await TimelineReader.isMusicVideo(youtubeId);
|
||||
const isMusic = await this.isMusicVideo(youtubeId);
|
||||
if (!isMusic) {
|
||||
log.debug('Probably not a music video', url, odesliInfo);
|
||||
this.logger.debug('Probably not a music video', youtubeId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
const spotify: Platform = 'spotify';
|
||||
const tidal: Platform = 'tidal';
|
||||
const tidalId = odesliInfo.linksByPlatform[tidal]?.entityUniqueId;
|
||||
const tidalUri = tidalId ? odesliInfo.entitiesByUniqueId[tidalId].id : undefined;
|
||||
|
||||
const songInfo = {
|
||||
...info,
|
||||
pageUrl: odesliInfo.pageUrl,
|
||||
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
|
||||
spotifyUrl: odesliInfo.linksByPlatform[spotify]?.url,
|
||||
spotifyUri: odesliInfo.linksByPlatform[spotify]?.nativeAppUriDesktop,
|
||||
tidalUri: tidalUri,
|
||||
postedUrl: url.toString()
|
||||
} as SongInfo;
|
||||
return songInfo;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.cause === 429) {
|
||||
log.warn('song.link rate limit reached. Trying again in 10 seconds');
|
||||
this.logger.warn('song.link rate limit reached. Trying again in 10 seconds');
|
||||
await sleep(10_000);
|
||||
return await this.getSongInfo(url, remainingTries - 1);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Failed to load ${url} info from song.link. Trying again in 3 seconds`,
|
||||
e
|
||||
);
|
||||
await sleep(3_000);
|
||||
}
|
||||
log.error(`Failed to load ${url} info from song.link`, e);
|
||||
return null;
|
||||
return await this.getSongInfo(url, remainingTries - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static async resizeAvatar(
|
||||
private async addToPlaylist(song: SongInfo) {
|
||||
for (let adder of this.playlistAdders) {
|
||||
await adder.addToPlaylist(song);
|
||||
}
|
||||
}
|
||||
|
||||
private async resizeAvatar(
|
||||
baseName: string,
|
||||
size: number,
|
||||
suffix: string,
|
||||
@ -209,15 +230,15 @@ export class TimelineReader {
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (exists) {
|
||||
log.debug('File already exists', fileName);
|
||||
this.logger.debug('File already exists', fileName);
|
||||
return null;
|
||||
}
|
||||
log.debug('Saving avatar', fileName);
|
||||
this.logger.debug('Saving avatar', fileName);
|
||||
await sharpAvatar.resize(size).toFile(fileName);
|
||||
return fileName;
|
||||
}
|
||||
|
||||
private static resizeAvatarPromiseMaker(
|
||||
private resizeAvatarPromiseMaker(
|
||||
avatarFilenameBase: string,
|
||||
baseSize: number,
|
||||
maxPixelDensity: number,
|
||||
@ -230,20 +251,14 @@ export class TimelineReader {
|
||||
for (let i = 1; i <= maxPixelDensity; i++) {
|
||||
promises.push(
|
||||
...formats.map((f) =>
|
||||
TimelineReader.resizeAvatar(
|
||||
avatarFilenameBase,
|
||||
baseSize * i,
|
||||
`${i}x.${f}`,
|
||||
'avatars',
|
||||
sharpAvatar
|
||||
)
|
||||
this.resizeAvatar(avatarFilenameBase, baseSize * i, `${i}x.${f}`, 'avatars', sharpAvatar)
|
||||
.then(
|
||||
(fn) =>
|
||||
({
|
||||
accountUrl: accountUrl,
|
||||
file: fn,
|
||||
sizeDescriptor: `${i}x`
|
||||
} as AccountAvatar)
|
||||
}) as AccountAvatar
|
||||
)
|
||||
.then(saveAvatar)
|
||||
)
|
||||
@ -252,7 +267,7 @@ export class TimelineReader {
|
||||
return promises;
|
||||
}
|
||||
|
||||
private static resizeThumbnailPromiseMaker(
|
||||
private resizeThumbnailPromiseMaker(
|
||||
filenameBase: string,
|
||||
baseSize: number,
|
||||
maxPixelDensity: number,
|
||||
@ -266,13 +281,7 @@ export class TimelineReader {
|
||||
for (let i = 1; i <= maxPixelDensity; i++) {
|
||||
promises.push(
|
||||
...formats.map((f) =>
|
||||
TimelineReader.resizeAvatar(
|
||||
filenameBase,
|
||||
baseSize * i,
|
||||
`${i}x.${f}`,
|
||||
'thumbnails',
|
||||
sharpAvatar
|
||||
)
|
||||
this.resizeAvatar(filenameBase, baseSize * i, `${i}x.${f}`, 'thumbnails', sharpAvatar)
|
||||
.then(
|
||||
(fn) =>
|
||||
({
|
||||
@ -280,7 +289,7 @@ export class TimelineReader {
|
||||
file: fn,
|
||||
sizeDescriptor: `${i}x`,
|
||||
kind: kind
|
||||
} as SongThumbnailImage)
|
||||
}) as SongThumbnailImage
|
||||
)
|
||||
.then(saveSongThumbnail)
|
||||
)
|
||||
@ -289,7 +298,7 @@ export class TimelineReader {
|
||||
return promises;
|
||||
}
|
||||
|
||||
private static async saveAvatar(account: Account) {
|
||||
private async saveAvatar(account: Account) {
|
||||
try {
|
||||
const existingAvatars = await getAvatars(account.url, 1);
|
||||
const existingAvatarBase = existingAvatars.shift()?.file.split('/').pop()?.split('_').shift();
|
||||
@ -302,7 +311,7 @@ export class TimelineReader {
|
||||
const avatarsToDelete = (await fs.readdir('avatars'))
|
||||
.filter((x) => x.startsWith(existingAvatarBase + '_'))
|
||||
.map((x) => {
|
||||
log.debug('Removing existing avatar file', x);
|
||||
this.logger.debug('Removing existing avatar file', x);
|
||||
return x;
|
||||
})
|
||||
.map((x) => fs.unlink('avatars/' + x));
|
||||
@ -311,7 +320,7 @@ export class TimelineReader {
|
||||
const avatarResponse = await fetch(account.avatar);
|
||||
const avatar = await avatarResponse.arrayBuffer();
|
||||
await Promise.all(
|
||||
TimelineReader.resizeAvatarPromiseMaker(
|
||||
this.resizeAvatarPromiseMaker(
|
||||
avatarFilenameBase,
|
||||
50,
|
||||
3,
|
||||
@ -325,7 +334,7 @@ export class TimelineReader {
|
||||
}
|
||||
}
|
||||
|
||||
private static async saveSongThumbnails(songs: SongInfo[]) {
|
||||
private async saveSongThumbnails(songs: SongInfo[]) {
|
||||
for (const song of songs) {
|
||||
if (!song.thumbnailUrl) {
|
||||
continue;
|
||||
@ -339,7 +348,7 @@ export class TimelineReader {
|
||||
const imageResponse = await fetch(song.thumbnailUrl);
|
||||
const avatar = await imageResponse.arrayBuffer();
|
||||
await Promise.all(
|
||||
TimelineReader.resizeThumbnailPromiseMaker(
|
||||
this.resizeThumbnailPromiseMaker(
|
||||
fileBaseName + '_large',
|
||||
200,
|
||||
3,
|
||||
@ -350,7 +359,7 @@ export class TimelineReader {
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
TimelineReader.resizeThumbnailPromiseMaker(
|
||||
this.resizeThumbnailPromiseMaker(
|
||||
fileBaseName + '_small',
|
||||
60,
|
||||
3,
|
||||
@ -371,63 +380,181 @@ export class TimelineReader {
|
||||
}
|
||||
}
|
||||
|
||||
private startWebsocket() {
|
||||
const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`);
|
||||
socket.onopen = () => {
|
||||
log.log('Connected to WS');
|
||||
socket.send('{ "type": "subscribe", "stream": "public:local"}');
|
||||
};
|
||||
socket.onmessage = async (event) => {
|
||||
try {
|
||||
const data: TimelineEvent = JSON.parse(event.data.toString());
|
||||
if (data.event !== 'update') {
|
||||
private async checkAndSavePost(post: Post) {
|
||||
const isIgnored = this.ignoredUsers.includes(post.account.acct);
|
||||
if (isIgnored) {
|
||||
this.logger.info(
|
||||
'Ignoring post by ignored user',
|
||||
post.account.acct,
|
||||
'is ignored',
|
||||
this.ignoredUsers,
|
||||
isIgnored
|
||||
);
|
||||
return;
|
||||
}
|
||||
const post: Post = JSON.parse(data.payload);
|
||||
|
||||
const hashttags: string[] = HASHTAG_FILTER.split(',');
|
||||
const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name));
|
||||
|
||||
const songs = await TimelineReader.getSongInfoInPost(post);
|
||||
const songs = await this.getSongInfoInPost(post);
|
||||
|
||||
// If we don't have any tags or non-youtube urls, check youtube
|
||||
// YT is handled separately, because it requires an API call and therefore is slower
|
||||
if (songs.length === 0 && found_tags.length === 0) {
|
||||
log.log('Ignoring post', post.url);
|
||||
this.logger.log('Ignoring post', post.url);
|
||||
return;
|
||||
}
|
||||
|
||||
await savePost(post, songs);
|
||||
|
||||
await TimelineReader.saveAvatar(post.account);
|
||||
await TimelineReader.saveSongThumbnails(songs);
|
||||
await this.saveAvatar(post.account);
|
||||
await this.saveSongThumbnails(songs);
|
||||
|
||||
log.debug('Saved post', post.url);
|
||||
this.logger.debug('Saved post', post.url, 'songs', songs);
|
||||
|
||||
const posts = await getPosts(null, null, 100);
|
||||
await saveAtomFeed(createFeed(posts));
|
||||
|
||||
for (let song of songs) {
|
||||
this.logger.debug('Adding to playlist', song);
|
||||
await this.addToPlaylist(song);
|
||||
}
|
||||
}
|
||||
|
||||
private startWebsocket() {
|
||||
const socketLogger = new Logger('Websocket');
|
||||
const socket = new WebSocket(
|
||||
`wss://${MASTODON_INSTANCE}/api/v1/streaming?type=subscribe&stream=public:local&access_token=${MASTODON_ACCESS_TOKEN}`
|
||||
);
|
||||
|
||||
// Sometimes, the app just stops receiving WS updates.
|
||||
// Regularly check if it is necessary to reset it
|
||||
const wsTimeout = 5;
|
||||
let timeoutId = setTimeout(
|
||||
() => {
|
||||
socketLogger.warn(
|
||||
'Websocket has not received a new post in',
|
||||
wsTimeout,
|
||||
'hours. Resetting, it might be stuck'
|
||||
);
|
||||
socket.close();
|
||||
this.startWebsocket();
|
||||
},
|
||||
1000 * 60 * 60 * wsTimeout
|
||||
); // 5 hours
|
||||
socket.onopen = () => {
|
||||
socketLogger.log('Connected to WS');
|
||||
};
|
||||
socket.onmessage = async (event) => {
|
||||
try {
|
||||
// Reset timer
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(
|
||||
() => {
|
||||
socketLogger.warn(
|
||||
'Websocket has not received a new post in',
|
||||
wsTimeout,
|
||||
'hours. Resetting, it might be stuck'
|
||||
);
|
||||
socket.close();
|
||||
this.startWebsocket();
|
||||
},
|
||||
1000 * 60 * 60 * wsTimeout
|
||||
);
|
||||
|
||||
const data: TimelineEvent = JSON.parse(event.data.toString());
|
||||
socketLogger.debug('ES event', data.event);
|
||||
if (data.event !== 'update') {
|
||||
socketLogger.log('Ignoring ES event', data.event);
|
||||
return;
|
||||
}
|
||||
const post: Post = JSON.parse(data.payload);
|
||||
|
||||
// Sometimes onmessage is called twice for the same post.
|
||||
// This looks to be an issue with automatic reloading in the dev environment,
|
||||
// but hard to tell
|
||||
if (this.lastPosts.includes(post.id)) {
|
||||
socketLogger.log('Skipping post, already handled', post.id);
|
||||
return;
|
||||
}
|
||||
this.lastPosts.push(post.id);
|
||||
while (this.lastPosts.length > 10) {
|
||||
this.lastPosts.shift();
|
||||
}
|
||||
await this.checkAndSavePost(post);
|
||||
} catch (e) {
|
||||
log.error('error message', event, event.data, e);
|
||||
socketLogger.error('error message', event, event.data, e);
|
||||
}
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
log.warn(
|
||||
`Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`
|
||||
socketLogger.warn(
|
||||
`Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`,
|
||||
event
|
||||
);
|
||||
setTimeout(() => {
|
||||
log.info(`Attempting to reconenct to WS`);
|
||||
socketLogger.info(`Attempting to reconenct to WS`);
|
||||
this.startWebsocket();
|
||||
}, 10000);
|
||||
};
|
||||
socket.onerror = (event) => {
|
||||
log.error(
|
||||
socketLogger.error(
|
||||
`Websocket connection to ${MASTODON_INSTANCE} failed. ${event.type}: ${event.error}, message: '${event.message}'`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private async loadPostsSinceLastRun() {
|
||||
const now = new Date().toISOString();
|
||||
let latestPost = await getPosts(null, now, 1);
|
||||
if (latestPost.length > 0) {
|
||||
this.logger.log('Last post in DB since', now, latestPost[0].created_at);
|
||||
} else {
|
||||
this.logger.log('No posts in DB since');
|
||||
}
|
||||
let u = new URL(`https://${MASTODON_INSTANCE}/api/v1/timelines/public?local=true&limit=40`);
|
||||
if (latestPost.length > 0) {
|
||||
u.searchParams.append('since_id', latestPost[0].id);
|
||||
}
|
||||
for (let tag of HASHTAG_FILTER.split(',')) {
|
||||
u.searchParams.append('q', '#' + tag);
|
||||
}
|
||||
const headers = {
|
||||
Authorization: `Bearer ${MASTODON_ACCESS_TOKEN}`
|
||||
};
|
||||
const latestPosts: Post[] = await fetch(u, { headers }).then((r) => r.json());
|
||||
this.logger.info('searched posts', latestPosts.length);
|
||||
for (const post of latestPosts) {
|
||||
await this.checkAndSavePost(post);
|
||||
}
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
this.logger = new Logger('Timeline');
|
||||
this.logger.log('Constructing timeline object');
|
||||
this.playlistAdders = [
|
||||
new YoutubePlaylistAdder(),
|
||||
new SpotifyPlaylistAdder(),
|
||||
new TidalPlaylistAdder()
|
||||
];
|
||||
this.ignoredUsers =
|
||||
IGNORE_USERS === undefined || IGNORE_USERS === 'CHANGE_ME' || !!IGNORE_USERS
|
||||
? []
|
||||
: IGNORE_USERS.split(',')
|
||||
.map((u) => (u.startsWith('@') ? u.substring(1) : u))
|
||||
.map((u) =>
|
||||
u.endsWith('@' + MASTODON_INSTANCE)
|
||||
? u.substring(0, u.length - ('@' + MASTODON_INSTANCE).length)
|
||||
: u
|
||||
);
|
||||
this.startWebsocket();
|
||||
|
||||
this.loadPostsSinceLastRun()
|
||||
.then((_) => {
|
||||
this.logger.info('loaded posts since last run');
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.error('cannot fetch latest posts', e);
|
||||
});
|
||||
}
|
||||
|
||||
public static init() {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import FooterComponent from '$lib/components/FooterComponent.svelte';
|
||||
import { SvelteToast } from '@zerodevx/svelte-toast';
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
const options = {
|
||||
pausable: true,
|
||||
@ -8,7 +13,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
<SvelteToast {options} />
|
||||
<div class="footer">
|
||||
<FooterComponent />
|
||||
@ -19,6 +24,7 @@
|
||||
position: sticky;
|
||||
bottom: 0px;
|
||||
display: inline-block;
|
||||
z-index: 99;
|
||||
}
|
||||
:global(.toast.error) {
|
||||
--toastColor: var(--color-button-text);
|
||||
|
@ -12,7 +12,12 @@
|
||||
import { cubicInOut } from 'svelte/easing';
|
||||
import { errorToast } from '$lib/errorToast';
|
||||
|
||||
export let data: PageData;
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data = $bindable() }: Props = $props();
|
||||
let posts: Post[] = $state(data.posts);
|
||||
|
||||
interface FetchOptions {
|
||||
since?: string;
|
||||
@ -26,8 +31,8 @@
|
||||
|
||||
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
|
||||
let interval: ReturnType<typeof setTimeout> | null = null;
|
||||
let moreOlderPostsAvailable = true;
|
||||
let loadingOlderPosts = false;
|
||||
let moreOlderPostsAvailable = $state(true);
|
||||
let loadingOlderPosts = $state(false);
|
||||
|
||||
// Needed, so that edgeFly() can do its thing:
|
||||
// To determine whether a newly loaded post is older than the existing ones, is required to know what the oldest
|
||||
@ -40,11 +45,11 @@
|
||||
*/
|
||||
function edgeFly(node: Element, opts: EdgeFlyParams) {
|
||||
const createdAt = new Date(opts.created_at).getTime();
|
||||
const diffNewest = Math.abs(new Date(data.posts[0].created_at).getTime() - createdAt);
|
||||
const diffNewest = Math.abs(new Date(posts[0].created_at).getTime() - createdAt);
|
||||
const oldest =
|
||||
oldestBeforeLastFetch !== null
|
||||
? oldestBeforeLastFetch
|
||||
: new Date(data.posts[data.posts.length - 1].created_at).getTime();
|
||||
: new Date(posts[posts.length - 1].created_at).getTime();
|
||||
const diffOldest = Math.abs(oldest - createdAt);
|
||||
const fromTop = diffNewest <= diffOldest;
|
||||
|
||||
@ -79,15 +84,15 @@
|
||||
|
||||
function refresh() {
|
||||
let filter: FetchOptions = {};
|
||||
if (data.posts.length > 0) {
|
||||
filter = { since: data.posts[0].created_at };
|
||||
if (posts.length > 0) {
|
||||
filter = { since: posts[0].created_at };
|
||||
}
|
||||
fetchPosts(filter)
|
||||
.then((resp) => {
|
||||
if (resp.length > 0) {
|
||||
// Prepend new posts, filter dupes
|
||||
// There shouldn't be any duplicates, but better be safe than sorry
|
||||
data.posts = filterDuplicates(resp.concat(data.posts));
|
||||
posts = filterDuplicates(resp.concat(posts));
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
@ -95,9 +100,10 @@
|
||||
});
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (data.posts.length > 0) {
|
||||
oldestBeforeLastFetch = new Date(data.posts[data.posts.length - 1].created_at).getTime();
|
||||
onMount(() => {
|
||||
posts = data.posts;
|
||||
if (posts.length > 0) {
|
||||
oldestBeforeLastFetch = new Date(posts[posts.length - 1].created_at).getTime();
|
||||
}
|
||||
interval = setInterval(refresh, refreshInterval);
|
||||
|
||||
@ -121,8 +127,8 @@
|
||||
function loadOlderPosts() {
|
||||
loadingOlderPosts = true;
|
||||
const filter: FetchOptions = { count: 20 };
|
||||
if (data.posts.length > 0) {
|
||||
const before = data.posts[data.posts.length - 1].created_at;
|
||||
if (posts.length > 0) {
|
||||
const before = posts[posts.length - 1].created_at;
|
||||
filter.before = before;
|
||||
oldestBeforeLastFetch = new Date(before).getTime();
|
||||
}
|
||||
@ -132,7 +138,7 @@
|
||||
if (resp.length > 0) {
|
||||
// Append old posts, filter dupes
|
||||
// There shouldn't be any duplicates, but better be safe than sorry
|
||||
data.posts = filterDuplicates(data.posts.concat(resp));
|
||||
posts = filterDuplicates(posts.concat(resp));
|
||||
// If we got less than we expected, there are no older posts available
|
||||
moreOlderPostsAvailable = resp.length >= (filter.count ?? 20);
|
||||
} else {
|
||||
@ -152,15 +158,15 @@
|
||||
</svelte:head>
|
||||
<h2>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</h2>
|
||||
<div class="wrapper">
|
||||
<div />
|
||||
<div></div>
|
||||
<div class="posts">
|
||||
{#if data.posts.length === 0}
|
||||
{#if posts.length === 0}
|
||||
Sorry, no posts recommending music have been found yet
|
||||
{/if}
|
||||
{#each data.posts as post (post.url)}
|
||||
{#each posts as post (post.url)}
|
||||
<div
|
||||
class="post"
|
||||
transition:edgeFly={{
|
||||
transition:edgeFly|global={{
|
||||
y: 10,
|
||||
created_at: post.created_at,
|
||||
duration: 300,
|
||||
@ -171,12 +177,12 @@
|
||||
</div>
|
||||
{/each}
|
||||
<LoadMoreComponent
|
||||
on:loadOlderPosts={loadOlderPosts}
|
||||
{loadOlderPosts}
|
||||
moreAvailable={moreOlderPostsAvailable}
|
||||
isLoading={loadingOlderPosts}
|
||||
/>
|
||||
</div>
|
||||
<div />
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
@ -1,8 +1,11 @@
|
||||
import type { Post } from '$lib/mastodon/response';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ fetch }) => {
|
||||
export const load = (async ({ fetch, setHeaders }) => {
|
||||
const p = await fetch('/');
|
||||
setHeaders({
|
||||
'cache-control': 'public,max-age=60'
|
||||
});
|
||||
return {
|
||||
posts: (await p.json()) as Post[]
|
||||
};
|
||||
|
@ -1,5 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET = (async ({ fetch }) => {
|
||||
export const GET = (async ({ fetch, setHeaders }) => {
|
||||
setHeaders({
|
||||
'cache-control': 'max-age=10'
|
||||
});
|
||||
return await fetch('api/posts');
|
||||
}) satisfies RequestHandler;
|
||||
|
42
src/routes/spotifyAuth/+page.server.ts
Normal file
42
src/routes/spotifyAuth/+page.server.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { BASE_URL } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import { SpotifyPlaylistAdder } from '$lib/server/playlist/spotifyPlaylistAdder';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
const { DEV } = import.meta.env;
|
||||
|
||||
const logger = new Logger('SpotifyAuth');
|
||||
|
||||
export const load: PageServerLoad = async ({ url, request }) => {
|
||||
const forwardedHost = request.headers.get('X-Forwarded-Host');
|
||||
let redirect_base;
|
||||
if (DEV) {
|
||||
redirect_base = url.origin;
|
||||
} else if (forwardedHost) {
|
||||
redirect_base = `${url.protocol}//${forwardedHost}`;
|
||||
} else {
|
||||
redirect_base = BASE_URL;
|
||||
}
|
||||
const redirect_uri = new URL(`${redirect_base}${url.pathname}`);
|
||||
|
||||
const adder = new SpotifyPlaylistAdder();
|
||||
if (url.hostname === 'localhost' && DEV) {
|
||||
redirect_uri.hostname = '127.0.0.1';
|
||||
}
|
||||
logger.debug(url.searchParams, url.hostname);
|
||||
if (url.searchParams.has('code')) {
|
||||
await adder.receivedAuthCode(url.searchParams.get('code') || '', redirect_uri);
|
||||
redirect(307, '/');
|
||||
} else if (url.searchParams.has('error')) {
|
||||
logger.error('received error', url.searchParams.get('error'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (await adder.authCodeExists()) {
|
||||
redirect(307, '/');
|
||||
}
|
||||
|
||||
const authUrl = adder.constructAuthUrl(redirect_uri);
|
||||
logger.debug('+page.server.ts', authUrl.toString());
|
||||
redirect(307, authUrl);
|
||||
};
|
1
src/routes/spotifyAuth/+page.svelte
Normal file
1
src/routes/spotifyAuth/+page.svelte
Normal file
@ -0,0 +1 @@
|
||||
<h1>Something went wrong</h1>
|
39
src/routes/tidalAuth/+page.server.ts
Normal file
39
src/routes/tidalAuth/+page.server.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { BASE_URL } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { TidalPlaylistAdder } from '$lib/server/playlist/tidalPlaylistAdder';
|
||||
import { URL } from 'node:url';
|
||||
const { DEV } = import.meta.env;
|
||||
|
||||
const logger = new Logger('TidalAuth');
|
||||
|
||||
export const load: PageServerLoad = async ({ url, request }) => {
|
||||
const forwardedHost = request.headers.get('X-Forwarded-Host');
|
||||
let redirect_base;
|
||||
if (DEV) {
|
||||
redirect_base = url.origin;
|
||||
} else if (forwardedHost) {
|
||||
redirect_base = `${url.protocol}//${forwardedHost}`;
|
||||
} else {
|
||||
redirect_base = BASE_URL;
|
||||
}
|
||||
const redirect_uri = new URL(`${redirect_base}${url.pathname}`);
|
||||
const adder = new TidalPlaylistAdder();
|
||||
logger.debug(url.searchParams, url.hostname, redirect_uri);
|
||||
if (url.searchParams.has('code')) {
|
||||
await adder.receivedAuthCode(url.searchParams.get('code') || '', redirect_uri);
|
||||
redirect(307, '/');
|
||||
} else if (url.searchParams.has('error')) {
|
||||
logger.error('received error', url.searchParams.get('error'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (await adder.authCodeExists()) {
|
||||
redirect(307, '/');
|
||||
}
|
||||
|
||||
const authUrl = adder.constructAuthUrl(redirect_uri);
|
||||
logger.debug('+page.server.ts', authUrl.toString());
|
||||
redirect(307, authUrl);
|
||||
};
|
1
src/routes/tidalAuth/+page.svelte
Normal file
1
src/routes/tidalAuth/+page.svelte
Normal file
@ -0,0 +1 @@
|
||||
<h1>Something went wrong</h1>
|
39
src/routes/ytauth/+page.server.ts
Normal file
39
src/routes/ytauth/+page.server.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { BASE_URL } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import { YoutubePlaylistAdder } from '$lib/server/playlist/ytPlaylistAdder';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
const { DEV } = import.meta.env;
|
||||
|
||||
const logger = new Logger('YT Auth');
|
||||
|
||||
export const load: PageServerLoad = async ({ url, request }) => {
|
||||
const forwardedHost = request.headers.get('X-Forwarded-Host');
|
||||
let redirect_base;
|
||||
if (DEV) {
|
||||
redirect_base = url.origin;
|
||||
} else if (forwardedHost) {
|
||||
redirect_base = `${url.protocol}//${forwardedHost}`;
|
||||
} else {
|
||||
redirect_base = BASE_URL;
|
||||
}
|
||||
const redirect_uri = new URL(`${redirect_base}${url.pathname}`);
|
||||
|
||||
const adder = new YoutubePlaylistAdder();
|
||||
logger.debug('redirect URL', redirect_uri);
|
||||
if (url.searchParams.has('code')) {
|
||||
logger.debug(url.searchParams);
|
||||
await adder.receivedAuthCode(url.searchParams.get('code') || '', redirect_uri);
|
||||
redirect(307, '/');
|
||||
} else if (url.searchParams.has('error')) {
|
||||
logger.error('received error', url.searchParams.get('error'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (await adder.authCodeExists()) {
|
||||
redirect(307, '/');
|
||||
}
|
||||
const authUrl = adder.constructAuthUrl(redirect_uri);
|
||||
logger.debug('+page.server.ts', authUrl.toString());
|
||||
redirect(307, authUrl);
|
||||
};
|
1
src/routes/ytauth/+page.svelte
Normal file
1
src/routes/ytauth/+page.svelte
Normal file
@ -0,0 +1 @@
|
||||
<h1>Something went wrong</h1>
|
@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
. /home/moshing-mammut/.nvm/nvm.sh
|
||||
node -r dotenv/config build
|
||||
node -r dotenv/config build &
|
@ -1,5 +1,5 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/kit/vite';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
@ -11,15 +11,16 @@ const config = {
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// 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()
|
||||
adapter: adapter(),
|
||||
version: {
|
||||
name: process.env.npm_package_version
|
||||
},
|
||||
|
||||
csp: {
|
||||
directives: {
|
||||
'script-src': ['self']
|
||||
},
|
||||
reportOnly: {
|
||||
'script-src': ['self']
|
||||
'script-src': ['self', 'unsafe-inline'],
|
||||
'base-uri': ['self'],
|
||||
'object-src': ['none']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -9,6 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
//"lib": ["ESNext.Array"]
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
//
|
||||
|
Reference in New Issue
Block a user