Merge pull request '7-create-playlist' (#37) from 7-create-playlist into main

Reviewed-on: #37
This commit is contained in:
2025-07-04 09:46:11 +00:00
22 changed files with 969 additions and 363 deletions

View File

@ -1,5 +1,8 @@
HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening
YOUTUBE_API_KEY = CHANGE_ME YOUTUBE_API_KEY = CHANGE_ME
YOUTUBE_PLAYLIST_ID = CHANGE_ME
YOUTUBE_CLIENT_ID = CHANGE_ME
YOUTUBE_CLIENT_SECRET = CHANGE_ME
ODESLI_API_KEY = CHANGE_ME ODESLI_API_KEY = CHANGE_ME
MASTODON_INSTANCE = 'metalhead.club' MASTODON_INSTANCE = 'metalhead.club'
MASTODON_ACCESS_TOKEN = 'YOUR_ACCESS_TOKEN_HERE' MASTODON_ACCESS_TOKEN = 'YOUR_ACCESS_TOKEN_HERE'
@ -10,3 +13,4 @@ WEBSUB_HUB = 'http://pubsubhubbub.superfeedr.com'
PUBLIC_REFRESH_INTERVAL = 10000 PUBLIC_REFRESH_INTERVAL = 10000
PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME = 'Metalhead.club' PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME = 'Metalhead.club'
PORT = 3001

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
yt_auth_token
spotify_auth_token
*.db *.db
feed.xml feed.xml
playbook.yml playbook.yml

1
.nvmrc Normal file
View File

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

View File

@ -98,7 +98,7 @@ and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY` and `ODESLI_API_KEY`. Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY` and `ODESLI_API_KEY`.
To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an
_API key_. _API key_.
If `YOUTUBE_API_KEY` is unset, no playlist will be updated. Also, _all_ YouTube links will be treated as music videos, If `YOUTUBE_API_KEY` is unset _all_ YouTube links will be treated as music videos,
because the API is the only way to check if a YouTube link leads to music or something else. because the API is the only way to check if a YouTube link leads to music or something else.
If `ODESLI_API_KEY` is unset, your rate limit to the song.link API will be lower. If `ODESLI_API_KEY` is unset, your rate limit to the song.link API will be lower.
@ -106,10 +106,15 @@ If `ODESLI_API_KEY` is unset, your rate limit to the song.link API will be lower
Add `MASTODON_ACCESS_TOKEN` as well, see [Creating our application Add `MASTODON_ACCESS_TOKEN` as well, see [Creating our application
](https://docs.joinmastodon.org/client/token/#app) in the Mastodon documentation. ](https://docs.joinmastodon.org/client/token/#app) in the Mastodon documentation.
`read:statuses` is the only required scope. An access token will be displayed in your settings. Use that! `read:statuses` and `read:search` the only required scope. An access token will be displayed in your settings. Use that!
There are currently no plans to implement an actual authentication flow. There are currently no plans to implement an actual authentication flow.
If you want the app to save the songs it encounters into a playlist, YouTube requires OAuth 2.0 credentials.
Once again, follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) and the OAuth 2.0 described there
to obtain a clientId and clientSecret. Add the values as `YOUTUBE_CLIENT_ID` and `YOUTUBE_CLIENT_SECRET`.
Create a playlist and configure its ID as `YOUTUBE_PLAYLIST_ID`.
Run `npm run build` and copy the output folder, usually `build` to `$APP_DIR` on your server. Run `npm run build` and copy the output folder, usually `build` to `$APP_DIR` on your server.
#### On your server again #### On your server again
@ -125,6 +130,9 @@ Verify that everything is okay with `service moshing-mammut status`.
The app should now be reachable on http://localhost:3000 or whatever you configured your domain to be! The app should now be reachable on http://localhost:3000 or whatever you configured your domain to be!
If you want to add the songs available on YouTube to a playlist and have configured the environment variables to do so,
you now need to visit `/ytauth`, e.g. `http://localhost:3000/ytauth`. This will obtain the necessary access tokens from Google.
# Icons # Icons
Favicon is a combination of [speaker-line by remix icon](https://remixicon.com/icon/speaker-line) Favicon is a combination of [speaker-line by remix icon](https://remixicon.com/icon/speaker-line)

483
package-lock.json generated
View File

@ -1,15 +1,15 @@
{ {
"name": "moshing-mammut", "name": "moshing-mammut",
"version": "1.3.2", "version": "1.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "moshing-mammut", "name": "moshing-mammut",
"version": "1.3.2", "version": "1.4.0",
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"dependencies": { "dependencies": {
"dotenv": "^16.0.3", "dotenv": "^17.0.0",
"feed": "^5.1.0", "feed": "^5.1.0",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"sqlite3": "^5.0.0", "sqlite3": "^5.0.0",
@ -17,9 +17,9 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.21.5", "@sveltejs/kit": "^2.22.2",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.1.0",
"@types/node": "^22.6.1", "@types/node": "^22.9.0",
"@types/sqlite3": "^3.0.0", "@types/sqlite3": "^3.0.0",
"@types/ws": "^8.5.0", "@types/ws": "^8.5.0",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
@ -519,9 +519,9 @@
} }
}, },
"node_modules/@eslint/config-array": { "node_modules/@eslint/config-array": {
"version": "0.20.1", "version": "0.21.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
"integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -558,9 +558,9 @@
} }
}, },
"node_modules/@eslint/config-helpers": { "node_modules/@eslint/config-helpers": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
"integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -639,9 +639,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.28.0", "version": "9.30.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.28.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz",
"integrity": "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==", "integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -662,13 +662,13 @@
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.3.2", "version": "0.3.3",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz",
"integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.15.0", "@eslint/core": "^0.15.1",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
@ -676,9 +676,9 @@
} }
}, },
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
"version": "0.15.0", "version": "0.15.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
"integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -1158,18 +1158,14 @@
} }
}, },
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8", "version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.11.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "integrity": "sha512-C512c1ytBTio4MrpWKlJpyFHT6+qfFL8SZ58zBzJ1OOzUEjHeF1BtjY2fH7n4x/g2OV/KiiMLAivOp1DXmiMMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
@ -1182,27 +1178,17 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0", "version": "1.5.3",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.3.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "integrity": "sha512-AiR5uKpFxP3PjO4R19kQGIMwxyRyPuXmKEEy301V1C0+1rVjS94EZQXf1QKZYN8Q0YM+estSPhmx5JwNftv6nw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25", "version": "0.3.28",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.28.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "integrity": "sha512-KNNHHwW3EIp4EDYOvYFGyIFfx36R2dNJYH4knnZlF8T5jdbD5Wx8xmSaQ2gP9URkJ04LGEtlcCtwArKcmFcwKw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1282,9 +1268,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/plugin-commonjs": { "node_modules/@rollup/plugin-commonjs": {
"version": "28.0.3", "version": "28.0.6",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.6.tgz",
"integrity": "sha512-pyltgilam1QPdn+Zd9gaCfOLcnjMEJ9gV+bTw6/r73INdvzf1ah9zLIJBm+kW7R6IUFIQ1YO+VqZtYxZNWFPEQ==", "integrity": "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1355,9 +1341,9 @@
} }
}, },
"node_modules/@rollup/pluginutils": { "node_modules/@rollup/pluginutils": {
"version": "5.1.4", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz",
"integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1378,9 +1364,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz",
"integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==", "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1392,9 +1378,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz",
"integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==", "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1406,9 +1392,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz",
"integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==", "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1420,9 +1406,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz",
"integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==", "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1434,9 +1420,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz",
"integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==", "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1448,9 +1434,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz",
"integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==", "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1462,9 +1448,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz",
"integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==", "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1476,9 +1462,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz",
"integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==", "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1490,9 +1476,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz",
"integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==", "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1504,9 +1490,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz",
"integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==", "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1518,9 +1504,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loongarch64-gnu": { "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz",
"integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==", "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -1532,9 +1518,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz",
"integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==", "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1546,9 +1532,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz",
"integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==", "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1560,9 +1546,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz",
"integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==", "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1574,9 +1560,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz",
"integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==", "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1588,9 +1574,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz",
"integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1602,9 +1588,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz",
"integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==", "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1616,9 +1602,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz",
"integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==", "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1630,9 +1616,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz",
"integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==", "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1644,9 +1630,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz",
"integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==", "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1684,9 +1670,9 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.21.5", "version": "2.22.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.21.5.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.22.2.tgz",
"integrity": "sha512-P5m7yZtvD1Kx/Z6JcjgJtdMqef/tCGMDrd9B9S2q8j+FMnkeKTMxW1nidnjVzk4HEDRGf4IlBI94/niy6t3hLA==", "integrity": "sha512-2MvEpSYabUrsJAoq5qCOBGAlkICjfjunrnLcx3YAk2XV7TvAIhomlKsAgR4H/4uns5rAfYmj7Wet5KRtc8dPIg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1711,9 +1697,9 @@
"node": ">=18.13" "node": ">=18.13"
}, },
"peerDependencies": { "peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"svelte": "^4.0.0 || ^5.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0",
"vite": "^5.0.3 || ^6.0.0" "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
} }
}, },
"node_modules/@sveltejs/vite-plugin-svelte": { "node_modules/@sveltejs/vite-plugin-svelte": {
@ -1788,9 +1774,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.15.31", "version": "22.15.34",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.31.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.34.tgz",
"integrity": "sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==", "integrity": "sha512-8Y6E5WUupYy1Dd0II32BsWAx5MWdcnRd8L84Oys3veg1YrYtNtzgO4CFhiBg6MDSjk7Ay36HYOnU7/tuOzIzcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1825,17 +1811,17 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.34.0", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
"integrity": "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w==", "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/type-utils": "8.34.0", "@typescript-eslint/type-utils": "8.35.1",
"@typescript-eslint/utils": "8.34.0", "@typescript-eslint/utils": "8.35.1",
"@typescript-eslint/visitor-keys": "8.34.0", "@typescript-eslint/visitor-keys": "8.35.1",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^7.0.0", "ignore": "^7.0.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -1849,22 +1835,22 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.34.0", "@typescript-eslint/parser": "^8.35.1",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <5.9.0"
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.34.0", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz",
"integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/types": "8.34.0", "@typescript-eslint/types": "8.35.1",
"@typescript-eslint/typescript-estree": "8.34.0", "@typescript-eslint/typescript-estree": "8.35.1",
"@typescript-eslint/visitor-keys": "8.34.0", "@typescript-eslint/visitor-keys": "8.35.1",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -1880,14 +1866,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.34.0", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz",
"integrity": "sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw==", "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.34.0", "@typescript-eslint/tsconfig-utils": "^8.35.1",
"@typescript-eslint/types": "^8.34.0", "@typescript-eslint/types": "^8.35.1",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -1902,14 +1888,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.34.0", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz",
"integrity": "sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw==", "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.34.0", "@typescript-eslint/types": "8.35.1",
"@typescript-eslint/visitor-keys": "8.34.0" "@typescript-eslint/visitor-keys": "8.35.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1920,9 +1906,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.34.0", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz",
"integrity": "sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA==", "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1937,14 +1923,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.34.0", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz",
"integrity": "sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg==", "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.34.0", "@typescript-eslint/typescript-estree": "8.35.1",
"@typescript-eslint/utils": "8.34.0", "@typescript-eslint/utils": "8.35.1",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.1.0"
}, },
@ -1961,9 +1947,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.34.0", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz",
"integrity": "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA==", "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1975,16 +1961,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.34.0", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz",
"integrity": "sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg==", "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.34.0", "@typescript-eslint/project-service": "8.35.1",
"@typescript-eslint/tsconfig-utils": "8.34.0", "@typescript-eslint/tsconfig-utils": "8.35.1",
"@typescript-eslint/types": "8.34.0", "@typescript-eslint/types": "8.35.1",
"@typescript-eslint/visitor-keys": "8.34.0", "@typescript-eslint/visitor-keys": "8.35.1",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -2004,16 +1990,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.34.0", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz",
"integrity": "sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ==", "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.7.0", "@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/types": "8.34.0", "@typescript-eslint/types": "8.35.1",
"@typescript-eslint/typescript-estree": "8.34.0" "@typescript-eslint/typescript-estree": "8.35.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2028,14 +2014,14 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.34.0", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz",
"integrity": "sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==", "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.34.0", "@typescript-eslint/types": "8.35.1",
"eslint-visitor-keys": "^4.2.0" "eslint-visitor-keys": "^4.2.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2619,9 +2605,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.5.0", "version": "17.0.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", "integrity": "sha512-A0BJ5lrpJVSfnMMXjmeO0xUnoxqsBHWCoqqTnGwGYVdnctqXXUEhJOO7LxmgxJon9tEZFGpe0xPRX0h2v3AANQ==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -2648,9 +2634,9 @@
} }
}, },
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
"version": "1.4.4", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"once": "^1.4.0" "once": "^1.4.0"
@ -2728,19 +2714,19 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.28.0", "version": "9.30.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.28.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.0.tgz",
"integrity": "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==", "integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.20.0", "@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.2.1", "@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.28.0", "@eslint/js": "9.30.0",
"@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",
@ -2752,9 +2738,9 @@
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"debug": "^4.3.2", "debug": "^4.3.2",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.3.0", "eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.0", "eslint-visitor-keys": "^4.2.1",
"espree": "^10.3.0", "espree": "^10.4.0",
"esquery": "^1.5.0", "esquery": "^1.5.0",
"esutils": "^2.0.2", "esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@ -2805,9 +2791,9 @@
} }
}, },
"node_modules/eslint-plugin-svelte": { "node_modules/eslint-plugin-svelte": {
"version": "3.9.2", "version": "3.10.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.9.2.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.10.1.tgz",
"integrity": "sha512-aqzfHtG9RPaFhCUFm5QFC6eFY/yHFQIT8VYYFe7/mT2A9mbgVR3XV2keCqU19LN8iVD9mdvRvqHU+4+CzJImvg==", "integrity": "sha512-csCh2x0ge/DugXC7dCANh46Igi7bjMZEy6rHZCdS13AoGVJSu7a90Kru3I8oMYLGEemPRE1hQXadxvRPVMAAXQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2815,7 +2801,7 @@
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"esutils": "^2.0.3", "esutils": "^2.0.3",
"globals": "^16.0.0", "globals": "^16.0.0",
"known-css-properties": "^0.36.0", "known-css-properties": "^0.37.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"postcss-load-config": "^3.1.4", "postcss-load-config": "^3.1.4",
"postcss-safe-parser": "^7.0.0", "postcss-safe-parser": "^7.0.0",
@ -2839,9 +2825,9 @@
} }
}, },
"node_modules/eslint-plugin-svelte/node_modules/globals": { "node_modules/eslint-plugin-svelte/node_modules/globals": {
"version": "16.2.0", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
"integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -3719,9 +3705,9 @@
} }
}, },
"node_modules/known-css-properties": { "node_modules/known-css-properties": {
"version": "0.36.0", "version": "0.37.0",
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.36.0.tgz", "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
"integrity": "sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==", "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -4308,9 +4294,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.5", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.5.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -4481,9 +4467,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.5.3", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -4529,9 +4515,9 @@
} }
}, },
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.2", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"end-of-stream": "^1.1.0", "end-of-stream": "^1.1.0",
@ -4691,13 +4677,13 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.43.0", "version": "4.44.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz",
"integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.7" "@types/estree": "1.0.8"
}, },
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
@ -4707,36 +4693,29 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.43.0", "@rollup/rollup-android-arm-eabi": "4.44.1",
"@rollup/rollup-android-arm64": "4.43.0", "@rollup/rollup-android-arm64": "4.44.1",
"@rollup/rollup-darwin-arm64": "4.43.0", "@rollup/rollup-darwin-arm64": "4.44.1",
"@rollup/rollup-darwin-x64": "4.43.0", "@rollup/rollup-darwin-x64": "4.44.1",
"@rollup/rollup-freebsd-arm64": "4.43.0", "@rollup/rollup-freebsd-arm64": "4.44.1",
"@rollup/rollup-freebsd-x64": "4.43.0", "@rollup/rollup-freebsd-x64": "4.44.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.43.0", "@rollup/rollup-linux-arm-gnueabihf": "4.44.1",
"@rollup/rollup-linux-arm-musleabihf": "4.43.0", "@rollup/rollup-linux-arm-musleabihf": "4.44.1",
"@rollup/rollup-linux-arm64-gnu": "4.43.0", "@rollup/rollup-linux-arm64-gnu": "4.44.1",
"@rollup/rollup-linux-arm64-musl": "4.43.0", "@rollup/rollup-linux-arm64-musl": "4.44.1",
"@rollup/rollup-linux-loongarch64-gnu": "4.43.0", "@rollup/rollup-linux-loongarch64-gnu": "4.44.1",
"@rollup/rollup-linux-powerpc64le-gnu": "4.43.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1",
"@rollup/rollup-linux-riscv64-gnu": "4.43.0", "@rollup/rollup-linux-riscv64-gnu": "4.44.1",
"@rollup/rollup-linux-riscv64-musl": "4.43.0", "@rollup/rollup-linux-riscv64-musl": "4.44.1",
"@rollup/rollup-linux-s390x-gnu": "4.43.0", "@rollup/rollup-linux-s390x-gnu": "4.44.1",
"@rollup/rollup-linux-x64-gnu": "4.43.0", "@rollup/rollup-linux-x64-gnu": "4.44.1",
"@rollup/rollup-linux-x64-musl": "4.43.0", "@rollup/rollup-linux-x64-musl": "4.44.1",
"@rollup/rollup-win32-arm64-msvc": "4.43.0", "@rollup/rollup-win32-arm64-msvc": "4.44.1",
"@rollup/rollup-win32-ia32-msvc": "4.43.0", "@rollup/rollup-win32-ia32-msvc": "4.44.1",
"@rollup/rollup-win32-x64-msvc": "4.43.0", "@rollup/rollup-win32-x64-msvc": "4.44.1",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rollup/node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -5145,9 +5124,9 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.34.1", "version": "5.34.9",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.1.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.9.tgz",
"integrity": "sha512-jWNnN2hZFNtnzKPptCcJHBWrD9CtbHPDwIRIODufOYaWkR0kLmAIlM384lMt4ucwuIRX4hCJwD2D8ZtEcGJQ0Q==", "integrity": "sha512-sld35zFpooaSRSj4qw8Vl/cyyK0/sLQq9qhJ7BGZo/Kd0ggYtEnvNYLlzhhoqYsYQzA0hJqkzt3RBO/8KoTZOg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5171,9 +5150,9 @@
} }
}, },
"node_modules/svelte-check": { "node_modules/svelte-check": {
"version": "4.2.1", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.2.1.tgz", "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.2.2.tgz",
"integrity": "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA==", "integrity": "sha512-1+31EOYZ7NKN0YDMKusav2hhEoA51GD9Ws6o//0SphMT0ve9mBTsTUEX7OmDMadUP3KjNHsSKtJrqdSaD8CrGQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5524,9 +5503,9 @@
} }
}, },
"node_modules/vitefu": { "node_modules/vitefu": {
"version": "1.0.6", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.7.tgz",
"integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==", "integrity": "sha512-eRWXLBbJjW3X5z5P5IHcSm2yYbYRPb2kQuc+oqsbAl99WB5kVsPbiiox+cymo8twTzifA6itvhr2CmjnaZZp0Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
@ -5534,7 +5513,7 @@
"tests/projects/*" "tests/projects/*"
], ],
"peerDependencies": { "peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"vite": { "vite": {
@ -5585,9 +5564,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.18.2", "version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View File

@ -1,6 +1,6 @@
{ {
"name": "moshing-mammut", "name": "moshing-mammut",
"version": "1.3.2", "version": "1.4.0",
"private": true, "private": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"scripts": { "scripts": {
@ -15,9 +15,9 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.21.5", "@sveltejs/kit": "^2.22.2",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.1.0",
"@types/node": "^22.6.1", "@types/node": "^22.9.0",
"@types/sqlite3": "^3.0.0", "@types/sqlite3": "^3.0.0",
"@types/ws": "^8.5.0", "@types/ws": "^8.5.0",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
@ -36,7 +36,7 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"dotenv": "^16.0.3", "dotenv": "^17.0.0",
"feed": "^5.1.0", "feed": "^5.1.0",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"sqlite3": "^5.0.0", "sqlite3": "^5.0.0",

View File

@ -1,15 +1,17 @@
import { log } from '$lib/log'; import { Logger } from '$lib/log';
import { TimelineReader } from '$lib/server/timeline'; 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';
log.log('App startup'); const logger = new Logger('App');
logger.log('App startup');
TimelineReader.init(); TimelineReader.init();
export const handleError = (({ error }) => { export const handleError = (({ error }) => {
if (error instanceof Error) { if (error instanceof Error) {
log.error('Something went wrong: ', error.name, error.message); logger.error('Something went wrong: ', error.name, error.message);
} }
return { return {
@ -18,6 +20,12 @@ 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' } });
@ -39,7 +47,7 @@ export const handle = (async ({ event, resolve }) => {
const readStream = fd const readStream = fd
.readableWebStream() .readableWebStream()
.getReader({ mode: 'byob' }) as ReadableStream<Uint8Array>; .getReader({ mode: 'byob' }) as ReadableStream<Uint8Array>;
log.info('sending. size: ', stat.size); logger.info('sending. size: ', stat.size);
return new Response(readStream, { return new Response(readStream, {
headers: [ headers: [
['Content-Type', 'image/' + suffix], ['Content-Type', 'image/' + suffix],
@ -51,7 +59,7 @@ export const handle = (async ({ event, resolve }) => {
const f = await fs.readFile('avatars/' + fileName); const f = await fs.readFile('avatars/' + fileName);
return new Response(f, { headers: [['Content-Type', 'image/' + suffix]] }); return new Response(f, { headers: [['Content-Type', 'image/' + suffix]] });
} catch (e) { } catch (e) {
log.error('no stream', e); logger.error('no stream', e);
error(404); error(404);
} }
} }

View File

@ -4,6 +4,9 @@ const { DEV } = import.meta.env;
export const enableVerboseLog = isTruthy(env.VERBOSE); export const enableVerboseLog = isTruthy(env.VERBOSE);
/**
* @deprecated Use the new {@link Logger} class instead.
*/
export const log = { export const log = {
verbose: (...params: any[]) => { verbose: (...params: any[]) => {
if (!enableVerboseLog) { if (!enableVerboseLog) {
@ -12,7 +15,7 @@ export const log = {
console.debug(new Date().toISOString(), ...params); console.debug(new Date().toISOString(), ...params);
}, },
debug: (...params: any[]) => { debug: (...params: any[]) => {
if (!DEV) { if (!log.isDebugEnabled()) {
return; return;
} }
console.debug(new Date().toISOString(), ...params); console.debug(new Date().toISOString(), ...params);
@ -28,5 +31,59 @@ export const log = {
}, },
error: (...params: any[]) => { error: (...params: any[]) => {
console.error(new Date().toISOString(), ...params); console.error(new Date().toISOString(), ...params);
},
isDebugEnabled: (): boolean => {
return DEV;
} }
}; };
export class Logger {
public constructor(private name: string) {}
public static isDebugEnabled(): boolean {
return DEV;
}
public verbose(...params: any[]) {
if (!enableVerboseLog) {
return;
}
console.debug(new Date().toISOString(), `- ${this.name} -`, ...params);
}
public debug(...params: any[]) {
if (!Logger.isDebugEnabled()) {
return;
}
console.debug(new Date().toISOString(), `- ${this.name} -`, ...params);
}
public log(...params: any[]) {
console.log(new Date().toISOString(), `- ${this.name} -`, ...params);
}
public info(...params: any[]) {
console.info(new Date().toISOString(), `- ${this.name} -`, ...params);
}
public warn(...params: any[]) {
console.warn(new Date().toISOString(), `- ${this.name} -`, ...params);
}
public error(...params: any[]) {
console.error(new Date().toISOString(), `- ${this.name} -`, ...params);
}
public static error(...params: any[]) {
console.error(new Date().toISOString(), ...params);
}
public static debug(...params: any[]) {
if (!Logger.isDebugEnabled()) {
return;
}
console.debug(new Date().toISOString(), ...params);
}
public static log(...params: any[]) {
console.log(new Date().toISOString(), ...params);
}
public static info(...params: any[]) {
console.info(new Date().toISOString(), ...params);
}
public static warn(...params: any[]) {
console.warn(new Date().toISOString(), ...params);
}
}

View File

@ -16,6 +16,17 @@ export interface Post {
songs?: SongInfo[]; songs?: SongInfo[];
} }
export interface OauthResponse {
access_token: string;
expires_in: number;
expires?: Date;
refresh_token?: string;
refresh_token_expires_in?: number;
scope: string;
token_type: string;
error?: any;
}
export interface PreviewCard { export interface PreviewCard {
url: string; url: string;
title: string; title: string;

View File

@ -3,6 +3,8 @@ import type { SongThumbnailImage } from '$lib/mastodon/response';
export type SongInfo = { export type SongInfo = {
pageUrl: string; pageUrl: string;
youtubeUrl?: string; youtubeUrl?: string;
spotifyUrl?: string;
spotifyUri?: string;
type: 'song' | 'album'; type: 'song' | 'album';
title?: string; title?: string;
artistName?: string; artistName?: string;

View File

@ -1,10 +1,12 @@
import { IGNORE_USERS, MASTODON_INSTANCE } from '$env/static/private'; import { IGNORE_USERS, MASTODON_INSTANCE } from '$env/static/private';
import { enableVerboseLog, log } from '$lib/log'; import { enableVerboseLog, Logger } from '$lib/log';
import type { Account, AccountAvatar, Post, SongThumbnailImage, Tag } from '$lib/mastodon/response'; import type { Account, AccountAvatar, Post, SongThumbnailImage, Tag } from '$lib/mastodon/response';
import type { SongInfo } from '$lib/odesliResponse'; import type { SongInfo } from '$lib/odesliResponse';
import { TimelineReader } from '$lib/server/timeline'; import { TimelineReader } from '$lib/server/timeline';
import sqlite3 from 'sqlite3'; import sqlite3 from 'sqlite3';
const logger = new Logger('Database');
type FilterParameter = { type FilterParameter = {
$limit?: number | undefined | null; $limit?: number | undefined | null;
$since?: string | undefined | null; $since?: string | undefined | null;
@ -37,6 +39,8 @@ type SongRow = {
overviewUrl?: string; overviewUrl?: string;
type: 'album' | 'song'; type: 'album' | 'song';
youtubeUrl?: string; youtubeUrl?: string;
spotifyUrl?: string;
spotifyUri?: string;
title?: string; title?: string;
artistName?: string; artistName?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
@ -81,15 +85,15 @@ let databaseReady = false;
if (enableVerboseLog) { if (enableVerboseLog) {
sqlite3.verbose(); sqlite3.verbose();
db.on('change', (t, d, table, rowid) => { db.on('change', (t, d, table, rowid) => {
log.verbose('DB change event', t, d, table, rowid); logger.verbose('DB change event', t, d, table, rowid);
}); });
db.on('trace', (sql) => { db.on('trace', (sql) => {
log.verbose('Running', sql); logger.verbose('Running', sql);
}); });
db.on('profile', (sql) => { db.on('profile', (sql) => {
log.verbose('Finished', sql); logger.verbose('Finished', sql);
}); });
} }
@ -97,7 +101,7 @@ function applyDbMigration(migration: Migration): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.exec(migration.statement, (err) => { db.exec(migration.statement, (err) => {
if (err !== null) { if (err !== null) {
log.error(`Failed to apply migration ${migration.name}`, err); logger.error(`Failed to apply migration ${migration.name}`, err);
reject(err); reject(err);
return; return;
} }
@ -118,31 +122,31 @@ async function applyMigration(migration: Migration) {
if (post.songs && post.songs.length) { if (post.songs && post.songs.length) {
continue; continue;
} }
log.info( logger.info(
`Fetching songs for existing post ${current.toString().padStart(4, '0')} of ${total}`, `Fetching songs for existing post ${current.toString().padStart(4, '0')} of ${total}`,
post.url post.url
); );
const songs = await TimelineReader.getSongInfoInPost(post); const songs = await TimelineReader.getSongInfoInPost(post);
await saveSongInfoData(post.url, songs); await saveSongInfoData(post.url, songs);
log.debug(`Fetched ${songs.length} songs for existing post`, post.url); logger.debug(`Fetched ${songs.length} songs for existing post`, post.url);
} }
log.debug(`Finished fetching songs`); logger.debug(`Finished fetching songs`);
} else { } else {
await applyDbMigration(migration); await applyDbMigration(migration);
} }
} }
db.on('open', () => { db.on('open', () => {
log.info('Opened database'); logger.info('Opened database');
db.serialize(); db.serialize();
db.run('CREATE TABLE IF NOT EXISTS "migrations" ("id" integer,"name" TEXT, PRIMARY KEY (id))'); db.run('CREATE TABLE IF NOT EXISTS "migrations" ("id" integer,"name" TEXT, PRIMARY KEY (id))');
db.all('SELECT id FROM migrations', (err, rows: Migration[]) => { db.all('SELECT id FROM migrations', (err, rows: Migration[]) => {
if (err !== null) { if (err !== null) {
log.error('Could not fetch existing migrations', err); logger.error('Could not fetch existing migrations', err);
databaseReady = true; databaseReady = true;
return; return;
} }
log.debug('Already applied migrations', rows); logger.debug('Already applied migrations', rows);
const appliedMigrations: Set<number> = new Set(rows.map((row) => row['id'])); const appliedMigrations: Set<number> = new Set(rows.map((row) => row['id']));
const toApply = getMigrations().filter((m) => !appliedMigrations.has(m.id)); const toApply = getMigrations().filter((m) => !appliedMigrations.has(m.id));
let remaining = toApply.length; let remaining = toApply.length;
@ -159,7 +163,7 @@ db.on('open', () => {
databaseReady = true; databaseReady = true;
} }
if (err !== null) { if (err !== null) {
log.error(`Failed to apply migration ${migration.name}`, err); logger.error(`Failed to apply migration ${migration.name}`, err);
return; return;
} }
db.run( db.run(
@ -167,10 +171,10 @@ db.on('open', () => {
[migration.id, migration.name], [migration.id, migration.name],
(e: Error) => { (e: Error) => {
if (e !== null) { if (e !== null) {
log.error(`Failed to mark migration ${migration.name} as applied`, e); logger.error(`Failed to mark migration ${migration.name} as applied`, e);
return; return;
} }
log.info(`Applied migration ${migration.name}`); logger.info(`Applied migration ${migration.name}`);
} }
); );
}); });
@ -178,7 +182,7 @@ db.on('open', () => {
}); });
}); });
db.on('error', (err) => { db.on('error', (err) => {
log.error('Error opening database', err); logger.error('Error opening database', err);
}); });
function getMigrations(): Migration[] { function getMigrations(): Migration[] {
@ -313,6 +317,13 @@ function getMigrations(): Migration[] {
statement: ` statement: `
ALTER TABLE songs ADD COLUMN thumbnailWidth INTEGER NULL; ALTER TABLE songs ADD COLUMN thumbnailWidth INTEGER NULL;
ALTER TABLE songs ADD COLUMN thumbnailHeight INTEGER NULL;` ALTER TABLE songs ADD COLUMN thumbnailHeight INTEGER NULL;`
},
{
id: 8,
name: 'song spotify url/uri',
statement: `
ALTER TABLE songs ADD COLUMN spotifyUrl TEXT NULL;
ALTER TABLE songs ADD COLUMN spotifyUri TEXT NULL;`
} }
]; ];
} }
@ -321,9 +332,9 @@ async function waitReady(): Promise<void> {
// Simpler than a semaphore and is really only needed on startup // Simpler than a semaphore and is really only needed on startup
return new Promise((resolve) => { return new Promise((resolve) => {
const interval = setInterval(() => { const interval = setInterval(() => {
log.verbose('Waiting for database to be ready'); logger.verbose('Waiting for database to be ready');
if (databaseReady) { if (databaseReady) {
log.verbose('DB is ready'); logger.verbose('DB is ready');
clearInterval(interval); clearInterval(interval);
resolve(); resolve();
} }
@ -354,7 +365,7 @@ function saveAccountData(account: Account): Promise<void> {
], ],
(err) => { (err) => {
if (err !== null) { if (err !== null) {
log.error(`Could not insert/update account ${account.id}`, err); logger.error(`Could not insert/update account ${account.id}`, err);
reject(err); reject(err);
return; return;
} }
@ -377,7 +388,7 @@ function savePostData(post: Post): Promise<void> {
[post.id, post.content, post.created_at, post.url, post.account.url], [post.id, post.content, post.created_at, post.url, post.account.url],
(postErr) => { (postErr) => {
if (postErr !== null) { if (postErr !== null) {
log.error(`Could not insert post ${post.url}`, postErr); logger.error(`Could not insert post ${post.url}`, postErr);
reject(postErr); reject(postErr);
return; return;
} }
@ -405,7 +416,7 @@ function savePostTagData(post: Post): Promise<void> {
[tag.url, tag.name], [tag.url, tag.name],
(tagErr) => { (tagErr) => {
if (tagErr !== null) { if (tagErr !== null) {
log.error(`Could not insert/update tag ${tag.url}`, tagErr); logger.error(`Could not insert/update tag ${tag.url}`, tagErr);
reject(tagErr); reject(tagErr);
return; return;
} }
@ -414,7 +425,7 @@ function savePostTagData(post: Post): Promise<void> {
[post.url, tag.url], [post.url, tag.url],
(posttagserr) => { (posttagserr) => {
if (posttagserr !== null) { if (posttagserr !== null) {
log.error(`Could not insert poststags ${tag.url}, ${post.url}`, posttagserr); logger.error(`Could not insert poststags ${tag.url}, ${post.url}`, posttagserr);
reject(posttagserr); reject(posttagserr);
return; return;
} }
@ -444,14 +455,16 @@ 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, title, artistName, thumbnailUrl, post_url, thumbnailWidth, thumbnailHeight) INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, spotifyUrl, spotifyUri, title, artistName, thumbnailUrl, post_url, thumbnailWidth, thumbnailHeight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[ [
song.postedUrl, song.postedUrl,
song.pageUrl, song.pageUrl,
song.type, song.type,
song.youtubeUrl, song.youtubeUrl,
song.spotifyUrl,
song.spotifyUri,
song.title, song.title,
song.artistName, song.artistName,
song.thumbnailUrl, song.thumbnailUrl,
@ -461,7 +474,7 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
], ],
(songErr) => { (songErr) => {
if (songErr !== null) { if (songErr !== null) {
log.error(`Could not insert song ${song.postedUrl}`, songErr); logger.error(`Could not insert song ${song.postedUrl}`, songErr);
reject(songErr); reject(songErr);
return; return;
} }
@ -479,20 +492,20 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
} }
export async function savePost(post: Post, songs: SongInfo[]) { export async function savePost(post: Post, songs: SongInfo[]) {
log.debug(`Saving post ${post.url}`); logger.debug(`Saving post ${post.url}`);
if (!databaseReady) { if (!databaseReady) {
await waitReady(); await waitReady();
} }
const account = post.account; const account = post.account;
await saveAccountData(account); await saveAccountData(account);
log.debug(`Saved account data ${post.url}`); logger.debug(`Saved account data ${post.url}`);
await savePostData(post); await savePostData(post);
log.debug(`Saved post data ${post.url}`); logger.debug(`Saved post data ${post.url}`);
await savePostTagData(post); await savePostTagData(post);
log.debug(`Saved ${post.tags.length} tag data ${post.url}`); logger.debug(`Saved ${post.tags.length} tag data ${post.url}`);
await saveSongInfoData(post.url, songs); await saveSongInfoData(post.url, songs);
log.debug( logger.debug(
`Saved ${songs.length} song info data ${post.url}`, `Saved ${songs.length} song info data ${post.url}`,
songs.map((s) => s.thumbnailHeight) songs.map((s) => s.thumbnailHeight)
); );
@ -511,7 +524,7 @@ function getPostData(filterQuery: string, params: FilterParameter): Promise<Post
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows: PostRow[]) => { db.all(sql, params, (err, rows: PostRow[]) => {
if (err != null) { if (err != null) {
log.error('Error loading posts', err); logger.error('Error loading posts', err);
reject(err); reject(err);
return; return;
} }
@ -530,7 +543,7 @@ function getTagData(postIdsParams: string, postIds: string[]): Promise<Map<strin
postIds, postIds,
(tagErr, tagRows: PostTagRow[]) => { (tagErr, tagRows: PostTagRow[]) => {
if (tagErr != null) { if (tagErr != null) {
log.error('Error loading post tags', tagErr); logger.error('Error loading post tags', tagErr);
reject(tagErr); reject(tagErr);
return; return;
} }
@ -551,14 +564,14 @@ function getTagData(postIdsParams: string, postIds: string[]): Promise<Map<strin
function getSongData(postIdsParams: string, postIds: string[]): Promise<Map<string, SongInfo[]>> { function getSongData(postIdsParams: string, postIds: string[]): Promise<Map<string, SongInfo[]>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.all( db.all(
`SELECT post_url, songs.postedUrl, songs.overviewUrl, songs.type, songs.youtubeUrl, `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.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,
(tagErr, tagRows: SongRow[]) => { (tagErr, tagRows: SongRow[]) => {
if (tagErr != null) { if (tagErr != null) {
log.error('Error loading post songs', tagErr); logger.error('Error loading post songs', tagErr);
reject(tagErr); reject(tagErr);
return; return;
} }
@ -567,6 +580,8 @@ function getSongData(postIdsParams: string, postIds: string[]): Promise<Map<stri
const info = { const info = {
pageUrl: item.overviewUrl, pageUrl: item.overviewUrl,
youtubeUrl: item.youtubeUrl, youtubeUrl: item.youtubeUrl,
spotifyUrl: item.spotifyUrl,
spotifyUri: item.spotifyUri,
type: item.type, type: item.type,
title: item.title, title: item.title,
artistName: item.artistName, artistName: item.artistName,
@ -580,7 +595,7 @@ function getSongData(postIdsParams: string, postIds: string[]): Promise<Map<stri
}, },
new Map() new Map()
); );
log.verbose('songMap', songMap); logger.verbose('songMap', songMap);
resolve(songMap); resolve(songMap);
} }
); );
@ -599,7 +614,7 @@ function getAvatarData(
accountUrls, accountUrls,
(err, rows: AccountAvatarRow[]) => { (err, rows: AccountAvatarRow[]) => {
if (err != null) { if (err != null) {
log.error('Error loading avatars', err); logger.error('Error loading avatars', err);
reject(err); reject(err);
return; return;
} }
@ -633,7 +648,7 @@ function getSongThumbnailData(
thumbUrls, thumbUrls,
(err, rows: SongThumbnailAvatarRow[]) => { (err, rows: SongThumbnailAvatarRow[]) => {
if (err != null) { if (err != null) {
log.error('Error loading avatars', err); logger.error('Error loading avatars', err);
reject(err); reject(err);
return; return;
} }

View File

@ -0,0 +1,159 @@
import { Logger } from '$lib/log';
import type { OauthResponse } from '$lib/mastodon/response';
import fs from 'fs/promises';
export abstract class OauthPlaylistAdder {
/// How many minutes before expiry the token will be refreshed
protected refresh_time: number = 15;
protected logger: Logger = new Logger('OauthPlaylistAdder');
protected constructor(
protected apiBase: string,
protected token_file_name: string
) {}
public async authCodeExists(): Promise<boolean> {
try {
const fileHandle = await fs.open(this.token_file_name);
await fileHandle.close();
return true;
} catch {
this.logger.info('No auth token yet, authorizing...');
return false;
}
}
protected constructAuthUrlInternal(
endpointUrl: string,
clientId: string,
scope: string,
redirectUri: URL,
additionalParameters: Map<string, string> = new Map()
): URL {
const authUrl = new URL(endpointUrl);
authUrl.searchParams.append('client_id', clientId);
authUrl.searchParams.append('redirect_uri', redirectUri.toString());
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('scope', scope);
for (let p of additionalParameters.entries()) {
authUrl.searchParams.append(p[0], p[1]);
}
return authUrl;
}
public async receivedAuthCodeInternal(
tokenUrl: URL,
clientId: string,
code: string,
url: URL,
client_secret?: string,
customHeader?: HeadersInit
) {
this.logger.debug('received code');
const params = new URLSearchParams();
params.append('client_id', clientId);
params.append('code', code);
params.append('grant_type', 'authorization_code');
params.append('redirect_uri', `${url.origin}${url.pathname}`);
if (client_secret) {
params.append('client_secret', client_secret);
}
this.logger.debug('sending token req', params);
const resp: OauthResponse = await fetch(tokenUrl, {
method: 'POST',
body: params,
headers: customHeader
}).then((r) => r.json());
this.logger.debug('received access token', resp);
let expiration = new Date();
expiration.setTime(expiration.getTime() + resp.expires_in * 1000);
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
resp.expires = expiration;
await fs.writeFile(this.token_file_name, JSON.stringify(resp));
}
protected async auth(): Promise<OauthResponse | null> {
try {
const token_file = await fs.readFile(this.token_file_name, { encoding: 'utf8' });
let token = JSON.parse(token_file);
if (token.expires) {
if (typeof token.expires === typeof '') {
token.expires = new Date(token.expires);
}
}
return token;
} catch (e) {
this.logger.error('Could not read access token', e);
return null;
}
}
protected async shouldRefreshToken(): Promise<{ token: OauthResponse; refresh: boolean } | null> {
const token = await this.auth();
if (token == null || !token?.expires) {
return null;
}
let refreshAt = new Date();
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()) {
return {
token: token,
refresh: false
};
}
this.logger.info(
'Token expires',
token.expires,
token.expires.getTime(),
`which is after the refresh time`,
refreshAt,
refreshAt.getTime()
);
return {
token: token,
refresh: true
};
}
protected async requestRefreshToken(
tokenUrl: URL,
clientId: string,
refresh_token: string,
redirect_uri?: string,
client_secret?: string,
customHeader?: HeadersInit
) {
const params = new URLSearchParams();
params.append('client_id', clientId);
params.append('grant_type', 'refresh_token');
params.append('refresh_token', refresh_token);
if (client_secret) {
params.append('client_secret', client_secret);
}
if (redirect_uri) {
params.append('redirect_uri', redirect_uri);
}
this.logger.debug('sending token req', params);
const resp: OauthResponse = await fetch(tokenUrl, {
method: 'POST',
body: params,
headers: customHeader
}).then((r) => r.json());
this.logger.verbose('received access token', resp);
if (resp.error) {
this.logger.error('token resp error', resp);
return null;
}
if (!resp.refresh_token) {
resp.refresh_token = refresh_token;
}
let expiration = new Date();
expiration.setTime(expiration.getTime() + resp.expires_in * 1000);
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
resp.expires = expiration;
await fs.writeFile(this.token_file_name, JSON.stringify(resp));
return resp;
}
}

View File

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

View File

@ -0,0 +1,124 @@
import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_PLAYLIST_ID } from '$env/static/private';
import { Logger } from '$lib/log';
import type { OauthResponse } from '$lib/mastodon/response';
import type { SongInfo } from '$lib/odesliResponse';
import { OauthPlaylistAdder } from './oauthPlaylistAdder';
import type { PlaylistAdder } from './playlistAdder';
export class SpotifyPlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder {
public constructor() {
super('https://api.spotify.com/v1', 'spotify_auth_token');
this.logger = new Logger('SpotifyPlaylistAdder');
}
public constructAuthUrl(redirectUri: URL): URL {
const endpoint = 'https://accounts.spotify.com/authorize';
return this.constructAuthUrlInternal(
endpoint,
SPOTIFY_CLIENT_ID,
'playlist-modify-private playlist-modify-public',
redirectUri
);
}
public async receivedAuthCode(code: string, url: URL) {
this.logger.debug('received code');
const authHeader =
'Basic ' + Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET).toString('base64');
const tokenUrl = new URL('https://accounts.spotify.com/api/token');
await this.receivedAuthCodeInternal(tokenUrl, SPOTIFY_CLIENT_ID, code, url, undefined, {
Authorization: authHeader
});
}
private async refreshToken(force: boolean = false): Promise<OauthResponse | null> {
const tokenInfo = await this.shouldRefreshToken();
if (tokenInfo == null) {
return null;
}
let token = tokenInfo.token;
if (!tokenInfo.refresh && !force) {
return token;
}
if (!token.refresh_token) {
this.logger.error('Need to refresh access token, but no refresh token provided');
return null;
}
const authHeader =
'Basic ' + Buffer.from(SPOTIFY_CLIENT_ID + ':' + SPOTIFY_CLIENT_SECRET).toString('base64');
const tokenUrl = new URL('https://accounts.spotify.com/api/token');
return await this.requestRefreshToken(
tokenUrl,
SPOTIFY_CLIENT_ID,
token.refresh_token,
undefined,
undefined,
{ Authorization: authHeader }
);
}
private async addToPlaylistRetry(song: SongInfo, remaning: number = 3) {
if (remaning < 0) {
this.logger.error('max retries reached, song will not be added to spotify playlist');
}
this.logger.debug('addToSpotifyPlaylist', remaning);
const token = await this.refreshToken();
if (token == null) {
return;
}
if (!SPOTIFY_PLAYLIST_ID || SPOTIFY_PLAYLIST_ID === 'CHANGE_ME') {
this.logger.debug('no spotify playlist ID configured');
return;
}
if (!song.spotifyUri) {
this.logger.info('Skip adding song to spotify playlist, no Uri', song);
return;
}
// TODO. Spotify's check for "is this song already in the playlist" is... ugh
/*
const playlistItemsUrl = new URL(`${this.apiBase}/playlists/${SPOTIFY_PLAYLIST_ID}/tracks`);
playlistItemsUrl.searchParams.append('videoId', youtubeId);
playlistItemsUrl.searchParams.append('playlistId', SPOTIFY_PLAYLIST_ID);
playlistItemsUrl.searchParams.append('part', 'id');*/
/*const existingPlaylistItem = await fetch(this.apiBase + '/playlistItems', {
headers: { Authorization: `${token.token_type} ${token.access_token}` }
}).then((r) => r.json());
logger.debug('existingPlaylistItem', existingPlaylistItem);
if (existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0) {
logger.info('Item already in playlist');
return;
}*/
//const searchParams = new URLSearchParams([['part', 'snippet']]);
const options: RequestInit = {
method: 'POST',
headers: { Authorization: `${token.token_type} ${token.access_token}` },
body: JSON.stringify({
uris: [song.spotifyUri]
})
};
const apiUrl = new URL(`${this.apiBase}/playlists/${SPOTIFY_PLAYLIST_ID}/tracks`);
const resp = await fetch(apiUrl, options);
const respObj = await resp.json();
if (respObj.error) {
this.logger.debug('Add to playlist failed', respObj.error);
if (respObj.error.status === 401) {
const token = await this.refreshToken(true);
if (token == null) {
return;
}
this.addToPlaylistRetry(song, remaning--);
}
} else {
this.logger.info('Added to playlist', song.spotifyUri, song.title);
}
}
public async addToPlaylist(song: SongInfo) {
await this.addToPlaylistRetry(song);
}
}

View File

@ -0,0 +1,131 @@
import {
BASE_URL,
YOUTUBE_CLIENT_ID,
YOUTUBE_CLIENT_SECRET,
YOUTUBE_PLAYLIST_ID
} from '$env/static/private';
import { Logger } from '$lib/log';
import type { OauthResponse } from '$lib/mastodon/response';
import type { SongInfo } from '$lib/odesliResponse';
import { OauthPlaylistAdder } from './oauthPlaylistAdder';
import type { PlaylistAdder } from './playlistAdder';
export class YoutubePlaylistAdder extends OauthPlaylistAdder implements PlaylistAdder {
public constructor() {
super('https://www.googleapis.com/youtube/v3', 'yt_auth_token');
this.logger = new Logger('YoutubePlaylistAdder');
}
public constructAuthUrl(redirectUri: URL): URL {
let additionalParameters = new Map([
['access_type', 'offline'],
['include_granted_scopes', 'false']
]);
const endpoint = 'https://accounts.google.com/o/oauth2/v2/auth';
return this.constructAuthUrlInternal(
endpoint,
YOUTUBE_CLIENT_ID,
'https://www.googleapis.com/auth/youtube',
redirectUri,
additionalParameters
);
}
public async receivedAuthCode(code: string, url: URL) {
this.logger.debug('received code');
const tokenUrl = new URL('https://oauth2.googleapis.com/token');
await this.receivedAuthCodeInternal(
tokenUrl,
YOUTUBE_CLIENT_ID,
code,
url,
YOUTUBE_CLIENT_SECRET
);
}
private async refreshToken(): Promise<OauthResponse | null> {
const tokenInfo = await this.shouldRefreshToken();
if (tokenInfo == null) {
return null;
}
let token = tokenInfo.token;
if (!tokenInfo.refresh) {
return token;
}
if (!token.refresh_token) {
this.logger.error('Need to refresh access token, but no refresh token provided');
return null;
}
const tokenUrl = new URL('https://oauth2.googleapis.com/token');
return await this.requestRefreshToken(
tokenUrl,
YOUTUBE_CLIENT_ID,
token.refresh_token,
`${BASE_URL}/ytauth`,
YOUTUBE_CLIENT_SECRET
);
}
public async addToPlaylist(song: SongInfo) {
this.logger.debug('addToYoutubePlaylist');
const token = await this.refreshToken();
if (token == null) {
return;
}
if (!YOUTUBE_PLAYLIST_ID || YOUTUBE_PLAYLIST_ID === 'CHANGE_ME') {
this.logger.debug('no playlist ID configured');
return;
}
if (!song.youtubeUrl) {
this.logger.info('Skip adding song to YT playlist, no youtube Url', song);
return;
}
const songUrl = new URL(song.youtubeUrl);
const youtubeId = songUrl.searchParams.get('v');
if (!youtubeId) {
this.logger.debug(
'Skip adding song to YT playlist, could not extract YT id from URL',
song.youtubeUrl
);
return;
}
this.logger.debug('Found YT id from URL', song.youtubeUrl, youtubeId);
const playlistItemsUrl = new URL(this.apiBase + '/playlistItems');
playlistItemsUrl.searchParams.append('videoId', youtubeId);
playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID);
playlistItemsUrl.searchParams.append('part', 'id');
const existingPlaylistItem = await fetch(playlistItemsUrl, {
headers: { Authorization: `${token.token_type} ${token.access_token}` }
}).then((r) => r.json());
if (existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0) {
this.logger.info('Item already in playlist', existingPlaylistItem);
return;
}
const addItemUrl = new URL(this.apiBase + '/playlistItems');
addItemUrl.searchParams.append('part', 'snippet');
const options: RequestInit = {
method: 'POST',
headers: { Authorization: `${token.token_type} ${token.access_token}` },
body: JSON.stringify({
snippet: {
playlistId: YOUTUBE_PLAYLIST_ID,
resourceId: {
videoId: youtubeId,
kind: 'youtube#video'
}
}
})
};
const resp = await fetch(addItemUrl, options);
const respObj = await resp.json();
this.logger.info('Added to playlist', youtubeId, song.title);
if (respObj.error) {
this.logger.debug('Add to playlist failed', respObj.error.errors);
}
}
}

View File

@ -1,10 +1,12 @@
import { BASE_URL, WEBSUB_HUB } from '$env/static/private'; import { BASE_URL, WEBSUB_HUB } from '$env/static/private';
import { PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public'; import { PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public';
import type { Post } from '$lib//mastodon/response'; import type { Post } from '$lib//mastodon/response';
import { log } 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 logger = new Logger('RSS');
export function createFeed(posts: Post[]): Feed { export function createFeed(posts: Post[]): Feed {
const baseUrl = BASE_URL.endsWith('/') ? BASE_URL : BASE_URL + '/'; const baseUrl = BASE_URL.endsWith('/') ? BASE_URL : BASE_URL + '/';
const hub = WEBSUB_HUB ? WEBSUB_HUB : undefined; const hub = WEBSUB_HUB ? WEBSUB_HUB : undefined;
@ -60,6 +62,6 @@ export async function saveAtomFeed(feed: Feed) {
body: params body: params
}); });
} catch (e) { } catch (e) {
log.error('Failed to update WebSub hub', e); logger.error('Failed to update WebSub hub', e);
} }
} }

View File

@ -5,7 +5,7 @@ import {
ODESLI_API_KEY, ODESLI_API_KEY,
YOUTUBE_API_KEY YOUTUBE_API_KEY
} from '$env/static/private'; } from '$env/static/private';
import { log } from '$lib/log'; import { Logger } from '$lib/log';
import type { import type {
Account, Account,
AccountAvatar, AccountAvatar,
@ -25,12 +25,17 @@ import {
savePost, savePost,
saveSongThumbnail saveSongThumbnail
} from '$lib/server/db'; } from '$lib/server/db';
import { SpotifyPlaylistAdder } from '$lib/server/playlist/spotifyPlaylistAdder';
import { YoutubePlaylistAdder } from '$lib/server/playlist/ytPlaylistAdder';
import { createFeed, saveAtomFeed } from '$lib/server/rss'; import { createFeed, saveAtomFeed } from '$lib/server/rss';
import { sleep } from '$lib/sleep'; import { sleep } from '$lib/sleep';
import crypto from 'crypto'; import crypto from 'crypto';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { console } from 'inspector/promises';
import sharp from 'sharp'; import sharp from 'sharp';
import { URL, URLSearchParams } from 'url';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
import type { PlaylistAdder } from './playlist/playlistAdder';
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);
@ -40,10 +45,14 @@ const YOUTUBE_REGEX = new RegExp(
export class TimelineReader { export class TimelineReader {
private static _instance: TimelineReader; private static _instance: TimelineReader;
private lastPosts: string[] = [];
private playlistAdders: PlaylistAdder[];
private logger: Logger;
private static 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') {
// Assume that it *is* a music link when no YT API key is provided // Assume that it *is* a music link when no YT API key is provided
this.logger.debug('YT API not configured');
return true; return true;
} }
const searchParams = new URLSearchParams([ const searchParams = new URLSearchParams([
@ -55,13 +64,13 @@ export class TimelineReader {
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.items.length) {
console.warn('Could not find video with id', videoId); this.logger.warn('Could not find video with id', videoId);
return false; return false;
} }
const item = respObj.items[0]; const item = respObj.items[0];
if (!item.snippet) { if (!item.snippet) {
console.warn('Could not load snippet for video', videoId, item); this.logger.warn('Could not load snippet for video', videoId, item);
return false; return false;
} }
if (item.snippet.tags?.includes('music')) { if (item.snippet.tags?.includes('music')) {
@ -79,15 +88,19 @@ export class TimelineReader {
const categoryTitle: string = await fetch(youtubeCategoryUrl) const categoryTitle: string = await fetch(youtubeCategoryUrl)
.then((r) => r.json()) .then((r) => r.json())
.then((r) => r.items[0]?.snippet?.title); .then((r) => r.items[0]?.snippet?.title);
this.logger.debug('YT category', categoryTitle);
return categoryTitle === 'Music'; return categoryTitle === 'Music';
} }
public static async getSongInfoInPost(post: Post): Promise<SongInfo[]> { public async getSongInfoInPost(post: Post): Promise<SongInfo[]> {
const urlMatches = post.content.matchAll(URL_REGEX); const urlMatches = post.content.matchAll(URL_REGEX);
const songs: SongInfo[] = []; const songs: SongInfo[] = [];
for (const match of urlMatches) { for (const match of urlMatches) {
if (match === undefined || match.groups === undefined) { if (match === undefined || match.groups === undefined) {
log.warn('Match listed in allMatches, but either it or its groups are undefined', match); this.logger.warn(
'Match listed in allMatches, but either it or its groups are undefined',
match
);
continue; continue;
} }
const urlMatch = match.groups.postUrl.toString(); const urlMatch = match.groups.postUrl.toString();
@ -95,14 +108,14 @@ export class TimelineReader {
try { try {
url = new URL(urlMatch); url = new URL(urlMatch);
} catch (e) { } catch (e) {
log.error('URL found via Regex does not seem to be a valud url', urlMatch, e); this.logger.error('URL found via Regex does not seem to be a valud url', urlMatch, e);
continue; continue;
} }
// Check *all* found url and let odesli determine if it is music or not // Check *all* found url and let odesli determine if it is music or not
log.debug(`Checking ${url} if it contains song data`); this.logger.debug(`Checking ${url} if it contains song data`);
const info = await TimelineReader.getSongInfo(url); const info = await this.getSongInfo(url);
log.debug(`Found song info for ${url}?`, info); //this.logger.debug(`Found song info for ${url}?`, info);
if (info) { if (info) {
songs.push(info); songs.push(info);
} }
@ -110,9 +123,9 @@ export class TimelineReader {
return songs; return songs;
} }
private static async getSongInfo(url: URL, remainingTries = 6): Promise<SongInfo | null> { private async getSongInfo(url: URL, remainingTries = 6): Promise<SongInfo | null> {
if (remainingTries === 0) { if (remainingTries === 0) {
log.error('No tries remaining. Lookup failed!'); this.logger.error('No tries remaining. Lookup failed!');
return null; return null;
} }
if (url.hostname === 'songwhip.com') { if (url.hostname === 'songwhip.com') {
@ -144,6 +157,7 @@ export class TimelineReader {
return null; return null;
} }
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 =
@ -151,33 +165,46 @@ export class TimelineReader {
YOUTUBE_REGEX.exec(url.href)?.groups?.videoId ?? YOUTUBE_REGEX.exec(url.href)?.groups?.videoId ??
new URL(odesliInfo.pageUrl).pathname.split('/y/').pop(); new URL(odesliInfo.pageUrl).pathname.split('/y/').pop();
if (youtubeId === undefined) { if (youtubeId === undefined) {
log.warn('Looks like a youtube video, but could not extract a video id', url, odesliInfo); this.logger.warn(
'Looks like a youtube video, but could not extract a video id',
url,
odesliInfo
);
return null; return null;
} }
const isMusic = await TimelineReader.isMusicVideo(youtubeId); const isMusic = await this.isMusicVideo(youtubeId);
if (!isMusic) { if (!isMusic) {
log.debug('Probably not a music video', url); this.logger.debug('Probably not a music video', youtubeId, url);
return null; return null;
} }
} }
const spotify: Platform = 'spotify';
return { return {
...info, ...info,
pageUrl: odesliInfo.pageUrl, pageUrl: odesliInfo.pageUrl,
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url, youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
spotifyUrl: odesliInfo.linksByPlatform[spotify]?.url,
spotifyUri: odesliInfo.linksByPlatform[spotify]?.nativeAppUriDesktop,
postedUrl: url.toString() postedUrl: url.toString()
} as SongInfo; } as SongInfo;
} catch (e) { } catch (e) {
if (e instanceof Error && e.cause === 429) { if (e instanceof Error && e.cause === 429) {
log.warn('song.link rate limit reached. Trying again in 10 seconds'); this.logger.warn('song.link rate limit reached. Trying again in 10 seconds');
await sleep(10_000); await sleep(10_000);
return await this.getSongInfo(url, remainingTries - 1); return await this.getSongInfo(url, remainingTries - 1);
} }
log.error(`Failed to load ${url} info from song.link`, e); this.logger.error(`Failed to load ${url} info from song.link`, e);
return null; return null;
} }
} }
private static async resizeAvatar( private async addToPlaylist(song: SongInfo) {
for (let adder of this.playlistAdders) {
await adder.addToPlaylist(song);
}
}
private async resizeAvatar(
baseName: string, baseName: string,
size: number, size: number,
suffix: string, suffix: string,
@ -190,15 +217,15 @@ export class TimelineReader {
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
if (exists) { if (exists) {
log.debug('File already exists', fileName); this.logger.debug('File already exists', fileName);
return null; return null;
} }
log.debug('Saving avatar', fileName); this.logger.debug('Saving avatar', fileName);
await sharpAvatar.resize(size).toFile(fileName); await sharpAvatar.resize(size).toFile(fileName);
return fileName; return fileName;
} }
private static resizeAvatarPromiseMaker( private resizeAvatarPromiseMaker(
avatarFilenameBase: string, avatarFilenameBase: string,
baseSize: number, baseSize: number,
maxPixelDensity: number, maxPixelDensity: number,
@ -211,13 +238,7 @@ export class TimelineReader {
for (let i = 1; i <= maxPixelDensity; i++) { for (let i = 1; i <= maxPixelDensity; i++) {
promises.push( promises.push(
...formats.map((f) => ...formats.map((f) =>
TimelineReader.resizeAvatar( this.resizeAvatar(avatarFilenameBase, baseSize * i, `${i}x.${f}`, 'avatars', sharpAvatar)
avatarFilenameBase,
baseSize * i,
`${i}x.${f}`,
'avatars',
sharpAvatar
)
.then( .then(
(fn) => (fn) =>
({ ({
@ -233,7 +254,7 @@ export class TimelineReader {
return promises; return promises;
} }
private static resizeThumbnailPromiseMaker( private resizeThumbnailPromiseMaker(
filenameBase: string, filenameBase: string,
baseSize: number, baseSize: number,
maxPixelDensity: number, maxPixelDensity: number,
@ -247,13 +268,7 @@ export class TimelineReader {
for (let i = 1; i <= maxPixelDensity; i++) { for (let i = 1; i <= maxPixelDensity; i++) {
promises.push( promises.push(
...formats.map((f) => ...formats.map((f) =>
TimelineReader.resizeAvatar( this.resizeAvatar(filenameBase, baseSize * i, `${i}x.${f}`, 'thumbnails', sharpAvatar)
filenameBase,
baseSize * i,
`${i}x.${f}`,
'thumbnails',
sharpAvatar
)
.then( .then(
(fn) => (fn) =>
({ ({
@ -270,7 +285,7 @@ export class TimelineReader {
return promises; return promises;
} }
private static async saveAvatar(account: Account) { private async saveAvatar(account: Account) {
try { try {
const existingAvatars = await getAvatars(account.url, 1); const existingAvatars = await getAvatars(account.url, 1);
const existingAvatarBase = existingAvatars.shift()?.file.split('/').pop()?.split('_').shift(); const existingAvatarBase = existingAvatars.shift()?.file.split('/').pop()?.split('_').shift();
@ -283,7 +298,7 @@ export class TimelineReader {
const avatarsToDelete = (await fs.readdir('avatars')) const avatarsToDelete = (await fs.readdir('avatars'))
.filter((x) => x.startsWith(existingAvatarBase + '_')) .filter((x) => x.startsWith(existingAvatarBase + '_'))
.map((x) => { .map((x) => {
log.debug('Removing existing avatar file', x); this.logger.debug('Removing existing avatar file', x);
return x; return x;
}) })
.map((x) => fs.unlink('avatars/' + x)); .map((x) => fs.unlink('avatars/' + x));
@ -292,7 +307,7 @@ export class TimelineReader {
const avatarResponse = await fetch(account.avatar); const avatarResponse = await fetch(account.avatar);
const avatar = await avatarResponse.arrayBuffer(); const avatar = await avatarResponse.arrayBuffer();
await Promise.all( await Promise.all(
TimelineReader.resizeAvatarPromiseMaker( this.resizeAvatarPromiseMaker(
avatarFilenameBase, avatarFilenameBase,
50, 50,
3, 3,
@ -306,7 +321,7 @@ export class TimelineReader {
} }
} }
private static async saveSongThumbnails(songs: SongInfo[]) { private async saveSongThumbnails(songs: SongInfo[]) {
for (const song of songs) { for (const song of songs) {
if (!song.thumbnailUrl) { if (!song.thumbnailUrl) {
continue; continue;
@ -320,7 +335,7 @@ export class TimelineReader {
const imageResponse = await fetch(song.thumbnailUrl); const imageResponse = await fetch(song.thumbnailUrl);
const avatar = await imageResponse.arrayBuffer(); const avatar = await imageResponse.arrayBuffer();
await Promise.all( await Promise.all(
TimelineReader.resizeThumbnailPromiseMaker( this.resizeThumbnailPromiseMaker(
fileBaseName + '_large', fileBaseName + '_large',
200, 200,
3, 3,
@ -331,7 +346,7 @@ export class TimelineReader {
) )
); );
await Promise.all( await Promise.all(
TimelineReader.resizeThumbnailPromiseMaker( this.resizeThumbnailPromiseMaker(
fileBaseName + '_small', fileBaseName + '_small',
60, 60,
3, 3,
@ -356,58 +371,77 @@ export class TimelineReader {
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));
const songs = await TimelineReader.getSongInfoInPost(post); const songs = await this.getSongInfoInPost(post);
// If we don't have any tags or non-youtube urls, check youtube // If we don't have any tags or non-youtube urls, check youtube
// YT is handled separately, because it requires an API call and therefore is slower // YT is handled separately, because it requires an API call and therefore is slower
if (songs.length === 0 && found_tags.length === 0) { if (songs.length === 0 && found_tags.length === 0) {
log.log('Ignoring post', post.url); this.logger.log('Ignoring post', post.url);
return; return;
} }
await savePost(post, songs); await savePost(post, songs);
await TimelineReader.saveAvatar(post.account); await this.saveAvatar(post.account);
await TimelineReader.saveSongThumbnails(songs); await this.saveSongThumbnails(songs);
log.debug('Saved post', post.url); this.logger.debug('Saved post', post.url, 'songs', songs);
const posts = await getPosts(null, null, 100); const posts = await getPosts(null, null, 100);
await saveAtomFeed(createFeed(posts)); await saveAtomFeed(createFeed(posts));
for (let song of songs) {
this.logger.debug('Adding to playlist', song);
await this.addToPlaylist(song);
}
} }
private startWebsocket() { private startWebsocket() {
const socketLogger = new Logger('Websocket');
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}`
); );
socket.onopen = () => { socket.onopen = () => {
log.log('Connected to WS'); socketLogger.log('Connected to WS');
}; };
socket.onmessage = async (event) => { socket.onmessage = async (event) => {
try { try {
const data: TimelineEvent = JSON.parse(event.data.toString()); const data: TimelineEvent = JSON.parse(event.data.toString());
socketLogger.debug('ES event', data.event);
if (data.event !== 'update') { if (data.event !== 'update') {
log.log('Ignoring ES event', data.event); socketLogger.log('Ignoring ES event', data.event);
return; return;
} }
const post: Post = JSON.parse(data.payload); const post: Post = JSON.parse(data.payload);
// Sometimes onmessage is called twice for the same post.
// This looks to be an issue with automatic reloading in the dev environment,
// but hard to tell
if (this.lastPosts.includes(post.id)) {
socketLogger.log('Skipping post, already handled', post.id);
return;
}
this.lastPosts.push(post.id);
while (this.lastPosts.length > 10) {
this.lastPosts.shift();
}
await this.checkAndSavePost(post); await this.checkAndSavePost(post);
} catch (e) { } catch (e) {
log.error('error message', event, event.data, e); socketLogger.error('error message', event, event.data, e);
} }
}; };
socket.onclose = (event) => { socket.onclose = (event) => {
log.warn( socketLogger.warn(
`Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`, `Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`,
event event
); );
setTimeout(() => { setTimeout(() => {
log.info(`Attempting to reconenct to WS`); socketLogger.info(`Attempting to reconenct to WS`);
this.startWebsocket(); this.startWebsocket();
}, 10000); }, 10000);
}; };
socket.onerror = (event) => { socket.onerror = (event) => {
log.error( socketLogger.error(
`Websocket connection to ${MASTODON_INSTANCE} failed. ${event.type}: ${event.error}, message: '${event.message}'` `Websocket connection to ${MASTODON_INSTANCE} failed. ${event.type}: ${event.error}, message: '${event.message}'`
); );
}; };
@ -416,7 +450,11 @@ export class TimelineReader {
private async loadPostsSinceLastRun() { private async loadPostsSinceLastRun() {
const now = new Date().toISOString(); const now = new Date().toISOString();
let latestPost = await getPosts(null, now, 1); let latestPost = await getPosts(null, now, 1);
log.log('Last post in DB since', now, latestPost); if (latestPost.length > 0) {
this.logger.log('Last post in DB since', now, latestPost[0].created_at);
} else {
this.logger.log('No posts in DB since');
}
let u = new URL(`https://${MASTODON_INSTANCE}/api/v1/timelines/public?local=true&limit=40`); let u = new URL(`https://${MASTODON_INSTANCE}/api/v1/timelines/public?local=true&limit=40`);
if (latestPost.length > 0) { if (latestPost.length > 0) {
u.searchParams.append('since_id', latestPost[0].id); u.searchParams.append('since_id', latestPost[0].id);
@ -428,27 +466,28 @@ export class TimelineReader {
Authorization: `Bearer ${MASTODON_ACCESS_TOKEN}` Authorization: `Bearer ${MASTODON_ACCESS_TOKEN}`
}; };
const latestPosts: Post[] = await fetch(u, { headers }).then((r) => r.json()); const latestPosts: Post[] = await fetch(u, { headers }).then((r) => r.json());
log.info('searched posts', latestPosts); this.logger.info('searched posts', latestPosts.length);
for (const post of latestPosts) { for (const post of latestPosts) {
await this.checkAndSavePost(post); await this.checkAndSavePost(post);
} }
} }
private constructor() { private constructor() {
log.log('Constructing timeline object'); this.logger = new Logger('Timeline');
this.logger.log('Constructing timeline object');
this.playlistAdders = [new YoutubePlaylistAdder(), new SpotifyPlaylistAdder()];
this.startWebsocket(); this.startWebsocket();
this.loadPostsSinceLastRun() this.loadPostsSinceLastRun()
.then((_) => { .then((_) => {
log.info('loaded posts since last run'); this.logger.info('loaded posts since last run');
}) })
.catch((e) => { .catch((e) => {
log.error('cannot fetch latest posts', e); this.logger.error('cannot fetch latest posts', e);
}); });
} }
public static init() { public static init() {
log.log('Timeline object init');
if (this._instance === undefined) { if (this._instance === undefined) {
this._instance = new TimelineReader(); this._instance = new TimelineReader();
} }

View File

@ -0,0 +1,30 @@
import { Logger } from '$lib/log';
import { SpotifyPlaylistAdder } from '$lib/server/playlist/spotifyPlaylistAdder';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const logger = new Logger('SpotifyAuth');
export const load: PageServerLoad = async ({ url }) => {
const adder = new SpotifyPlaylistAdder();
let redirectUri = url;
if (url.hostname === 'localhost') {
redirectUri.hostname = '127.0.0.1';
}
logger.debug(url.searchParams, url.hostname);
if (url.searchParams.has('code')) {
await adder.receivedAuthCode(url.searchParams.get('code') || '', url);
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(url);
logger.debug('+page.server.ts', authUrl.toString());
redirect(307, authUrl);
};

View File

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

View File

@ -0,0 +1,26 @@
import { Logger } from '$lib/log';
import { YoutubePlaylistAdder } from '$lib/server/playlist/ytPlaylistAdder';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const logger = new Logger('YT Auth');
export const load: PageServerLoad = async ({ url }) => {
const adder = new YoutubePlaylistAdder();
if (url.searchParams.has('code')) {
logger.debug(url.searchParams);
await adder.receivedAuthCode(url.searchParams.get('code') || '', url);
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(url);
logger.debug('+page.server.ts', authUrl.toString());
redirect(307, authUrl);
};

View File

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

View File

@ -9,6 +9,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true "strict": true
//"lib": ["ESNext.Array"]
} }
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// //