72 Commits

Author SHA1 Message Date
bad6072d70 Add album to playlist for youtube 2025-07-15 14:23:36 +02:00
a9178b340a tidal: chunk playlist adding, fix next-paging handling 2025-07-15 14:22:45 +02:00
f309cd87d1 only push to websub hub in prod mode 2025-07-15 14:20:49 +02:00
611563fe5b adjust log levels 2025-07-15 14:20:28 +02:00
b9c098cde3 improve error handling for youtube requests, skip video category check if unnecessary 2025-07-15 10:42:49 +02:00
2308356c1b implement tidal’s (new?) retry-after header for rate-limiting 2025-07-14 14:36:07 +02:00
4c3689016f Add whole albums to playlist for tidal 2025-07-14 14:24:21 +02:00
68a139f287 Make DB log verbose 2025-07-14 11:00:41 +02:00
53ee5fabbe Fix implement crude checking if a song already exists in tidal 2025-07-14 10:59:26 +02:00
df35c48e8c wait before retrying tidal requests 2025-07-11 14:37:57 +02:00
44fc2bb621 Fix add playlists and version to footer 2025-07-10 13:38:41 +02:00
7cdfa00af5 switch DEBUG_LOG to string handling 2025-07-08 21:09:07 +02:00
35572a48e7 Fix , additional minor enhncements 2025-07-08 20:48:22 +02:00
3186f375e1 fix improve token expiry 2025-07-08 15:36:32 +02:00
3c1b7dba0e ignoring tidal auth token 2025-07-08 14:44:11 +02:00
5591070979 finalize ignore feature 2025-07-08 14:38:11 +02:00
38e8b4c2ba prepare for being available on multiple domain names 2025-07-06 18:43:36 +02:00
260cef7b73 improve unit file 2025-07-06 18:42:32 +02:00
c57f9ec3ea refactor debug logging, add debug info for YT authorized tokens 2025-07-04 15:58:00 +02:00
6874804703 update eslint config 2025-07-04 14:06:06 +02:00
8cb5ab8340 update version 2025-07-04 13:40:03 +02:00
270cd9ad05 Cleanup, fixing YT/Spotify auth 2025-07-04 13:37:36 +02:00
dfd6d559bf Merge pull request 'Finalize Version 2.0.0' () from 7-create-playlist into main
Reviewed-on: 
2025-07-04 09:47:38 +00:00
7f616b4c7d Merge branch 'main' into 7-create-playlist 2025-07-04 09:47:30 +00:00
2e7d2004af update version 2025-07-04 11:46:55 +02:00
b2e6d20d27 Merge pull request '7-create-playlist' () from 7-create-playlist into main
Reviewed-on: 
2025-07-04 09:46:11 +00:00
64d7538ff4 improve logging 2025-07-04 11:44:18 +02:00
77e483d637 update logging 2025-07-04 08:46:41 +02:00
b0465a020d refactor playlist adders 2025-07-03 18:52:00 +02:00
a0757ea3ff support adding to spotify playlist 2025-07-03 18:38:40 +02:00
a8b6a309f0 improve logging 2025-07-01 20:23:04 +02:00
317f4d7fba add documentation for youtube playlist integration 2025-07-01 20:22:57 +02:00
b7a930c69a update dependencies, add songs to youtube playlist 2025-07-01 16:01:19 +02:00
3c6e742e43 check for missed posts 2025-06-15 07:01:45 +02:00
7296582b0d updated dependencies, fixed sorting 2025-03-26 08:36:20 +01:00
66f09cf5a3 update to svelte 5 2024-10-29 16:26:07 +01:00
d39ccba927 minor refactors and additional logs 2024-09-24 14:47:50 +02:00
498b1d82d9 Update dependencies 2024-09-24 12:05:36 +02:00
79405cd08c Update dependencies & version 2024-01-22 17:33:31 +01:00
39c9689af4 Format 2024-01-22 17:26:49 +01:00
ad7c8af9de Migrate to Sveltekit 2.0 2024-01-22 17:26:36 +01:00
f1cb0b2159 Migrate to Svelte 4 2024-01-22 16:26:06 +01:00
049cd86ae0 Update dependencies 2024-01-22 16:06:43 +01:00
aab4433a55 Add oauth token to websocket connection 2023-10-15 19:39:36 +02:00
d3b599738e Update version tag 2023-06-24 10:53:02 +02:00
ba89182791 Revert "Update dependencies"
This reverts commit 5b6dbd327d.
2023-06-24 10:49:57 +02:00
5b6dbd327d Update dependencies 2023-06-24 10:10:52 +02:00
b960d35a58 Fix 2023-06-24 10:06:52 +02:00
87b8317c90 Fix 2023-06-20 15:47:00 +02:00
e103bef84c Fix 2023-06-20 15:45:09 +02:00
6d13aed0f0 Fix CSP config 2023-06-20 15:30:30 +02:00
185d28c295 Fix CSP config being in the wrong section 2023-06-20 15:09:48 +02:00
d57888678d Fix 2023-06-20 08:20:30 +02:00
db80b929ca Fix 2023-06-16 15:51:57 +02:00
3103d3e098 Fix : Scale images to the correct size and use more efficient image formats 2023-06-14 20:37:30 +02:00
61d24ddd7f refactor avatar resizing 2023-05-10 16:13:24 +02:00
736b8498af Convert and resize avatars to fit the displayed images 2023-05-02 17:31:16 +02:00
fbaedaf45b Fix specify width and height for non-cover images 2023-04-26 18:52:18 +02:00
d65eca1faa Inlined stylesheet, fixed colors 2023-04-26 18:40:06 +02:00
cfa5a950f1 Fix specify a CSP 2023-04-26 17:52:21 +02:00
1318b8f9c3 Update packages 2023-04-26 17:29:19 +02:00
2e63be50a4 Keep aspect ratio for mobile cover images 2023-04-26 17:02:47 +02:00
e3cf6fb5f2 Upgrade DB migration to info log 2023-04-24 20:45:08 +02:00
bca4382988 Add song info to existing posts 2023-04-24 20:43:13 +02:00
68aade4f1f Fix , refactor URL detection 2023-04-24 19:38:13 +02:00
9bbcc843c2 Fix , fix . Display posts as grid instead of flexbox, add song info 2023-04-23 20:10:45 +02:00
42d91a097f Fix youtube links not being parsed for song info 2023-04-23 13:07:52 +02:00
971c846dd1 Saving song infos to DB, refactor logging 2023-04-23 12:46:14 +02:00
1cd9d83910 Improve type safety 2023-04-22 09:28:42 +02:00
b62936ed54 Extract song info from odesli (song.link) 2023-04-22 08:50:17 +02:00
45eeb550b3 Auto reconnect to Mastodon WebSocket if it fails 2023-04-15 09:56:03 +02:00
52c7922002 Improved layout on smaller devices 2023-04-14 20:32:49 +02:00
54 changed files with 6655 additions and 1802 deletions

@ -1,12 +1,17 @@
HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening
URL_FILTER = song.link,album.link,spotify.com,music.apple.com,bandcamp.com
YOUTUBE_API_KEY = CHANGE_ME
YOUTUBE_DISABLE = false
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'
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,

6
.gitignore vendored

@ -1,9 +1,15 @@
moshing-mammut.pid
yt_auth_token
spotify_auth_token
tidal_auth_token
*.db
feed.xml
playbook.yml
inventory.yml
ansible.cfg
avatars/*
thumbnails/*
node_modules
/build
/.svelte-kit

@ -1,7 +1,10 @@
{
"apexskier.typescript.config.formatDocumentOnSave" : "true",
"apexskier.eslint.config.eslintConfigPath" : ".eslint.cjs",
"apexskier.eslint.config.fixOnSave" : "Enable",
"apexskier.typescript.config.formatDocumentOnSave" : "false",
"apexskier.typescript.config.isEnabledForJavascript" : "Enable",
"apexskier.typescript.config.organizeImportsOnSave" : "true",
"apexskier.typescript.config.userPreferences.quotePreference" : "single",
"apexskier.typescript.config.userPreferences.useLabelDetailsInCompletionEntries" : true
"apexskier.typescript.config.userPreferences.useLabelDetailsInCompletionEntries" : true,
"prettier.format-on-save" : "Global Default"
}

1
.nvmrc Normal file

@ -0,0 +1 @@
lts/*

@ -6,6 +6,7 @@ node_modules
.env
.env.*
!.env.example
/.nova
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml

@ -5,6 +5,5 @@
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

@ -11,8 +11,8 @@ Having a quick overview over what is being posted can be a great way to discover
This is fairly simple from a technical point of view! metalhead.club's local timeline is being watched using the
Mastodon Streaming API over a Websocket. Every time a new post arrives, it is checked if it contains any music by
checking included hashtags and URLs. A list of tags and URLs can be found in [the configuration](.env.EXAMPLE).
Additionally, lins to YouTube are queried, if they are music or other videos using the YouTube API.
checking included hashtags and URLs. A list of tags can be found in [the configuration](.env.EXAMPLE).
Additionally, links are vetted if they are music by checking if https://song.link finds info on them.
If a post passes this check it is saved to a SQLite database.
@ -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,16 +88,32 @@ 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.
#### On your development machine
Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY`.
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, all YouTube videos will be assumed to contain music links.
If this is unwanted, set `YOUTUBE_DISABLE` to `true`).
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.
@ -114,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)
@ -132,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

@ -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

4569
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
{
"name": "moshing-mammut",
"version": "1.1.0",
"version": "2.0.1",
"private": true,
"license": "LGPL-3.0-or-later",
"scripts": {
@ -14,29 +14,37 @@
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.2.3",
"@sveltejs/kit": "^1.5.0",
"@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",
"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": ">=20.0.0"
}
}

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@ -10,7 +10,11 @@
<meta name="apple-mobile-web-app-title" content="Moshing Mammut" />
<meta name="application-name" content="Moshing Mammut" />
<meta name="msapplication-TileColor" content="#2e0b78" />
<link rel="stylesheet" href="%sveltekit.assets%/style.css" />
<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)" />
<meta name="theme-color" content="#BCB9B2" media="(prefers-color-scheme: light)" />
@ -19,14 +23,53 @@
%sveltekit.head%
<style>
body {
--color-text: #2f0c7a;
--color-bg: white;
--color-border: #17063b;
--color-link: #563acc;
--color-link-visited: #858afa;
--color-blue: hsl(259, 82%, 26%);
--color-blue-dark: hsl(259, 82%, 13%);
--color-lavender: hsl(253, 82%, 33%);
--color-mauve: hsl(273, 82%, 38%);
--color-grey: hsl(44, 7%, 41%);
--color-grey-translucent: hsla(44, 7%, 41%, 0.2);
--color-grey-light: hsl(0, 0%, 98%);
--color-red: hsl(7, 100%, 56%);
--color-red-light: hsl(7, 100%, 61%);
--color-red-lighter: hsl(7, 100%, 68%);
--color-red-dark: hsl(7, 100%, 48%);
--color-red-desat: hsl(7, 20%, 56%);
--color-red-desat-dark: hsl(7, 20%, 30%);
--color-red-desat-desat: hsl(7, 8%, 56%);
--color-text: var(--color-blue-dark);
--color-border: var(--color-grey);
--color-link: var(--color-mauve);
--color-link-visited: var(--color-lavender);
--color-bg: var(--color-grey-light);
--color-bg-translucent: hsla(42, 7%, 72%, 0.5);
--color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat-dark);
--color-button-hover: var(--color-red);
--color-button-deactivated: var(--color-red-desat-desat);
--color-button-text: var(--color-blue-dark);
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';
}
a {
@ -38,11 +81,20 @@
@media (prefers-color-scheme: dark) {
body {
--color-text: white;
--color-bg: #17063b;
--color-border: white;
--color-link: #8a9bf0;
--color-link-visited: #c384fb;
--color-lavender: hsl(273, 43%, 65%);
--color-mauve: hsl(286, 73%, 81%);
--color-text: var(--color-grey-light);
--color-border: var(--color-grey-light);
--color-link: var(--color-lavender);
--color-link-visited: var(--color-mauve);
--color-bg: var(--color-blue-dark);
--color-bg-translucent: hsla(259, 82%, 26%, 0.5);
--color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat);
--color-button-hover: var(--color-red);
--color-button-deactivated: var(--color-red-desat-desat);
--color-button-text: var(--color-blue-dark);
}
}
</style>

@ -1,22 +1,42 @@
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) {
console.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 {
message: 'Whoops!',
code: (error as any)?.code ?? 'UNKNOWN'
message: `Something went wrong! ${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') {
@ -27,6 +47,35 @@ export const handle = (async ({ event, resolve }) => {
return new Response(f, { headers: [['Content-Type', 'application/atom+xml']] });
}
// Ideally, this would be served by apache
if (event.url.pathname.startsWith('/avatars/')) {
const fileName = event.url.pathname.split('/').pop() ?? 'unknown.jpeg';
const suffix = fileName.split('.').pop() ?? 'jpeg';
try {
//This should work, but doesn't yet. See: https://github.com/nodejs/node/issues/45853
/*
const stat = await fs.stat('avatars/' + fileName);
const fd = await fs.open('avatars/' + fileName);
const readStream = fd
.readableWebStream()
.getReader({ mode: 'byob' }) as ReadableStream<Uint8Array>;
logger.info('sending. size: ', stat.size);
return new Response(readStream, {
headers: [
['Content-Type', 'image/' + suffix],
['Content-Length', stat.size.toString()]
]
});
*/
const f = await fs.readFile('avatars/' + fileName);
return new Response(f, { headers: [['Content-Type', 'image/' + suffix]] });
} catch (e) {
logger.error('no stream', e);
error(404);
}
}
const response = await resolve(event);
return response;
}) satisfies Handle;

@ -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

(image error) Size: 1.0 KiB

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

(image error) Size: 1.3 KiB

@ -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

(image error) 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,19 +1,55 @@
<script lang="ts">
import type { Account } from '$lib/mastodon/response';
export let account: Account;
let avatarDescription: 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>([
['avif', 0],
['webp', 1],
['jpg', 99],
['jpeg', 99]
]);
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;
const prioB = formatPriority.get(extensionB) ?? 3;
return prioA - prioB;
});
const m = new Map<string, string[]>();
for (const resizedAvatar of resizedAvatars) {
const extension = resizedAvatar.file.split('.').pop();
const mime = extension ? `image/${extension}` : 'application/octet-stream';
const sourceSetEntry = `${resizedAvatar.file} ${resizedAvatar.sizeDescriptor}`;
m.set(mime, [...(m.get(mime) || []), sourceSetEntry]);
}
let html = '';
for (const entry of m.entries()) {
const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" />`;
}
return html;
});
</script>
<img src={account.avatar} alt={avatarDescription} />
<picture>
{@html sourceSetHtml}
<img src={account.avatar} alt={avatarDescription} loading="lazy" width="50" height="50" />
</picture>
<style>
img {
max-width: 50px;
max-height: 50px;
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
width: 50px;
height: 50px;
object-fit: contain;
border-radius: 3px;
}

@ -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&nbsp;</span>v{version}</span>
</a>
</div>
|
@ -26,6 +30,30 @@
<span class="label">RSS<span class="feedSuffix">&nbsp;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>
@ -49,6 +77,7 @@
top: 0.25em;
color: white;
height: 1em;
width: 1em;
}
@media (prefers-color-scheme: dark) {
.icon {
@ -58,7 +87,7 @@
background-color: var(--color-grey-translucent);
}
}
@media only screen and (max-device-width: 620px) {
@media only screen and (max-width: 620px) {
.mastodonInstance,
.feedSuffix {
display: none;
@ -68,7 +97,7 @@
}
}
@media only screen and (max-device-width: 430px) {
@media only screen and (max-width: 430px) {
.mastodonInstance,
.feedSuffix,
.secretIngredient {
@ -76,7 +105,7 @@
}
}
@media only screen and (max-device-width: 370px) {
@media only screen and (max-width: 370px) {
.label {
display: none;
}

@ -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);
function loadOlderPosts() {
dispatch('loadOlderPosts');
}
/*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 {

@ -1,17 +1,109 @@
<script lang="ts">
import type { Post } from '$lib/mastodon/response';
import { type Post, SongThumbnailImageKind } from '$lib/mastodon/response';
import type { SongInfo } from '$lib/odesliResponse';
import AvatarComponent from '$lib/components/AvatarComponent.svelte';
import AccountComponent from '$lib/components/AccountComponent.svelte';
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 = false): string {
const small = new Map<string, string[]>();
const large = new Map<string, string[]>();
// 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>([
['avif', 0],
['webp', 1],
['jpg', 99],
['jpeg', 99]
]);
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;
const prioB = formatPriority.get(extensionB) ?? 3;
return prioA - prioB;
});
for (const resizedThumb of thumbs) {
if (isBlurred && resizedThumb.kind !== SongThumbnailImageKind.Small) {
continue;
}
const extension = resizedThumb.file.split('.').pop();
const mime = extension ? `image/${extension}` : 'application/octet-stream';
const sourceSetEntry = `${resizedThumb.file} ${resizedThumb.sizeDescriptor}`;
switch (resizedThumb.kind) {
case SongThumbnailImageKind.Big:
large.set(mime, [...(large.get(mime) || []), sourceSetEntry]);
break;
case SongThumbnailImageKind.Small:
small.set(mime, [...(small.get(mime) || []), sourceSetEntry]);
break;
case SongThumbnailImageKind.Blurred: // currently not generated
break;
}
}
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} width="${widthSmall}" height="${heightSmall}" />`;
}
html += '\n';
for (const entry of large.entries()) {
const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" width="${width}" height="${height}"/>`;
}
return html;
}
onMount(() => {
@ -24,34 +116,146 @@
<div class="wrapper">
<div class="avatar"><AvatarComponent account={post.account} /></div>
<div class="post">
<div class="meta">
<AccountComponent account={post.account} />
<small><a href={post.url} target="_blank" title={absoluteDate}>{dateCreated}</a></small>
</div>
<div class="content">{@html post.content}</div>
<div class="account"><AccountComponent account={post.account} /></div>
<div class="meta">
<small><a href={post.url} target="_blank" title={absoluteDate}>{dateCreated}</a></small>
</div>
<div class="content">{@html post.content}</div>
<div class="song">
{#if post.songs}
{#each songs as song (song.pageUrl)}
<div class="info-wrapper">
<picture>
{@html getSourceSetHtml(song)}
<img class="bgimage" src={song.thumbnailUrl} loading="lazy" alt="Blurred cover" />
</picture>
<a href={song.pageUrl ?? song.postedUrl} target="_blank">
<div class="info">
<picture class="cover">
{@html getSourceSetHtml(song)}
<img
src={song.thumbnailUrl}
alt="Cover for {song.artistName} - {song.title}"
loading="lazy"
width={song.thumbnailWidth}
height={song.thumbnailHeight}
/>
</picture>
<span class="text">{song.artistName} - {song.title}</span>
</div>
</a>
</div>
{/each}
{/if}
</div>
</div>
<style>
.wrapper {
display: flex;
}
.post {
display: flex;
flex-direction: column;
flex-grow: 2;
word-break: break-word;
}
.meta {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: 50px 1fr auto auto;
grid-template-rows: auto 1fr auto;
grid-template-areas:
'avatar account account meta'
'avatar content content song'
'. content content song';
grid-column-gap: 6px;
column-gap: 6px;
grid-row-gap: 6px;
row-gap: 6px;
}
.avatar {
margin-right: 1em;
grid-area: avatar;
max-width: 50px;
max-height: 50px;
}
.account {
grid-area: account;
}
.meta {
grid-area: meta;
justify-self: end;
}
.content {
max-width: calc(600px - 1em - 50px);
overflow-x: auto;
grid-area: content;
word-break: break-word;
translate: 0 -0.5em;
}
.song {
grid-area: song;
align-self: center;
justify-self: center;
max-width: 200px;
}
.cover {
max-width: 200px;
display: block;
border-radius: 3px;
margin-bottom: 3px;
}
.bgimage {
display: none;
background-color: var(--color-bg);
}
.info {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5em;
z-index: 1;
}
.info * {
z-index: inherit;
}
@media only screen and (max-width: 650px) {
.wrapper {
grid-template-areas:
'avatar account account meta'
'content content content content'
'song song song song';
grid-row-gap: 3px;
row-gap: 3px;
}
.song {
width: 100%;
}
.song,
.cover {
max-width: 100%;
}
.cover {
height: 60px;
}
.cover:not(.background) {
z-index: 1;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.bgimage {
display: block;
width: 100%;
height: 60px;
z-index: 0;
filter: blur(10px);
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.info {
position: relative;
top: -60px;
flex-direction: row;
}
.info-wrapper {
margin-bottom: -50px;
}
.text {
padding: 3px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 3px;
background-color: var(--color-bg-translucent);
color: var(--color-text);
}
}
</style>

91
src/lib/log.ts Normal file

@ -0,0 +1,91 @@
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) {
return;
}
console.debug(new Date().toISOString(), ...params);
},
debug: (...params: any[]) => {
if (!log.isDebugEnabled()) {
return;
}
console.debug(new Date().toISOString(), ...params);
},
log: (...params: any[]) => {
console.log(new Date().toISOString(), ...params);
},
info: (...params: any[]) => {
console.info(new Date().toISOString(), ...params);
},
warn: (...params: any[]) => {
console.warn(new Date().toISOString(), ...params);
},
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);
}
}

@ -1,3 +1,5 @@
import type { SongInfo } from '$lib/odesliResponse';
export interface TimelineEvent {
event: string;
payload: string;
@ -10,6 +12,28 @@ export interface Post {
url: string;
content: string;
account: Account;
card?: PreviewCard;
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;
image?: string;
blurhash?: string;
width: number;
height: number;
}
export interface Tag {
@ -24,4 +48,24 @@ export interface Account {
display_name: string;
url: string;
avatar: string;
resizedAvatars?: AccountAvatar[];
}
export type AccountAvatar = {
accountUrl: string;
file: string;
sizeDescriptor: string;
};
export enum SongThumbnailImageKind {
Big = 1,
Small,
Blurred
}
export type SongThumbnailImage = {
songThumbnailUrl: string;
file: string;
sizeDescriptor: string;
kind: SongThumbnailImageKind;
};

152
src/lib/odesliResponse.ts Normal file

@ -0,0 +1,152 @@
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 = {
type: 'track' | 'album';
name: string;
image?: string;
url: string;
};
export type OdesliResponse = {
/**
* The unique ID for the input entity that was supplied in the request. The
* data for this entity, such as title, artistName, etc. will be found in
* an object at `nodesByUniqueId[entityUniqueId]`
*/
entityUniqueId: string;
/**
* The userCountry query param that was supplied in the request. It signals
* the country/availability we use to query the streaming platforms. Defaults
* to 'US' if no userCountry supplied in the request.
*
* NOTE: As a fallback, our service may respond with matches that were found
* in a locale other than the userCountry supplied
*/
userCountry: string;
/**
* A URL that will render the Songlink page for this entity
*/
pageUrl: string;
/**
* A collection of objects. Each key is a platform, and each value is an
* object that contains data for linking to the match
*/
linksByPlatform: {
/**
* Each key in `linksByPlatform` is a Platform. A Platform will exist here
* only if there is a match found. E.g. if there is no YouTube match found,
* then neither `youtube` or `youtubeMusic` properties will exist here
*/
[k in Platform]: {
/**
* The unique ID for this entity. Use it to look up data about this entity
* at `entitiesByUniqueId[entityUniqueId]`
*/
entityUniqueId: string;
/**
* The URL for this match
*/
url: string;
/**
* The native app URI that can be used on mobile devices to open this
* entity directly in the native app
*/
nativeAppUriMobile?: string;
/**
* The native app URI that can be used on desktop devices to open this
* entity directly in the native app
*/
nativeAppUriDesktop?: string;
};
};
// A collection of objects. Each key is a unique identifier for a streaming
// entity, and each value is an object that contains data for that entity,
// such as `title`, `artistName`, `thumbnailUrl`, etc.
entitiesByUniqueId: {
[entityUniqueId: string]: {
// This is the unique identifier on the streaming platform/API provider
id: string;
type: 'song' | 'album';
title?: string;
artistName?: string;
thumbnailUrl?: string;
thumbnailWidth?: number;
thumbnailHeight?: number;
// The API provider that powered this match. Useful if you'd like to use
// this entity's data to query the API directly
apiProvider: APIProvider;
// An array of platforms that are "powered" by this entity. E.g. an entity
// from Apple Music will generally have a `platforms` array of
// `["appleMusic", "itunes"]` since both those platforms/links are derived
// from this single entity
platforms: Platform[];
};
};
};
export type Platform =
| 'spotify'
| 'itunes'
| 'appleMusic'
| 'youtube'
| 'youtubeMusic'
| 'google'
| 'googleStore'
| 'pandora'
| 'deezer'
| 'tidal'
| 'amazonStore'
| 'amazonMusic'
| 'soundcloud'
| 'napster'
| 'yandex'
| 'spinrilla'
| 'audius'
| 'audiomack'
| 'anghami'
| 'boomplay';
export type APIProvider =
| 'spotify'
| 'itunes'
| 'youtube'
| 'google'
| 'pandora'
| 'deezer'
| 'tidal'
| 'amazon'
| 'soundcloud'
| 'napster'
| 'yandex'
| 'spinrilla'
| 'audius'
| 'audiomack'
| 'anghami'
| 'boomplay';

File diff suppressed because it is too large Load Diff

@ -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
};
}
}

@ -0,0 +1,5 @@
import type { SongInfo } from '$lib/odesliResponse';
export interface PlaylistAdder {
addToPlaylist(song: SongInfo): Promise<void>;
}

@ -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.info('no spotify playlist ID configured');
return;
}
if (!song.spotifyUri) {
this.logger.debug('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);
}
}

@ -0,0 +1,363 @@
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 retryAfterHeader = headers.get('Retry-After');
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
);
}
if (retryAfterHeader) {
const retryAfter = parseInt(retryAfterHeader);
this.logger.debug('Tidal rate limit. Retry-After', retryAfter);
}
}
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) {
this.logger.debug('getAlbumItems nextPage', nextLink);
albumUrl = new URL(nextLink);
} else {
this.logger.debug(
'getAlbumItems albumUrl',
`${this.apiBase}/albums/${albumId}/relationships/items`
);
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) {
this.logger.debug('getAlbumItems requesting next page', respData.links.next);
const nextPage = await this.getAlbumItems(
albumId,
remaning,
this.apiBase + 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);
}
// Tidal can only handle max. 20 items to be added at once
// This isn't documented, but the API helpfully provides a useful error message
let chunkSize = 20;
const chunkedTracks: { id: string; type: string }[][] = [];
let chunkIndex = 0;
while (chunkIndex < tracks.length) {
chunkedTracks.push(tracks.slice(chunkIndex, chunkIndex + chunkSize));
chunkIndex += chunkSize;
}
const apiUrl = new URL(`${this.apiBase}/playlists/${TIDAL_PLAYLIST_ID}/relationships/items`);
let options: RequestInit;
let request: Request;
let resp: Response | null = null;
let respTxt: string | null = null;
for (let chunk of chunkedTracks) {
options = {
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: chunk,
meta: {
positionBefore: 'ffb6286e-237a-4dfc-bbf1-2fb0eb004ed5' // Hardcoded last element of list
}
})
};
request = new Request(apiUrl, options);
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;
}
await 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:
let secondsToWait = -1;
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 === -1) {
this.logger.warn('Could not read headers how long to wait', resp.headers);
} else {
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
await sleep(secondsToWait * 1000);
await this.addToPlaylistRetry(song, remaning--);
}
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);
}
}

@ -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;
};

@ -0,0 +1,81 @@
export type YoutubePlaylistItem = {
kind: 'youtube#playlistItem';
etag: string;
id: string;
snippet: {
resourceId: {
kind: string;
videoId: string;
};
};
};
export type YoutubePlaylistItemResponse = YoutubeResponse & {
kind: 'youtube#playlistItemListResponse';
etag: string;
nextPageToken: string;
prevPageToken: string;
pageInfo: {
totalResults: number;
resultsPerPage: number;
};
items: YoutubePlaylistItem[];
};
export type YoutubeResponse = {
error?: YoutubeErrorResponse;
};
export type YoutubeErrorResponse = {
errors: YoutubeError[];
code: number;
message: string;
};
export type YoutubeError = {
domain: 'global' | string;
reason: YoutubeErrorReason;
message: string;
locationType: string;
location: string;
};
export type YoutubeErrorReason =
| 'movedPermanently'
| 'seeOther'
| 'mediaDownloadRedirect'
| 'notModified'
| 'temporaryRedirect'
| 'badRequest'
| 'badBinaryDomainRequest'
| 'badContent'
| 'badLockedDomainRequest'
| 'corsRequestWithXOrigin'
| 'endpointConstraintMismatch'
| 'invalid'
| 'invalidAltValue'
| 'invalidHeader'
| 'invalidParameter'
| 'invalidQuery'
| 'keyExpired'
| 'keyInvalid'
| 'lockedDomainCreationFailure'
| 'notDownload'
| 'notUpload'
| 'parseError'
| 'required'
| 'tooManyParts'
| 'unknownApi'
| 'unsupportedMediaProtocol'
| 'unsupportedOutputFormat'
| 'wrongUrlForUpload'
| 'unauthorized'
| 'authError'
| 'expired'
| 'lockedDomainExpired'
| 'required'
| 'dailyLimitExceeded402'
| 'quotaExceeded402'
| 'user402'
| 'quotaExceeded'
| 'rateLimitExceeded'
| 'limitExceeded'
| 'unknownAuth'
| string; // many more

@ -0,0 +1,230 @@
import { YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, YOUTUBE_PLAYLIST_ID } from '$env/static/private';
import { Logger } from '$lib/log';
import type { OauthResponse } from '$lib/mastodon/response';
import type {
YoutubePlaylistItemResponse,
YoutubeResponse
} from '$lib/server/playlist/youtubeResponse';
import type { SongInfo } from '$lib/odesliResponse';
import { OauthPlaylistAdder } from './oauthPlaylistAdder';
import type { PlaylistAdder } from './playlistAdder';
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;
}
}
private async checkAuthorizedToken(token: OauthResponse) {
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.error('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');
const refreshedToken = await this.requestRefreshToken(
tokenUrl,
YOUTUBE_CLIENT_ID,
token.refresh_token,
this.getRedirectUri('ytauth').toString(),
YOUTUBE_CLIENT_SECRET
);
if (refreshedToken !== null) {
await this.checkAuthorizedToken(refreshedToken);
}
return refreshedToken;
}
public async addToPlaylist(song: SongInfo) {
this.logger.debug('addToYoutubePlaylist');
let 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);
let videoIds: string[] = [];
if (song.type === 'album') {
const albumPlaylistId = songUrl.searchParams.get('list') ?? '';
const albumItemsUrl = new URL(this.apiBase + '/playlistItems');
albumItemsUrl.searchParams.append('maxResults', '50');
albumItemsUrl.searchParams.append('playlistId', albumPlaylistId);
albumItemsUrl.searchParams.append('part', 'snippet');
const albumPlaylistItem = await fetch(albumItemsUrl, {
headers: { Authorization: `${token.token_type} ${token.access_token}` }
}).then((r) => r.json());
const albumTracks: any[] = albumPlaylistItem.items ?? [];
videoIds = albumTracks.map((x) => x.snippet?.resourceId?.videoId).filter((x) => x);
this.logger.info(
'Found',
albumPlaylistItem.pageInfo?.totalResults,
'songs in album, received',
albumTracks.length
);
this.logger.debug(videoIds);
if (videoIds.length === 0) {
this.logger.debug(
'Skip adding album to YT playlist, is empty',
song.youtubeUrl,
albumPlaylistItem
);
return;
}
} else {
const youtubeId = songUrl.searchParams.get('v');
if (!youtubeId) {
this.logger.debug(
'Skip adding song to YT playlist, could not extract YT id from URL',
song.youtubeUrl
);
return;
}
this.logger.debug('Found YT id from URL', song.youtubeUrl, youtubeId);
videoIds.push(youtubeId);
}
for (let youtubeId of videoIds) {
this.logger.debug('Adding to playlist', youtubeId);
const alreadyAdded = await this.isVideoInPlaylist(youtubeId, token);
if (alreadyAdded) {
this.logger.info('Item already in playlist', song.youtubeUrl, song.title);
continue;
} else {
this.logger.debug('Item not already in playlist', song.youtubeUrl, song.title);
}
let retries = 3;
let success = false;
while (retries > 0 && !success) {
try {
this.logger.debug('Retries', retries);
await this.addSongToPlaylist(youtubeId, token);
success = true;
this.logger.info('Added to playlist', youtubeId, song.title);
} catch (e) {
retries--;
if (e instanceof Error && e.message === 'authError') {
this.logger.info('Refreshing auth token');
token = await this.refreshToken(true);
if (token == null) {
this.logger.error('Refreshing auth token failed');
return;
}
} else {
this.logger.error('Add to playlist failed', e);
}
}
}
}
}
private async addSongToPlaylist(videoId: string, token: OauthResponse): Promise<void> {
const addItemUrl = new URL(this.apiBase + '/playlistItems');
addItemUrl.searchParams.append('part', 'snippet');
const options: RequestInit = {
method: 'POST',
headers: { Authorization: `${token.token_type} ${token.access_token}` },
body: JSON.stringify({
snippet: {
playlistId: YOUTUBE_PLAYLIST_ID,
resourceId: {
videoId: videoId,
kind: 'youtube#video'
}
}
})
};
const request = new Request(addItemUrl, options);
const resp = await fetch(request);
let respObj: YoutubeResponse = await resp.json();
if (respObj.error) {
this.logger.error(
'Add to playlist failed',
respObj.error.errors,
respObj.error.code,
respObj.error.message
);
throw new Error(respObj.error.errors[0].reason);
}
}
private async isVideoInPlaylist(videoId: string, token: OauthResponse): Promise<boolean> {
const playlistItemsUrl = new URL(this.apiBase + '/playlistItems');
playlistItemsUrl.searchParams.append('videoId', videoId);
playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID);
playlistItemsUrl.searchParams.append('part', 'id');
const existingPlaylistItem: YoutubePlaylistItemResponse = await fetch(playlistItemsUrl, {
headers: { Authorization: `${token.token_type} ${token.access_token}` }
}).then((r) => r.json());
if (existingPlaylistItem.error) {
this.logger.error(
'Could not check if item is already in playlist',
existingPlaylistItem.error
);
}
return existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0;
}
}

@ -1,8 +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 { Logger } from '$lib/log';
import { Feed } from 'feed';
import fs from 'fs/promises';
const { PROD } = import.meta.env;
const logger = new Logger('RSS');
export function createFeed(posts: Post[]): Feed {
const baseUrl = BASE_URL.endsWith('/') ? BASE_URL : BASE_URL + '/';
@ -47,18 +51,20 @@ export function createFeed(posts: Post[]): Feed {
}
export async function saveAtomFeed(feed: Feed) {
await fs.writeFile('feed.xml', feed.atom1(), { encoding: 'utf8' });
if (!WEBSUB_HUB) {
if (!WEBSUB_HUB || !PROD) {
logger.info('Skipping Websub publish. hub configured?', WEBSUB_HUB, 'Production?', PROD);
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`);
await fetch(WEBSUB_HUB, {
method: 'POST',
body: params
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: param
});
} catch (e) {
console.error('Failed to update WebSub hub', e);
logger.error('Failed to update WebSub hub', e);
}
}

@ -1,27 +1,61 @@
import {
HASHTAG_FILTER,
MASTODON_ACCESS_TOKEN,
MASTODON_INSTANCE,
URL_FILTER,
YOUTUBE_API_KEY,
YOUTUBE_DISABLE
IGNORE_USERS,
ODESLI_API_KEY,
YOUTUBE_API_KEY
} from '$env/static/private';
import type { Post, Tag, TimelineEvent } from '$lib/mastodon/response';
import { getPosts, savePost } from '$lib/server/db';
import { Logger } from '$lib/log';
import type {
Account,
AccountAvatar,
Post,
SongThumbnailImage,
Tag,
TimelineEvent
} from '$lib/mastodon/response';
import { SongThumbnailImageKind } from '$lib/mastodon/response';
import type { OdesliResponse, Platform, SongInfo } from '$lib/odesliResponse';
import {
getAvatars,
getPosts,
getSongThumbnails,
removeAvatars,
saveAvatar,
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 { isTruthy } from '$lib/truthyString';
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);
const YOUTUBE_REGEX = new RegExp(
/https?:\/\/(www\.)?youtu((be.com\/.*?v=)|(\.be\/))(?<videoId>[a-zA-Z_0-9-]+)/gm
);
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) {
if (YOUTUBE_API_KEY === undefined) {
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
// If it should assumed to not be YOUTUBE_DISABLE needs to be set to something truthy
this.logger.debug('YT API not configured');
return true;
}
const searchParams = new URLSearchParams([
@ -32,19 +66,27 @@ export class TimelineReader {
const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`);
const resp = await fetch(youtubeVideoUrl);
const respObj = await resp.json();
if (!respObj.items.length) {
console.warn('Could not find video with id', videoId);
if (respObj.error) {
this.logger.warn('YT API error', respObj.error);
return false;
}
if (!respObj.items?.length) {
this.logger.warn('Could not find video with id', videoId);
return false;
}
const item = respObj.items[0];
if (item.tags?.includes('music')) {
if (!item.snippet) {
this.logger.warn('Could not load snippet for video', videoId, item);
return false;
}
if (item.snippet?.tags?.includes('music')) {
return true;
}
const categorySearchParams = new URLSearchParams([
['part', 'snippet'],
['id', item.categoryId],
['id', item.snippet.categoryId],
['key', YOUTUBE_API_KEY]
]);
const youtubeCategoryUrl = new URL(
@ -52,74 +94,479 @@ export class TimelineReader {
);
const categoryTitle: string = await fetch(youtubeCategoryUrl)
.then((r) => r.json())
.then((r) => r.items[0]?.title);
.then((r) => r.items[0]?.snippet?.title);
this.logger.debug('YT category', categoryTitle);
return categoryTitle === 'Music';
}
private static async checkYoutubeMatches(postContent: string): Promise<boolean> {
if (isTruthy(YOUTUBE_DISABLE)) {
return false;
}
const matches = postContent.matchAll(YOUTUBE_REGEX);
for (const match of matches) {
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) {
this.logger.warn(
'Match listed in allMatches, but either it or its groups are undefined',
match
);
continue;
}
const videoId = match.groups.videoId.toString();
const urlMatch = match.groups.postUrl.toString();
let url: URL;
try {
const isMusic = await TimelineReader.isMusicVideo(videoId);
if (isMusic) {
return true;
}
url = new URL(urlMatch);
} catch (e) {
console.error('Could not check if', videoId, 'is a music video', 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
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);
}
}
return false;
return songs;
}
private constructor() {
const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`);
private async getSongInfo(url: URL, remainingTries = 6): Promise<SongInfo | null> {
if (remainingTries === 0) {
this.logger.error('No tries remaining. Lookup failed!');
return null;
}
if (url.hostname === 'songwhip.com') {
// song.link doesn't support songwhip links and songwhip themselves will provide metadata if you pass in a
// Apple Music/Spotify/etc link, but won't when provided with their own link, so no way to extract song info
// except maybe scraping their HTML
return null;
}
const videoId = INVIDIOUS_REGEX.exec(url.href)?.groups?.videoId;
const urlString =
videoId !== undefined ? `https://youtube.com/watch?v=${videoId}` : url.toString();
const odesliParams = new URLSearchParams();
odesliParams.append('url', urlString);
odesliParams.append('userCountry', 'DE');
odesliParams.append('songIfSingle', 'true');
if (ODESLI_API_KEY && ODESLI_API_KEY !== 'CHANGE_ME') {
odesliParams.append('key', ODESLI_API_KEY);
}
const odesliApiUrl = `https://api.song.link/v1-alpha.1/links?${odesliParams}`;
try {
const response = await fetch(odesliApiUrl);
if (response.status === 429) {
throw new Error('Rate limit reached', { cause: 429 });
}
const odesliInfo: OdesliResponse = await response.json();
if (!odesliInfo || !odesliInfo.entitiesByUniqueId || !odesliInfo.entityUniqueId) {
return null;
}
const spotify: Platform = 'spotify';
const tidal: Platform = 'tidal';
const deezer: Platform = 'deezer';
const tidalId = odesliInfo.linksByPlatform[tidal]?.entityUniqueId;
const tidalUri = tidalId ? odesliInfo.entitiesByUniqueId[tidalId].id : undefined;
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
const platform: Platform = 'youtube';
if (info.platforms.includes(platform)) {
const youtubeId =
videoId ??
YOUTUBE_REGEX.exec(url.href)?.groups?.videoId ??
new URL(odesliInfo.pageUrl).pathname.split('/y/').pop();
if (youtubeId === undefined) {
this.logger.warn(
'Looks like a youtube video, but could not extract a video id',
url,
odesliInfo
);
return null;
}
// If it is on tidal or deezer, it's probably music
// Do not check spotify, they carry too much other stuff (podcasts, audiobooks, etc)
let isMusic = odesliInfo.linksByPlatform[tidal] || odesliInfo.linksByPlatform[deezer];
// If not, check the YT API
isMusic = isMusic || (await this.isMusicVideo(youtubeId));
if (!isMusic) {
this.logger.debug('Probably not a music video', youtubeId);
return null;
}
}
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) {
this.logger.warn('song.link rate limit reached. Trying again in 10 seconds');
await sleep(10_000);
} else {
this.logger.error(
`Failed to load ${url} info from song.link. Trying again in 3 seconds`,
e
);
await sleep(3_000);
}
return await this.getSongInfo(url, remainingTries - 1);
}
}
private async addToPlaylist(song: SongInfo) {
for (let adder of this.playlistAdders) {
await adder.addToPlaylist(song);
}
}
private async resizeAvatar(
baseName: string,
size: number,
suffix: string,
folder: string,
sharpAvatar: sharp.Sharp
): Promise<string | null> {
const fileName = `${folder}/${baseName}_${suffix}`;
const exists = await fs
.access(fileName, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
if (exists) {
this.logger.debug('File already exists', fileName);
return null;
}
this.logger.debug('Saving avatar', fileName);
await sharpAvatar.resize(size).toFile(fileName);
return fileName;
}
private resizeAvatarPromiseMaker(
avatarFilenameBase: string,
baseSize: number,
maxPixelDensity: number,
accountUrl: string,
formats: string[],
avatar: ArrayBuffer
): Promise<void>[] {
const sharpAvatar = sharp(avatar);
const promises: Promise<void>[] = [];
for (let i = 1; i <= maxPixelDensity; i++) {
promises.push(
...formats.map((f) =>
this.resizeAvatar(avatarFilenameBase, baseSize * i, `${i}x.${f}`, 'avatars', sharpAvatar)
.then(
(fn) =>
({
accountUrl: accountUrl,
file: fn,
sizeDescriptor: `${i}x`
}) as AccountAvatar
)
.then(saveAvatar)
)
);
}
return promises;
}
private resizeThumbnailPromiseMaker(
filenameBase: string,
baseSize: number,
maxPixelDensity: number,
songThumbnailUrl: string,
formats: string[],
image: ArrayBuffer,
kind: SongThumbnailImageKind
): Promise<void>[] {
const sharpAvatar = sharp(image);
const promises: Promise<void>[] = [];
for (let i = 1; i <= maxPixelDensity; i++) {
promises.push(
...formats.map((f) =>
this.resizeAvatar(filenameBase, baseSize * i, `${i}x.${f}`, 'thumbnails', sharpAvatar)
.then(
(fn) =>
({
songThumbnailUrl: songThumbnailUrl,
file: fn,
sizeDescriptor: `${i}x`,
kind: kind
}) as SongThumbnailImage
)
.then(saveSongThumbnail)
)
);
}
return promises;
}
private async saveAvatar(account: Account) {
try {
const existingAvatars = await getAvatars(account.url, 1);
const existingAvatarBase = existingAvatars.shift()?.file.split('/').pop()?.split('_').shift();
const avatarFilenameBase =
new URL(account.avatar).pathname.split('/').pop()?.split('.').shift() ?? account.acct;
// User's avatar changed. Remove the old one!
if (existingAvatarBase && existingAvatarBase !== avatarFilenameBase) {
await removeAvatars(account.url);
const avatarsToDelete = (await fs.readdir('avatars'))
.filter((x) => x.startsWith(existingAvatarBase + '_'))
.map((x) => {
this.logger.debug('Removing existing avatar file', x);
return x;
})
.map((x) => fs.unlink('avatars/' + x));
await Promise.allSettled(avatarsToDelete);
}
const avatarResponse = await fetch(account.avatar);
const avatar = await avatarResponse.arrayBuffer();
await Promise.all(
this.resizeAvatarPromiseMaker(
avatarFilenameBase,
50,
3,
account.url,
['webp', 'avif', 'jpeg'],
avatar
)
);
} catch (e) {
console.error('Could not resize and save avatar for', account.acct, account.avatar, e);
}
}
private async saveSongThumbnails(songs: SongInfo[]) {
for (const song of songs) {
if (!song.thumbnailUrl) {
continue;
}
try {
const existingThumbs = await getSongThumbnails(song);
if (existingThumbs.length) {
continue;
}
const fileBaseName = crypto.createHash('sha256').update(song.thumbnailUrl).digest('hex');
const imageResponse = await fetch(song.thumbnailUrl);
const avatar = await imageResponse.arrayBuffer();
await Promise.all(
this.resizeThumbnailPromiseMaker(
fileBaseName + '_large',
200,
3,
song.thumbnailUrl,
['webp', 'avif', 'jpeg'],
avatar,
SongThumbnailImageKind.Big
)
);
await Promise.all(
this.resizeThumbnailPromiseMaker(
fileBaseName + '_small',
60,
3,
song.thumbnailUrl,
['webp', 'avif', 'jpeg'],
avatar,
SongThumbnailImageKind.Small
)
);
} catch (e) {
console.error(
'Could not resize and save song thumbnail for',
song.pageUrl,
song.thumbnailUrl,
e
);
}
}
}
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 hashttags: string[] = HASHTAG_FILTER.split(',');
const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name));
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) {
this.logger.log('Ignoring post', post.url);
return;
}
await savePost(post, songs);
await this.saveAvatar(post.account);
await this.saveSongThumbnails(songs);
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 = () => {
socket.send('{ "type": "subscribe", "stream": "public:local"}');
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);
const hashttags: string[] = HASHTAG_FILTER.split(',');
const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name));
const urls: string[] = URL_FILTER.split(',');
const found_urls = urls.filter((t) => post.content.includes(t));
// 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 (
found_urls.length === 0 &&
found_tags.length === 0 &&
!(await TimelineReader.checkYoutubeMatches(post.content))
) {
// 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;
}
await savePost(post);
const posts = await getPosts(null, null, 100);
await saveAtomFeed(createFeed(posts));
this.lastPosts.push(post.id);
while (this.lastPosts.length > 10) {
this.lastPosts.shift();
}
await this.checkAndSavePost(post);
} catch (e) {
console.error('error message', event, event.data, e);
socketLogger.error('error message', event, event.data, e);
}
};
socket.onclose = (event) => {
console.log('Closed', event, event.code, event.reason);
socketLogger.warn(
`Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`,
event
);
setTimeout(() => {
socketLogger.info(`Attempting to reconenct to WS`);
this.startWebsocket();
}, 10000);
};
socket.onerror = (event) => {
console.log('error', event, event.message, event.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() {
if (this._instance === undefined) {
this._instance = new TimelineReader();

5
src/lib/sleep.ts Normal file

@ -0,0 +1,5 @@
export function sleep(timeInMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, timeInMs);
});
}

@ -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);
@ -34,9 +40,9 @@
align-items: center;
gap: 10px;
}
@media only screen and (max-device-width: 620px) {
@media only screen and (max-width: 620px) {
.footer {
width: calc(100% + 16px);
width: 100%;
}
}
</style>

@ -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;
@ -25,9 +30,9 @@
}
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
let interval: NodeJS.Timer | null = null;
let moreOlderPostsAvailable = true;
let loadingOlderPosts = false;
let interval: ReturnType<typeof setTimeout> | null = null;
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>
@ -187,7 +193,7 @@
}
.post {
width: 100%;
max-width: 600px;
max-width: min(800px, 80vw);
margin-bottom: 1em;
border-bottom: 1px solid var(--color-border);
padding: 1em;
@ -202,9 +208,10 @@
z-index: 100;
}
@media only screen and (max-device-width: 650px) {
@media only screen and (max-width: 650px) {
.post {
max-width: 100vw;
max-width: calc(100vw - 16px);
padding: 1em 0;
}
}
</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;

@ -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);
};

@ -0,0 +1 @@
<h1>Something went wrong</h1>

@ -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);
};

@ -0,0 +1 @@
<h1>Something went wrong</h1>

@ -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);
};

@ -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,57 +0,0 @@
body {
--color-blue: hsl(259, 82%, 26%);
--color-blue-dark: hsl(259, 82%, 10%);
--color-lavender: hsl(273, 43%, 65%);
--color-mauve: hsl(286, 73%, 81%);
--color-grey: hsl(44, 7%, 41%);
--color-grey-translucent: hsla(44, 7%, 41%, 0.2);
--color-grey-light: hsl(42, 7%, 72%);
--color-red: hsl(7, 100%, 56%);
--color-red-light: hsl(7, 100%, 61%);
--color-red-lighter: hsl(7, 100%, 68%);
--color-red-dark: hsl(7, 100%, 48%);
--color-red-desat: hsl(7, 20%, 56%);
--color-red-desat-dark: hsl(7, 20%, 30%);
--color-red-desat-desat: hsl(7, 8%, 56%);
--color-text: var(--color-blue);
--color-border: var(--color-grey);
--color-link: var(--color-mauve);
--color-link-visited: var(--color-lavender);
--color-bg: var(--color-grey-light);
--color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat-dark);
--color-button-hover: var(--color-red);
--color-button-deactivated: var(--color-red-desat-desat);
--color-button-text: var(--color-blue-dark);
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';
}
a {
color: var(--color-link);
}
a:visited {
color: var(--color-link-visited);
}
@media (prefers-color-scheme: dark) {
body {
--color-text: var(--color-grey-light);
--color-border: var(--color-grey-light);
--color-link: var(--color-mauve);
--color-link-visited: var(--color-lavender);
--color-bg: var(--color-blue);
--color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat);
--color-button-hover: var(--color-red);
--color-button-deactivated: var(--color-red-desat-desat);
--color-button-text: var(--color-blue-dark);
}
}

@ -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,7 +11,17 @@ 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', '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
//

@ -1,6 +1,16 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { defineConfig, searchForWorkspaceRoot } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
plugins: [sveltekit()],
server: {
fs: {
allow: [
// search up for workspace root
searchForWorkspaceRoot(process.cwd()),
// your custom rules
'avatars'
]
}
}
});