Compare commits
94 Commits
2409fa2b8d
...
main
Author | SHA1 | Date | |
---|---|---|---|
4c3689016f
|
|||
68a139f287
|
|||
53ee5fabbe
|
|||
df35c48e8c
|
|||
44fc2bb621
|
|||
7cdfa00af5
|
|||
35572a48e7
|
|||
3186f375e1
|
|||
3c1b7dba0e
|
|||
5591070979
|
|||
38e8b4c2ba
|
|||
260cef7b73
|
|||
c57f9ec3ea
|
|||
6874804703
|
|||
8cb5ab8340
|
|||
270cd9ad05
|
|||
dfd6d559bf | |||
7f616b4c7d | |||
2e7d2004af
|
|||
b2e6d20d27 | |||
64d7538ff4
|
|||
77e483d637
|
|||
b0465a020d
|
|||
a0757ea3ff
|
|||
a8b6a309f0
|
|||
317f4d7fba
|
|||
b7a930c69a
|
|||
3c6e742e43
|
|||
7296582b0d
|
|||
66f09cf5a3
|
|||
d39ccba927
|
|||
498b1d82d9
|
|||
79405cd08c
|
|||
39c9689af4
|
|||
ad7c8af9de
|
|||
f1cb0b2159
|
|||
049cd86ae0
|
|||
aab4433a55
|
|||
d3b599738e
|
|||
ba89182791
|
|||
5b6dbd327d
|
|||
b960d35a58
|
|||
87b8317c90
|
|||
e103bef84c
|
|||
6d13aed0f0
|
|||
185d28c295
|
|||
d57888678d
|
|||
db80b929ca
|
|||
3103d3e098
|
|||
61d24ddd7f
|
|||
736b8498af
|
|||
fbaedaf45b
|
|||
d65eca1faa
|
|||
cfa5a950f1
|
|||
1318b8f9c3
|
|||
2e63be50a4
|
|||
e3cf6fb5f2
|
|||
bca4382988
|
|||
68aade4f1f
|
|||
9bbcc843c2
|
|||
42d91a097f
|
|||
971c846dd1
|
|||
1cd9d83910
|
|||
b62936ed54
|
|||
45eeb550b3
|
|||
52c7922002
|
|||
5ab1167d38
|
|||
c57828d3e2
|
|||
4e7196182c
|
|||
8d3a23ee88
|
|||
77c29bdd8a
|
|||
e346928d32
|
|||
ef4c517ff2
|
|||
052c93d461
|
|||
d716b3882b
|
|||
4fbd9a260f
|
|||
6c9546b74a
|
|||
268128c2f4
|
|||
e3c15be31c | |||
150d86b50c
|
|||
cce926866d
|
|||
2eed6ab4ea
|
|||
cc180b40f1
|
|||
a484810d2f
|
|||
6267972605
|
|||
94e749960f
|
|||
95e9b8cabf
|
|||
8428f6e70e
|
|||
20cdd8e688
|
|||
c16bfd9c82
|
|||
a273c8de4b
|
|||
bbe3883979
|
|||
fee1475779
|
|||
a3fb47a329
|
@ -2,4 +2,4 @@ root = true
|
||||
|
||||
[*.{svelte, ts, cjs, json}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
indent_size = 2
|
10
.env.EXAMPLE
@ -1,9 +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_PLAYLIST_ID = CHANGE_ME
|
||||
YOUTUBE_CLIENT_ID = CHANGE_ME
|
||||
YOUTUBE_CLIENT_SECRET = CHANGE_ME
|
||||
ODESLI_API_KEY = CHANGE_ME
|
||||
MASTODON_INSTANCE = 'metalhead.club'
|
||||
MASTODON_ACCESS_TOKEN = 'YOUR_ACCESS_TOKEN_HERE'
|
||||
BASE_URL = 'https://moshingmammut.phlaym.net'
|
||||
VERBOSE = false
|
||||
DEBUG_LOG = false
|
||||
IGNORE_USERS = @moshhead@metalhead.club
|
||||
WEBSUB_HUB = 'http://pubsubhubbub.superfeedr.com'
|
||||
|
||||
PUBLIC_REFRESH_INTERVAL = 10000
|
||||
PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME = 'Metalhead.club'
|
||||
PORT = 3001
|
@ -1,16 +1,25 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['svelte3', '@typescript-eslint'],
|
||||
extends: ['plugin:svelte/recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
ignorePatterns: ['*.cjs'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.svelte'],
|
||||
parser: 'svelte-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
}
|
||||
],
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
ecmaVersion: 2020,
|
||||
extraFileExtensions: ['.svelte']
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
|
9
.gitignore
vendored
@ -1,6 +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.typescript.config.isEnabledForJavascript": "Enable",
|
||||
"apexskier.typescript.config.organizeImportsOnSave": "true",
|
||||
"apexskier.typescript.config.userPreferences.quotePreference": "single",
|
||||
"apexskier.typescript.config.userPreferences.useLabelDetailsInCompletionEntries": 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,
|
||||
"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" } }]
|
||||
}
|
||||
|
61
README.md
@ -1,3 +1,6 @@
|
||||
[](https://opensource.org/license/lgpl-3-0/)
|
||||
[](https://phlaym.net/phlaym/moshing-mammut/releases)
|
||||
|
||||
# Moshing Mammut
|
||||
|
||||
The fine folks on the [metalhead.club Mastodon Server](https://metalhead.club) occasionally share like to share
|
||||
@ -8,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.
|
||||
|
||||
@ -30,12 +33,12 @@ I can see that there are plenty of posts using only descriptions and links witho
|
||||
be missed. This isn't a great solution.
|
||||
|
||||
Another idea was to store only URLs of posts and resolve the content and account information live.
|
||||
This would be better, but I'm *still* storing post information while also slowing the app down and introduce more code
|
||||
This would be better, but I'm _still_ storing post information while also slowing the app down and introduce more code
|
||||
complexity. I'm willing to make this change if people prefer this though.
|
||||
|
||||
Additionally, I ended up adding a few things which turned out to be not needed:
|
||||
The `tags` table (tags are included in the post's content and I don't do anything separately with tags) and
|
||||
`accounts.username` and `accounts.avatar_static`. I will keep these in until the initial wave of feedback arrives, and
|
||||
~~`accounts.username`~~ (s being used for #18) ~~and `accounts.avatar_static`~~ (has been removed). I will keep these in until the initial wave of feedback arrives, and
|
||||
remove it if no new features required them.
|
||||
|
||||
I'll gladly accept any help in coming up with a good solution which doesn't need to store anything at all!
|
||||
@ -48,7 +51,6 @@ By default, NVM is used to install NodeJS, but you can install it any way you wa
|
||||
|
||||
This is based on [SvelteKit's instructions](https://kit.svelte.dev/docs/adapter-node#deploying) and [How To Deploy Node.js Applications Using Systemd and Nginx](https://www.digitalocean.com/community/tutorials/how-to-deploy-node-js-applications-using-systemd-and-nginx)
|
||||
|
||||
|
||||
#### On your server
|
||||
|
||||
Install Apache2 if not already installed.
|
||||
@ -63,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
|
||||
```
|
||||
@ -75,7 +77,6 @@ Enter `$APP_DIR`.
|
||||
Place `package-lock.json` and `start.sh.EXAMPLE` in this directory.
|
||||
Run `npm ci --omit dev` to install the dependencies.
|
||||
|
||||
|
||||
Rename `start.sh.EXAMPLE` to `start.sh` and set the path to your NVM.
|
||||
|
||||
If you do not have NVM installed, simply remove the line and make sure your node executable can be found either by
|
||||
@ -87,15 +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*. As soon as #13 is implemented, this will be optional!
|
||||
_API key_.
|
||||
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.
|
||||
|
||||
@ -112,3 +130,26 @@ 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)
|
||||
and [the official Mastodon icon](https://joinmastodon.org/en/branding).
|
||||
|
||||
It has been mushed together by me, saved as SVG file in [icon.svg](./icon.svg)
|
||||
and turned into a slew of icons by [https://realfavicongenerator.net/](https://realfavicongenerator.net/):
|
||||
|
||||
```sh
|
||||
npm install -g cli-real-favicon
|
||||
real-favicon generate faviconDescription.json faviconData.json static
|
||||
```
|
||||
|
||||
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'
|
||||
])
|
||||
]);
|
63
faviconDescription.json
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
"masterPicture": "./icon.svg",
|
||||
"iconsPath": "/static",
|
||||
"design": {
|
||||
"ios": {
|
||||
"pictureAspect": "backgroundAndMargin",
|
||||
"backgroundColor": "#ffffff",
|
||||
"margin": "0%",
|
||||
"assets": {
|
||||
"ios6AndPriorIcons": false,
|
||||
"ios7AndLaterIcons": true,
|
||||
"precomposedIcons": true,
|
||||
"declareOnlyDefaultIcon": true
|
||||
},
|
||||
"appName": "Moshing Mammut"
|
||||
},
|
||||
"desktopBrowser": {
|
||||
"design": "raw"
|
||||
},
|
||||
"windows": {
|
||||
"pictureAspect": "whiteSilhouette",
|
||||
"backgroundColor": "#2e0b78",
|
||||
"onConflict": "override",
|
||||
"assets": {
|
||||
"windows80Ie10Tile": false,
|
||||
"windows10Ie11EdgeTiles": {
|
||||
"small": true,
|
||||
"medium": true,
|
||||
"big": true,
|
||||
"rectangle": true
|
||||
}
|
||||
},
|
||||
"appName": "Moshing Mammut"
|
||||
},
|
||||
"androidChrome": {
|
||||
"pictureAspect": "noChange",
|
||||
"themeColor": "#2e0b78",
|
||||
"manifest": {
|
||||
"name": "Moshing Mammut",
|
||||
"display": "standalone",
|
||||
"orientation": "notSet",
|
||||
"onConflict": "override",
|
||||
"declared": true
|
||||
},
|
||||
"assets": {
|
||||
"legacyIcon": false,
|
||||
"lowResolutionIcons": false
|
||||
}
|
||||
},
|
||||
"safariPinnedTab": {
|
||||
"pictureAspect": "blackAndWhite",
|
||||
"threshold": 27.5,
|
||||
"themeColor": "#2e0b78"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"scalingAlgorithm": "Mitchell",
|
||||
"errorOnImageTooSmall": false,
|
||||
"readmeFile": false,
|
||||
"htmlCodeFile": false,
|
||||
"usePathAsIs": false
|
||||
}
|
||||
}
|
7
icon.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?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 1080 1080" 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(1.1479,0,0,1.1479,-30.6849,-184.606)">
|
||||
<path d="M394.962,828.42C395.182,833.842 395.637,839.066 396.328,844.053C407.304,927.369 478.944,932.362 546.806,934.687C615.3,937.031 676.288,917.8 676.288,917.8L679.102,979.724C679.102,979.724 631.194,1005.45 545.847,1010.18C498.785,1012.77 440.352,1009 372.293,990.982C224.682,951.913 199.297,794.568 195.412,634.913C194.228,587.513 194.958,542.815 194.958,505.432C194.958,389.045 229.261,290.625 266.058,262.36C280.549,251.229 294.537,251.58 309.264,251.58L770.736,251.58C786.666,251.58 799.578,264.493 799.578,280.422L799.578,799.578C799.578,815.508 786.666,828.42 770.736,828.42L394.962,828.42ZM540,759.411C619.644,759.411 684.21,694.845 684.21,615.201C684.21,535.557 619.644,470.991 540,470.991C523.462,470.991 507.575,473.775 492.781,478.899C436.326,498.454 395.79,552.094 395.79,615.201C395.79,694.845 460.355,759.411 540,759.411ZM540,701.727C492.212,701.727 453.474,662.989 453.474,615.201C453.474,567.413 492.212,528.675 540,528.675C587.788,528.675 626.526,567.413 626.526,615.201C626.526,662.989 587.788,701.727 540,701.727ZM540,408.951C563.893,408.951 583.263,389.582 583.263,365.688C583.263,341.794 563.893,322.425 540,322.425C516.107,322.425 496.737,341.794 496.737,365.688C496.737,389.582 516.107,408.951 540,408.951Z" style="fill:rgb(46,11,120);stroke:white;stroke-width:29.62px;"/>
|
||||
</g>
|
||||
</svg>
|
After (image error) Size: 1.8 KiB |
@ -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
|
5178
package-lock.json
generated
53
package.json
@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "moshing-mammut",
|
||||
"version": "0.0.1",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"devn": "vite dev --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@ -12,28 +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": "^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/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": "^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"
|
||||
}
|
||||
}
|
||||
|
165
src/LICENSE
Normal file
@ -0,0 +1,165 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
92
src/app.html
@ -1,24 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/style.css" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="theme-color" content="#17063B" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
|
||||
<link rel="mask-icon" href="%sveltekit.assets%/safari-pinned-tab.svg" color="#2e0b78" />
|
||||
<meta name="apple-mobile-web-app-title" content="Moshing Mammut" />
|
||||
<meta name="application-name" content="Moshing Mammut" />
|
||||
<meta name="msapplication-TileColor" content="#2e0b78" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A collection of music recommendations and now-listenings by the users of metalhead.club"
|
||||
/>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#17063b" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#BCB9B2" media="(prefers-color-scheme: light)" />
|
||||
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Atom Feed" />
|
||||
<link rel="hub" href="https://pubsubhubbub.superfeedr.com" />
|
||||
%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 {
|
||||
@ -30,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,29 +1,79 @@
|
||||
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') {
|
||||
return new Response('', { status: 301, headers: { Location: '/feed.xml' } });
|
||||
}
|
||||
if (event.url.pathname === '/feed.xml') {
|
||||
const f = await fs.readFile('feed.xml', { encoding: 'utf8' });
|
||||
return new Response(
|
||||
f,
|
||||
{ headers: [['Content-Type', 'application/atom+xml']] }
|
||||
);
|
||||
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);
|
||||
|
1
src/lib/assets/error-warning-fill.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM11 15V17H13V15H11ZM11 7V13H13V7H11Z" fill="#000"></path></svg>
|
After (image error) Size: 268 B |
1
src/lib/assets/spotify-fill.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12.001 2C6.50098 2 2.00098 6.5 2.00098 12C2.00098 17.5 6.50098 22 12.001 22C17.501 22 22.001 17.5 22.001 12C22.001 6.5 17.551 2 12.001 2ZM15.751 16.65C13.401 15.2 10.451 14.8992 6.95014 15.6992C6.60181 15.8008 6.30098 15.55 6.20098 15.25C6.10098 14.8992 6.35098 14.6 6.65098 14.5C10.451 13.6492 13.751 14 16.351 15.6C16.701 15.75 16.7501 16.1492 16.6018 16.45C16.4018 16.7492 16.0518 16.85 15.751 16.65ZM16.7501 13.95C14.051 12.3 9.95098 11.8 6.80098 12.8C6.40181 12.9 5.95098 12.7 5.85098 12.3C5.75098 11.9 5.95098 11.4492 6.35098 11.3492C10.001 10.25 14.501 10.8008 17.601 12.7C17.9018 12.8508 18.051 13.35 17.8018 13.7C17.551 14.05 17.101 14.2 16.7501 13.95ZM6.30098 9.75083C5.80098 9.9 5.30098 9.6 5.15098 9.15C5.00098 8.64917 5.30098 8.15 5.75098 7.99917C9.30098 6.94917 15.151 7.14917 18.8518 9.35C19.301 9.6 19.451 10.2 19.201 10.65C18.9518 11.0008 18.351 11.1492 17.9018 10.9C14.701 9 9.35098 8.8 6.30098 9.75083Z"></path></svg>
|
After (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 |
1
src/lib/assets/youtube-fill.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12.2439 4C12.778 4.00294 14.1143 4.01586 15.5341 4.07273L16.0375 4.09468C17.467 4.16236 18.8953 4.27798 19.6037 4.4755C20.5486 4.74095 21.2913 5.5155 21.5423 6.49732C21.942 8.05641 21.992 11.0994 21.9982 11.8358L21.9991 11.9884L21.9991 11.9991C21.9991 11.9991 21.9991 12.0028 21.9991 12.0099L21.9982 12.1625C21.992 12.8989 21.942 15.9419 21.5423 17.501C21.2878 18.4864 20.5451 19.261 19.6037 19.5228C18.8953 19.7203 17.467 19.8359 16.0375 19.9036L15.5341 19.9255C14.1143 19.9824 12.778 19.9953 12.2439 19.9983L12.0095 19.9991L11.9991 19.9991C11.9991 19.9991 11.9956 19.9991 11.9887 19.9991L11.7545 19.9983C10.6241 19.9921 5.89772 19.941 4.39451 19.5228C3.4496 19.2573 2.70692 18.4828 2.45587 17.501C2.0562 15.9419 2.00624 12.8989 2 12.1625V11.8358C2.00624 11.0994 2.0562 8.05641 2.45587 6.49732C2.7104 5.51186 3.45308 4.73732 4.39451 4.4755C5.89772 4.05723 10.6241 4.00622 11.7545 4H12.2439ZM9.99911 8.49914V15.4991L15.9991 11.9991L9.99911 8.49914Z"></path></svg>
|
After (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>
|
||||
<a href={account.url} target="_blank">{account.display_name} @{account.acct}</a>
|
||||
|
@ -1,20 +1,56 @@
|
||||
<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;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;;
|
||||
}
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
@ -1,25 +1,57 @@
|
||||
<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">
|
||||
<div>
|
||||
<span>Made with 🤘 by </span>
|
||||
<a href="https://metalhead.club/@aymm" rel="me">@aymm@metalhead.club</a>
|
||||
<span class="label"
|
||||
>Made<span class="secretIngredient"> with 🤘</span> by </span
|
||||
>
|
||||
<a href="https://metalhead.club/@aymm" rel="me"
|
||||
>@aymm<span class="mastodonInstance">@metalhead.club</span></a
|
||||
>
|
||||
</div>
|
||||
|
|
||||
<div>
|
||||
<a href="https://phlaym.net/git/phlaym/moshing-mammut">
|
||||
<img alt="Git branch" src={git} class="icon" />
|
||||
Source Code
|
||||
<span class="label"><span class="feedSuffix">Source Code </span>v{version}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
||||
<div>
|
||||
<a href="/feed.xml">
|
||||
<img alt="RSS" src={rss} class="icon" />
|
||||
RSS Feed
|
||||
<span class="label">RSS<span class="feedSuffix"> Feed</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
||||
<div>
|
||||
<a href="https://open.spotify.com/playlist/62B8GOmJE3YrASAXSQVRVU" target="_blank">
|
||||
<img alt="Spotify" src={spotify} class="icon" />
|
||||
<span class="label">Spotify</span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
||||
<div>
|
||||
<a
|
||||
href="https://www.youtube.com/playlist?list=PLrSjNPaM6N4S54jT5R-ebKAYLBIEDb8sX"
|
||||
target="_blank"
|
||||
>
|
||||
<img alt="Youtube" src={youtube} class="icon" />
|
||||
<span class="label">Youtube</span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
||||
<div>
|
||||
<a href="https://tidal.com/playlist/9f60278a-7b9b-459b-b7e5-65a7849fe498" target="_blank">
|
||||
<img alt="Tidal" src={tidal} class="icon" />
|
||||
<span class="label">Tidal</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -34,23 +66,48 @@
|
||||
gap: 10px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
background-color: #bbbbcd73;
|
||||
background-color: var(--color-grey-translucent);
|
||||
padding: 0.3em 1em;
|
||||
margin: 0 -8px;
|
||||
border-radius: 3px;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.icon {
|
||||
position: relative;
|
||||
top: 0.25em;
|
||||
color: white;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon {
|
||||
filter: invert();
|
||||
}
|
||||
.footer {
|
||||
background-color: #6364FF88;
|
||||
background-color: var(--color-grey-translucent);
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 620px) {
|
||||
.mastodonInstance,
|
||||
.feedSuffix {
|
||||
display: none;
|
||||
}
|
||||
.footer {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 430px) {
|
||||
.mastodonInstance,
|
||||
.feedSuffix,
|
||||
.secretIngredient {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 370px) {
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,40 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import LoadingSpinnerComponent from '$lib/components/LoadingSpinnerComponent.svelte';
|
||||
|
||||
export let moreAvailable: boolean = false;
|
||||
export let isLoading: boolean = false;
|
||||
let displayText = '';
|
||||
let title = '';
|
||||
let disabled: boolean;
|
||||
|
||||
$: displayText = isLoading ? 'Loading...' : 'Load More';
|
||||
$: disabled = !moreAvailable || isLoading;
|
||||
$: title = moreAvailable ? 'Load More' : 'No more posts available';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function loadOlderPosts() {
|
||||
dispatch('loadOlderPosts');
|
||||
interface Props {
|
||||
moreAvailable?: boolean;
|
||||
isLoading?: boolean;
|
||||
loadOlderPosts: any;
|
||||
}
|
||||
|
||||
let { moreAvailable = false, isLoading = false, loadOlderPosts }: Props = $props();
|
||||
let displayText = $derived.by(() => {
|
||||
if (isLoading) {
|
||||
return 'Loading...';
|
||||
} else if (!moreAvailable) {
|
||||
return 'You reached the end';
|
||||
}
|
||||
return 'Load More';
|
||||
});
|
||||
let title = $derived(moreAvailable ? 'Load More' : 'There be dragons!');
|
||||
let disabled: boolean = $derived(!moreAvailable || isLoading);
|
||||
|
||||
/*const dispatch = createEventDispatcher<{
|
||||
loadOlderPosts: string;
|
||||
}>();
|
||||
|
||||
function loadOlderPosts() {
|
||||
dispatch('loadOlderPosts');
|
||||
}*/
|
||||
</script>
|
||||
|
||||
<button on:click={loadOlderPosts} {disabled} {title}>{displayText}</button>
|
||||
<button onclick={() => loadOlderPosts()} {disabled} {title}>
|
||||
<div class="loading" class:collapsed={!isLoading}>
|
||||
<LoadingSpinnerComponent size="0.5em" thickness="6px" />
|
||||
</div>
|
||||
<span>{displayText}</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button {
|
||||
padding: 0.75em;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
background-color: var(--color-link);
|
||||
color: white;
|
||||
background-color: var(--color-button);
|
||||
color: var(--color-button-text);
|
||||
cursor: grab;
|
||||
transition: all 0.2s ease-in-out;
|
||||
transition: all 0.3s ease-in-out;
|
||||
font-size: large;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background-color: var(--color-link-visited);
|
||||
background-color: var(--color-button-hover);
|
||||
}
|
||||
|
||||
button:hover:not(:disabled):not(:active) {
|
||||
box-shadow: 6px 6px 5px 0 var(--color-button-shadow);
|
||||
translate: -2px -2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: grey;
|
||||
background-color: var(--color-button-deactivated);
|
||||
}
|
||||
|
||||
button:not(:disabled) {
|
||||
box-shadow: 4px 4px 2px 0 var(--color-button-shadow);
|
||||
}
|
||||
|
||||
.loading {
|
||||
margin-right: 3px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
transition: all 0.3s 0.5s;
|
||||
}
|
||||
|
||||
/* Cannot be removed, so that it animates its width change */
|
||||
.collapsed {
|
||||
max-width: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
42
src/lib/components/LoadingSpinnerComponent.svelte
Normal file
@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
<style>
|
||||
.lds-dual-ring {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.lds-dual-ring:after {
|
||||
content: ' ';
|
||||
display: block;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
border: var(--thickness) solid #fff;
|
||||
border-color: #fff transparent #fff transparent;
|
||||
animation: lds-dual-ring 1.2s linear infinite;
|
||||
}
|
||||
@keyframes lds-dual-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(36deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(234deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,43 +1,261 @@
|
||||
<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 dateCreated: string;
|
||||
$: dateCreated = new Date(post.created_at).toLocaleString();
|
||||
interface Props {
|
||||
post: Post;
|
||||
}
|
||||
|
||||
let { post }: Props = $props();
|
||||
let displayRelativeTime = $state(false);
|
||||
const absoluteDate = new Date(post.created_at).toLocaleString();
|
||||
const timePassed = secondsSince(new Date(post.created_at));
|
||||
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(() => {
|
||||
// Display relative time only after mount:
|
||||
// When JS is disabled the server-side rendered absolute date will be shown,
|
||||
// otherwise the relative date would be outdated very quickly
|
||||
displayRelativeTime = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<div class="wrapper">
|
||||
<div class="avatar"><AvatarComponent account={post.account} /></div>
|
||||
<div class="post">
|
||||
<div class="account"><AccountComponent account={post.account} /></div>
|
||||
<div class="meta">
|
||||
<AccountComponent account={post.account} />
|
||||
<small><a href={post.url} target="_blank">{dateCreated}</a></small>
|
||||
<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;
|
||||
}
|
||||
.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>
|
8
src/lib/errorToast.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import errorIcon from '$lib/assets/error-warning-fill.svg';
|
||||
import { toast } from '@zerodevx/svelte-toast';
|
||||
|
||||
export function errorToast(message: string): number {
|
||||
return toast.push(`<img src="${errorIcon}" />${message}`, {
|
||||
classes: ['error']
|
||||
});
|
||||
}
|
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,29 +1,71 @@
|
||||
import type { SongInfo } from '$lib/odesliResponse';
|
||||
|
||||
export interface TimelineEvent {
|
||||
event: string,
|
||||
payload: string
|
||||
event: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string,
|
||||
created_at: string,
|
||||
tags: Tag[],
|
||||
url: string,
|
||||
content: string,
|
||||
account: Account
|
||||
id: string;
|
||||
created_at: string;
|
||||
tags: Tag[];
|
||||
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 {
|
||||
name: string,
|
||||
url: string
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string,
|
||||
acct: string,
|
||||
username: string,
|
||||
display_name: string,
|
||||
url: string,
|
||||
avatar: string,
|
||||
avatar_static: string
|
||||
id: string;
|
||||
acct: string;
|
||||
username: string;
|
||||
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';
|
37
src/lib/relativeTime.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { derived, readable, type Readable } from 'svelte/store';
|
||||
|
||||
export const time = readable(new Date(), function start(set) {
|
||||
const interval = setInterval(() => {
|
||||
set(new Date());
|
||||
}, 10000); //Every 10sec is enough, we don't need that much granularity
|
||||
|
||||
return function stop() {
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
|
||||
export function secondsSince(date: Date): Readable<number> {
|
||||
return derived(time, ($time) => Math.round(($time.getTime() - date.getTime()) / 1000));
|
||||
}
|
||||
|
||||
export function relativeTime(seconds: number): string | null {
|
||||
const min = 60;
|
||||
if (seconds < min) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
const hour = 60 * min;
|
||||
if (seconds < hour) {
|
||||
return `${Math.floor(seconds / min)}min`;
|
||||
}
|
||||
|
||||
const day = hour * 24;
|
||||
if (seconds < day) {
|
||||
return `${Math.floor(seconds / hour)}h`;
|
||||
}
|
||||
const maxRelative = day * 31;
|
||||
if (seconds < maxRelative) {
|
||||
return `${Math.floor(seconds / day)}d`;
|
||||
}
|
||||
return null;
|
||||
}
|
208
src/lib/server/playlist/oauthPlaylistAdder.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { BASE_URL } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import type { OauthResponse } from '$lib/mastodon/response';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
export abstract class OauthPlaylistAdder {
|
||||
/// How many minutes before expiry the token will be refreshed
|
||||
protected refresh_time: number = 15;
|
||||
protected logger: Logger = new Logger('OauthPlaylistAdder');
|
||||
protected redirectUri?: URL;
|
||||
|
||||
protected constructor(
|
||||
protected apiBase: string,
|
||||
protected token_file_name: string
|
||||
) {}
|
||||
|
||||
public async authCodeExists(): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.auth();
|
||||
return token !== null && !token.error;
|
||||
} catch {
|
||||
this.logger.info('No auth token yet, authorizing...');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected getRedirectUri(suffix: string): URL {
|
||||
const uri = this.redirectUri ?? new URL(`${BASE_URL}/${suffix}`);
|
||||
this.logger.debug('getRedirectUri', uri);
|
||||
return uri;
|
||||
}
|
||||
|
||||
protected constructAuthUrlInternal(
|
||||
endpointUrl: string,
|
||||
clientId: string,
|
||||
scope: string,
|
||||
redirectUri: URL,
|
||||
additionalParameters: Map<string, string> = new Map()
|
||||
): URL {
|
||||
const authUrl = new URL(endpointUrl);
|
||||
authUrl.searchParams.append('client_id', clientId);
|
||||
authUrl.searchParams.append('redirect_uri', redirectUri.toString());
|
||||
authUrl.searchParams.append('response_type', 'code');
|
||||
authUrl.searchParams.append('scope', scope);
|
||||
for (let p of additionalParameters.entries()) {
|
||||
authUrl.searchParams.append(p[0], p[1]);
|
||||
}
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
public async receivedAuthCodeInternal(
|
||||
tokenUrl: URL,
|
||||
clientId: string,
|
||||
code: string,
|
||||
redirectUri: URL,
|
||||
client_secret?: string,
|
||||
customHeader?: HeadersInit,
|
||||
code_verifier?: string
|
||||
) {
|
||||
this.logger.debug('received code');
|
||||
const params = new URLSearchParams();
|
||||
params.append('client_id', clientId);
|
||||
params.append('code', code);
|
||||
params.append('grant_type', 'authorization_code');
|
||||
params.append('redirect_uri', redirectUri.toString());
|
||||
if (client_secret) {
|
||||
params.append('client_secret', client_secret);
|
||||
}
|
||||
if (code_verifier) {
|
||||
params.append('code_verifier', code_verifier);
|
||||
}
|
||||
this.logger.debug('sending token req', params);
|
||||
const resp: OauthResponse = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: customHeader
|
||||
}).then((r) => r.json());
|
||||
this.logger.debug('received access token', resp);
|
||||
let expiration = new Date();
|
||||
expiration.setTime(expiration.getTime() + resp.expires_in * 1000);
|
||||
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
|
||||
resp.expires = expiration;
|
||||
await fs.writeFile(this.token_file_name, JSON.stringify(resp));
|
||||
}
|
||||
|
||||
protected async auth(): Promise<OauthResponse | null> {
|
||||
try {
|
||||
const token_file = await fs.readFile(this.token_file_name, { encoding: 'utf8' });
|
||||
let token = JSON.parse(token_file);
|
||||
if (token.expires) {
|
||||
if (typeof token.expires === typeof '') {
|
||||
token.expires = new Date(token.expires);
|
||||
}
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
this.logger.error('Could not read access token', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected async shouldRefreshToken(): Promise<{ token: OauthResponse; refresh: boolean } | null> {
|
||||
const token = await this.auth();
|
||||
if (token == null || !token?.expires) {
|
||||
this.logger.warn('Cannot check if token should be refreshed. Token expiry is unreadablle');
|
||||
return null;
|
||||
}
|
||||
if (token.error) {
|
||||
this.logger.error('Access token is invalid, should refresh');
|
||||
return {
|
||||
token: token,
|
||||
refresh: true
|
||||
};
|
||||
}
|
||||
let refreshAt = new Date(token.expires);
|
||||
// Refresh token this.refresh_time minutes before it expires
|
||||
refreshAt.setTime(refreshAt.getTime() - this.refresh_time * 60 * 1000);
|
||||
|
||||
this.logger.info('refresh @', refreshAt, 'token expires', token.expires);
|
||||
if (refreshAt.getTime() > new Date().getTime()) {
|
||||
return {
|
||||
token: token,
|
||||
refresh: false
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
'Token expires',
|
||||
token.expires,
|
||||
token.expires.getTime(),
|
||||
`so it should be refreshed before or at`,
|
||||
refreshAt,
|
||||
refreshAt.getTime()
|
||||
);
|
||||
return {
|
||||
token: token,
|
||||
refresh: true
|
||||
};
|
||||
}
|
||||
|
||||
protected async requestRefreshToken(
|
||||
tokenUrl: URL,
|
||||
clientId: string,
|
||||
refresh_token: string,
|
||||
redirect_uri?: string,
|
||||
client_secret?: string,
|
||||
customHeader?: HeadersInit
|
||||
): Promise<OauthResponse | null> {
|
||||
return (
|
||||
await this.requestRefreshTokenWithHeaders(
|
||||
tokenUrl,
|
||||
clientId,
|
||||
refresh_token,
|
||||
redirect_uri,
|
||||
client_secret,
|
||||
customHeader
|
||||
)
|
||||
).resp;
|
||||
}
|
||||
|
||||
protected async requestRefreshTokenWithHeaders(
|
||||
tokenUrl: URL,
|
||||
clientId: string,
|
||||
refresh_token: string,
|
||||
redirect_uri?: string,
|
||||
client_secret?: string,
|
||||
customHeader?: HeadersInit
|
||||
): Promise<{ resp: OauthResponse | null; headers: Headers }> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('client_id', clientId);
|
||||
params.append('grant_type', 'refresh_token');
|
||||
params.append('refresh_token', refresh_token);
|
||||
if (client_secret) {
|
||||
params.append('client_secret', client_secret);
|
||||
}
|
||||
if (redirect_uri) {
|
||||
params.append('redirect_uri', redirect_uri);
|
||||
}
|
||||
this.logger.debug('sending token req', params);
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: customHeader
|
||||
});
|
||||
|
||||
const resp: OauthResponse = await response.json();
|
||||
this.logger.verbose('received access token', resp);
|
||||
if (resp.error) {
|
||||
this.logger.error('token resp error', resp);
|
||||
return {
|
||||
resp: null,
|
||||
headers: response.headers
|
||||
};
|
||||
}
|
||||
if (!resp.refresh_token) {
|
||||
resp.refresh_token = refresh_token;
|
||||
}
|
||||
let expiration = new Date();
|
||||
expiration.setTime(expiration.getTime() + resp.expires_in * 1000);
|
||||
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
|
||||
resp.expires = expiration;
|
||||
await fs.writeFile(this.token_file_name, JSON.stringify(resp));
|
||||
return {
|
||||
resp: resp,
|
||||
headers: response.headers
|
||||
};
|
||||
}
|
||||
}
|
5
src/lib/server/playlist/playlistAdder.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { SongInfo } from '$lib/odesliResponse';
|
||||
|
||||
export interface PlaylistAdder {
|
||||
addToPlaylist(song: SongInfo): Promise<void>;
|
||||
}
|
124
src/lib/server/playlist/spotifyPlaylistAdder.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_PLAYLIST_ID } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import type { OauthResponse } from '$lib/mastodon/response';
|
||||
import type { SongInfo } from '$lib/odesliResponse';
|
||||
import { OauthPlaylistAdder } from './oauthPlaylistAdder';
|
||||
import type { PlaylistAdder } from './playlistAdder';
|
||||
|
||||
export class SpotifyPlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder {
|
||||
public constructor() {
|
||||
super('https://api.spotify.com/v1', 'spotify_auth_token');
|
||||
this.logger = new Logger('SpotifyPlaylistAdder');
|
||||
}
|
||||
|
||||
public constructAuthUrl(redirectUri: URL): URL {
|
||||
const endpoint = 'https://accounts.spotify.com/authorize';
|
||||
return this.constructAuthUrlInternal(
|
||||
endpoint,
|
||||
SPOTIFY_CLIENT_ID,
|
||||
'playlist-modify-private playlist-modify-public',
|
||||
redirectUri
|
||||
);
|
||||
}
|
||||
|
||||
public async receivedAuthCode(code: string, url: URL) {
|
||||
this.logger.debug('received code');
|
||||
const authHeader =
|
||||
'Basic ' + Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET).toString('base64');
|
||||
|
||||
const tokenUrl = new URL('https://accounts.spotify.com/api/token');
|
||||
await this.receivedAuthCodeInternal(tokenUrl, SPOTIFY_CLIENT_ID, code, url, undefined, {
|
||||
Authorization: authHeader
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshToken(force: boolean = false): Promise<OauthResponse | null> {
|
||||
const tokenInfo = await this.shouldRefreshToken();
|
||||
if (tokenInfo == null) {
|
||||
return null;
|
||||
}
|
||||
let token = tokenInfo.token;
|
||||
if (!tokenInfo.refresh && !force) {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (!token.refresh_token) {
|
||||
this.logger.error('Need to refresh access token, but no refresh token provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
const authHeader =
|
||||
'Basic ' + Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET).toString('base64');
|
||||
const tokenUrl = new URL('https://accounts.spotify.com/api/token');
|
||||
return await this.requestRefreshToken(
|
||||
tokenUrl,
|
||||
SPOTIFY_CLIENT_ID,
|
||||
token.refresh_token,
|
||||
undefined,
|
||||
undefined,
|
||||
{ Authorization: authHeader }
|
||||
);
|
||||
}
|
||||
|
||||
private async addToPlaylistRetry(song: SongInfo, remaning: number = 3) {
|
||||
if (remaning < 0) {
|
||||
this.logger.error('max retries reached, song will not be added to spotify playlist');
|
||||
}
|
||||
this.logger.debug('addToSpotifyPlaylist', remaning);
|
||||
const token = await this.refreshToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SPOTIFY_PLAYLIST_ID || SPOTIFY_PLAYLIST_ID === 'CHANGE_ME') {
|
||||
this.logger.debug('no spotify playlist ID configured');
|
||||
return;
|
||||
}
|
||||
if (!song.spotifyUri) {
|
||||
this.logger.info('Skip adding song to spotify playlist, no Uri', song);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO. Spotify's check for "is this song already in the playlist" is... ugh
|
||||
/*
|
||||
const playlistItemsUrl = new URL(`${this.apiBase}/playlists/${SPOTIFY_PLAYLIST_ID}/tracks`);
|
||||
playlistItemsUrl.searchParams.append('videoId', youtubeId);
|
||||
playlistItemsUrl.searchParams.append('playlistId', SPOTIFY_PLAYLIST_ID);
|
||||
playlistItemsUrl.searchParams.append('part', 'id');*/
|
||||
/*const existingPlaylistItem = await fetch(this.apiBase + '/playlistItems', {
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` }
|
||||
}).then((r) => r.json());
|
||||
logger.debug('existingPlaylistItem', existingPlaylistItem);
|
||||
if (existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0) {
|
||||
logger.info('Item already in playlist');
|
||||
return;
|
||||
}*/
|
||||
|
||||
//const searchParams = new URLSearchParams([['part', 'snippet']]);
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
body: JSON.stringify({
|
||||
uris: [song.spotifyUri]
|
||||
})
|
||||
};
|
||||
const apiUrl = new URL(`${this.apiBase}/playlists/${SPOTIFY_PLAYLIST_ID}/tracks`);
|
||||
const resp = await fetch(apiUrl, options);
|
||||
const respObj = await resp.json();
|
||||
if (respObj.error) {
|
||||
this.logger.debug('Add to playlist failed', song.spotifyUri, respObj.error);
|
||||
if (respObj.error.status === 401) {
|
||||
const token = await this.refreshToken(true);
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
this.addToPlaylistRetry(song, remaning--);
|
||||
}
|
||||
} else {
|
||||
this.logger.info('Added to playlist', song.spotifyUri, song.title);
|
||||
}
|
||||
}
|
||||
public async addToPlaylist(song: SongInfo) {
|
||||
await this.addToPlaylistRetry(song);
|
||||
}
|
||||
}
|
328
src/lib/server/playlist/tidalPlaylistAdder.ts
Normal file
@ -0,0 +1,328 @@
|
||||
import { TIDAL_PLAYLIST_ID, TIDAL_CLIENT_ID, TIDAL_CLIENT_SECRET } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import type { OauthResponse } from '$lib/mastodon/response';
|
||||
import type { SongInfo } from '$lib/odesliResponse';
|
||||
import { createHash } from 'crypto';
|
||||
import { OauthPlaylistAdder } from './oauthPlaylistAdder';
|
||||
import type { PlaylistAdder } from './playlistAdder';
|
||||
import type { TidalAddToPlaylistResponse, TidalAlbumResponse } from './tidalResponse';
|
||||
import { doesTidalSongExist } from '$lib/server/db';
|
||||
import { sleep } from '$lib/sleep';
|
||||
|
||||
export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder {
|
||||
private static code_verifier?: string;
|
||||
|
||||
public constructor() {
|
||||
super('https://openapi.tidal.com/v2', 'tidal_auth_token');
|
||||
//super('https://api.tidal.com/v2', 'tidal_auth_token');
|
||||
this.logger = new Logger('TidalPlaylistAdder');
|
||||
// Tidal aggressively rate-limits, so reduce the number of refreshing requests
|
||||
this.refresh_time = 3;
|
||||
}
|
||||
|
||||
public constructAuthUrl(redirectUri: URL): URL {
|
||||
const endpoint = 'https://login.tidal.com/authorize';
|
||||
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(100)).toString(), 'ascii')
|
||||
.toString('base64url')
|
||||
.slice(0, 128);
|
||||
const code_challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
TidalPlaylistAdder.code_verifier = verifier;
|
||||
|
||||
let additionalParameters = new Map([
|
||||
['code_challenge_method', 'S256'],
|
||||
['code_challenge', code_challenge]
|
||||
]);
|
||||
return this.constructAuthUrlInternal(
|
||||
endpoint,
|
||||
TIDAL_CLIENT_ID,
|
||||
'playlists.write playlists.read user.read', //r_usr w_usr
|
||||
redirectUri,
|
||||
additionalParameters
|
||||
);
|
||||
}
|
||||
|
||||
public async receivedAuthCode(code: string, url: URL) {
|
||||
this.logger.debug('received code');
|
||||
const tokenUrl = new URL('https://auth.tidal.com/v1/oauth2/token');
|
||||
await this.receivedAuthCodeInternal(
|
||||
tokenUrl,
|
||||
TIDAL_CLIENT_ID,
|
||||
code,
|
||||
url,
|
||||
TIDAL_CLIENT_SECRET,
|
||||
undefined,
|
||||
TidalPlaylistAdder.code_verifier
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshToken(force: boolean = false): Promise<OauthResponse | null> {
|
||||
const tokenInfo = await this.shouldRefreshToken();
|
||||
if (tokenInfo == null) {
|
||||
return null;
|
||||
}
|
||||
let token = tokenInfo.token;
|
||||
if (!tokenInfo.refresh && !force) {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (!token.refresh_token) {
|
||||
this.logger.error('Need to refresh access token, but no refresh token provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenUrl = new URL('https://auth.tidal.com/v1/oauth2/token');
|
||||
const response = await this.requestRefreshTokenWithHeaders(
|
||||
tokenUrl,
|
||||
TIDAL_CLIENT_ID,
|
||||
token.refresh_token
|
||||
);
|
||||
this.processTidalHeaders(response.headers);
|
||||
return response.resp;
|
||||
}
|
||||
|
||||
private processTidalHeaders(headers: Headers) {
|
||||
const remainingTokens = headers.get('x-ratelimit-remaining');
|
||||
const requiredTokens = headers.get('x-ratelimit-requested-tokens');
|
||||
const replenishRate = headers.get('x-ratelimit-replenish-rate');
|
||||
if (remainingTokens !== null && replenishRate !== null) {
|
||||
const remainingTokensValue = parseInt(remainingTokens);
|
||||
const replenishRateValue = parseInt(replenishRate);
|
||||
let requiredTokensValue = parseInt(requiredTokens ?? '-1');
|
||||
this.logger.debug(
|
||||
'Tidal rate limit. Remaining',
|
||||
remainingTokensValue,
|
||||
'reuqired for last request',
|
||||
requiredTokensValue,
|
||||
'replenish rate',
|
||||
replenishRateValue
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAlbumItems(
|
||||
albumId: string,
|
||||
remaning: number = 3,
|
||||
nextLink?: string
|
||||
): Promise<{ id: string; type: string }[]> {
|
||||
this.logger.debug('getAlbumItems', albumId, remaning, nextLink);
|
||||
if (remaning <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const token = await this.refreshToken();
|
||||
if (token == null) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
let albumUrl: URL;
|
||||
if (nextLink) {
|
||||
albumUrl = new URL(nextLink);
|
||||
} else {
|
||||
albumUrl = new URL(`${this.apiBase}/albums/${albumId}/relationships/items`);
|
||||
albumUrl.searchParams.append('countryCode', 'DE');
|
||||
}
|
||||
|
||||
const options: RequestInit = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `${token.token_type} ${token.access_token}`,
|
||||
Accept: 'application/vnd.api+json'
|
||||
}
|
||||
};
|
||||
const request = new Request(albumUrl, options);
|
||||
const resp = await fetch(request);
|
||||
this.processTidalHeaders(resp.headers);
|
||||
if (resp.ok) {
|
||||
const respData: TidalAlbumResponse = await resp.json();
|
||||
if (respData.data !== undefined) {
|
||||
let tracks = respData.data.map((x) => {
|
||||
return { id: x.id, type: x.type };
|
||||
});
|
||||
if (respData.links?.next) {
|
||||
const nextPage = await this.getAlbumItems(albumId, remaning, respData.links.next);
|
||||
tracks = tracks.concat(nextPage);
|
||||
}
|
||||
return tracks;
|
||||
} else {
|
||||
this.logger.error('Error response for album', respData.errors, respData);
|
||||
return [];
|
||||
}
|
||||
} else if (resp.status === 429) {
|
||||
// Tidal docs say, this endpoint uses Retry-After header,
|
||||
// but other endpoints use x-rate-limit headers. Check both
|
||||
let secondsToWait = 0;
|
||||
const retryAfterHeader = resp.headers.get('Retry-After');
|
||||
if (retryAfterHeader) {
|
||||
secondsToWait = parseInt(retryAfterHeader);
|
||||
} else {
|
||||
const remainingTokens = resp.headers.get('x-ratelimit-remaining');
|
||||
const requiredTokens = resp.headers.get('x-ratelimit-requested-tokens');
|
||||
const replenishRate = resp.headers.get('x-ratelimit-replenish-rate');
|
||||
if (remainingTokens !== null && requiredTokens !== null && replenishRate !== null) {
|
||||
const remainingTokensValue = parseInt(remainingTokens);
|
||||
const requiredTokensValue = parseInt(requiredTokens);
|
||||
const replenishRateValue = parseInt(replenishRate);
|
||||
const needToReplenish = requiredTokensValue - remainingTokensValue;
|
||||
secondsToWait = 1 + needToReplenish / replenishRateValue;
|
||||
}
|
||||
}
|
||||
if (secondsToWait === 0) {
|
||||
// Try again secondsToWait sec later, just to be safe one additional second
|
||||
this.logger.warn(
|
||||
'Received HTTP 429 Too Many Requests. Retrying in',
|
||||
secondsToWait,
|
||||
'sec'
|
||||
);
|
||||
await sleep(secondsToWait * 1000);
|
||||
return await this.getAlbumItems(albumId, remaning - 1, nextLink);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
'Received HTTP 429 Too Many Requests, but no instructions on how long to wait. Aborting',
|
||||
secondsToWait,
|
||||
'sec'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
const respText = await resp.text();
|
||||
this.logger.error('Cannot check album contents', resp.status, respText);
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Error checking album contents', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async addToPlaylistRetry(song: SongInfo, remaning: number = 3) {
|
||||
if (remaning < 0) {
|
||||
this.logger.error('max retries reached, song will not be added to playlist');
|
||||
}
|
||||
this.logger.debug('addToTidalPlaylist', remaning);
|
||||
const token = await this.refreshToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('token check successful');
|
||||
|
||||
if (!TIDAL_PLAYLIST_ID || TIDAL_PLAYLIST_ID === 'CHANGE_ME') {
|
||||
this.logger.debug('no playlist ID configured');
|
||||
return;
|
||||
}
|
||||
if (!song.tidalUri) {
|
||||
this.logger.info('Skip adding song to playlist, no Uri', song);
|
||||
return;
|
||||
}
|
||||
|
||||
let tracks = [
|
||||
{
|
||||
id: song.tidalUri,
|
||||
type: 'tracks'
|
||||
}
|
||||
];
|
||||
if (song.type === 'album') {
|
||||
tracks = await this.getAlbumItems(song.tidalUri);
|
||||
this.logger.debug('received tracks', tracks);
|
||||
if (tracks.length === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const alreadyExists = await doesTidalSongExist(song);
|
||||
try {
|
||||
if (alreadyExists) {
|
||||
this.logger.info('Skip adding song to playlist, has already been added', song);
|
||||
return;
|
||||
}
|
||||
} catch (dbe) {
|
||||
this.logger.error('Could not check for tidal dupes', dbe);
|
||||
}
|
||||
|
||||
// This would be API v2, but that's still in beta and only allows adding an item *before* another one
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `${token.token_type} ${token.access_token}`,
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
Accept: 'application/vnd.api+json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: tracks,
|
||||
meta: {
|
||||
positionBefore: 'ffb6286e-237a-4dfc-bbf1-2fb0eb004ed5' // Hardcoded last element of list
|
||||
}
|
||||
})
|
||||
};
|
||||
const apiUrl = new URL(`${this.apiBase}/playlists/${TIDAL_PLAYLIST_ID}/relationships/items`);
|
||||
const request = new Request(apiUrl, options);
|
||||
|
||||
let resp: Response | null = null;
|
||||
let respTxt: string | null = null;
|
||||
try {
|
||||
resp = await fetch(request);
|
||||
this.processTidalHeaders(resp.headers);
|
||||
let respObj: TidalAddToPlaylistResponse | null = null;
|
||||
// If the request was successful, a 201 with no content is received
|
||||
// Errors will have content and a different status code
|
||||
if (resp.status !== 201 && resp.status !== 429) {
|
||||
respObj = await resp.json();
|
||||
} else {
|
||||
respTxt = await resp.text();
|
||||
}
|
||||
if (respObj !== null && respObj.errors) {
|
||||
this.logger.error('Add to playlist failed', song.tidalUri, resp.status, respObj.errors);
|
||||
if (resp.status === 401 || respObj.errors.some((x) => x.code === 'UNAUTHORIZED')) {
|
||||
const token = await this.refreshToken(true);
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
this.addToPlaylistRetry(song, remaning--);
|
||||
}
|
||||
} else if (respObj === null) {
|
||||
switch (resp.status) {
|
||||
case 201:
|
||||
this.logger.info('Added to playlist', song.tidalUri, song.title);
|
||||
break;
|
||||
case 429:
|
||||
const remainingTokens = resp.headers.get('x-ratelimit-remaining');
|
||||
const requiredTokens = resp.headers.get('x-ratelimit-requested-tokens');
|
||||
const replenishRate = resp.headers.get('x-ratelimit-replenish-rate');
|
||||
if (remainingTokens !== null && requiredTokens !== null && replenishRate !== null) {
|
||||
const remainingTokensValue = parseInt(remainingTokens);
|
||||
const requiredTokensValue = parseInt(requiredTokens);
|
||||
const replenishRateValue = parseInt(replenishRate);
|
||||
const needToReplenish = requiredTokensValue - remainingTokensValue;
|
||||
const secondsToWait = 1 + needToReplenish / replenishRateValue;
|
||||
this.logger.warn(
|
||||
'Received HTTP 429 Too Many Requests. Retrying in',
|
||||
secondsToWait,
|
||||
'sec'
|
||||
);
|
||||
// Try again secondsToWait sec later, just to be safe one additional second
|
||||
setTimeout(() => {
|
||||
this.addToPlaylistRetry(song, remaning--);
|
||||
}, secondsToWait * 1000);
|
||||
} else {
|
||||
this.logger.warn('Could not read headers how long to wait', resp.headers);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.logger.warn('Unknown response', resp.status, respTxt);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
this.logger.info(
|
||||
'Add to playlist result is neither 201 nor error',
|
||||
song.tidalUri,
|
||||
song.title,
|
||||
respObj
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Add to playlist request failed', resp?.status, e);
|
||||
}
|
||||
}
|
||||
public async addToPlaylist(song: SongInfo) {
|
||||
await this.addToPlaylistRetry(song);
|
||||
}
|
||||
}
|
50
src/lib/server/playlist/tidalResponse.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export type TidalAddToPlaylistResponse = {
|
||||
errors: TidalAddToPlaylistError[];
|
||||
};
|
||||
|
||||
export type TidalAddToPlaylistError = TidalError & {
|
||||
id: string;
|
||||
status: number;
|
||||
source: TidalAddToPlaylistErrorSource;
|
||||
};
|
||||
|
||||
export type TidalAddToPlaylistErrorSource = {
|
||||
parameter: string;
|
||||
};
|
||||
export type TidalErrorMeta = {
|
||||
category: string;
|
||||
};
|
||||
export type TidalError = {
|
||||
code: string;
|
||||
detail: string;
|
||||
meta: TidalErrorMeta;
|
||||
};
|
||||
export type TidalErrorCode =
|
||||
| 'INVALID_ENUM_VALUE'
|
||||
| 'VALUE_REGEX_MISMATCH'
|
||||
| 'NOT_FOUND'
|
||||
| 'METHOD_NOT_SUPPORTED'
|
||||
| 'NOT_ACCEPTABLE'
|
||||
| 'UNSUPPORTED_MEDIA_TYPE'
|
||||
| 'UNAVAILABLE_FOR_LEGAL_REASONS_RESPONSE'
|
||||
| 'INTERNAL_SERVER_ERROR'
|
||||
| 'UNAUTHORIZED';
|
||||
|
||||
export type TidalAlbumResponse = {
|
||||
data?: TidalAlbumTrack[];
|
||||
links?: {
|
||||
next?: string;
|
||||
self: string;
|
||||
};
|
||||
errors?: TidalError[];
|
||||
};
|
||||
export type TidalAlbumTrack = {
|
||||
id: string;
|
||||
type: 'tracks' | string;
|
||||
meta: TidalAlbumTrackMeta;
|
||||
};
|
||||
|
||||
export type TidalAlbumTrackMeta = {
|
||||
trackNumber: number;
|
||||
volumeNumber: number;
|
||||
};
|
157
src/lib/server/playlist/ytPlaylistAdder.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, YOUTUBE_PLAYLIST_ID } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import type { OauthResponse } from '$lib/mastodon/response';
|
||||
import type { SongInfo } from '$lib/odesliResponse';
|
||||
import { OauthPlaylistAdder } from './oauthPlaylistAdder';
|
||||
import type { PlaylistAdder } from './playlistAdder';
|
||||
|
||||
export class YoutubePlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder {
|
||||
public constructor() {
|
||||
super('https://www.googleapis.com/youtube/v3', 'yt_auth_token');
|
||||
this.logger = new Logger('YoutubePlaylistAdder');
|
||||
}
|
||||
|
||||
public constructAuthUrl(redirectUri: URL): URL {
|
||||
this.redirectUri = redirectUri;
|
||||
let additionalParameters = new Map([
|
||||
['access_type', 'offline'],
|
||||
['include_granted_scopes', 'false']
|
||||
]);
|
||||
const endpoint = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
return this.constructAuthUrlInternal(
|
||||
endpoint,
|
||||
YOUTUBE_CLIENT_ID,
|
||||
'https://www.googleapis.com/auth/youtube',
|
||||
redirectUri,
|
||||
additionalParameters
|
||||
);
|
||||
}
|
||||
|
||||
public async receivedAuthCode(code: string, url: URL) {
|
||||
this.logger.debug('received code');
|
||||
this.redirectUri = url;
|
||||
const tokenUrl = new URL('https://oauth2.googleapis.com/token');
|
||||
await this.receivedAuthCodeInternal(
|
||||
tokenUrl,
|
||||
YOUTUBE_CLIENT_ID,
|
||||
code,
|
||||
url,
|
||||
YOUTUBE_CLIENT_SECRET
|
||||
);
|
||||
const token = await this.refreshToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.logger.debug('Checking authorized token');
|
||||
const res = await fetch(this.apiBase + '/channels?part=id&mine=true', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` }
|
||||
}).then((r) => r.json());
|
||||
this.logger.debug('Checked authorized token', res);
|
||||
} catch (e) {
|
||||
this.logger.debug('Error checking authorized token', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshToken(force: boolean = false): Promise<OauthResponse | null> {
|
||||
const tokenInfo = await this.shouldRefreshToken();
|
||||
if (tokenInfo == null) {
|
||||
return null;
|
||||
}
|
||||
let token = tokenInfo.token;
|
||||
if (!tokenInfo.refresh && !force) {
|
||||
return token;
|
||||
}
|
||||
if (!token.refresh_token) {
|
||||
this.logger.error('Need to refresh access token, but no refresh token provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenUrl = new URL('https://oauth2.googleapis.com/token');
|
||||
return await this.requestRefreshToken(
|
||||
tokenUrl,
|
||||
YOUTUBE_CLIENT_ID,
|
||||
token.refresh_token,
|
||||
this.getRedirectUri('ytauth').toString(),
|
||||
YOUTUBE_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
public async addToPlaylist(song: SongInfo) {
|
||||
await this.addToPlaylistRetry(song);
|
||||
}
|
||||
|
||||
private async addToPlaylistRetry(song: SongInfo, remaning: number = 3) {
|
||||
if (remaning < 0) {
|
||||
this.logger.error('max retries reached, song will not be added to spotify playlist');
|
||||
}
|
||||
this.logger.debug('addToYoutubePlaylist');
|
||||
const token = await this.refreshToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!YOUTUBE_PLAYLIST_ID || YOUTUBE_PLAYLIST_ID === 'CHANGE_ME') {
|
||||
this.logger.debug('no playlist ID configured');
|
||||
return;
|
||||
}
|
||||
if (!song.youtubeUrl) {
|
||||
this.logger.info('Skip adding song to YT playlist, no youtube Url', song);
|
||||
return;
|
||||
}
|
||||
|
||||
const songUrl = new URL(song.youtubeUrl);
|
||||
const youtubeId = songUrl.searchParams.get('v');
|
||||
if (!youtubeId) {
|
||||
this.logger.debug(
|
||||
'Skip adding song to YT playlist, could not extract YT id from URL',
|
||||
song.youtubeUrl
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Found YT id from URL', song.youtubeUrl, youtubeId);
|
||||
|
||||
const playlistItemsUrl = new URL(this.apiBase + '/playlistItems');
|
||||
playlistItemsUrl.searchParams.append('videoId', youtubeId);
|
||||
playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID);
|
||||
playlistItemsUrl.searchParams.append('part', 'id');
|
||||
const existingPlaylistItem = await fetch(playlistItemsUrl, {
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` }
|
||||
}).then((r) => r.json());
|
||||
if (existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0) {
|
||||
this.logger.info('Item already in playlist', existingPlaylistItem);
|
||||
return;
|
||||
}
|
||||
|
||||
const addItemUrl = new URL(this.apiBase + '/playlistItems');
|
||||
addItemUrl.searchParams.append('part', 'snippet');
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
body: JSON.stringify({
|
||||
snippet: {
|
||||
playlistId: YOUTUBE_PLAYLIST_ID,
|
||||
resourceId: {
|
||||
videoId: youtubeId,
|
||||
kind: 'youtube#video'
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
const resp = await fetch(addItemUrl, options);
|
||||
const respObj = await resp.json();
|
||||
this.logger.info('Added to playlist', youtubeId, song.title);
|
||||
if (respObj.error) {
|
||||
this.logger.error('Add to playlist failed', respObj.error.errors);
|
||||
if (respObj.error.errors && respObj.error.errors[0].reason === 'authError') {
|
||||
this.logger.info('Refreshing auth token');
|
||||
const token = await this.refreshToken(true);
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
this.addToPlaylistRetry(song, remaning--);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,11 +1,15 @@
|
||||
import { BASE_URL } from '$env/static/private';
|
||||
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 logger = new Logger('RSS');
|
||||
|
||||
export function createFeed(posts: Post[]): Feed {
|
||||
const baseUrl = BASE_URL.endsWith('/') ? BASE_URL : BASE_URL + '/';
|
||||
const hub = WEBSUB_HUB ? WEBSUB_HUB : undefined;
|
||||
const feed = new Feed({
|
||||
title: `${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music feed`,
|
||||
description: `Posts about music on ${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME}`,
|
||||
@ -19,27 +23,49 @@ export function createFeed(posts: Post[]): Feed {
|
||||
feedLinks: {
|
||||
atom: `${BASE_URL}/feed.xml`
|
||||
},
|
||||
hub: hub,
|
||||
author: {
|
||||
name: '@aymm',
|
||||
link: 'https://metalhead.club/@aymm'
|
||||
},
|
||||
}
|
||||
});
|
||||
posts.forEach(p => {
|
||||
posts.forEach((p) => {
|
||||
feed.addItem({
|
||||
title: p.content,
|
||||
id: p.url,
|
||||
link: p.url,
|
||||
content: p.content,
|
||||
author: [{
|
||||
author: [
|
||||
{
|
||||
name: p.account.acct,
|
||||
link: p.account.url
|
||||
}],
|
||||
}
|
||||
],
|
||||
date: new Date(p.created_at)
|
||||
})
|
||||
});
|
||||
});
|
||||
feed.addCategory('Music');
|
||||
|
||||
return feed;
|
||||
}
|
||||
export async function saveAtomFeed(feed: Feed) {
|
||||
await fs.writeFile('feed.xml', feed.atom1(), { encoding: 'utf8' });
|
||||
if (!WEBSUB_HUB) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const param = new FormData();
|
||||
param.append('hub.mode', 'publish');
|
||||
param.append('hub.url', `${BASE_URL}/feed.xml`);
|
||||
//const params = new URLSearchParams();
|
||||
//params.append('hub.mode', 'publish');
|
||||
//params.append('hub.url', `${BASE_URL}/feed.xml`);
|
||||
await fetch(WEBSUB_HUB, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: param
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to update WebSub hub', e);
|
||||
}
|
||||
}
|
@ -1,101 +1,560 @@
|
||||
import { HASHTAG_FILTER, MASTODON_INSTANCE, URL_FILTER, YOUTUBE_API_KEY } from '$env/static/private';
|
||||
import type { Post, Tag, TimelineEvent } from '$lib/mastodon/response';
|
||||
import { getPosts, savePost } from '$lib/server/db';
|
||||
import {
|
||||
HASHTAG_FILTER,
|
||||
MASTODON_ACCESS_TOKEN,
|
||||
MASTODON_INSTANCE,
|
||||
IGNORE_USERS,
|
||||
ODESLI_API_KEY,
|
||||
YOUTUBE_API_KEY
|
||||
} from '$env/static/private';
|
||||
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 { WebSocket } from "ws";
|
||||
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 YOUTUBE_REGEX = new RegExp(/https?:\/\/(www\.)?youtu((be.com\/.*?v=)|(\.be\/))(?<videoId>[a-zA-Z_0-9-]+)/gm);
|
||||
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) {
|
||||
private async isMusicVideo(videoId: string) {
|
||||
if (!YOUTUBE_API_KEY || YOUTUBE_API_KEY === 'CHANGE_ME') {
|
||||
// Assume that it *is* a music link when no YT API key is provided
|
||||
this.logger.debug('YT API not configured');
|
||||
return true;
|
||||
}
|
||||
const searchParams = new URLSearchParams([
|
||||
['part', 'snippet'],
|
||||
['id', videoId],
|
||||
['key', YOUTUBE_API_KEY]]);
|
||||
['key', YOUTUBE_API_KEY]
|
||||
]);
|
||||
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);
|
||||
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],
|
||||
['key', YOUTUBE_API_KEY]]);
|
||||
const youtubeCategoryUrl = new URL(`https://www.googleapis.com/youtube/v3/videoCategories?${categorySearchParams}`);
|
||||
const categoryTitle: string = await fetch(youtubeCategoryUrl).then(r => r.json()).then(r => r.items[0]?.title);
|
||||
['id', item.snippet.categoryId],
|
||||
['key', YOUTUBE_API_KEY]
|
||||
]);
|
||||
const youtubeCategoryUrl = new URL(
|
||||
`https://www.googleapis.com/youtube/v3/videoCategories?${categorySearchParams}`
|
||||
);
|
||||
const categoryTitle: string = await fetch(youtubeCategoryUrl)
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.items[0]?.snippet?.title);
|
||||
this.logger.debug('YT category', categoryTitle);
|
||||
return categoryTitle === 'Music';
|
||||
}
|
||||
|
||||
private static async checkYoutubeMatches(postContent: string): Promise<boolean> {
|
||||
const matches = postContent.matchAll(YOUTUBE_REGEX);
|
||||
for (let 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);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
this.logger.error('URL found via Regex does not seem to be a valud url', urlMatch, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`);
|
||||
socket.onopen = (_event) => {
|
||||
socket.send('{ "type": "subscribe", "stream": "public:local"}');
|
||||
};
|
||||
socket.onmessage = (async (event) => {
|
||||
// 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 songs;
|
||||
}
|
||||
|
||||
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 data: TimelineEvent = JSON.parse(event.data.toString());
|
||||
if (data.event !== 'update') {
|
||||
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 info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
|
||||
//this.logger.debug('odesli response', info);
|
||||
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;
|
||||
}
|
||||
const isMusic = await this.isMusicVideo(youtubeId);
|
||||
if (!isMusic) {
|
||||
this.logger.debug('Probably not a music video', youtubeId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const spotify: Platform = 'spotify';
|
||||
const tidal: Platform = 'tidal';
|
||||
const tidalId = odesliInfo.linksByPlatform[tidal]?.entityUniqueId;
|
||||
const tidalUri = tidalId ? odesliInfo.entitiesByUniqueId[tidalId].id : undefined;
|
||||
|
||||
const songInfo = {
|
||||
...info,
|
||||
pageUrl: odesliInfo.pageUrl,
|
||||
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
|
||||
spotifyUrl: odesliInfo.linksByPlatform[spotify]?.url,
|
||||
spotifyUri: odesliInfo.linksByPlatform[spotify]?.nativeAppUriDesktop,
|
||||
tidalUri: tidalUri,
|
||||
postedUrl: url.toString()
|
||||
} as SongInfo;
|
||||
return songInfo;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.cause === 429) {
|
||||
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 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));
|
||||
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 (found_urls.length === 0 &&
|
||||
found_tags.length === 0 &&
|
||||
!await TimelineReader.checkYoutubeMatches(post.content)) {
|
||||
if (songs.length === 0 && found_tags.length === 0) {
|
||||
this.logger.log('Ignoring post', post.url);
|
||||
return;
|
||||
}
|
||||
await savePost(post);
|
||||
|
||||
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));
|
||||
|
||||
} catch (e) {
|
||||
console.error("error message", event, event.data, e)
|
||||
for (let song of songs) {
|
||||
this.logger.debug('Adding to playlist', song);
|
||||
await this.addToPlaylist(song);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
private startWebsocket() {
|
||||
const socketLogger = new Logger('Websocket');
|
||||
const socket = new WebSocket(
|
||||
`wss://${MASTODON_INSTANCE}/api/v1/streaming?type=subscribe&stream=public:local&access_token=${MASTODON_ACCESS_TOKEN}`
|
||||
);
|
||||
|
||||
// Sometimes, the app just stops receiving WS updates.
|
||||
// Regularly check if it is necessary to reset it
|
||||
const wsTimeout = 5;
|
||||
let timeoutId = setTimeout(
|
||||
() => {
|
||||
socketLogger.warn(
|
||||
'Websocket has not received a new post in',
|
||||
wsTimeout,
|
||||
'hours. Resetting, it might be stuck'
|
||||
);
|
||||
socket.close();
|
||||
this.startWebsocket();
|
||||
},
|
||||
1000 * 60 * 60 * wsTimeout
|
||||
); // 5 hours
|
||||
socket.onopen = () => {
|
||||
socketLogger.log('Connected to WS');
|
||||
};
|
||||
socket.onmessage = async (event) => {
|
||||
try {
|
||||
// Reset timer
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(
|
||||
() => {
|
||||
socketLogger.warn(
|
||||
'Websocket has not received a new post in',
|
||||
wsTimeout,
|
||||
'hours. Resetting, it might be stuck'
|
||||
);
|
||||
socket.close();
|
||||
this.startWebsocket();
|
||||
},
|
||||
1000 * 60 * 60 * wsTimeout
|
||||
);
|
||||
|
||||
const data: TimelineEvent = JSON.parse(event.data.toString());
|
||||
socketLogger.debug('ES event', data.event);
|
||||
if (data.event !== 'update') {
|
||||
socketLogger.log('Ignoring ES event', data.event);
|
||||
return;
|
||||
}
|
||||
const post: Post = JSON.parse(data.payload);
|
||||
|
||||
// Sometimes onmessage is called twice for the same post.
|
||||
// This looks to be an issue with automatic reloading in the dev environment,
|
||||
// but hard to tell
|
||||
if (this.lastPosts.includes(post.id)) {
|
||||
socketLogger.log('Skipping post, already handled', post.id);
|
||||
return;
|
||||
}
|
||||
this.lastPosts.push(post.id);
|
||||
while (this.lastPosts.length > 10) {
|
||||
this.lastPosts.shift();
|
||||
}
|
||||
await this.checkAndSavePost(post);
|
||||
} catch (e) {
|
||||
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() {
|
||||
|
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);
|
||||
});
|
||||
}
|
10
src/lib/truthyString.ts
Normal file
@ -0,0 +1,10 @@
|
||||
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
|
||||
}
|
||||
|
||||
return !!value;
|
||||
}
|
@ -1,7 +1,20 @@
|
||||
<script lang="ts">
|
||||
import FooterComponent from '$lib/components/FooterComponent.svelte'
|
||||
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,
|
||||
classes: ['toast']
|
||||
};
|
||||
</script>
|
||||
<slot />
|
||||
|
||||
{@render children?.()}
|
||||
<SvelteToast {options} />
|
||||
<div class="footer">
|
||||
<FooterComponent />
|
||||
</div>
|
||||
@ -11,5 +24,25 @@
|
||||
position: sticky;
|
||||
bottom: 0px;
|
||||
display: inline-block;
|
||||
z-index: 99;
|
||||
}
|
||||
:global(.toast.error) {
|
||||
--toastColor: var(--color-button-text);
|
||||
--toastBackground: var(--color-red-dark);
|
||||
--toastBarBackground: var(--color-red-lighter);
|
||||
}
|
||||
:global(._toastMsg > img) {
|
||||
position: relative;
|
||||
height: 1.5em;
|
||||
}
|
||||
:global(._toastMsg) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
@media only screen and (max-width: 620px) {
|
||||
.footer {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,43 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { PageData } from './$types';
|
||||
import type { Post } from '$lib/mastodon/response';
|
||||
import { PUBLIC_REFRESH_INTERVAL, PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public';
|
||||
import PostComponent from '$lib/components/PostComponent.svelte';
|
||||
import LoadMoreComponent from '$lib/components/LoadMoreComponent.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import type { Post } from '$lib/mastodon/response';
|
||||
import {
|
||||
PUBLIC_REFRESH_INTERVAL,
|
||||
PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME
|
||||
} from '$env/static/public';
|
||||
import PostComponent from '$lib/components/PostComponent.svelte';
|
||||
import LoadMoreComponent from '$lib/components/LoadMoreComponent.svelte';
|
||||
import { fly, type FlyParams } from 'svelte/transition';
|
||||
import { cubicInOut } from 'svelte/easing';
|
||||
import { errorToast } from '$lib/errorToast';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
|
||||
let interval: NodeJS.Timer | null = null;
|
||||
let moreOlderPostsAvailable = true;
|
||||
let loadingOlderPosts = false;
|
||||
|
||||
function refresh() {
|
||||
const params = new URLSearchParams();
|
||||
if (data.posts.length > 0) {
|
||||
params.set('since', data.posts[0].created_at);
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
fetch(`/api/posts?${params}`)
|
||||
.then(r => r.json())
|
||||
.then((resp: Post[]) => {
|
||||
|
||||
let { data = $bindable() }: Props = $props();
|
||||
let posts: Post[] = $state(data.posts);
|
||||
|
||||
interface FetchOptions {
|
||||
since?: string;
|
||||
before?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface EdgeFlyParams extends FlyParams {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
|
||||
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
|
||||
// post was, before the fetch happened.
|
||||
let oldestBeforeLastFetch: number | null = null;
|
||||
|
||||
/**
|
||||
* Animate either from the top, or the bottom of the window, depending if the post is
|
||||
* newer than the existing ones or older.
|
||||
*/
|
||||
function edgeFly(node: Element, opts: EdgeFlyParams) {
|
||||
const createdAt = new Date(opts.created_at).getTime();
|
||||
const diffNewest = Math.abs(new Date(posts[0].created_at).getTime() - createdAt);
|
||||
const oldest =
|
||||
oldestBeforeLastFetch !== null
|
||||
? oldestBeforeLastFetch
|
||||
: new Date(posts[posts.length - 1].created_at).getTime();
|
||||
const diffOldest = Math.abs(oldest - createdAt);
|
||||
const fromTop = diffNewest <= diffOldest;
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
const paramY = +`${opts.y}`;
|
||||
let offset = isNaN(paramY) ? 0 : paramY + rect.height;
|
||||
opts.y = fromTop ? -offset : window.innerHeight + offset;
|
||||
return fly(node, opts);
|
||||
}
|
||||
|
||||
async function fetchPosts(options: FetchOptions): Promise<Post[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.since !== undefined) {
|
||||
params.set('since', options.since);
|
||||
}
|
||||
if (options?.before !== undefined) {
|
||||
params.set('before', options.before);
|
||||
}
|
||||
if (options?.count !== undefined) {
|
||||
params.set('count', options.count.toFixed(0));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/posts?${params}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
function filterDuplicates(posts: Post[]): Post[] {
|
||||
return posts.filter((obj, index, arr) => {
|
||||
return arr.map((mapObj) => mapObj.url).indexOf(obj.url) === index;
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
let filter: FetchOptions = {};
|
||||
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
|
||||
const combined = resp.concat(data.posts);
|
||||
const filteredPosts = combined.filter((obj, index, arr) => {
|
||||
return arr.map(mapObj => mapObj.url).indexOf(obj.url) === index;
|
||||
});
|
||||
data.posts = filteredPosts;
|
||||
posts = filterDuplicates(resp.concat(posts));
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
// TODO: Show error in UI
|
||||
console.error('Error loading newest posts', e);
|
||||
.catch((e: Error) => {
|
||||
errorToast('Error loading newest posts: ' + e.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
posts = data.posts;
|
||||
if (posts.length > 0) {
|
||||
oldestBeforeLastFetch = new Date(posts[posts.length - 1].created_at).getTime();
|
||||
}
|
||||
interval = setInterval(refresh, refreshInterval);
|
||||
|
||||
// - If the page is hidden, slow down refresh rate
|
||||
@ -52,43 +119,40 @@ onMount(async () => {
|
||||
|
||||
return () => {
|
||||
if (interval !== null) {
|
||||
clearInterval(interval)
|
||||
clearInterval(interval);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
async function loadOlderPosts() {
|
||||
function loadOlderPosts() {
|
||||
loadingOlderPosts = true;
|
||||
const params = new URLSearchParams();
|
||||
if (data.posts.length > 0) {
|
||||
params.set('before', data.posts[data.posts.length - 1].created_at);
|
||||
const filter: FetchOptions = { count: 20 };
|
||||
if (posts.length > 0) {
|
||||
const before = posts[posts.length - 1].created_at;
|
||||
filter.before = before;
|
||||
oldestBeforeLastFetch = new Date(before).getTime();
|
||||
}
|
||||
await fetch(`/api/posts?${params}`)
|
||||
.then(r => r.json())
|
||||
.then((resp: Post[]) => {
|
||||
|
||||
fetchPosts(filter)
|
||||
.then((resp) => {
|
||||
if (resp.length > 0) {
|
||||
// Append old posts, filter dupes
|
||||
// There shouldn't be any duplicates, but better be safe than sorry
|
||||
const combined = data.posts.concat(resp);
|
||||
const filteredPosts = combined.filter((obj, index, arr) => {
|
||||
return arr.map(mapObj => mapObj.url).indexOf(obj.url) === index;
|
||||
});
|
||||
data.posts = filteredPosts;
|
||||
moreOlderPostsAvailable = true;
|
||||
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 {
|
||||
moreOlderPostsAvailable = false;
|
||||
}
|
||||
loadingOlderPosts = false;
|
||||
})
|
||||
.catch(e => {
|
||||
.catch((e) => {
|
||||
loadingOlderPosts = false;
|
||||
// TODO: Show error in UI
|
||||
console.error('Error loading older posts', e);
|
||||
errorToast('Error loading older posts: ' + e.message);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</title>
|
||||
</svelte:head>
|
||||
@ -96,19 +160,31 @@ async function loadOlderPosts() {
|
||||
<div class="wrapper">
|
||||
<div></div>
|
||||
<div class="posts">
|
||||
{#if data.posts.length === 0}
|
||||
Sorry, no posts recommending music aave been found yet
|
||||
{#if posts.length === 0}
|
||||
Sorry, no posts recommending music have been found yet
|
||||
{/if}
|
||||
{#each data.posts as post (post.url)}
|
||||
<div class="post"><PostComponent {post} /></div>
|
||||
{#each posts as post (post.url)}
|
||||
<div
|
||||
class="post"
|
||||
transition:edgeFly|global={{
|
||||
y: 10,
|
||||
created_at: post.created_at,
|
||||
duration: 300,
|
||||
easing: cubicInOut
|
||||
}}
|
||||
>
|
||||
<PostComponent {post} />
|
||||
</div>
|
||||
{/each}
|
||||
<LoadMoreComponent
|
||||
on:loadOlderPosts={loadOlderPosts}
|
||||
{loadOlderPosts}
|
||||
moreAvailable={moreOlderPostsAvailable}
|
||||
isLoading={loadingOlderPosts}/>
|
||||
isLoading={loadingOlderPosts}
|
||||
/>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.posts {
|
||||
display: flex;
|
||||
@ -117,7 +193,7 @@ async function loadOlderPosts() {
|
||||
}
|
||||
.post {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-width: min(800px, 80vw);
|
||||
margin-bottom: 1em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 1em;
|
||||
@ -129,5 +205,13 @@ async function loadOlderPosts() {
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 650px) {
|
||||
.post {
|
||||
max-width: calc(100vw - 16px);
|
||||
padding: 1em 0;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,9 +1,12 @@
|
||||
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[]
|
||||
posts: (await p.json()) as Post[]
|
||||
};
|
||||
}) satisfies PageLoad;
|
@ -1,5 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET = (async ({ fetch }) => {
|
||||
export const GET = (async ({ fetch, setHeaders }) => {
|
||||
setHeaders({
|
||||
'cache-control': 'max-age=10'
|
||||
});
|
||||
return await fetch('api/posts');
|
||||
}) satisfies RequestHandler;
|
42
src/routes/spotifyAuth/+page.server.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { BASE_URL } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import { SpotifyPlaylistAdder } from '$lib/server/playlist/spotifyPlaylistAdder';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
const { DEV } = import.meta.env;
|
||||
|
||||
const logger = new Logger('SpotifyAuth');
|
||||
|
||||
export const load: PageServerLoad = async ({ url, request }) => {
|
||||
const forwardedHost = request.headers.get('X-Forwarded-Host');
|
||||
let redirect_base;
|
||||
if (DEV) {
|
||||
redirect_base = url.origin;
|
||||
} else if (forwardedHost) {
|
||||
redirect_base = `${url.protocol}//${forwardedHost}`;
|
||||
} else {
|
||||
redirect_base = BASE_URL;
|
||||
}
|
||||
const redirect_uri = new URL(`${redirect_base}${url.pathname}`);
|
||||
|
||||
const adder = new SpotifyPlaylistAdder();
|
||||
if (url.hostname === 'localhost' && DEV) {
|
||||
redirect_uri.hostname = '127.0.0.1';
|
||||
}
|
||||
logger.debug(url.searchParams, url.hostname);
|
||||
if (url.searchParams.has('code')) {
|
||||
await adder.receivedAuthCode(url.searchParams.get('code') || '', redirect_uri);
|
||||
redirect(307, '/');
|
||||
} else if (url.searchParams.has('error')) {
|
||||
logger.error('received error', url.searchParams.get('error'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (await adder.authCodeExists()) {
|
||||
redirect(307, '/');
|
||||
}
|
||||
|
||||
const authUrl = adder.constructAuthUrl(redirect_uri);
|
||||
logger.debug('+page.server.ts', authUrl.toString());
|
||||
redirect(307, authUrl);
|
||||
};
|
1
src/routes/spotifyAuth/+page.svelte
Normal file
@ -0,0 +1 @@
|
||||
<h1>Something went wrong</h1>
|
39
src/routes/tidalAuth/+page.server.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { BASE_URL } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { TidalPlaylistAdder } from '$lib/server/playlist/tidalPlaylistAdder';
|
||||
import { URL } from 'node:url';
|
||||
const { DEV } = import.meta.env;
|
||||
|
||||
const logger = new Logger('TidalAuth');
|
||||
|
||||
export const load: PageServerLoad = async ({ url, request }) => {
|
||||
const forwardedHost = request.headers.get('X-Forwarded-Host');
|
||||
let redirect_base;
|
||||
if (DEV) {
|
||||
redirect_base = url.origin;
|
||||
} else if (forwardedHost) {
|
||||
redirect_base = `${url.protocol}//${forwardedHost}`;
|
||||
} else {
|
||||
redirect_base = BASE_URL;
|
||||
}
|
||||
const redirect_uri = new URL(`${redirect_base}${url.pathname}`);
|
||||
const adder = new TidalPlaylistAdder();
|
||||
logger.debug(url.searchParams, url.hostname, redirect_uri);
|
||||
if (url.searchParams.has('code')) {
|
||||
await adder.receivedAuthCode(url.searchParams.get('code') || '', redirect_uri);
|
||||
redirect(307, '/');
|
||||
} else if (url.searchParams.has('error')) {
|
||||
logger.error('received error', url.searchParams.get('error'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (await adder.authCodeExists()) {
|
||||
redirect(307, '/');
|
||||
}
|
||||
|
||||
const authUrl = adder.constructAuthUrl(redirect_uri);
|
||||
logger.debug('+page.server.ts', authUrl.toString());
|
||||
redirect(307, authUrl);
|
||||
};
|
1
src/routes/tidalAuth/+page.svelte
Normal file
@ -0,0 +1 @@
|
||||
<h1>Something went wrong</h1>
|
39
src/routes/ytauth/+page.server.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { BASE_URL } from '$env/static/private';
|
||||
import { Logger } from '$lib/log';
|
||||
import { YoutubePlaylistAdder } from '$lib/server/playlist/ytPlaylistAdder';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
const { DEV } = import.meta.env;
|
||||
|
||||
const logger = new Logger('YT Auth');
|
||||
|
||||
export const load: PageServerLoad = async ({ url, request }) => {
|
||||
const forwardedHost = request.headers.get('X-Forwarded-Host');
|
||||
let redirect_base;
|
||||
if (DEV) {
|
||||
redirect_base = url.origin;
|
||||
} else if (forwardedHost) {
|
||||
redirect_base = `${url.protocol}//${forwardedHost}`;
|
||||
} else {
|
||||
redirect_base = BASE_URL;
|
||||
}
|
||||
const redirect_uri = new URL(`${redirect_base}${url.pathname}`);
|
||||
|
||||
const adder = new YoutubePlaylistAdder();
|
||||
logger.debug('redirect URL', redirect_uri);
|
||||
if (url.searchParams.has('code')) {
|
||||
logger.debug(url.searchParams);
|
||||
await adder.receivedAuthCode(url.searchParams.get('code') || '', redirect_uri);
|
||||
redirect(307, '/');
|
||||
} else if (url.searchParams.has('error')) {
|
||||
logger.error('received error', url.searchParams.get('error'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (await adder.authCodeExists()) {
|
||||
redirect(307, '/');
|
||||
}
|
||||
const authUrl = adder.constructAuthUrl(redirect_uri);
|
||||
logger.debug('+page.server.ts', authUrl.toString());
|
||||
redirect(307, authUrl);
|
||||
};
|
1
src/routes/ytauth/+page.svelte
Normal file
@ -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 &
|
BIN
static/android-chrome-192x192.png
Normal file
After ![]() (image error) Size: 8.5 KiB |
BIN
static/android-chrome-512x512.png
Normal file
After ![]() (image error) Size: 24 KiB |
BIN
static/apple-touch-icon-120x120-precomposed.png
Normal file
After ![]() (image error) Size: 2.4 KiB |
BIN
static/apple-touch-icon-120x120.png
Normal file
After ![]() (image error) Size: 2.1 KiB |
BIN
static/apple-touch-icon-152x152-precomposed.png
Normal file
After ![]() (image error) Size: 2.9 KiB |
BIN
static/apple-touch-icon-152x152.png
Normal file
After ![]() (image error) Size: 2.5 KiB |
BIN
static/apple-touch-icon-180x180-precomposed.png
Normal file
After ![]() (image error) Size: 5.9 KiB |
BIN
static/apple-touch-icon-180x180.png
Normal file
After ![]() (image error) Size: 2.9 KiB |
BIN
static/apple-touch-icon-60x60-precomposed.png
Normal file
After ![]() (image error) Size: 1.6 KiB |
BIN
static/apple-touch-icon-60x60.png
Normal file
After ![]() (image error) Size: 1.4 KiB |
BIN
static/apple-touch-icon-76x76-precomposed.png
Normal file
After ![]() (image error) Size: 1.8 KiB |
BIN
static/apple-touch-icon-76x76.png
Normal file
After ![]() (image error) Size: 1.6 KiB |
BIN
static/apple-touch-icon-precomposed.png
Normal file
After ![]() (image error) Size: 5.9 KiB |
BIN
static/apple-touch-icon.png
Normal file
After ![]() (image error) Size: 2.9 KiB |
12
static/browserconfig.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square70x70logo src="/mstile-70x70.png"/>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<square310x310logo src="/mstile-310x310.png"/>
|
||||
<wide310x150logo src="/mstile-310x150.png"/>
|
||||
<TileColor>#2e0b78</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
BIN
static/favicon-16x16.png
Normal file
After ![]() (image error) Size: 952 B |
BIN
static/favicon-32x32.png
Normal file
After ![]() (image error) Size: 1.6 KiB |
BIN
static/favicon.ico
Normal file
After Width: 48px | Height: 48px | Size: 15 KiB |
Before ![]() (image error) Size: 1.5 KiB |
BIN
static/mstile-144x144.png
Normal file
After ![]() (image error) Size: 2.9 KiB |
BIN
static/mstile-150x150.png
Normal file
After ![]() (image error) Size: 2.9 KiB |
BIN
static/mstile-310x150.png
Normal file
After ![]() (image error) Size: 3.1 KiB |
BIN
static/mstile-310x310.png
Normal file
After ![]() (image error) Size: 6.1 KiB |
BIN
static/mstile-70x70.png
Normal file
After ![]() (image error) Size: 1.9 KiB |
28
static/safari-pinned-tab.svg
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1945 6201 c-52 -13 -88 -37 -142 -94 -199 -211 -362 -703 -418
|
||||
-1267 -32 -318 -29 -1388 5 -1835 62 -826 246 -1369 576 -1708 181 -186 380
|
||||
-297 669 -372 707 -185 1455 -178 2006 18 l94 33 -4 120 c-1 65 -4 120 -6 122
|
||||
-1 1 -58 -9 -126 -22 -202 -41 -342 -57 -559 -63 -349 -10 -656 22 -857 89
|
||||
-239 79 -412 242 -492 462 -57 160 -79 360 -44 413 37 56 -35 53 1506 53
|
||||
l1424 0 26 24 c14 13 28 39 32 57 3 19 4 908 3 1977 l-3 1944 -33 29 -32 29
|
||||
-1798 -1 c-988 -1 -1810 -4 -1827 -8z m1994 -310 c125 -39 218 -120 274 -239
|
||||
30 -63 32 -74 32 -178 0 -107 -1 -112 -38 -186 -133 -269 -470 -329 -688 -122
|
||||
-176 169 -179 443 -6 616 50 50 140 102 205 117 64 15 157 12 221 -8z m99
|
||||
-1107 c397 -70 754 -371 891 -751 57 -157 74 -267 68 -453 -4 -120 -11 -186
|
||||
-25 -241 -78 -293 -249 -539 -489 -700 -482 -326 -1122 -256 -1524 168 -42 44
|
||||
-94 106 -116 139 -91 133 -166 319 -194 474 -17 96 -17 304 0 400 64 363 301
|
||||
685 628 852 239 123 489 160 761 112z"/>
|
||||
<path d="M3730 4143 c-165 -28 -327 -153 -395 -306 -162 -368 123 -774 523
|
||||
-744 323 24 551 332 477 646 -64 268 -335 449 -605 404z"/>
|
||||
</g>
|
||||
</svg>
|
After (image error) Size: 1.5 KiB |
19
static/site.webmanifest
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Moshing Mammut",
|
||||
"short_name": "Moshing Mammut",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#2e0b78",
|
||||
"background_color": "#2e0b78",
|
||||
"display": "standalone"
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
body {
|
||||
--color-text: #2F0C7A;
|
||||
--color-bg: white;
|
||||
--color-border: #17063B;
|
||||
--color-link: #563ACC;
|
||||
--color-link-visited: #858AFA;
|
||||
|
||||
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: white;
|
||||
--color-bg: #17063B;
|
||||
--color-border: white;
|
||||
--color-link: #8A9BF0;
|
||||
--color-link-visited: #C384FB;
|
||||
}
|
||||
}
|
@ -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 = {
|
||||
@ -12,7 +12,17 @@ const config = {
|
||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
version: {
|
||||
name: process.env.npm_package_version
|
||||
},
|
||||
csp: {
|
||||
directives: {
|
||||
'script-src': ['self', 'unsafe-inline'],
|
||||
'base-uri': ['self'],
|
||||
'object-src': ['none']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
@ -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'
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|