23 Commits
v2.0.1 ... main

Author SHA1 Message Date
d99e840e8d append to end of playlist for tidal 2025-07-16 13:21:42 +02:00
09efe80ee6 improve error handling 2025-07-16 13:20:42 +02:00
67249a10bb Add albums to spotify playlist 2025-07-16 13:20:16 +02:00
bad6072d70 Add album to playlist for youtube 2025-07-15 14:23:36 +02:00
a9178b340a tidal: chunk playlist adding, fix next-paging handling 2025-07-15 14:22:45 +02:00
f309cd87d1 only push to websub hub in prod mode 2025-07-15 14:20:49 +02:00
611563fe5b adjust log levels 2025-07-15 14:20:28 +02:00
b9c098cde3 improve error handling for youtube requests, skip video category check if unnecessary 2025-07-15 10:42:49 +02:00
2308356c1b implement tidal’s (new?) retry-after header for rate-limiting 2025-07-14 14:36:07 +02:00
4c3689016f Add whole albums to playlist for tidal 2025-07-14 14:24:21 +02:00
68a139f287 Make DB log verbose 2025-07-14 11:00:41 +02:00
53ee5fabbe Fix #45 implement crude checking if a song already exists in tidal 2025-07-14 10:59:26 +02:00
df35c48e8c wait before retrying tidal requests 2025-07-11 14:37:57 +02:00
44fc2bb621 Fix #46 add playlists and version to footer 2025-07-10 13:38:41 +02:00
7cdfa00af5 switch DEBUG_LOG to string handling 2025-07-08 21:09:07 +02:00
35572a48e7 Fix #44, additional minor enhncements 2025-07-08 20:48:22 +02:00
3186f375e1 fix #41 improve token expiry 2025-07-08 15:36:32 +02:00
3c1b7dba0e ignoring tidal auth token 2025-07-08 14:44:11 +02:00
5591070979 finalize ignore feature 2025-07-08 14:38:11 +02:00
38e8b4c2ba prepare for being available on multiple domain names 2025-07-06 18:43:36 +02:00
260cef7b73 improve unit file 2025-07-06 18:42:32 +02:00
c57f9ec3ea refactor debug logging, add debug info for YT authorized tokens 2025-07-04 15:58:00 +02:00
6874804703 update eslint config 2025-07-04 14:06:06 +02:00
37 changed files with 1362 additions and 369 deletions

View File

@ -8,6 +8,7 @@ MASTODON_INSTANCE = 'metalhead.club'
MASTODON_ACCESS_TOKEN = 'YOUR_ACCESS_TOKEN_HERE' MASTODON_ACCESS_TOKEN = 'YOUR_ACCESS_TOKEN_HERE'
BASE_URL = 'https://moshingmammut.phlaym.net' BASE_URL = 'https://moshingmammut.phlaym.net'
VERBOSE = false VERBOSE = false
DEBUG_LOG = false
IGNORE_USERS = @moshhead@metalhead.club IGNORE_USERS = @moshhead@metalhead.club
WEBSUB_HUB = 'http://pubsubhubbub.superfeedr.com' WEBSUB_HUB = 'http://pubsubhubbub.superfeedr.com'

29
.eslintrc.cjs Normal file
View File

@ -0,0 +1,29 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['plugin:svelte/recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
}
};

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
moshing-mammut.pid
yt_auth_token yt_auth_token
spotify_auth_token spotify_auth_token
tidal_auth_token
*.db *.db
feed.xml feed.xml
playbook.yml playbook.yml

View File

@ -1,6 +1,5 @@
{ {
"apexskier.eslint.config.eslintConfigPath" : ".eslintrc.cjs", "apexskier.eslint.config.eslintConfigPath" : ".eslint.cjs",
"apexskier.eslint.config.eslintPath" : "node_modules\/@eslint\/eslintrc\/dist\/eslintrc.cjs",
"apexskier.eslint.config.fixOnSave" : "Enable", "apexskier.eslint.config.fixOnSave" : "Enable",
"apexskier.typescript.config.formatDocumentOnSave" : "false", "apexskier.typescript.config.formatDocumentOnSave" : "false",
"apexskier.typescript.config.isEnabledForJavascript" : "Enable", "apexskier.typescript.config.isEnabledForJavascript" : "Enable",

View File

@ -151,3 +151,5 @@ Other icons:
- [error-warning-fill by remix icon](https://remixicon.com/icon/error-warning-fill) - [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) - [git-branch-fill by remix icon](https://remixicon.com/icon/git-branch-fill)
- [rss-fill by remix icon](https://remixicon.com/icon/rss-line) - [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)

View File

@ -1,14 +1,17 @@
[Unit] [Unit]
Description=Moshing Mammut Description=Moshing Mammut
After=network.target
[Service] [Service]
ExecStart=/home/moshing-mammut/app/start.sh ExecStart=/home/moshing-mammut/app/start.sh
Restart=always Restart=on-failure
User=moshing-mammut User=moshing-mammut
Group=moshing-mammut Group=moshing-mammut
Environment=PATH=/usr/bin:/usr/local/bin Environment=PATH=/usr/bin:/usr/local/bin
Environment=NODE_ENV=production Environment=NODE_ENV=production
WorkingDirectory=/home/moshing-mammut/app WorkingDirectory=/home/moshing-mammut/app
Type=forking
PIDFile=/home/moshing-mammut/app/moshing-mammut.pid
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

457
package-lock.json generated
View File

@ -27,7 +27,7 @@
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"@zerodevx/svelte-toast": "^0.9.3", "@zerodevx/svelte-toast": "^0.9.3",
"eslint": "^9.11.1", "eslint": "^9.30.1",
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
"globals": "^16.3.0", "globals": "^16.3.0",
@ -36,8 +36,7 @@
"svelte": "^5", "svelte": "^5",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tslib": "^2.0.0", "tslib": "^2.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0"
"vite": "^6.0.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@ -80,6 +79,7 @@
"os": [ "os": [
"aix" "aix"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -97,6 +97,7 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -114,6 +115,7 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -131,6 +133,7 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -148,6 +151,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -165,6 +169,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -182,6 +187,7 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -199,6 +205,7 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -216,6 +223,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -233,6 +241,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -250,6 +259,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -267,6 +277,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -284,6 +295,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -301,6 +313,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -318,6 +331,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -335,6 +349,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -352,6 +367,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -369,6 +385,7 @@
"os": [ "os": [
"netbsd" "netbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -386,6 +403,7 @@
"os": [ "os": [
"netbsd" "netbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -403,6 +421,7 @@
"os": [ "os": [
"openbsd" "openbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -420,6 +439,7 @@
"os": [ "os": [
"openbsd" "openbsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -437,6 +457,7 @@
"os": [ "os": [
"sunos" "sunos"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -454,6 +475,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -471,6 +493,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -488,6 +511,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -536,30 +560,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/config-helpers": { "node_modules/@eslint/config-helpers": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
@ -607,17 +607,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/eslintrc/node_modules/globals": { "node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@ -631,29 +620,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.30.1", "version": "9.30.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz",
@ -1174,9 +1140,9 @@
} }
}, },
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.11", "version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.11.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-C512c1ytBTio4MrpWKlJpyFHT6+qfFL8SZ58zBzJ1OOzUEjHeF1BtjY2fH7n4x/g2OV/KiiMLAivOp1DXmiMMw==", "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1195,16 +1161,16 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.3", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.3.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
"integrity": "sha512-AiR5uKpFxP3PjO4R19kQGIMwxyRyPuXmKEEy301V1C0+1rVjS94EZQXf1QKZYN8Q0YM+estSPhmx5JwNftv6nw==", "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.28", "version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.28.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-KNNHHwW3EIp4EDYOvYFGyIFfx36R2dNJYH4knnZlF8T5jdbD5Wx8xmSaQ2gP9URkJ04LGEtlcCtwArKcmFcwKw==", "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1380,9 +1346,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz",
"integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==", "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1394,9 +1360,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz",
"integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==", "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1408,9 +1374,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz",
"integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==", "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1422,9 +1388,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz",
"integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==", "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1436,9 +1402,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz",
"integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==", "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1450,9 +1416,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz",
"integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==", "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1464,9 +1430,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz",
"integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==", "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1478,9 +1444,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz",
"integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==", "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1492,9 +1458,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz",
"integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==", "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1506,9 +1472,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz",
"integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==", "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1520,9 +1486,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loongarch64-gnu": { "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz",
"integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==", "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -1534,9 +1500,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz",
"integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==", "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1548,9 +1514,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz",
"integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==", "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1562,9 +1528,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz",
"integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==", "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1576,9 +1542,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz",
"integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==", "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1590,9 +1556,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz",
"integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1604,9 +1570,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz",
"integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1618,9 +1584,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz",
"integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==", "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1632,9 +1598,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz",
"integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==", "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1646,9 +1612,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz",
"integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==", "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1790,9 +1756,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.15.34", "version": "22.16.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.34.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.0.tgz",
"integrity": "sha512-8Y6E5WUupYy1Dd0II32BsWAx5MWdcnRd8L84Oys3veg1YrYtNtzgO4CFhiBg6MDSjk7Ay36HYOnU7/tuOzIzcw==", "integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1856,6 +1822,16 @@
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <5.9.0"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.35.1", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz",
@ -2005,6 +1981,32 @@
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <5.9.0"
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.35.1", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz",
@ -2280,13 +2282,14 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0",
"concat-map": "0.0.1"
} }
}, },
"node_modules/braces": { "node_modules/braces": {
@ -2621,9 +2624,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.0.0", "version": "17.0.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.1.tgz",
"integrity": "sha512-A0BJ5lrpJVSfnMMXjmeO0xUnoxqsBHWCoqqTnGwGYVdnctqXXUEhJOO7LxmgxJon9tEZFGpe0xPRX0h2v3AANQ==", "integrity": "sha512-GLjkduuAL7IMJg/ZnOPm9AnWKJ82mSE2tzXLaJ/6hD6DhwGfZaXG77oB8qbReyiczNxnbxQKyh0OE5mXq0bAHA==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -2682,6 +2685,7 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@ -2730,9 +2734,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.30.0", "version": "9.30.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz",
"integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==", "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2742,7 +2746,7 @@
"@eslint/config-helpers": "^0.3.0", "@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.14.0", "@eslint/core": "^0.14.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.30.0", "@eslint/js": "9.30.1",
"@eslint/plugin-kit": "^0.3.1", "@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
@ -2870,30 +2874,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/@eslint/js": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz",
"integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": { "node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
@ -2907,29 +2887,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/esm-env": { "node_modules/esm-env": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@ -2982,9 +2939,9 @@
} }
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "1.4.9", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.9.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz",
"integrity": "sha512-3OMlcd0a03UGuZpPeUC1HxR3nA23l+HEyCiZw3b3FumJIN9KphoGzDJKMXI1S72jVS1dsenDyQC0kJlO1U9E1g==", "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3311,30 +3268,6 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"optional": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/globals": { "node_modules/globals": {
"version": "16.3.0", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
@ -3472,9 +3405,9 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/ignore": { "node_modules/ignore": {
"version": "7.0.5", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -3882,19 +3815,16 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.5", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^1.1.7"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/minimist": { "node_modules/minimist": {
@ -4693,9 +4623,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.44.1", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz",
"integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -4709,26 +4639,26 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.44.1", "@rollup/rollup-android-arm-eabi": "4.44.2",
"@rollup/rollup-android-arm64": "4.44.1", "@rollup/rollup-android-arm64": "4.44.2",
"@rollup/rollup-darwin-arm64": "4.44.1", "@rollup/rollup-darwin-arm64": "4.44.2",
"@rollup/rollup-darwin-x64": "4.44.1", "@rollup/rollup-darwin-x64": "4.44.2",
"@rollup/rollup-freebsd-arm64": "4.44.1", "@rollup/rollup-freebsd-arm64": "4.44.2",
"@rollup/rollup-freebsd-x64": "4.44.1", "@rollup/rollup-freebsd-x64": "4.44.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.44.1", "@rollup/rollup-linux-arm-gnueabihf": "4.44.2",
"@rollup/rollup-linux-arm-musleabihf": "4.44.1", "@rollup/rollup-linux-arm-musleabihf": "4.44.2",
"@rollup/rollup-linux-arm64-gnu": "4.44.1", "@rollup/rollup-linux-arm64-gnu": "4.44.2",
"@rollup/rollup-linux-arm64-musl": "4.44.1", "@rollup/rollup-linux-arm64-musl": "4.44.2",
"@rollup/rollup-linux-loongarch64-gnu": "4.44.1", "@rollup/rollup-linux-loongarch64-gnu": "4.44.2",
"@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2",
"@rollup/rollup-linux-riscv64-gnu": "4.44.1", "@rollup/rollup-linux-riscv64-gnu": "4.44.2",
"@rollup/rollup-linux-riscv64-musl": "4.44.1", "@rollup/rollup-linux-riscv64-musl": "4.44.2",
"@rollup/rollup-linux-s390x-gnu": "4.44.1", "@rollup/rollup-linux-s390x-gnu": "4.44.2",
"@rollup/rollup-linux-x64-gnu": "4.44.1", "@rollup/rollup-linux-x64-gnu": "4.44.2",
"@rollup/rollup-linux-x64-musl": "4.44.1", "@rollup/rollup-linux-x64-musl": "4.44.2",
"@rollup/rollup-win32-arm64-msvc": "4.44.1", "@rollup/rollup-win32-arm64-msvc": "4.44.2",
"@rollup/rollup-win32-ia32-msvc": "4.44.1", "@rollup/rollup-win32-ia32-msvc": "4.44.2",
"@rollup/rollup-win32-x64-msvc": "4.44.1", "@rollup/rollup-win32-x64-msvc": "4.44.2",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -5140,9 +5070,9 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.34.9", "version": "5.35.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.9.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.35.2.tgz",
"integrity": "sha512-sld35zFpooaSRSj4qw8Vl/cyyK0/sLQq9qhJ7BGZo/Kd0ggYtEnvNYLlzhhoqYsYQzA0hJqkzt3RBO/8KoTZOg==", "integrity": "sha512-uW/rRXYrhZ7Dh4UQNZ0t+oVGL1dEM+95GavCO8afAk1IY2cPq9BcZv9C3um5aLIya2y8lIeLPxLII9ASGg9Dzw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5155,7 +5085,7 @@
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^1.4.8", "esrap": "^2.1.0",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",
@ -5307,6 +5237,7 @@
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fdir": "^6.4.4", "fdir": "^6.4.4",
"picomatch": "^4.0.2" "picomatch": "^4.0.2"
@ -5449,6 +5380,7 @@
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@ -5519,14 +5451,15 @@
} }
}, },
"node_modules/vitefu": { "node_modules/vitefu": {
"version": "1.0.7", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.7.tgz", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
"integrity": "sha512-eRWXLBbJjW3X5z5P5IHcSm2yYbYRPb2kQuc+oqsbAl99WB5kVsPbiiox+cymo8twTzifA6itvhr2CmjnaZZp0Q==", "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"tests/deps/*", "tests/deps/*",
"tests/projects/*" "tests/projects/*",
"tests/projects/workspace/packages/*"
], ],
"peerDependencies": { "peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"

View File

@ -25,7 +25,7 @@
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"@zerodevx/svelte-toast": "^0.9.3", "@zerodevx/svelte-toast": "^0.9.3",
"eslint": "^9.11.1", "eslint": "^9.30.1",
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
"globals": "^16.3.0", "globals": "^16.3.0",
@ -34,8 +34,7 @@
"svelte": "^5", "svelte": "^5",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tslib": "^2.0.0", "tslib": "^2.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0"
"vite": "^6.0.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {

View File

@ -3,15 +3,33 @@ import { TimelineReader } from '$lib/server/timeline';
import type { Handle, HandleServerError } from '@sveltejs/kit'; import type { Handle, HandleServerError } from '@sveltejs/kit';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { close } from '$lib/server/db';
import { version } from '$app/environment';
const logger = new Logger('App'); const logger = new Logger('App');
logger.log('App startup'); 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(); TimelineReader.init();
export const handleError = (({ error }) => { process.on('sveltekit:shutdown', (reason) => {
if (error instanceof Error) { close();
logger.error('Something went wrong: ', error.name, error.message); 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 { return {
@ -20,12 +38,6 @@ export const handleError = (({ error }) => {
}) satisfies HandleServerError; }) satisfies HandleServerError;
export const handle = (async ({ event, resolve }) => { export const handle = (async ({ event, resolve }) => {
const searchParams = event.url.searchParams;
const authCode = searchParams.get('code');
if (authCode) {
logger.debug('received GET hook', event.url.searchParams);
}
// Reeder *insists* on checking /feed instead of /feed.xml // Reeder *insists* on checking /feed instead of /feed.xml
if (event.url.pathname === '/feed') { if (event.url.pathname === '/feed') {
return new Response('', { status: 301, headers: { Location: '/feed.xml' } }); return new Response('', { status: 301, headers: { Location: '/feed.xml' } });

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12.001 2C6.50098 2 2.00098 6.5 2.00098 12C2.00098 17.5 6.50098 22 12.001 22C17.501 22 22.001 17.5 22.001 12C22.001 6.5 17.551 2 12.001 2ZM15.751 16.65C13.401 15.2 10.451 14.8992 6.95014 15.6992C6.60181 15.8008 6.30098 15.55 6.20098 15.25C6.10098 14.8992 6.35098 14.6 6.65098 14.5C10.451 13.6492 13.751 14 16.351 15.6C16.701 15.75 16.7501 16.1492 16.6018 16.45C16.4018 16.7492 16.0518 16.85 15.751 16.65ZM16.7501 13.95C14.051 12.3 9.95098 11.8 6.80098 12.8C6.40181 12.9 5.95098 12.7 5.85098 12.3C5.75098 11.9 5.95098 11.4492 6.35098 11.3492C10.001 10.25 14.501 10.8008 17.601 12.7C17.9018 12.8508 18.051 13.35 17.8018 13.7C17.551 14.05 17.101 14.2 16.7501 13.95ZM6.30098 9.75083C5.80098 9.9 5.30098 9.6 5.15098 9.15C5.00098 8.64917 5.30098 8.15 5.75098 7.99917C9.30098 6.94917 15.151 7.14917 18.8518 9.35C19.301 9.6 19.451 10.2 19.201 10.65C18.9518 11.0008 18.351 11.1492 17.9018 10.9C14.701 9 9.35098 8.8 6.30098 9.75083Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

19
src/lib/assets/tidal.svg Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.98352,0,0,1,0.395532,0)">
<rect x="-0.402" y="0" width="24.402" height="24" style="fill-opacity:0;"/>
</g>
<g transform="matrix(0.755537,0,0,0.755537,0,0.100656)">
<path d="M21.177,5.705L15.883,10.999L10.589,5.705L15.883,0.412L21.177,5.705Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.755537,0,0,0.755537,0,0.100656)">
<path d="M21.177,16.294L15.883,21.588L10.589,16.294L15.883,10.999L21.177,16.294Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.755537,0,0,0.755537,0,0.100656)">
<path d="M10.589,5.705L5.294,11L0,5.705L5.294,0.412L10.589,5.705Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.755537,0,0,0.755537,0,0.100656)">
<path d="M31.766,5.705L26.472,11L21.177,5.705L26.472,0.412L31.766,5.705Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12.2439 4C12.778 4.00294 14.1143 4.01586 15.5341 4.07273L16.0375 4.09468C17.467 4.16236 18.8953 4.27798 19.6037 4.4755C20.5486 4.74095 21.2913 5.5155 21.5423 6.49732C21.942 8.05641 21.992 11.0994 21.9982 11.8358L21.9991 11.9884L21.9991 11.9991C21.9991 11.9991 21.9991 12.0028 21.9991 12.0099L21.9982 12.1625C21.992 12.8989 21.942 15.9419 21.5423 17.501C21.2878 18.4864 20.5451 19.261 19.6037 19.5228C18.8953 19.7203 17.467 19.8359 16.0375 19.9036L15.5341 19.9255C14.1143 19.9824 12.778 19.9953 12.2439 19.9983L12.0095 19.9991L11.9991 19.9991C11.9991 19.9991 11.9956 19.9991 11.9887 19.9991L11.7545 19.9983C10.6241 19.9921 5.89772 19.941 4.39451 19.5228C3.4496 19.2573 2.70692 18.4828 2.45587 17.501C2.0562 15.9419 2.00624 12.8989 2 12.1625V11.8358C2.00624 11.0994 2.0562 8.05641 2.45587 6.49732C2.7104 5.51186 3.45308 4.73732 4.39451 4.4755C5.89772 4.05723 10.6241 4.00622 11.7545 4H12.2439ZM9.99911 8.49914V15.4991L15.9991 11.9991L9.99911 8.49914Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,6 +1,10 @@
<script> <script>
import git from '$lib/assets/git-branch-fill.svg'; import git from '$lib/assets/git-branch-fill.svg';
import rss from '$lib/assets/rss-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> </script>
<div class="footer"> <div class="footer">
@ -16,7 +20,7 @@
<div> <div>
<a href="https://phlaym.net/git/phlaym/moshing-mammut"> <a href="https://phlaym.net/git/phlaym/moshing-mammut">
<img alt="Git branch" src={git} class="icon" /> <img alt="Git branch" src={git} class="icon" />
<span class="label">Source Code</span> <span class="label"><span class="feedSuffix">Source Code&nbsp;</span>v{version}</span>
</a> </a>
</div> </div>
| |
@ -26,6 +30,30 @@
<span class="label">RSS<span class="feedSuffix">&nbsp;Feed</span></span> <span class="label">RSS<span class="feedSuffix">&nbsp;Feed</span></span>
</a> </a>
</div> </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> </div>
<style> <style>

View File

@ -131,11 +131,10 @@
</picture> </picture>
<a href={song.pageUrl ?? song.postedUrl} target="_blank"> <a href={song.pageUrl ?? song.postedUrl} target="_blank">
<div class="info"> <div class="info">
<picture> <picture class="cover">
{@html getSourceSetHtml(song)} {@html getSourceSetHtml(song)}
<img <img
src={song.thumbnailUrl} src={song.thumbnailUrl}
class="cover"
alt="Cover for {song.artistName} - {song.title}" alt="Cover for {song.artistName} - {song.title}"
loading="lazy" loading="lazy"
width={song.thumbnailWidth} width={song.thumbnailWidth}

View File

@ -1,8 +1,10 @@
import { DEBUG_LOG } from '$env/static/private';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { isTruthy } from '$lib/truthyString'; import { isTruthy } from '$lib/truthyString';
const { DEV } = import.meta.env; const { DEV } = import.meta.env;
export const enableVerboseLog = isTruthy(env.VERBOSE); export const enableVerboseLog = isTruthy(env.VERBOSE);
export const debugLogEnv = isTruthy(DEBUG_LOG);
/** /**
* @deprecated Use the new {@link Logger} class instead. * @deprecated Use the new {@link Logger} class instead.
@ -41,31 +43,31 @@ export class Logger {
public constructor(private name: string) {} public constructor(private name: string) {}
public static isDebugEnabled(): boolean { public static isDebugEnabled(): boolean {
return DEV || enableVerboseLog; return debugLogEnv || DEV || enableVerboseLog;
} }
public verbose(...params: any[]) { public verbose(...params: any[]) {
if (!enableVerboseLog) { if (!enableVerboseLog) {
return; return;
} }
console.debug(new Date().toISOString(), `- ${this.name} -`, ...params); console.debug(new Date().toISOString(), '- [VRBSE]', `- ${this.name} -`, ...params);
} }
public debug(...params: any[]) { public debug(...params: any[]) {
if (false && !Logger.isDebugEnabled()) { if (!Logger.isDebugEnabled()) {
return; return;
} }
console.debug(new Date().toISOString(), `- ${this.name} -`, ...params); console.debug(new Date().toISOString(), '- [DEBUG]', `- ${this.name} -`, ...params);
} }
public log(...params: any[]) { public log(...params: any[]) {
console.log(new Date().toISOString(), `- ${this.name} -`, ...params); console.log(new Date().toISOString(), '- [ LOG ]', `- ${this.name} -`, ...params);
} }
public info(...params: any[]) { public info(...params: any[]) {
console.info(new Date().toISOString(), `- ${this.name} -`, ...params); console.info(new Date().toISOString(), '- [INFO ]', `- ${this.name} -`, ...params);
} }
public warn(...params: any[]) { public warn(...params: any[]) {
console.warn(new Date().toISOString(), `- ${this.name} -`, ...params); console.warn(new Date().toISOString(), '- [WARN ]', `- ${this.name} -`, ...params);
} }
public error(...params: any[]) { public error(...params: any[]) {
console.error(new Date().toISOString(), `- ${this.name} -`, ...params); console.error(new Date().toISOString(), '- [ERROR]', `- ${this.name} -`, ...params);
} }
public static error(...params: any[]) { public static error(...params: any[]) {

View File

@ -5,6 +5,7 @@ export type SongInfo = {
youtubeUrl?: string; youtubeUrl?: string;
spotifyUrl?: string; spotifyUrl?: string;
spotifyUri?: string; spotifyUri?: string;
tidalUri?: string;
type: 'song' | 'album'; type: 'song' | 'album';
title?: string; title?: string;
artistName?: string; artistName?: string;

View File

@ -46,6 +46,7 @@ type SongRow = {
thumbnailUrl?: string; thumbnailUrl?: string;
thumbnailWidth?: number; thumbnailWidth?: number;
thumbnailHeight?: number; thumbnailHeight?: number;
tidalId: string;
}; };
type AccountAvatarRow = { type AccountAvatarRow = {
@ -68,6 +69,15 @@ type Migration = {
}; };
const db: sqlite3.Database = new sqlite3.Database('moshingmammut.db'); const db: sqlite3.Database = new sqlite3.Database('moshingmammut.db');
export function close() {
try {
db.close();
} catch (e) {
logger.error('Could not close DB');
}
}
// for the local masto instance, the instance name is *not* saved // for the local masto instance, the instance name is *not* saved
// as part of the username or acct, so it needs to be stripped // as part of the username or acct, so it needs to be stripped
const ignoredUsers: string[] = const ignoredUsers: string[] =
@ -324,6 +334,12 @@ function getMigrations(): Migration[] {
statement: ` statement: `
ALTER TABLE songs ADD COLUMN spotifyUrl TEXT NULL; ALTER TABLE songs ADD COLUMN spotifyUrl TEXT NULL;
ALTER TABLE songs ADD COLUMN spotifyUri TEXT NULL;` ALTER TABLE songs ADD COLUMN spotifyUri TEXT NULL;`
},
{
id: 9,
name: 'song tidal id',
statement: `
ALTER TABLE songs ADD COLUMN tidalId TEXT NULL;`
} }
]; ];
} }
@ -455,8 +471,9 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
for (const song of songs) { for (const song of songs) {
db.run( db.run(
` `
INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, spotifyUrl, spotifyUri, title, artistName, thumbnailUrl, post_url, thumbnailWidth, thumbnailHeight) INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, spotifyUrl, spotifyUri, tidalId,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) title, artistName, thumbnailUrl, post_url, thumbnailWidth, thumbnailHeight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[ [
song.postedUrl, song.postedUrl,
@ -465,6 +482,7 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
song.youtubeUrl, song.youtubeUrl,
song.spotifyUrl, song.spotifyUrl,
song.spotifyUri, song.spotifyUri,
song.tidalUri,
song.title, song.title,
song.artistName, song.artistName,
song.thumbnailUrl, song.thumbnailUrl,
@ -565,7 +583,7 @@ function getSongData(postIdsParams: string, postIds: string[]): Promise<Map<stri
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.all( db.all(
`SELECT post_url, songs.postedUrl, songs.overviewUrl, songs.type, songs.youtubeUrl, songs.spotifyUri, songs.spotifyUri, `SELECT post_url, songs.postedUrl, songs.overviewUrl, songs.type, songs.youtubeUrl, songs.spotifyUri, songs.spotifyUri,
songs.title, songs.artistName, songs.thumbnailUrl, songs.post_url, songs.thumbnailWidth, songs.thumbnailHeight songs.tidalId, songs.title, songs.artistName, songs.thumbnailUrl, songs.post_url, songs.thumbnailWidth, songs.thumbnailHeight
FROM songs FROM songs
WHERE post_url IN (${postIdsParams});`, WHERE post_url IN (${postIdsParams});`,
postIds, postIds,
@ -582,6 +600,7 @@ function getSongData(postIdsParams: string, postIds: string[]): Promise<Map<stri
youtubeUrl: item.youtubeUrl, youtubeUrl: item.youtubeUrl,
spotifyUrl: item.spotifyUrl, spotifyUrl: item.spotifyUrl,
spotifyUri: item.spotifyUri, spotifyUri: item.spotifyUri,
tidalUri: item.tidalId,
type: item.type, type: item.type,
title: item.title, title: item.title,
artistName: item.artistName, artistName: item.artistName,
@ -674,6 +693,38 @@ function getSongThumbnailData(
}); });
} }
export async function doesTidalSongExist(song: SongInfo): Promise<boolean> {
if (!databaseReady) {
await waitReady();
}
if (!song.tidalUri) {
return false;
}
const sql = `SELECT songs.title, songs.artistName, songs.tidalId
FROM songs
WHERE songs.tidalId = $tidalId
LIMIT $limit`;
// If only one exists: This is the one that has just been added
// If more exits: It has been added before
const params = {
$tidalId: song.tidalUri,
$limit: 2
};
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err != null) {
logger.error('Error loading songs', err);
reject(err);
return;
}
logger.verbose('doesTidalSongExist', song.tidalUri, rows, rows.length > 1);
resolve(rows.length > 1);
});
});
}
export async function getPosts( export async function getPosts(
since: string | null, since: string | null,
before: string | null, before: string | null,

View File

@ -1,3 +1,4 @@
import { BASE_URL } from '$env/static/private';
import { Logger } from '$lib/log'; import { Logger } from '$lib/log';
import type { OauthResponse } from '$lib/mastodon/response'; import type { OauthResponse } from '$lib/mastodon/response';
import fs from 'fs/promises'; import fs from 'fs/promises';
@ -6,6 +7,7 @@ export abstract class OauthPlaylistAdder {
/// How many minutes before expiry the token will be refreshed /// How many minutes before expiry the token will be refreshed
protected refresh_time: number = 15; protected refresh_time: number = 15;
protected logger: Logger = new Logger('OauthPlaylistAdder'); protected logger: Logger = new Logger('OauthPlaylistAdder');
protected redirectUri?: URL;
protected constructor( protected constructor(
protected apiBase: string, protected apiBase: string,
@ -22,6 +24,12 @@ export abstract class OauthPlaylistAdder {
} }
} }
protected getRedirectUri(suffix: string): URL {
const uri = this.redirectUri ?? new URL(`${BASE_URL}/${suffix}`);
this.logger.debug('getRedirectUri', uri);
return uri;
}
protected constructAuthUrlInternal( protected constructAuthUrlInternal(
endpointUrl: string, endpointUrl: string,
clientId: string, clientId: string,
@ -46,7 +54,8 @@ export abstract class OauthPlaylistAdder {
code: string, code: string,
redirectUri: URL, redirectUri: URL,
client_secret?: string, client_secret?: string,
customHeader?: HeadersInit customHeader?: HeadersInit,
code_verifier?: string
) { ) {
this.logger.debug('received code'); this.logger.debug('received code');
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -57,6 +66,9 @@ export abstract class OauthPlaylistAdder {
if (client_secret) { if (client_secret) {
params.append('client_secret', client_secret); params.append('client_secret', client_secret);
} }
if (code_verifier) {
params.append('code_verifier', code_verifier);
}
this.logger.debug('sending token req', params); this.logger.debug('sending token req', params);
const resp: OauthResponse = await fetch(tokenUrl, { const resp: OauthResponse = await fetch(tokenUrl, {
method: 'POST', method: 'POST',
@ -66,7 +78,18 @@ export abstract class OauthPlaylistAdder {
this.logger.debug('received access token', resp); this.logger.debug('received access token', resp);
let expiration = new Date(); let expiration = new Date();
expiration.setTime(expiration.getTime() + resp.expires_in * 1000); expiration.setTime(expiration.getTime() + resp.expires_in * 1000);
expiration.setSeconds(expiration.getSeconds() + resp.expires_in); //expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
let now = new Date();
this.logger.debug(
'now is',
now,
now.getTime(),
'token expires in',
resp.expires_in,
'that would be',
expiration,
expiration.getTime()
);
resp.expires = expiration; resp.expires = expiration;
await fs.writeFile(this.token_file_name, JSON.stringify(resp)); await fs.writeFile(this.token_file_name, JSON.stringify(resp));
} }
@ -90,12 +113,22 @@ export abstract class OauthPlaylistAdder {
protected async shouldRefreshToken(): Promise<{ token: OauthResponse; refresh: boolean } | null> { protected async shouldRefreshToken(): Promise<{ token: OauthResponse; refresh: boolean } | null> {
const token = await this.auth(); const token = await this.auth();
if (token == null || !token?.expires) { if (token == null || !token?.expires) {
this.logger.warn('Cannot check if token should be refreshed. Token expiry is unreadablle');
return null; return null;
} }
let refreshAt = new Date(); 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); refreshAt.setTime(refreshAt.getTime() - this.refresh_time * 60 * 1000);
this.logger.info('token expiry', token.expires, 'vs refresh @', refreshAt);
if (token.expires.getTime() > refreshAt.getTime()) { this.logger.info('refresh @', refreshAt, 'token expires', token.expires);
if (refreshAt.getTime() > new Date().getTime()) {
return { return {
token: token, token: token,
refresh: false refresh: false
@ -106,7 +139,7 @@ export abstract class OauthPlaylistAdder {
'Token expires', 'Token expires',
token.expires, token.expires,
token.expires.getTime(), token.expires.getTime(),
`which is after the refresh time`, `so it should be refreshed before or at`,
refreshAt, refreshAt,
refreshAt.getTime() refreshAt.getTime()
); );
@ -123,7 +156,27 @@ export abstract class OauthPlaylistAdder {
redirect_uri?: string, redirect_uri?: string,
client_secret?: string, client_secret?: string,
customHeader?: HeadersInit 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(); const params = new URLSearchParams();
params.append('client_id', clientId); params.append('client_id', clientId);
params.append('grant_type', 'refresh_token'); params.append('grant_type', 'refresh_token');
@ -135,24 +188,43 @@ export abstract class OauthPlaylistAdder {
params.append('redirect_uri', redirect_uri); params.append('redirect_uri', redirect_uri);
} }
this.logger.debug('sending token req', params); this.logger.debug('sending token req', params);
const resp: OauthResponse = await fetch(tokenUrl, { const response = await fetch(tokenUrl, {
method: 'POST', method: 'POST',
body: params, body: params,
headers: customHeader headers: customHeader
}).then((r) => r.json()); });
const resp: OauthResponse = await response.json();
this.logger.verbose('received access token', resp); this.logger.verbose('received access token', resp);
if (resp.error) { if (resp.error) {
this.logger.error('token resp error', resp); this.logger.error('token resp error', resp);
return null; return {
resp: null,
headers: response.headers
};
} }
if (!resp.refresh_token) { if (!resp.refresh_token) {
resp.refresh_token = refresh_token; resp.refresh_token = refresh_token;
} }
let expiration = new Date(); let expiration = new Date();
expiration.setTime(expiration.getTime() + resp.expires_in * 1000); expiration.setTime(expiration.getTime() + resp.expires_in * 1000);
expiration.setSeconds(expiration.getSeconds() + resp.expires_in); //expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
resp.expires = expiration; resp.expires = expiration;
let now = new Date();
this.logger.debug(
'now is',
now,
now.getTime(),
'token expires in',
resp.expires_in,
'that would be',
expiration,
expiration.getTime()
);
await fs.writeFile(this.token_file_name, JSON.stringify(resp)); await fs.writeFile(this.token_file_name, JSON.stringify(resp));
return resp; return {
resp: resp,
headers: response.headers
};
} }
} }

View File

@ -4,6 +4,7 @@ import type { OauthResponse } from '$lib/mastodon/response';
import type { SongInfo } from '$lib/odesliResponse'; import type { SongInfo } from '$lib/odesliResponse';
import { OauthPlaylistAdder } from './oauthPlaylistAdder'; import { OauthPlaylistAdder } from './oauthPlaylistAdder';
import type { PlaylistAdder } from './playlistAdder'; import type { PlaylistAdder } from './playlistAdder';
import type { SpotifyAlbumTracksResponse } from './spotifyResponse';
export class SpotifyPlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder { export class SpotifyPlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder {
public constructor() { public constructor() {
@ -71,11 +72,11 @@ export class SpotifyPlaylistAdder extends OauthPlaylistAdder implements Playlist
} }
if (!SPOTIFY_PLAYLIST_ID || SPOTIFY_PLAYLIST_ID === 'CHANGE_ME') { if (!SPOTIFY_PLAYLIST_ID || SPOTIFY_PLAYLIST_ID === 'CHANGE_ME') {
this.logger.debug('no spotify playlist ID configured'); this.logger.info('no spotify playlist ID configured');
return; return;
} }
if (!song.spotifyUri) { if (!song.spotifyUri) {
this.logger.info('Skip adding song to spotify playlist, no Uri', song); this.logger.debug('Skip adding song to spotify playlist, no Uri', song);
return; return;
} }
@ -94,12 +95,39 @@ export class SpotifyPlaylistAdder extends OauthPlaylistAdder implements Playlist
return; return;
}*/ }*/
//const searchParams = new URLSearchParams([['part', 'snippet']]); let uris: string[] = [];
if (song.type === 'album') {
const albumId = song.spotifyUri.split(':').pop();
// This only fetches max. 50 tracks, otherwise we would have to implement paging
// Maybe in the future, for now it should be enough to get the first 50 tracks
const albumTracksUrl = new URL(`${this.apiBase}/albums/${albumId}/tracks?limit=50`);
const options: RequestInit = {
method: 'GET',
headers: { Authorization: `${token.token_type} ${token.access_token}` }
};
let trackResponse = await fetch(albumTracksUrl, options);
let tracks: SpotifyAlbumTracksResponse = await trackResponse.json();
if (tracks.error && tracks.error.status === 401) {
this.logger.info('Token expired, refreshing and retrying');
this.refreshToken(true);
trackResponse = await fetch(albumTracksUrl, options);
tracks = await trackResponse.json();
} else if (tracks.error) {
this.logger.error('Fetching album trackas failed', tracks.error);
}
if (tracks.items) {
uris = tracks.items.map((x) => x.uri);
}
} else {
uris = [song.spotifyUri];
}
// Maximum is 100 items, but we'Re requesting only max. 50 anyways
const options: RequestInit = { const options: RequestInit = {
method: 'POST', method: 'POST',
headers: { Authorization: `${token.token_type} ${token.access_token}` }, headers: { Authorization: `${token.token_type} ${token.access_token}` },
body: JSON.stringify({ body: JSON.stringify({
uris: [song.spotifyUri] uris: uris
}) })
}; };
const apiUrl = new URL(`${this.apiBase}/playlists/${SPOTIFY_PLAYLIST_ID}/tracks`); const apiUrl = new URL(`${this.apiBase}/playlists/${SPOTIFY_PLAYLIST_ID}/tracks`);

View File

@ -0,0 +1,33 @@
export type SpotifyAlbumTracksResponse = {
href?: string;
limit?: number;
offset?: number;
total?: number;
next?: string | null;
previous?: string | null;
items?: SpotifyAlbumTrack[];
error?: SpotifyError;
};
export type SpotifyAlbumTrack = {
artists: {
id: string;
uri: string;
name: string;
type: 'artist';
}[];
available_markets: string[];
external_urls: {
spotify: string;
};
href: string;
name: string;
type: 'track';
id: string;
uri: string;
};
export type SpotifyError = {
status: number;
message: string;
};

View File

@ -0,0 +1,363 @@
import { TIDAL_PLAYLIST_ID, TIDAL_CLIENT_ID, TIDAL_CLIENT_SECRET } from '$env/static/private';
import { Logger } from '$lib/log';
import type { OauthResponse } from '$lib/mastodon/response';
import type { SongInfo } from '$lib/odesliResponse';
import { createHash } from 'crypto';
import { OauthPlaylistAdder } from './oauthPlaylistAdder';
import type { PlaylistAdder } from './playlistAdder';
import type { TidalAddToPlaylistResponse, TidalAlbumResponse } from './tidalResponse';
import { doesTidalSongExist } from '$lib/server/db';
import { sleep } from '$lib/sleep';
export class TidalPlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder {
private static code_verifier?: string;
public constructor() {
super('https://openapi.tidal.com/v2', 'tidal_auth_token');
//super('https://api.tidal.com/v2', 'tidal_auth_token');
this.logger = new Logger('TidalPlaylistAdder');
// Tidal aggressively rate-limits, so reduce the number of refreshing requests
this.refresh_time = 3;
}
public constructAuthUrl(redirectUri: URL): URL {
const endpoint = 'https://login.tidal.com/authorize';
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(100)).toString(), 'ascii')
.toString('base64url')
.slice(0, 128);
const code_challenge = createHash('sha256').update(verifier).digest('base64url');
TidalPlaylistAdder.code_verifier = verifier;
let additionalParameters = new Map([
['code_challenge_method', 'S256'],
['code_challenge', code_challenge]
]);
return this.constructAuthUrlInternal(
endpoint,
TIDAL_CLIENT_ID,
'playlists.write playlists.read user.read', //r_usr w_usr
redirectUri,
additionalParameters
);
}
public async receivedAuthCode(code: string, url: URL) {
this.logger.debug('received code');
const tokenUrl = new URL('https://auth.tidal.com/v1/oauth2/token');
await this.receivedAuthCodeInternal(
tokenUrl,
TIDAL_CLIENT_ID,
code,
url,
TIDAL_CLIENT_SECRET,
undefined,
TidalPlaylistAdder.code_verifier
);
}
private async refreshToken(force: boolean = false): Promise<OauthResponse | null> {
const tokenInfo = await this.shouldRefreshToken();
if (tokenInfo == null) {
return null;
}
let token = tokenInfo.token;
if (!tokenInfo.refresh && !force) {
return token;
}
if (!token.refresh_token) {
this.logger.error('Need to refresh access token, but no refresh token provided');
return null;
}
const tokenUrl = new URL('https://auth.tidal.com/v1/oauth2/token');
const response = await this.requestRefreshTokenWithHeaders(
tokenUrl,
TIDAL_CLIENT_ID,
token.refresh_token
);
this.processTidalHeaders(response.headers);
return response.resp;
}
private processTidalHeaders(headers: Headers) {
const retryAfterHeader = headers.get('Retry-After');
const remainingTokens = headers.get('x-ratelimit-remaining');
const requiredTokens = headers.get('x-ratelimit-requested-tokens');
const replenishRate = headers.get('x-ratelimit-replenish-rate');
if (remainingTokens !== null && replenishRate !== null) {
const remainingTokensValue = parseInt(remainingTokens);
const replenishRateValue = parseInt(replenishRate);
let requiredTokensValue = parseInt(requiredTokens ?? '-1');
this.logger.debug(
'Tidal rate limit. Remaining',
remainingTokensValue,
'reuqired for last request',
requiredTokensValue,
'replenish rate',
replenishRateValue
);
}
if (retryAfterHeader) {
const retryAfter = parseInt(retryAfterHeader);
this.logger.debug('Tidal rate limit. Retry-After', retryAfter);
}
}
private async getAlbumItems(
albumId: string,
remaning: number = 3,
nextLink?: string
): Promise<{ id: string; type: string }[]> {
this.logger.debug('getAlbumItems', albumId, remaning, nextLink);
if (remaning <= 0) {
return [];
}
const token = await this.refreshToken();
if (token == null) {
return [];
}
try {
let albumUrl: URL;
if (nextLink) {
this.logger.debug('getAlbumItems nextPage', nextLink);
albumUrl = new URL(nextLink);
} else {
this.logger.debug(
'getAlbumItems albumUrl',
`${this.apiBase}/albums/${albumId}/relationships/items`
);
albumUrl = new URL(`${this.apiBase}/albums/${albumId}/relationships/items`);
albumUrl.searchParams.append('countryCode', 'DE');
}
const options: RequestInit = {
method: 'GET',
headers: {
Authorization: `${token.token_type} ${token.access_token}`,
Accept: 'application/vnd.api+json'
}
};
const request = new Request(albumUrl, options);
const resp = await fetch(request);
this.processTidalHeaders(resp.headers);
if (resp.ok) {
const respData: TidalAlbumResponse = await resp.json();
if (respData.data !== undefined) {
let tracks = respData.data.map((x) => {
return { id: x.id, type: x.type };
});
if (respData.links?.next) {
this.logger.debug('getAlbumItems requesting next page', respData.links.next);
const nextPage = await this.getAlbumItems(
albumId,
remaning,
this.apiBase + respData.links.next
);
tracks = tracks.concat(nextPage);
}
return tracks;
} else {
this.logger.error('Error response for album', respData.errors, respData);
return [];
}
} else if (resp.status === 429) {
// Tidal docs say, this endpoint uses Retry-After header,
// but other endpoints use x-rate-limit headers. Check both
let secondsToWait = 0;
const retryAfterHeader = resp.headers.get('Retry-After');
if (retryAfterHeader) {
secondsToWait = parseInt(retryAfterHeader);
} else {
const remainingTokens = resp.headers.get('x-ratelimit-remaining');
const requiredTokens = resp.headers.get('x-ratelimit-requested-tokens');
const replenishRate = resp.headers.get('x-ratelimit-replenish-rate');
if (remainingTokens !== null && requiredTokens !== null && replenishRate !== null) {
const remainingTokensValue = parseInt(remainingTokens);
const requiredTokensValue = parseInt(requiredTokens);
const replenishRateValue = parseInt(replenishRate);
const needToReplenish = requiredTokensValue - remainingTokensValue;
secondsToWait = 1 + needToReplenish / replenishRateValue;
}
}
if (secondsToWait === 0) {
// Try again secondsToWait sec later, just to be safe one additional second
this.logger.warn(
'Received HTTP 429 Too Many Requests. Retrying in',
secondsToWait,
'sec'
);
await sleep(secondsToWait * 1000);
return await this.getAlbumItems(albumId, remaning - 1, nextLink);
} else {
this.logger.warn(
'Received HTTP 429 Too Many Requests, but no instructions on how long to wait. Aborting',
secondsToWait,
'sec'
);
return [];
}
} else {
const respText = await resp.text();
this.logger.error('Cannot check album contents', resp.status, respText);
return [];
}
} catch (e) {
this.logger.error('Error checking album contents', e);
return [];
}
}
private async addToPlaylistRetry(song: SongInfo, remaning: number = 3) {
if (remaning < 0) {
this.logger.error('max retries reached, song will not be added to playlist');
}
this.logger.debug('addToTidalPlaylist', remaning);
const token = await this.refreshToken();
if (token == null) {
return;
}
this.logger.debug('token check successful');
if (!TIDAL_PLAYLIST_ID || TIDAL_PLAYLIST_ID === 'CHANGE_ME') {
this.logger.debug('no playlist ID configured');
return;
}
if (!song.tidalUri) {
this.logger.info('Skip adding song to playlist, no Uri', song);
return;
}
let tracks = [
{
id: song.tidalUri,
type: 'tracks'
}
];
if (song.type === 'album') {
tracks = await this.getAlbumItems(song.tidalUri);
this.logger.debug('received tracks', tracks);
if (tracks.length === 0) {
return;
}
}
const alreadyExists = await doesTidalSongExist(song);
try {
if (alreadyExists) {
this.logger.info('Skip adding song to playlist, has already been added', song);
return;
}
} catch (dbe) {
this.logger.error('Could not check for tidal dupes', dbe);
}
// Tidal can only handle max. 20 items to be added at once
// This isn't documented, but the API helpfully provides a useful error message
let chunkSize = 20;
const chunkedTracks: { id: string; type: string }[][] = [];
let chunkIndex = 0;
while (chunkIndex < tracks.length) {
chunkedTracks.push(tracks.slice(chunkIndex, chunkIndex + chunkSize));
chunkIndex += chunkSize;
}
const apiUrl = new URL(`${this.apiBase}/playlists/${TIDAL_PLAYLIST_ID}/relationships/items`);
let options: RequestInit;
let request: Request;
let resp: Response | null = null;
let respTxt: string | null = null;
for (let chunk of chunkedTracks) {
options = {
method: 'POST',
headers: {
Authorization: `${token.token_type} ${token.access_token}`,
'Content-Type': 'application/vnd.api+json',
Accept: 'application/vnd.api+json'
},
body: JSON.stringify({
data: chunk
/*meta: { // Can be left out now
positionBefore: 'ffb6286e-237a-4dfc-bbf1-2fb0eb004ed5' // Hardcoded last element of list
}*/
})
};
request = new Request(apiUrl, options);
try {
resp = await fetch(request);
this.processTidalHeaders(resp.headers);
let respObj: TidalAddToPlaylistResponse | null = null;
// If the request was successful, a 201 with no content is received
// Errors will have content and a different status code
if (resp.status !== 201 && resp.status !== 429) {
respObj = await resp.json();
} else {
respTxt = await resp.text();
}
if (respObj !== null && respObj.errors) {
this.logger.error('Add to playlist failed', song.tidalUri, resp.status, respObj.errors);
if (resp.status === 401 || respObj.errors.some((x) => x.code === 'UNAUTHORIZED')) {
const token = await this.refreshToken(true);
if (token == null) {
return;
}
await this.addToPlaylistRetry(song, remaning--);
}
} else if (respObj === null) {
switch (resp.status) {
case 201:
this.logger.info('Added to playlist', song.tidalUri, song.title);
break;
case 429:
let secondsToWait = -1;
const retryAfterHeader = resp.headers.get('Retry-After');
if (retryAfterHeader) {
secondsToWait = parseInt(retryAfterHeader);
} else {
const remainingTokens = resp.headers.get('x-ratelimit-remaining');
const requiredTokens = resp.headers.get('x-ratelimit-requested-tokens');
const replenishRate = resp.headers.get('x-ratelimit-replenish-rate');
if (remainingTokens !== null && requiredTokens !== null && replenishRate !== null) {
const remainingTokensValue = parseInt(remainingTokens);
const requiredTokensValue = parseInt(requiredTokens);
const replenishRateValue = parseInt(replenishRate);
const needToReplenish = requiredTokensValue - remainingTokensValue;
secondsToWait = 1 + needToReplenish / replenishRateValue;
}
}
if (secondsToWait === -1) {
this.logger.warn('Could not read headers how long to wait', resp.headers);
} else {
this.logger.warn(
'Received HTTP 429 Too Many Requests. Retrying in',
secondsToWait,
'sec'
);
// Try again secondsToWait sec later, just to be safe one additional second
await sleep(secondsToWait * 1000);
await this.addToPlaylistRetry(song, remaning--);
}
break;
default:
this.logger.warn('Unknown response', resp.status, respTxt);
break;
}
} else {
this.logger.info(
'Add to playlist result is neither 201 nor error',
song.tidalUri,
song.title,
respObj
);
}
} catch (e) {
this.logger.error('Add to playlist request failed', resp?.status, e);
}
}
}
public async addToPlaylist(song: SongInfo) {
await this.addToPlaylistRetry(song);
}
}

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

View File

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

View File

@ -1,11 +1,10 @@
import { import { YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, YOUTUBE_PLAYLIST_ID } from '$env/static/private';
BASE_URL,
YOUTUBE_CLIENT_ID,
YOUTUBE_CLIENT_SECRET,
YOUTUBE_PLAYLIST_ID
} from '$env/static/private';
import { Logger } from '$lib/log'; import { Logger } from '$lib/log';
import type { OauthResponse } from '$lib/mastodon/response'; import type { OauthResponse } from '$lib/mastodon/response';
import type {
YoutubePlaylistItemResponse,
YoutubeResponse
} from '$lib/server/playlist/youtubeResponse';
import type { SongInfo } from '$lib/odesliResponse'; import type { SongInfo } from '$lib/odesliResponse';
import { OauthPlaylistAdder } from './oauthPlaylistAdder'; import { OauthPlaylistAdder } from './oauthPlaylistAdder';
import type { PlaylistAdder } from './playlistAdder'; import type { PlaylistAdder } from './playlistAdder';
@ -17,6 +16,7 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
} }
public constructAuthUrl(redirectUri: URL): URL { public constructAuthUrl(redirectUri: URL): URL {
this.redirectUri = redirectUri;
let additionalParameters = new Map([ let additionalParameters = new Map([
['access_type', 'offline'], ['access_type', 'offline'],
['include_granted_scopes', 'false'] ['include_granted_scopes', 'false']
@ -33,6 +33,7 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
public async receivedAuthCode(code: string, url: URL) { public async receivedAuthCode(code: string, url: URL) {
this.logger.debug('received code'); this.logger.debug('received code');
this.redirectUri = url;
const tokenUrl = new URL('https://oauth2.googleapis.com/token'); const tokenUrl = new URL('https://oauth2.googleapis.com/token');
await this.receivedAuthCodeInternal( await this.receivedAuthCodeInternal(
tokenUrl, tokenUrl,
@ -41,15 +42,32 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
url, url,
YOUTUBE_CLIENT_SECRET YOUTUBE_CLIENT_SECRET
); );
const token = await this.refreshToken();
if (token == null) {
return;
}
} }
private async refreshToken(): Promise<OauthResponse | null> { private async checkAuthorizedToken(token: OauthResponse) {
try {
this.logger.debug('Checking authorized token');
const res = await fetch(this.apiBase + '/channels?part=id&mine=true', {
method: 'GET',
headers: { Authorization: `${token.token_type} ${token.access_token}` }
}).then((r) => r.json());
this.logger.debug('Checked authorized token', res);
} catch (e) {
this.logger.error('Error checking authorized token', e);
}
}
private async refreshToken(force: boolean = false): Promise<OauthResponse | null> {
const tokenInfo = await this.shouldRefreshToken(); const tokenInfo = await this.shouldRefreshToken();
if (tokenInfo == null) { if (tokenInfo == null) {
return null; return null;
} }
let token = tokenInfo.token; let token = tokenInfo.token;
if (!tokenInfo.refresh) { if (!tokenInfo.refresh && !force) {
return token; return token;
} }
if (!token.refresh_token) { if (!token.refresh_token) {
@ -58,18 +76,22 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
} }
const tokenUrl = new URL('https://oauth2.googleapis.com/token'); const tokenUrl = new URL('https://oauth2.googleapis.com/token');
return await this.requestRefreshToken( const refreshedToken = await this.requestRefreshToken(
tokenUrl, tokenUrl,
YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_ID,
token.refresh_token, token.refresh_token,
`${BASE_URL}/ytauth`, this.getRedirectUri('ytauth').toString(),
YOUTUBE_CLIENT_SECRET YOUTUBE_CLIENT_SECRET
); );
if (refreshedToken !== null) {
await this.checkAuthorizedToken(refreshedToken);
}
return refreshedToken;
} }
public async addToPlaylist(song: SongInfo) { public async addToPlaylist(song: SongInfo) {
this.logger.debug('addToYoutubePlaylist'); this.logger.debug('addToYoutubePlaylist');
const token = await this.refreshToken(); let token = await this.refreshToken();
if (token == null) { if (token == null) {
return; return;
} }
@ -84,28 +106,91 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
} }
const songUrl = new URL(song.youtubeUrl); const songUrl = new URL(song.youtubeUrl);
const youtubeId = songUrl.searchParams.get('v'); let videoIds: string[] = [];
if (!youtubeId) { if (song.type === 'album') {
this.logger.debug( const albumPlaylistId = songUrl.searchParams.get('list') ?? '';
'Skip adding song to YT playlist, could not extract YT id from URL', const albumItemsUrl = new URL(this.apiBase + '/playlistItems');
song.youtubeUrl albumItemsUrl.searchParams.append('maxResults', '50');
albumItemsUrl.searchParams.append('playlistId', albumPlaylistId);
albumItemsUrl.searchParams.append('part', 'snippet');
const albumPlaylistItemRequest = new Request(albumItemsUrl, {
headers: { Authorization: `${token.token_type} ${token.access_token}` }
});
const albumPlaylistItem = await fetch(albumPlaylistItemRequest).then((r) => r.json());
if (albumPlaylistItem.error) {
this.logger.info(
'Could not check album tracks',
albumPlaylistItem.error,
'request',
albumPlaylistItemRequest
);
}
const albumTracks: any[] = albumPlaylistItem.items ?? [];
videoIds = albumTracks.map((x) => x.snippet?.resourceId?.videoId).filter((x) => x);
this.logger.info(
'Found',
albumPlaylistItem.pageInfo?.totalResults,
'songs in album, received',
albumTracks.length
); );
return; this.logger.debug(videoIds);
} if (videoIds.length === 0) {
this.logger.debug('Found YT id from URL', song.youtubeUrl, youtubeId); this.logger.debug(
'Skip adding album to YT playlist, is empty',
const playlistItemsUrl = new URL(this.apiBase + '/playlistItems'); song.youtubeUrl,
playlistItemsUrl.searchParams.append('videoId', youtubeId); albumPlaylistItem
playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID); );
playlistItemsUrl.searchParams.append('part', 'id'); return;
const existingPlaylistItem = await fetch(playlistItemsUrl, { }
headers: { Authorization: `${token.token_type} ${token.access_token}` } } else {
}).then((r) => r.json()); const youtubeId = songUrl.searchParams.get('v');
if (existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0) { if (!youtubeId) {
this.logger.info('Item already in playlist', existingPlaylistItem); this.logger.debug(
return; 'Skip adding song to YT playlist, could not extract YT id from URL',
song.youtubeUrl
);
return;
}
this.logger.debug('Found YT id from URL', song.youtubeUrl, youtubeId);
videoIds.push(youtubeId);
} }
for (let youtubeId of videoIds) {
this.logger.debug('Adding to playlist', youtubeId);
const alreadyAdded = await this.isVideoInPlaylist(youtubeId, token);
if (alreadyAdded) {
this.logger.info('Item already in playlist', song.youtubeUrl, song.title);
continue;
} else {
this.logger.debug('Item not already in playlist', song.youtubeUrl, song.title);
}
let retries = 3;
let success = false;
while (retries > 0 && !success) {
try {
this.logger.debug('Retries', retries);
await this.addSongToPlaylist(youtubeId, token);
success = true;
this.logger.info('Added to playlist', youtubeId, song.title);
} catch (e) {
retries--;
if (e instanceof Error && e.message === 'authError') {
this.logger.info('Refreshing auth token');
token = await this.refreshToken(true);
if (token == null) {
this.logger.error('Refreshing auth token failed');
return;
}
} else {
this.logger.error('Add to playlist failed', e);
}
}
}
}
}
private async addSongToPlaylist(videoId: string, token: OauthResponse): Promise<void> {
const addItemUrl = new URL(this.apiBase + '/playlistItems'); const addItemUrl = new URL(this.apiBase + '/playlistItems');
addItemUrl.searchParams.append('part', 'snippet'); addItemUrl.searchParams.append('part', 'snippet');
const options: RequestInit = { const options: RequestInit = {
@ -115,17 +200,63 @@ export class YoutubePlaylistAdder extends OauthPlaylistAdder implements Playlist
snippet: { snippet: {
playlistId: YOUTUBE_PLAYLIST_ID, playlistId: YOUTUBE_PLAYLIST_ID,
resourceId: { resourceId: {
videoId: youtubeId, videoId: videoId,
kind: 'youtube#video' kind: 'youtube#video'
} }
} }
}) })
}; };
const resp = await fetch(addItemUrl, options); const request = new Request(addItemUrl, options);
const respObj = await resp.json(); const resp = await fetch(request);
this.logger.info('Added to playlist', youtubeId, song.title); let respObj: YoutubeResponse = await resp.json();
if (respObj.error) { if (respObj.error) {
this.logger.debug('Add to playlist failed', respObj.error.errors); this.logger.error(
'Add to playlist failed',
respObj.error.errors,
respObj.error.code,
respObj.error.message,
'request',
request
);
throw new Error(respObj.error.errors[0].reason);
} }
} }
private async isVideoInPlaylist(
videoId: string,
token: OauthResponse,
retry: boolean = true
): Promise<boolean> {
const playlistItemsUrl = new URL(this.apiBase + '/playlistItems');
playlistItemsUrl.searchParams.append('videoId', videoId);
playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID);
playlistItemsUrl.searchParams.append('part', 'id');
let existingPlaylistItemRequest = new Request(playlistItemsUrl, {
headers: { Authorization: `${token.token_type} ${token.access_token}` }
});
let existingPlaylistItem: YoutubePlaylistItemResponse = await fetch(
existingPlaylistItemRequest
).then((r) => r.json());
if (existingPlaylistItem.error) {
this.logger.error(
'Could not check if item is already in playlist',
existingPlaylistItem.error,
'request',
existingPlaylistItemRequest
);
if (existingPlaylistItem.error.code === 401) {
const newToken = await this.refreshToken(true);
if (newToken === null) {
return false;
}
if (retry) {
return await this.isVideoInPlaylist(videoId, newToken, false);
} else {
return false;
}
}
}
return existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0;
}
} }

View File

@ -4,6 +4,7 @@ import type { Post } from '$lib//mastodon/response';
import { Logger } from '$lib/log'; import { Logger } from '$lib/log';
import { Feed } from 'feed'; import { Feed } from 'feed';
import fs from 'fs/promises'; import fs from 'fs/promises';
const { PROD } = import.meta.env;
const logger = new Logger('RSS'); const logger = new Logger('RSS');
@ -50,16 +51,18 @@ export function createFeed(posts: Post[]): Feed {
} }
export async function saveAtomFeed(feed: Feed) { export async function saveAtomFeed(feed: Feed) {
await fs.writeFile('feed.xml', feed.atom1(), { encoding: 'utf8' }); await fs.writeFile('feed.xml', feed.atom1(), { encoding: 'utf8' });
if (!WEBSUB_HUB) { if (!WEBSUB_HUB || !PROD) {
logger.info('Skipping Websub publish. hub configured?', WEBSUB_HUB, 'Production?', PROD);
return; return;
} }
try { try {
const params = new URLSearchParams(); const param = new FormData();
params.append('hub.mode', 'publish'); param.append('hub.mode', 'publish');
params.append('hub.url', `${BASE_URL}/feed.xml`); param.append('hub.url', `${BASE_URL}/feed.xml`);
await fetch(WEBSUB_HUB, { await fetch(WEBSUB_HUB, {
method: 'POST', method: 'POST',
body: params headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: param
}); });
} catch (e) { } catch (e) {
logger.error('Failed to update WebSub hub', e); logger.error('Failed to update WebSub hub', e);

View File

@ -2,6 +2,7 @@ import {
HASHTAG_FILTER, HASHTAG_FILTER,
MASTODON_ACCESS_TOKEN, MASTODON_ACCESS_TOKEN,
MASTODON_INSTANCE, MASTODON_INSTANCE,
IGNORE_USERS,
ODESLI_API_KEY, ODESLI_API_KEY,
YOUTUBE_API_KEY YOUTUBE_API_KEY
} from '$env/static/private'; } from '$env/static/private';
@ -36,6 +37,7 @@ import sharp from 'sharp';
import { URL, URLSearchParams } from 'url'; import { URL, URLSearchParams } from 'url';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
import type { PlaylistAdder } from './playlist/playlistAdder'; import type { PlaylistAdder } from './playlist/playlistAdder';
import { TidalPlaylistAdder } from './playlist/tidalPlaylistAdder';
const URL_REGEX = new RegExp(/href="(?<postUrl>[^>]+?)" target="_blank"/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 INVIDIOUS_REGEX = new RegExp(/invidious.*?watch.*?v=(?<videoId>[a-zA-Z_0-9-]+)/gm);
@ -48,6 +50,7 @@ export class TimelineReader {
private lastPosts: string[] = []; private lastPosts: string[] = [];
private playlistAdders: PlaylistAdder[]; private playlistAdders: PlaylistAdder[];
private logger: Logger; private logger: Logger;
private ignoredUsers: string[];
private async isMusicVideo(videoId: string) { private async isMusicVideo(videoId: string) {
if (!YOUTUBE_API_KEY || YOUTUBE_API_KEY === 'CHANGE_ME') { if (!YOUTUBE_API_KEY || YOUTUBE_API_KEY === 'CHANGE_ME') {
@ -63,7 +66,11 @@ export class TimelineReader {
const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`); const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`);
const resp = await fetch(youtubeVideoUrl); const resp = await fetch(youtubeVideoUrl);
const respObj = await resp.json(); const respObj = await resp.json();
if (!respObj.items.length) { if (respObj.error) {
this.logger.warn('YT API error', respObj.error);
return false;
}
if (!respObj.items?.length) {
this.logger.warn('Could not find video with id', videoId); this.logger.warn('Could not find video with id', videoId);
return false; return false;
} }
@ -73,7 +80,7 @@ export class TimelineReader {
this.logger.warn('Could not load snippet for video', videoId, item); this.logger.warn('Could not load snippet for video', videoId, item);
return false; return false;
} }
if (item.snippet.tags?.includes('music')) { if (item.snippet?.tags?.includes('music')) {
return true; return true;
} }
@ -156,8 +163,14 @@ export class TimelineReader {
if (!odesliInfo || !odesliInfo.entitiesByUniqueId || !odesliInfo.entityUniqueId) { if (!odesliInfo || !odesliInfo.entitiesByUniqueId || !odesliInfo.entityUniqueId) {
return null; return null;
} }
const spotify: Platform = 'spotify';
const tidal: Platform = 'tidal';
const deezer: Platform = 'deezer';
const tidalId = odesliInfo.linksByPlatform[tidal]?.entityUniqueId;
const tidalUri = tidalId ? odesliInfo.entitiesByUniqueId[tidalId].id : undefined;
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId]; const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
//this.logger.debug('odesli response', info);
const platform: Platform = 'youtube'; const platform: Platform = 'youtube';
if (info.platforms.includes(platform)) { if (info.platforms.includes(platform)) {
const youtubeId = const youtubeId =
@ -172,33 +185,39 @@ export class TimelineReader {
); );
return null; return null;
} }
const isMusic = await this.isMusicVideo(youtubeId); // If it is on tidal or deezer, it's probably music
// Do not check spotify, they carry too much other stuff (podcasts, audiobooks, etc)
let isMusic = odesliInfo.linksByPlatform[tidal] || odesliInfo.linksByPlatform[deezer];
// If not, check the YT API
isMusic = isMusic || (await this.isMusicVideo(youtubeId));
if (!isMusic) { if (!isMusic) {
this.logger.debug('Probably not a music video', youtubeId, url); this.logger.debug('Probably not a music video', youtubeId);
return null; return null;
} }
} }
const spotify: Platform = 'spotify';
const songInfo = { const songInfo = {
...info, ...info,
pageUrl: odesliInfo.pageUrl, pageUrl: odesliInfo.pageUrl,
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url, youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
spotifyUrl: odesliInfo.linksByPlatform[spotify]?.url, spotifyUrl: odesliInfo.linksByPlatform[spotify]?.url,
spotifyUri: odesliInfo.linksByPlatform[spotify]?.nativeAppUriDesktop, spotifyUri: odesliInfo.linksByPlatform[spotify]?.nativeAppUriDesktop,
tidalUri: tidalUri,
postedUrl: url.toString() postedUrl: url.toString()
} as SongInfo; } as SongInfo;
if (songInfo.youtubeUrl && !songInfo.spotifyUrl) {
this.logger.warn('SongInfo with YT, but no spotify URL', odesliInfo);
}
return songInfo; return songInfo;
} catch (e) { } catch (e) {
if (e instanceof Error && e.cause === 429) { if (e instanceof Error && e.cause === 429) {
this.logger.warn('song.link rate limit reached. Trying again in 10 seconds'); this.logger.warn('song.link rate limit reached. Trying again in 10 seconds');
await sleep(10_000); await sleep(10_000);
return await this.getSongInfo(url, remainingTries - 1); } else {
this.logger.error(
`Failed to load ${url} info from song.link. Trying again in 3 seconds`,
e
);
await sleep(3_000);
} }
this.logger.error(`Failed to load ${url} info from song.link`, e); return await this.getSongInfo(url, remainingTries - 1);
return null;
} }
} }
@ -372,6 +391,18 @@ export class TimelineReader {
} }
private async checkAndSavePost(post: Post) { private async checkAndSavePost(post: Post) {
const isIgnored = this.ignoredUsers.includes(post.account.acct);
if (isIgnored) {
this.logger.info(
'Ignoring post by ignored user',
post.account.acct,
'is ignored',
this.ignoredUsers,
isIgnored
);
return;
}
const hashttags: string[] = HASHTAG_FILTER.split(','); const hashttags: string[] = HASHTAG_FILTER.split(',');
const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name)); const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name));
@ -405,11 +436,42 @@ export class TimelineReader {
const socket = new WebSocket( const socket = new WebSocket(
`wss://${MASTODON_INSTANCE}/api/v1/streaming?type=subscribe&stream=public:local&access_token=${MASTODON_ACCESS_TOKEN}` `wss://${MASTODON_INSTANCE}/api/v1/streaming?type=subscribe&stream=public:local&access_token=${MASTODON_ACCESS_TOKEN}`
); );
// Sometimes, the app just stops receiving WS updates.
// Regularly check if it is necessary to reset it
const wsTimeout = 5;
let timeoutId = setTimeout(
() => {
socketLogger.warn(
'Websocket has not received a new post in',
wsTimeout,
'hours. Resetting, it might be stuck'
);
socket.close();
this.startWebsocket();
},
1000 * 60 * 60 * wsTimeout
); // 5 hours
socket.onopen = () => { socket.onopen = () => {
socketLogger.log('Connected to WS'); socketLogger.log('Connected to WS');
}; };
socket.onmessage = async (event) => { socket.onmessage = async (event) => {
try { 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()); const data: TimelineEvent = JSON.parse(event.data.toString());
socketLogger.debug('ES event', data.event); socketLogger.debug('ES event', data.event);
if (data.event !== 'update') { if (data.event !== 'update') {
@ -479,7 +541,21 @@ export class TimelineReader {
private constructor() { private constructor() {
this.logger = new Logger('Timeline'); this.logger = new Logger('Timeline');
this.logger.log('Constructing timeline object'); this.logger.log('Constructing timeline object');
this.playlistAdders = [new YoutubePlaylistAdder(), new SpotifyPlaylistAdder()]; 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.startWebsocket();
this.loadPostsSinceLastRun() this.loadPostsSinceLastRun()

View File

@ -1,4 +1,7 @@
export function isTruthy(value: string | number | boolean | null | undefined): boolean { export function isTruthy(value: string | number | boolean | null | undefined): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value === 'string') { if (typeof value === 'string') {
return value.toLowerCase() === 'true' || !!+value; // here we parse to number first return value.toLowerCase() === 'true' || !!+value; // here we parse to number first
} }

View File

@ -24,6 +24,7 @@
position: sticky; position: sticky;
bottom: 0px; bottom: 0px;
display: inline-block; display: inline-block;
z-index: 99;
} }
:global(.toast.error) { :global(.toast.error) {
--toastColor: var(--color-button-text); --toastColor: var(--color-button-text);

View File

@ -1,8 +1,11 @@
import type { Post } from '$lib/mastodon/response'; import type { Post } from '$lib/mastodon/response';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => { export const load = (async ({ fetch, setHeaders }) => {
const p = await fetch('/'); const p = await fetch('/');
setHeaders({
'cache-control': 'public,max-age=60'
});
return { return {
posts: (await p.json()) as Post[] posts: (await p.json()) as Post[]
}; };

View File

@ -1,5 +1,8 @@
import type { RequestHandler } from './$types'; 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'); return await fetch('api/posts');
}) satisfies RequestHandler; }) satisfies RequestHandler;

View File

@ -7,9 +7,19 @@ const { DEV } = import.meta.env;
const logger = new Logger('SpotifyAuth'); const logger = new Logger('SpotifyAuth');
export const load: PageServerLoad = async ({ url }) => { 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(); const adder = new SpotifyPlaylistAdder();
let redirect_uri = new URL(`${BASE_URL}/spotifyAuth`);
if (url.hostname === 'localhost' && DEV) { if (url.hostname === 'localhost' && DEV) {
redirect_uri.hostname = '127.0.0.1'; redirect_uri.hostname = '127.0.0.1';
} }

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

View File

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

View File

@ -3,12 +3,24 @@ import { Logger } from '$lib/log';
import { YoutubePlaylistAdder } from '$lib/server/playlist/ytPlaylistAdder'; import { YoutubePlaylistAdder } from '$lib/server/playlist/ytPlaylistAdder';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
const { DEV } = import.meta.env;
const logger = new Logger('YT Auth'); const logger = new Logger('YT Auth');
export const load: PageServerLoad = async ({ url }) => { 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(); const adder = new YoutubePlaylistAdder();
const redirect_uri = new URL(`${BASE_URL}/ytauth`); logger.debug('redirect URL', redirect_uri);
if (url.searchParams.has('code')) { if (url.searchParams.has('code')) {
logger.debug(url.searchParams); logger.debug(url.searchParams);
await adder.receivedAuthCode(url.searchParams.get('code') || '', redirect_uri); await adder.receivedAuthCode(url.searchParams.get('code') || '', redirect_uri);

View File

@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
. /home/moshing-mammut/.nvm/nvm.sh . /home/moshing-mammut/.nvm/nvm.sh
node -r dotenv/config build node -r dotenv/config build &

View File

@ -12,7 +12,9 @@ const config = {
// If your environment is not supported or you settled on a specific environment, switch out the adapter. // 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. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(), adapter: adapter(),
version: {
name: process.env.npm_package_version
},
csp: { csp: {
directives: { directives: {
'script-src': ['self', 'unsafe-inline'], 'script-src': ['self', 'unsafe-inline'],