Compare commits

..

49 Commits
v1.1.0 ... main

Author SHA1 Message Date
d39ccba927
minor refactors and additional logs 2024-09-24 14:47:50 +02:00
498b1d82d9
Update dependencies 2024-09-24 12:05:36 +02:00
79405cd08c
Update dependencies & version 2024-01-22 17:33:31 +01:00
39c9689af4
Format 2024-01-22 17:26:49 +01:00
ad7c8af9de
Migrate to Sveltekit 2.0 2024-01-22 17:26:36 +01:00
f1cb0b2159
Migrate to Svelte 4 2024-01-22 16:26:06 +01:00
049cd86ae0
Update dependencies 2024-01-22 16:06:43 +01:00
aab4433a55
Add oauth token to websocket connection 2023-10-15 19:39:36 +02:00
d3b599738e
Update version tag 2023-06-24 10:53:02 +02:00
ba89182791
Revert "Update dependencies"
This reverts commit 5b6dbd327d.
2023-06-24 10:49:57 +02:00
5b6dbd327d
Update dependencies 2023-06-24 10:10:52 +02:00
b960d35a58
Fix #32 2023-06-24 10:06:52 +02:00
87b8317c90
Fix #34 2023-06-20 15:47:00 +02:00
e103bef84c
Fix #33 2023-06-20 15:45:09 +02:00
6d13aed0f0
Fix CSP config 2023-06-20 15:30:30 +02:00
185d28c295
Fix CSP config being in the wrong section 2023-06-20 15:09:48 +02:00
d57888678d
Fix #31 2023-06-20 08:20:30 +02:00
db80b929ca
Fix #25 2023-06-16 15:51:57 +02:00
3103d3e098
Fix #26: Scale images to the correct size and use more efficient image formats 2023-06-14 20:37:30 +02:00
61d24ddd7f
refactor avatar resizing 2023-05-10 16:13:24 +02:00
736b8498af
Convert and resize avatars to fit the displayed images 2023-05-02 17:31:16 +02:00
fbaedaf45b
Fix #29 specify width and height for non-cover images 2023-04-26 18:52:18 +02:00
d65eca1faa
Inlined stylesheet, fixed colors 2023-04-26 18:40:06 +02:00
cfa5a950f1
Fix #28 specify a CSP 2023-04-26 17:52:21 +02:00
1318b8f9c3
Update packages 2023-04-26 17:29:19 +02:00
2e63be50a4
Keep aspect ratio for mobile cover images 2023-04-26 17:02:47 +02:00
e3cf6fb5f2
Upgrade DB migration to info log 2023-04-24 20:45:08 +02:00
bca4382988
Add song info to existing posts 2023-04-24 20:43:13 +02:00
68aade4f1f
Fix #24, refactor URL detection 2023-04-24 19:38:13 +02:00
9bbcc843c2
Fix #22, fix #23. Display posts as grid instead of flexbox, add song info 2023-04-23 20:10:45 +02:00
42d91a097f
Fix youtube links not being parsed for song info 2023-04-23 13:07:52 +02:00
971c846dd1
Saving song infos to DB, refactor logging 2023-04-23 12:46:14 +02:00
1cd9d83910
Improve type safety 2023-04-22 09:28:42 +02:00
b62936ed54
Extract song info from odesli (song.link) 2023-04-22 08:50:17 +02:00
45eeb550b3
Auto reconnect to Mastodon WebSocket if it fails 2023-04-15 09:56:03 +02:00
52c7922002
Improved layout on smaller devices 2023-04-14 20:32:49 +02:00
5ab1167d38
Fix #9: Add WebSub support 2023-04-14 20:04:46 +02:00
c57828d3e2
Fix savePost() never resolving 2023-04-14 20:00:31 +02:00
4e7196182c
Fixed posts not saving correctly after DB migration 2023-04-13 16:18:30 +02:00
8d3a23ee88
Update README with changes to DB tables 2023-04-12 20:47:23 +02:00
77c29bdd8a
Fix @user@instance not being filtered correctly for blocked users; Fix #11 use urls as identifiers 2023-04-12 20:44:36 +02:00
e346928d32
Revert DB name 2023-04-12 20:10:18 +02:00
ef4c517ff2
Fix #12, wait for DB migrations to finish before attempting to use the database 2023-04-12 20:08:55 +02:00
052c93d461
Fix #18 add ability to block specific users 2023-04-12 17:08:40 +02:00
d716b3882b
Fix #13, make youtube API optional 2023-04-11 18:39:02 +02:00
4fbd9a260f
Fix #21 respect safe areas on ios safari 2023-04-11 16:21:15 +02:00
6c9546b74a
Improve formatting 2023-04-11 16:02:54 +02:00
268128c2f4
Improve layout for smaller devices 2023-04-11 15:49:50 +02:00
e3c15be31c Merge pull request 'Implement issues for v1.1.0' (#20) from v1.1.0 into main
Reviewed-on: https://phlaym.net/git/phlaym/moshing-mammut/pulls/20
2023-04-07 15:07:40 +00:00
37 changed files with 5851 additions and 1923 deletions

View File

@ -1,9 +1,12 @@
HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening
URL_FILTER = song.link,album.link,spotify.com,music.apple.com,bandcamp.com
YOUTUBE_API_KEY = CHANGE_ME YOUTUBE_API_KEY = CHANGE_ME
ODESLI_API_KEY = CHANGE_ME
MASTODON_INSTANCE = 'metalhead.club' MASTODON_INSTANCE = 'metalhead.club'
MASTODON_ACCESS_TOKEN = 'YOUR_ACCESS_TOKEN_HERE'
BASE_URL = 'https://moshingmammut.phlaym.net' BASE_URL = 'https://moshingmammut.phlaym.net'
VERBOSE = false VERBOSE = false
IGNORE_USERS = @moshhead@metalhead.club
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'

View File

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

2
.gitignore vendored
View File

@ -4,6 +4,8 @@ playbook.yml
inventory.yml inventory.yml
ansible.cfg ansible.cfg
avatars/*
thumbnails/*
node_modules node_modules
/build /build
/.svelte-kit /.svelte-kit

View File

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

View File

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

View File

@ -11,8 +11,8 @@ Having a quick overview over what is being posted can be a great way to discover
This is fairly simple from a technical point of view! metalhead.club's local timeline is being watched using the This is fairly simple from a technical point of view! metalhead.club's local timeline is being watched using the
Mastodon Streaming API over a Websocket. Every time a new post arrives, it is checked if it contains any music by Mastodon Streaming API over a Websocket. Every time a new post arrives, it is checked if it contains any music by
checking included hashtags and URLs. A list of tags and URLs can be found in [the configuration](.env.EXAMPLE). checking included hashtags and URLs. A list of tags can be found in [the configuration](.env.EXAMPLE).
Additionally, lins to YouTube are queried, if they are music or other videos using the YouTube API. Additionally, links are vetted if they are music by checking if https://song.link finds info on them.
If a post passes this check it is saved to a SQLite database. If a post passes this check it is saved to a SQLite database.
@ -33,12 +33,12 @@ I can see that there are plenty of posts using only descriptions and links witho
be missed. This isn't a great solution. be missed. This isn't a great solution.
Another idea was to store only URLs of posts and resolve the content and account information live. Another idea was to store only URLs of posts and resolve the content and account information live.
This would be better, but I'm *still* storing post information while also slowing the app down and introduce more code This would be better, but I'm _still_ storing post information while also slowing the app down and introduce more code
complexity. I'm willing to make this change if people prefer this though. complexity. I'm willing to make this change if people prefer this though.
Additionally, I ended up adding a few things which turned out to be not needed: Additionally, I ended up adding a few things which turned out to be not needed:
The `tags` table (tags are included in the post's content and I don't do anything separately with tags) and The `tags` table (tags are included in the post's content and I don't do anything separately with tags) and
`accounts.username` and `accounts.avatar_static`. I will keep these in until the initial wave of feedback arrives, and ~~`accounts.username`~~ (s being used for #18) ~~and `accounts.avatar_static`~~ (has been removed). I will keep these in until the initial wave of feedback arrives, and
remove it if no new features required them. remove it if no new features required them.
I'll gladly accept any help in coming up with a good solution which doesn't need to store anything at all! I'll gladly accept any help in coming up with a good solution which doesn't need to store anything at all!
@ -51,7 +51,6 @@ By default, NVM is used to install NodeJS, but you can install it any way you wa
This is based on [SvelteKit's instructions](https://kit.svelte.dev/docs/adapter-node#deploying) and [How To Deploy Node.js Applications Using Systemd and Nginx](https://www.digitalocean.com/community/tutorials/how-to-deploy-node-js-applications-using-systemd-and-nginx) This is based on [SvelteKit's instructions](https://kit.svelte.dev/docs/adapter-node#deploying) and [How To Deploy Node.js Applications Using Systemd and Nginx](https://www.digitalocean.com/community/tutorials/how-to-deploy-node-js-applications-using-systemd-and-nginx)
#### On your server #### On your server
Install Apache2 if not already installed. Install Apache2 if not already installed.
@ -78,7 +77,6 @@ Enter `$APP_DIR`.
Place `package-lock.json` and `start.sh.EXAMPLE` in this directory. Place `package-lock.json` and `start.sh.EXAMPLE` in this directory.
Run `npm ci --omit dev` to install the dependencies. Run `npm ci --omit dev` to install the dependencies.
Rename `start.sh.EXAMPLE` to `start.sh` and set the path to your NVM. Rename `start.sh.EXAMPLE` to `start.sh` and set the path to your NVM.
If you do not have NVM installed, simply remove the line and make sure your node executable can be found either by If you do not have NVM installed, simply remove the line and make sure your node executable can be found either by
@ -90,15 +88,27 @@ Copy `apache2.conf.EXAMPLE` to `/etc/apache2/sites-available/moshingmammut.conf`
Domain. If you do not need or want SSL support, remove the whole `<IfModule mod_ssl.c>` block. Domain. If you do not need or want SSL support, remove the whole `<IfModule mod_ssl.c>` block.
If you do, add the path to your SSLCertificateFile and SSLCertificateKeyFile. If you do, add the path to your SSLCertificateFile and SSLCertificateKeyFile.
Modify DocumentRoot and the two Alias and Directory statements, so that thumbnails and avatars are served directly by apache.
Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service` Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service`
and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly. and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
#### On your development machine #### On your development machine
Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY`. Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY` and `ODESLI_API_KEY`.
To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an
*API key*. As soon as #13 is implemented, this will be optional! _API key_.
If `YOUTUBE_API_KEY` is unset, no playlist will be updated. Also, _all_ YouTube links will be treated as music videos,
because the API is the only way to check if a YouTube link leads to music or something else.
If `ODESLI_API_KEY` is unset, your rate limit to the song.link API will be lower.
Add `MASTODON_ACCESS_TOKEN` as well, see [Creating our application
](https://docs.joinmastodon.org/client/token/#app) in the Mastodon documentation.
`read:statuses` is 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.
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.

View File

@ -15,6 +15,23 @@
Include /etc/letsencrypt/options-ssl-apache.conf Include /etc/letsencrypt/options-ssl-apache.conf
DocumentRoot /home/moshing-mammut/app/
ProxyPass /avatars/ !
ProxyPass /thumbnails/ !
Alias /avatars/ /home/moshing-mammut/app/avatars/
Alias /thumbnails/ /home/moshing-mammut/app/thumbnails/
<Directory "/home/moshing-mammut/app/avatars/">
Require all granted
Header set Cache-Control "public,max-age=31536000,immutable"
</Directory>
<Directory "/home/moshing-mammut/app/thumbnails/">
Require all granted
Header set Cache-Control "public,max-age=31536000,immutable"
</Directory>
ProxyPass / http://localhost:3000/ ProxyPass / http://localhost:3000/
ProxyPassReverse / http://localhost:3000/ ProxyPassReverse / http://localhost:3000/

View File

@ -1,4 +1,3 @@
{ {
"masterPicture": "./icon.svg", "masterPicture": "./icon.svg",
"iconsPath": "/static", "iconsPath": "/static",
@ -61,4 +60,4 @@
"htmlCodeFile": false, "htmlCodeFile": false,
"usePathAsIs": false "usePathAsIs": false
} }
} }

4903
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
{ {
"name": "moshing-mammut", "name": "moshing-mammut",
"version": "1.1.0", "version": "1.3.2",
"private": true, "private": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"devn": "vite dev --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@ -13,29 +14,35 @@
"format": "prettier --plugin-search-dir . --write ." "format": "prettier --plugin-search-dir . --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^1.2.3", "@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^1.5.0", "@sveltejs/kit": "^2.0.0",
"@types/sqlite3": "^3.1.8", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/ws": "^8.5.4", "@types/node": "^22.6.1",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@types/sqlite3": "^3.0.0",
"@typescript-eslint/parser": "^5.45.0", "@types/ws": "^8.5.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@zerodevx/svelte-toast": "^0.9.3", "@zerodevx/svelte-toast": "^0.9.3",
"eslint": "^8.28.0", "eslint": "^9.11.1",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-svelte3": "^4.0.0", "eslint-plugin-svelte": "^2.0.0",
"prettier": "^2.8.0", "prettier": "^3.0.0",
"prettier-plugin-svelte": "^2.8.1", "prettier-plugin-svelte": "^3.0.0",
"svelte": "^3.54.0", "svelte": "^4.0.0",
"svelte-check": "^3.0.1", "svelte-check": "^4.0.0",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^4.9.3", "typescript": "^5.0.0",
"vite": "^4.0.0" "vite": "^5.0.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"feed": "^4.2.2", "feed": "^4.2.2",
"sqlite3": "^5.1.6", "sharp": "^0.33.0",
"ws": "^8.13.0" "sqlite3": "^5.0.0",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
} }
} }

View File

@ -1,53 +1,105 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" /> <link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
<link rel="mask-icon" href="%sveltekit.assets%/safari-pinned-tab.svg" color="#2e0b78" /> <link rel="mask-icon" href="%sveltekit.assets%/safari-pinned-tab.svg" color="#2e0b78" />
<meta name="apple-mobile-web-app-title" content="Moshing Mammut" /> <meta name="apple-mobile-web-app-title" content="Moshing Mammut" />
<meta name="application-name" content="Moshing Mammut" /> <meta name="application-name" content="Moshing Mammut" />
<meta name="msapplication-TileColor" content="#2e0b78" /> <meta name="msapplication-TileColor" content="#2e0b78" />
<meta name="theme-color" content="#2e0b78" /> <meta
<link rel="stylesheet" href="%sveltekit.assets%/style.css" /> name="description"
<meta name="viewport" content="width=device-width" /> content="A collection of music recommendations and now-listenings by the users of metalhead.club"
<meta name="theme-color" content="#2E0B78" media="(prefers-color-scheme: dark)" /> />
<meta name="theme-color" content="#BCB9B2" media="(prefers-color-scheme: light)" />
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Atom Feed" />
%sveltekit.head%
<style>
body {
--color-text: #2F0C7A;
--color-bg: white;
--color-border: #17063B;
--color-link: #563ACC;
--color-link-visited: #858AFA;
color: var(--color-text); <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
background-color: var(--color-bg); <meta name="theme-color" content="#17063b" media="(prefers-color-scheme: dark)" />
} <meta name="theme-color" content="#BCB9B2" media="(prefers-color-scheme: light)" />
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Atom Feed" />
<link rel="hub" href="https://pubsubhubbub.superfeedr.com" />
%sveltekit.head%
<style>
body {
--color-blue: hsl(259, 82%, 26%);
--color-blue-dark: hsl(259, 82%, 13%);
--color-lavender: hsl(253, 82%, 33%);
--color-mauve: hsl(273, 82%, 38%);
a { --color-grey: hsl(44, 7%, 41%);
color: var(--color-link); --color-grey-translucent: hsla(44, 7%, 41%, 0.2);
} --color-grey-light: hsl(0, 0%, 98%);
a:visited {
color: var(--color-link-visited);
}
@media (prefers-color-scheme: dark) { --color-red: hsl(7, 100%, 56%);
body { --color-red-light: hsl(7, 100%, 61%);
--color-text: white; --color-red-lighter: hsl(7, 100%, 68%);
--color-bg: #17063B; --color-red-dark: hsl(7, 100%, 48%);
--color-border: white; --color-red-desat: hsl(7, 20%, 56%);
--color-link: #8A9BF0; --color-red-desat-dark: hsl(7, 20%, 30%);
--color-link-visited: #C384FB; --color-red-desat-desat: hsl(7, 8%, 56%);
}
} --color-text: var(--color-blue-dark);
</style> --color-border: var(--color-grey);
</head> --color-link: var(--color-mauve);
<body data-sveltekit-preload-data="hover"> --color-link-visited: var(--color-lavender);
<div style="display: contents">%sveltekit.body%</div> --color-bg: var(--color-grey-light);
</body> --color-bg-translucent: hsla(42, 7%, 72%, 0.5);
--color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat-dark);
--color-button-hover: var(--color-red);
--color-button-deactivated: var(--color-red-desat-desat);
--color-button-text: var(--color-blue-dark);
color: var(--color-text);
background-color: var(--color-bg);
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen-Sans,
Ubuntu,
Cantarell,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol';
}
a {
color: var(--color-link);
}
a:visited {
color: var(--color-link-visited);
}
@media (prefers-color-scheme: dark) {
body {
--color-lavender: hsl(273, 43%, 65%);
--color-mauve: hsl(286, 73%, 81%);
--color-text: var(--color-grey-light);
--color-border: var(--color-grey-light);
--color-link: var(--color-lavender);
--color-link-visited: var(--color-mauve);
--color-bg: var(--color-blue-dark);
--color-bg-translucent: hsla(259, 82%, 26%, 0.5);
--color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat);
--color-button-hover: var(--color-red);
--color-button-deactivated: var(--color-red-desat-desat);
--color-button-text: var(--color-blue-dark);
}
}
</style>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html> </html>

View File

@ -1,22 +1,22 @@
import { log } from '$lib/log';
import { TimelineReader } from '$lib/server/timeline'; import { TimelineReader } from '$lib/server/timeline';
import type { HandleServerError } from '@sveltejs/kit'; import type { Handle, HandleServerError } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import fs from 'fs/promises'; import fs from 'fs/promises';
log.log('App startup');
TimelineReader.init(); TimelineReader.init();
export const handleError = (({ error }) => { export const handleError = (({ error }) => {
if (error instanceof Error) { if (error instanceof Error) {
console.error('Something went wrong: ', error.name, error.message); log.error('Something went wrong: ', error.name, error.message);
} }
return { return {
message: 'Whoops!', message: `Something went wrong! ${error}`
code: (error as any)?.code ?? 'UNKNOWN'
}; };
}) satisfies HandleServerError; }) satisfies HandleServerError;
import type { Handle } from '@sveltejs/kit';
export const handle = (async ({ event, resolve }) => { export const handle = (async ({ event, resolve }) => {
// 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') {
@ -24,12 +24,38 @@ export const handle = (async ({ event, resolve }) => {
} }
if (event.url.pathname === '/feed.xml') { if (event.url.pathname === '/feed.xml') {
const f = await fs.readFile('feed.xml', { encoding: 'utf8' }); const f = await fs.readFile('feed.xml', { encoding: 'utf8' });
return new Response( return new Response(f, { headers: [['Content-Type', 'application/atom+xml']] });
f, }
{ headers: [['Content-Type', 'application/atom+xml']] }
); // Ideally, this would be served by apache
if (event.url.pathname.startsWith('/avatars/')) {
const fileName = event.url.pathname.split('/').pop() ?? 'unknown.jpeg';
const suffix = fileName.split('.').pop() ?? 'jpeg';
try {
//This should work, but doesn't yet. See: https://github.com/nodejs/node/issues/45853
/*
const stat = await fs.stat('avatars/' + fileName);
const fd = await fs.open('avatars/' + fileName);
const readStream = fd
.readableWebStream()
.getReader({ mode: 'byob' }) as ReadableStream<Uint8Array>;
log.info('sending. size: ', stat.size);
return new Response(readStream, {
headers: [
['Content-Type', 'image/' + suffix],
['Content-Length', stat.size.toString()]
]
});
*/
const f = await fs.readFile('avatars/' + fileName);
return new Response(f, { headers: [['Content-Type', 'image/' + suffix]] });
} catch (e) {
log.error('no stream', e);
error(404);
}
} }
const response = await resolve(event); const response = await resolve(event);
return response; return response;
}) satisfies Handle; }) satisfies Handle;

View File

@ -4,4 +4,4 @@
export let account: Account; export let account: Account;
</script> </script>
<a href="{account.url}" target="_blank">{account.display_name} @{account.acct}</a> <a href={account.url} target="_blank">{account.display_name} @{account.acct}</a>

View File

@ -3,18 +3,52 @@
export let account: Account; export let account: Account;
let avatarDescription: string; let avatarDescription: string;
$: avatarDescription = `Avatar for ${account.acct}` let sourceSetHtml: string;
$: avatarDescription = `Avatar for ${account.acct}`;
$: {
// Sort thumbnails by file type. This is important, because the order of the srcset entries matter.
// We need the best format to be first
const formatPriority = new Map<string, number>([
['avif', 0],
['webp', 1],
['jpg', 99],
['jpeg', 99]
]);
const resizedAvatars = (account.resizedAvatars ?? []).sort((a, b) => {
const extensionA = a.file.split('.').pop() ?? '';
const extensionB = b.file.split('.').pop() ?? '';
const prioA = formatPriority.get(extensionA) ?? 3;
const prioB = formatPriority.get(extensionB) ?? 3;
return prioA - prioB;
});
const m = new Map<string, string[]>();
for (const resizedAvatar of resizedAvatars) {
const extension = resizedAvatar.file.split('.').pop();
const mime = extension ? `image/${extension}` : 'application/octet-stream';
const sourceSetEntry = `${resizedAvatar.file} ${resizedAvatar.sizeDescriptor}`;
m.set(mime, [...(m.get(mime) || []), sourceSetEntry]);
}
let html = '';
for (const entry of m.entries()) {
const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" />`;
}
sourceSetHtml = html;
}
</script> </script>
<img src="{account.avatar}" alt={avatarDescription}/> <picture>
{@html sourceSetHtml}
<img src={account.avatar} alt={avatarDescription} loading="lazy" width="50" height="50" />
</picture>
<style> <style>
img { img {
max-width: 50px; max-width: 100%;
max-height: 50px; max-height: 100%;
width: auto; width: 50px;
height: auto; height: 50px;
object-fit: contain; object-fit: contain;
border-radius: 3px;; border-radius: 3px;
} }
</style> </style>

View File

@ -4,24 +4,28 @@
</script> </script>
<div class="footer"> <div class="footer">
<div> <div>
<span>Made with &#x1F918; by&nbsp;</span> <span class="label"
<a href="https://metalhead.club/@aymm" rel="me">@aymm@metalhead.club</a> >Made<span class="secretIngredient">&nbsp;with &#x1F918;</span>&nbsp;by&nbsp;</span
</div> >
| <a href="https://metalhead.club/@aymm" rel="me"
<div> >@aymm<span class="mastodonInstance">@metalhead.club</span></a
<a href="https://phlaym.net/git/phlaym/moshing-mammut"> >
<img alt="Git branch" src={git} class="icon" /> </div>
Source Code |
</a> <div>
</div> <a href="https://phlaym.net/git/phlaym/moshing-mammut">
| <img alt="Git branch" src={git} class="icon" />
<div> <span class="label">Source Code</span>
<a href="/feed.xml"> </a>
<img alt="RSS" src={rss} class="icon" /> </div>
RSS Feed |
</a> <div>
</div> <a href="/feed.xml">
<img alt="RSS" src={rss} class="icon" />
<span class="label">RSS<span class="feedSuffix">&nbsp;Feed</span></span>
</a>
</div>
</div> </div>
<style> <style>
@ -38,12 +42,14 @@
padding: 0.3em 1em; padding: 0.3em 1em;
margin: 0 -8px; margin: 0 -8px;
border-radius: 3px; border-radius: 3px;
padding-bottom: env(safe-area-inset-bottom);
} }
.icon { .icon {
position: relative; position: relative;
top: 0.25em; top: 0.25em;
color: white; color: white;
height: 1em; height: 1em;
width: 1em;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.icon { .icon {
@ -53,4 +59,27 @@
background-color: var(--color-grey-translucent); background-color: var(--color-grey-translucent);
} }
} }
</style> @media only screen and (max-width: 620px) {
.mastodonInstance,
.feedSuffix {
display: none;
}
.footer {
justify-content: center;
}
}
@media only screen and (max-width: 430px) {
.mastodonInstance,
.feedSuffix,
.secretIngredient {
display: none;
}
}
@media only screen and (max-width: 370px) {
.label {
display: none;
}
}
</style>

View File

@ -2,23 +2,25 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import LoadingSpinnerComponent from '$lib/components/LoadingSpinnerComponent.svelte'; import LoadingSpinnerComponent from '$lib/components/LoadingSpinnerComponent.svelte';
export let moreAvailable: boolean = false; export let moreAvailable = false;
export let isLoading: boolean = false; export let isLoading = false;
let displayText = ''; let displayText = '';
let title = ''; let title = '';
let disabled: boolean; let disabled: boolean;
$: if (isLoading) { $: if (isLoading) {
displayText = 'Loading...'; displayText = 'Loading...';
} else if (!moreAvailable) { } else if (!moreAvailable) {
displayText = 'You reached the end'; displayText = 'You reached the end';
} else { } else {
displayText = 'Load More'; displayText = 'Load More';
}; }
$: disabled = !moreAvailable || isLoading; $: disabled = !moreAvailable || isLoading;
$: title = moreAvailable ? 'Load More' : 'There be dragons!'; $: title = moreAvailable ? 'Load More' : 'There be dragons!';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher<{
loadOlderPosts: string;
}>();
function loadOlderPosts() { function loadOlderPosts() {
dispatch('loadOlderPosts'); dispatch('loadOlderPosts');
@ -26,10 +28,10 @@
</script> </script>
<button on:click={loadOlderPosts} {disabled} {title}> <button on:click={loadOlderPosts} {disabled} {title}>
<div class="loading" class:collapsed={!isLoading}> <div class="loading" class:collapsed={!isLoading}>
<LoadingSpinnerComponent size='0.5em' thickness='6px' /> <LoadingSpinnerComponent size="0.5em" thickness="6px" />
</div> </div>
<span>{displayText}</span> <span>{displayText}</span>
</button> </button>
<style> <style>
@ -78,4 +80,4 @@
max-width: 0; max-width: 0;
margin-right: 0; margin-right: 0;
} }
</style> </style>

View File

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
export let size: string = '64px'; export let size = '64px';
export let thickness: string = '6px'; export let thickness = '6px';
</script> </script>
<div class="lds-dual-ring" style="--size: {size}; --thickness: {thickness}"></div> <div class="lds-dual-ring" style="--size: {size}; --thickness: {thickness}" />
<style> <style>
.lds-dual-ring { .lds-dual-ring {
display: inline-block; display: inline-block;
@ -11,7 +12,7 @@
height: 100%; height: 100%;
} }
.lds-dual-ring:after { .lds-dual-ring:after {
content: " "; content: ' ';
display: block; display: block;
width: var(--size); width: var(--size);
height: var(--size); height: var(--size);
@ -34,5 +35,4 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
</style>
</style>

View File

@ -1,57 +1,256 @@
<script lang="ts"> <script lang="ts">
import type { Post } from '$lib/mastodon/response'; import { type Post, SongThumbnailImageKind } from '$lib/mastodon/response';
import type { SongInfo } from '$lib/odesliResponse';
import AvatarComponent from '$lib/components/AvatarComponent.svelte'; import AvatarComponent from '$lib/components/AvatarComponent.svelte';
import AccountComponent from '$lib/components/AccountComponent.svelte'; import AccountComponent from '$lib/components/AccountComponent.svelte';
import { secondsSince, relativeTime } from '$lib/relativeTime'; import { secondsSince, relativeTime } from '$lib/relativeTime';
import { onMount } from "svelte"; import { onMount } from 'svelte';
export let post: Post; export let post: Post;
let displayRelativeTime = false; let displayRelativeTime = false;
const absoluteDate = new Date(post.created_at).toLocaleString(); const absoluteDate = new Date(post.created_at).toLocaleString();
let dateCreated = absoluteDate; let dateCreated = absoluteDate;
const timePassed = secondsSince(new Date(post.created_at)); const timePassed = secondsSince(new Date(post.created_at));
$: if(displayRelativeTime) { $: if (displayRelativeTime) {
dateCreated = relativeTime($timePassed) ?? absoluteDate; dateCreated = relativeTime($timePassed) ?? absoluteDate;
} }
const songs = filterDuplicates(post.songs ?? []);
function filterDuplicates(songs: SongInfo[]): SongInfo[] {
return songs.filter((obj, index, arr) => {
return arr.map((mapObj) => mapObj.pageUrl).indexOf(obj.pageUrl) === index;
});
}
function getThumbnailSize(song: SongInfo): {
width?: number;
height?: number;
widthSmall?: number;
heightSmall?: number;
} {
if (song.thumbnailWidth === undefined || song.thumbnailHeight === undefined) {
return { width: undefined, height: undefined, widthSmall: undefined, heightSmall: undefined };
}
const factor = 200 / song.thumbnailWidth;
const smallFactor = 60 / song.thumbnailHeight;
const height = song.thumbnailHeight * factor;
return {
width: 200,
height: height,
widthSmall: smallFactor * song.thumbnailWidth,
heightSmall: 60
};
}
// Blurred thumbs aren't generated (yet, unclear of they ever will)
// So blurred forces using the small one, by skipping the others and removing its media query.
// This is technically unnecessary - the blurred one will only show if it matches the small media query,
// but this makes it more explicit
function getSourceSetHtml(song: SongInfo, isBlurred = false): string {
const small = new Map<string, string[]>();
const large = new Map<string, string[]>();
// Sort thumbnails by file type. This is important, because the order of the srcset entries matter.
// We need the best format to be first
const formatPriority = new Map<string, number>([
['avif', 0],
['webp', 1],
['jpg', 99],
['jpeg', 99]
]);
const thumbs = (song.resizedThumbnails ?? []).sort((a, b) => {
const extensionA = a.file.split('.').pop() ?? '';
const extensionB = b.file.split('.').pop() ?? '';
const prioA = formatPriority.get(extensionA) ?? 3;
const prioB = formatPriority.get(extensionB) ?? 3;
return prioA - prioB;
});
for (const resizedThumb of thumbs) {
if (isBlurred && resizedThumb.kind !== SongThumbnailImageKind.Small) {
continue;
}
const extension = resizedThumb.file.split('.').pop();
const mime = extension ? `image/${extension}` : 'application/octet-stream';
const sourceSetEntry = `${resizedThumb.file} ${resizedThumb.sizeDescriptor}`;
switch (resizedThumb.kind) {
case SongThumbnailImageKind.Big:
large.set(mime, [...(large.get(mime) || []), sourceSetEntry]);
break;
case SongThumbnailImageKind.Small:
small.set(mime, [...(small.get(mime) || []), sourceSetEntry]);
break;
case SongThumbnailImageKind.Blurred: // currently not generated
break;
}
}
let html = '';
const { width, height, widthSmall, heightSmall } = getThumbnailSize(song);
const mediaAttribute = isBlurred ? '' : 'media="(max-width: 650px)"';
for (const entry of small.entries()) {
const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" ${mediaAttribute} width="${widthSmall}" height="${heightSmall}" />`;
}
html += '\n';
for (const entry of large.entries()) {
const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" width="${width}" height="${height}"/>`;
}
return html;
}
onMount(() => { onMount(() => {
// Display relative time only after mount: // Display relative time only after mount:
// When JS is disabled the server-side rendered absolute date will be shown, // When JS is disabled the server-side rendered absolute date will be shown,
// otherwise the relative date would be outdated very quickly // otherwise the relative date would be outdated very quickly
displayRelativeTime = true; displayRelativeTime = true;
}) });
</script> </script>
<div class="wrapper"> <div class="wrapper">
<div class="avatar"><AvatarComponent account={post.account} /></div> <div class="avatar"><AvatarComponent account={post.account} /></div>
<div class="post"> <div class="account"><AccountComponent account={post.account} /></div>
<div class="meta"> <div class="meta">
<AccountComponent account={post.account} /> <small><a href={post.url} target="_blank" title={absoluteDate}>{dateCreated}</a></small>
<small><a href={post.url} target="_blank" title="{absoluteDate}">{dateCreated}</a></small> </div>
</div> <div class="content">{@html post.content}</div>
<div class="content">{@html post.content}</div> <div class="song">
{#if post.songs}
{#each songs as song (song.pageUrl)}
<div class="info-wrapper">
<picture>
{@html getSourceSetHtml(song)}
<img class="bgimage" src={song.thumbnailUrl} loading="lazy" alt="Blurred cover" />
</picture>
<a href={song.pageUrl ?? song.postedUrl} target="_blank">
<div class="info">
<picture>
{@html getSourceSetHtml(song)}
<img
src={song.thumbnailUrl}
class="cover"
alt="Cover for {song.artistName} - {song.title}"
loading="lazy"
width={song.thumbnailWidth}
height={song.thumbnailHeight}
/>
</picture>
<span class="text">{song.artistName} - {song.title}</span>
</div>
</a>
</div>
{/each}
{/if}
</div> </div>
</div> </div>
<style> <style>
.wrapper { .wrapper {
display: flex; display: grid;
} grid-template-columns: 50px 1fr auto auto;
.post { grid-template-rows: auto 1fr auto;
display: flex; grid-template-areas:
flex-direction: column; 'avatar account account meta'
flex-grow: 2; 'avatar content content song'
} '. content content song';
.meta { grid-column-gap: 6px;
display: flex; column-gap: 6px;
justify-content: space-between; grid-row-gap: 6px;
row-gap: 6px;
} }
.avatar { .avatar {
margin-right: 1em; grid-area: avatar;
max-width: 50px;
max-height: 50px;
}
.account {
grid-area: account;
}
.meta {
grid-area: meta;
justify-self: end;
} }
.content { .content {
max-width: calc(600px - 1em - 50px); grid-area: content;
overflow-x: auto; word-break: break-word;
translate: 0 -0.5em;
} }
</style> .song {
grid-area: song;
align-self: center;
justify-self: center;
max-width: 200px;
}
.cover {
max-width: 200px;
display: block;
border-radius: 3px;
margin-bottom: 3px;
}
.bgimage {
display: none;
background-color: var(--color-bg);
}
.info {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5em;
z-index: 1;
}
.info * {
z-index: inherit;
}
@media only screen and (max-width: 650px) {
.wrapper {
grid-template-areas:
'avatar account account meta'
'content content content content'
'song song song song';
grid-row-gap: 3px;
row-gap: 3px;
}
.song {
width: 100%;
}
.song,
.cover {
max-width: 100%;
}
.cover {
height: 60px;
}
.cover:not(.background) {
z-index: 1;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.bgimage {
display: block;
width: 100%;
height: 60px;
z-index: 0;
filter: blur(10px);
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.info {
position: relative;
top: -60px;
flex-direction: row;
}
.info-wrapper {
margin-bottom: -50px;
}
.text {
padding: 3px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 3px;
background-color: var(--color-bg-translucent);
color: var(--color-text);
}
}
</style>

View File

@ -5,4 +5,4 @@ export function errorToast(message: string): number {
return toast.push(`<img src="${errorIcon}" />${message}`, { return toast.push(`<img src="${errorIcon}" />${message}`, {
classes: ['error'] classes: ['error']
}); });
} }

32
src/lib/log.ts Normal file
View File

@ -0,0 +1,32 @@
import { env } from '$env/dynamic/private';
import { isTruthy } from '$lib/truthyString';
const { DEV } = import.meta.env;
export const enableVerboseLog = isTruthy(env.VERBOSE);
export const log = {
verbose: (...params: any[]) => {
if (!enableVerboseLog) {
return;
}
console.debug(new Date().toISOString(), ...params);
},
debug: (...params: any[]) => {
if (!DEV) {
return;
}
console.debug(new Date().toISOString(), ...params);
},
log: (...params: any[]) => {
console.log(new Date().toISOString(), ...params);
},
info: (...params: any[]) => {
console.info(new Date().toISOString(), ...params);
},
warn: (...params: any[]) => {
console.warn(new Date().toISOString(), ...params);
},
error: (...params: any[]) => {
console.error(new Date().toISOString(), ...params);
}
};

View File

@ -1,29 +1,60 @@
import type { SongInfo } from '$lib/odesliResponse';
export interface TimelineEvent { export interface TimelineEvent {
event: string, event: string;
payload: string payload: string;
} }
export interface Post { export interface Post {
id: string, id: string;
created_at: string, created_at: string;
tags: Tag[], tags: Tag[];
url: string, url: string;
content: string, content: string;
account: Account account: Account;
card?: PreviewCard;
songs?: SongInfo[];
}
export interface PreviewCard {
url: string;
title: string;
image?: string;
blurhash?: string;
width: number;
height: number;
} }
export interface Tag { export interface Tag {
name: string, name: string;
url: string url: string;
} }
export interface Account { export interface Account {
id: string, id: string;
acct: string, acct: string;
username: string, username: string;
display_name: string, display_name: string;
url: string, url: string;
avatar: string, avatar: string;
avatar_static: string resizedAvatars?: AccountAvatar[];
} }
export type AccountAvatar = {
accountUrl: string;
file: string;
sizeDescriptor: string;
};
export enum SongThumbnailImageKind {
Big = 1,
Small,
Blurred
}
export type SongThumbnailImage = {
songThumbnailUrl: string;
file: string;
sizeDescriptor: string;
kind: SongThumbnailImageKind;
};

149
src/lib/odesliResponse.ts Normal file
View File

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

View File

@ -11,10 +11,7 @@ export const time = readable(new Date(), function start(set) {
}); });
export function secondsSince(date: Date): Readable<number> { export function secondsSince(date: Date): Readable<number> {
return derived( return derived(time, ($time) => Math.round(($time.getTime() - date.getTime()) / 1000));
time,
$time => Math.round(($time.getTime() - date.getTime()) / 1000)
);
} }
export function relativeTime(seconds: number): string | null { export function relativeTime(seconds: number): string | null {
@ -30,11 +27,11 @@ export function relativeTime(seconds: number): string | null {
const day = hour * 24; const day = hour * 24;
if (seconds < day) { if (seconds < day) {
return `${(Math.floor(seconds / hour))}h`; return `${Math.floor(seconds / hour)}h`;
} }
const maxRelative = day * 31; const maxRelative = day * 31;
if (seconds < maxRelative) { if (seconds < maxRelative) {
return `${Math.floor(seconds / day)}d`; return `${Math.floor(seconds / day)}d`;
} }
return null; return null;
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,13 @@
import { BASE_URL } from '$env/static/private'; import { BASE_URL, WEBSUB_HUB } from '$env/static/private';
import { PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public'; import { 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 { Feed } from 'feed'; import { Feed } from 'feed';
import fs from 'fs/promises'; import fs from 'fs/promises';
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 feed = new Feed({ const feed = new Feed({
title: `${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music feed`, title: `${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music feed`,
description: `Posts about music on ${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME}`, description: `Posts about music on ${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME}`,
@ -19,27 +21,45 @@ export function createFeed(posts: Post[]): Feed {
feedLinks: { feedLinks: {
atom: `${BASE_URL}/feed.xml` atom: `${BASE_URL}/feed.xml`
}, },
hub: hub,
author: { author: {
name: '@aymm', name: '@aymm',
link: 'https://metalhead.club/@aymm' link: 'https://metalhead.club/@aymm'
}, }
}); });
posts.forEach(p => { posts.forEach((p) => {
feed.addItem({ feed.addItem({
title: p.content, title: p.content,
id: p.url, id: p.url,
link: p.url, link: p.url,
content: p.content, content: p.content,
author: [{ author: [
name: p.account.acct, {
link: p.account.url name: p.account.acct,
}], link: p.account.url
}
],
date: new Date(p.created_at) date: new Date(p.created_at)
}) });
}); });
feed.addCategory('Music'); feed.addCategory('Music');
return feed; return feed;
} }
export async function saveAtomFeed(feed: Feed) { export async function saveAtomFeed(feed: Feed) {
await fs.writeFile('feed.xml', feed.atom1(), { encoding: 'utf8' }); await fs.writeFile('feed.xml', feed.atom1(), { encoding: 'utf8' });
} if (!WEBSUB_HUB) {
return;
}
try {
const params = new URLSearchParams();
params.append('hub.mode', 'publish');
params.append('hub.url', `${BASE_URL}/feed.xml`);
await fetch(WEBSUB_HUB, {
method: 'POST',
body: params
});
} catch (e) {
log.error('Failed to update WebSub hub', e);
}
}

View File

@ -1,19 +1,56 @@
import { HASHTAG_FILTER, MASTODON_INSTANCE, URL_FILTER, YOUTUBE_API_KEY } from '$env/static/private'; import {
import type { Post, Tag, TimelineEvent } from '$lib/mastodon/response'; HASHTAG_FILTER,
import { getPosts, savePost } from '$lib/server/db'; MASTODON_ACCESS_TOKEN,
MASTODON_INSTANCE,
ODESLI_API_KEY,
YOUTUBE_API_KEY
} from '$env/static/private';
import { log } from '$lib/log';
import type {
Account,
AccountAvatar,
Post,
SongThumbnailImage,
Tag,
TimelineEvent
} from '$lib/mastodon/response';
import { SongThumbnailImageKind } from '$lib/mastodon/response';
import type { OdesliResponse, Platform, SongInfo } from '$lib/odesliResponse';
import {
getAvatars,
getPosts,
getSongThumbnails,
removeAvatars,
saveAvatar,
savePost,
saveSongThumbnail
} from '$lib/server/db';
import { createFeed, saveAtomFeed } from '$lib/server/rss'; import { createFeed, saveAtomFeed } from '$lib/server/rss';
import { WebSocket } from "ws"; import { sleep } from '$lib/sleep';
import crypto from 'crypto';
import fs from 'fs/promises';
import sharp from 'sharp';
import { WebSocket } from 'ws';
const YOUTUBE_REGEX = new RegExp(/https?:\/\/(www\.)?youtu((be.com\/.*?v=)|(\.be\/))(?<videoId>[a-zA-Z_0-9-]+)/gm); const URL_REGEX = new RegExp(/href="(?<postUrl>[^>]+?)" target="_blank"/gm);
const INVIDIOUS_REGEX = new RegExp(/invidious.*?watch.*?v=(?<videoId>[a-zA-Z_0-9-]+)/gm);
const YOUTUBE_REGEX = new RegExp(
/https?:\/\/(www\.)?youtu((be.com\/.*?v=)|(\.be\/))(?<videoId>[a-zA-Z_0-9-]+)/gm
);
export class TimelineReader { export class TimelineReader {
private static _instance: TimelineReader; private static _instance: TimelineReader;
private static async isMusicVideo(videoId: string) { private static async isMusicVideo(videoId: string) {
if (!YOUTUBE_API_KEY || YOUTUBE_API_KEY === 'CHANGE_ME') {
// Assume that it *is* a music link when no YT API key is provided
return true;
}
const searchParams = new URLSearchParams([ const searchParams = new URLSearchParams([
['part', 'snippet'], ['part', 'snippet'],
['id', videoId], ['id', videoId],
['key', YOUTUBE_API_KEY]]); ['key', YOUTUBE_API_KEY]
]);
const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`); const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`);
const resp = await fetch(youtubeVideoUrl); const resp = await fetch(youtubeVideoUrl);
const respObj = await resp.json(); const respObj = await resp.json();
@ -23,82 +60,363 @@ export class TimelineReader {
} }
const item = respObj.items[0]; const item = respObj.items[0];
if (item.tags?.includes('music')) { if (!item.snippet) {
console.warn('Could not load snippet for video', videoId, item);
return false;
}
if (item.snippet.tags?.includes('music')) {
return true; return true;
} }
const categorySearchParams = new URLSearchParams([ const categorySearchParams = new URLSearchParams([
['part', 'snippet'], ['part', 'snippet'],
['id', item.categoryId], ['id', item.snippet.categoryId],
['key', YOUTUBE_API_KEY]]); ['key', YOUTUBE_API_KEY]
const youtubeCategoryUrl = new URL(`https://www.googleapis.com/youtube/v3/videoCategories?${categorySearchParams}`); ]);
const categoryTitle: string = await fetch(youtubeCategoryUrl).then(r => r.json()).then(r => r.items[0]?.title); const youtubeCategoryUrl = new URL(
`https://www.googleapis.com/youtube/v3/videoCategories?${categorySearchParams}`
);
const categoryTitle: string = await fetch(youtubeCategoryUrl)
.then((r) => r.json())
.then((r) => r.items[0]?.snippet?.title);
return categoryTitle === 'Music'; return categoryTitle === 'Music';
} }
private static async checkYoutubeMatches(postContent: string): Promise<boolean> { public static async getSongInfoInPost(post: Post): Promise<SongInfo[]> {
const matches = postContent.matchAll(YOUTUBE_REGEX); const urlMatches = post.content.matchAll(URL_REGEX);
for (let match of matches) { const songs: SongInfo[] = [];
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);
continue; continue;
} }
const videoId = match.groups.videoId.toString(); const urlMatch = match.groups.postUrl.toString();
let url: URL;
try { try {
const isMusic = await TimelineReader.isMusicVideo(videoId); url = new URL(urlMatch);
if (isMusic) {
return true;
}
} catch (e) { } catch (e) {
console.error('Could not check if', videoId, 'is a music video', e); log.error('URL found via Regex does not seem to be a valud url', urlMatch, e);
continue;
}
// Check *all* found url and let odesli determine if it is music or not
log.debug(`Checking ${url} if it contains song data`);
const info = await TimelineReader.getSongInfo(url);
log.debug(`Found song info for ${url}?`, info);
if (info) {
songs.push(info);
} }
} }
return false; return songs;
} }
private constructor() { private static async getSongInfo(url: URL, remainingTries = 6): Promise<SongInfo | null> {
const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`); if (remainingTries === 0) {
socket.onopen = (_event) => { log.error('No tries remaining. Lookup failed!');
socket.send('{ "type": "subscribe", "stream": "public:local"}'); return null;
}
if (url.hostname === 'songwhip.com') {
// song.link doesn't support songwhip links and songwhip themselves will provide metadata if you pass in a
// Apple Music/Spotify/etc link, but won't when provided with their own link, so no way to extract song info
// except maybe scraping their HTML
return null;
}
const videoId = INVIDIOUS_REGEX.exec(url.href)?.groups?.videoId;
const urlString =
videoId !== undefined ? `https://youtube.com/watch?v=${videoId}` : url.toString();
const odesliParams = new URLSearchParams();
odesliParams.append('url', urlString);
odesliParams.append('userCountry', 'DE');
odesliParams.append('songIfSingle', 'true');
if (ODESLI_API_KEY && ODESLI_API_KEY !== 'CHANGE_ME') {
odesliParams.append('key', ODESLI_API_KEY);
}
const odesliApiUrl = `https://api.song.link/v1-alpha.1/links?${odesliParams}`;
try {
const response = await fetch(odesliApiUrl);
if (response.status === 429) {
throw new Error('Rate limit reached', { cause: 429 });
}
const odesliInfo: OdesliResponse = await response.json();
if (!odesliInfo || !odesliInfo.entitiesByUniqueId || !odesliInfo.entityUniqueId) {
return null;
}
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
const platform: Platform = 'youtube';
if (info.platforms.includes(platform)) {
const youtubeId =
videoId ??
YOUTUBE_REGEX.exec(url.href)?.groups?.videoId ??
new URL(odesliInfo.pageUrl).pathname.split('/y/').pop();
if (youtubeId === undefined) {
log.warn('Looks like a youtube video, but could not extract a video id', url, odesliInfo);
return null;
}
const isMusic = await TimelineReader.isMusicVideo(youtubeId);
if (!isMusic) {
log.debug('Probably not a music video', url);
return null;
}
}
return {
...info,
pageUrl: odesliInfo.pageUrl,
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
postedUrl: url.toString()
} as SongInfo;
} catch (e) {
if (e instanceof Error && e.cause === 429) {
log.warn('song.link rate limit reached. Trying again in 10 seconds');
await sleep(10_000);
return await this.getSongInfo(url, remainingTries - 1);
}
log.error(`Failed to load ${url} info from song.link`, e);
return null;
}
}
private static async resizeAvatar(
baseName: string,
size: number,
suffix: string,
folder: string,
sharpAvatar: sharp.Sharp
): Promise<string | null> {
const fileName = `${folder}/${baseName}_${suffix}`;
const exists = await fs
.access(fileName, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
if (exists) {
log.debug('File already exists', fileName);
return null;
}
log.debug('Saving avatar', fileName);
await sharpAvatar.resize(size).toFile(fileName);
return fileName;
}
private static resizeAvatarPromiseMaker(
avatarFilenameBase: string,
baseSize: number,
maxPixelDensity: number,
accountUrl: string,
formats: string[],
avatar: ArrayBuffer
): Promise<void>[] {
const sharpAvatar = sharp(avatar);
const promises: Promise<void>[] = [];
for (let i = 1; i <= maxPixelDensity; i++) {
promises.push(
...formats.map((f) =>
TimelineReader.resizeAvatar(
avatarFilenameBase,
baseSize * i,
`${i}x.${f}`,
'avatars',
sharpAvatar
)
.then(
(fn) =>
({
accountUrl: accountUrl,
file: fn,
sizeDescriptor: `${i}x`
}) as AccountAvatar
)
.then(saveAvatar)
)
);
}
return promises;
}
private static resizeThumbnailPromiseMaker(
filenameBase: string,
baseSize: number,
maxPixelDensity: number,
songThumbnailUrl: string,
formats: string[],
image: ArrayBuffer,
kind: SongThumbnailImageKind
): Promise<void>[] {
const sharpAvatar = sharp(image);
const promises: Promise<void>[] = [];
for (let i = 1; i <= maxPixelDensity; i++) {
promises.push(
...formats.map((f) =>
TimelineReader.resizeAvatar(
filenameBase,
baseSize * i,
`${i}x.${f}`,
'thumbnails',
sharpAvatar
)
.then(
(fn) =>
({
songThumbnailUrl: songThumbnailUrl,
file: fn,
sizeDescriptor: `${i}x`,
kind: kind
}) as SongThumbnailImage
)
.then(saveSongThumbnail)
)
);
}
return promises;
}
private static async saveAvatar(account: Account) {
try {
const existingAvatars = await getAvatars(account.url, 1);
const existingAvatarBase = existingAvatars.shift()?.file.split('/').pop()?.split('_').shift();
const avatarFilenameBase =
new URL(account.avatar).pathname.split('/').pop()?.split('.').shift() ?? account.acct;
// User's avatar changed. Remove the old one!
if (existingAvatarBase && existingAvatarBase !== avatarFilenameBase) {
await removeAvatars(account.url);
const avatarsToDelete = (await fs.readdir('avatars'))
.filter((x) => x.startsWith(existingAvatarBase + '_'))
.map((x) => {
log.debug('Removing existing avatar file', x);
return x;
})
.map((x) => fs.unlink('avatars/' + x));
await Promise.allSettled(avatarsToDelete);
}
const avatarResponse = await fetch(account.avatar);
const avatar = await avatarResponse.arrayBuffer();
await Promise.all(
TimelineReader.resizeAvatarPromiseMaker(
avatarFilenameBase,
50,
3,
account.url,
['webp', 'avif', 'jpeg'],
avatar
)
);
} catch (e) {
console.error('Could not resize and save avatar for', account.acct, account.avatar, e);
}
}
private static async saveSongThumbnails(songs: SongInfo[]) {
for (const song of songs) {
if (!song.thumbnailUrl) {
continue;
}
try {
const existingThumbs = await getSongThumbnails(song);
if (existingThumbs.length) {
continue;
}
const fileBaseName = crypto.createHash('sha256').update(song.thumbnailUrl).digest('hex');
const imageResponse = await fetch(song.thumbnailUrl);
const avatar = await imageResponse.arrayBuffer();
await Promise.all(
TimelineReader.resizeThumbnailPromiseMaker(
fileBaseName + '_large',
200,
3,
song.thumbnailUrl,
['webp', 'avif', 'jpeg'],
avatar,
SongThumbnailImageKind.Big
)
);
await Promise.all(
TimelineReader.resizeThumbnailPromiseMaker(
fileBaseName + '_small',
60,
3,
song.thumbnailUrl,
['webp', 'avif', 'jpeg'],
avatar,
SongThumbnailImageKind.Small
)
);
} catch (e) {
console.error(
'Could not resize and save song thumbnail for',
song.pageUrl,
song.thumbnailUrl,
e
);
}
}
}
private startWebsocket() {
const socket = new WebSocket(
`wss://${MASTODON_INSTANCE}/api/v1/streaming?type=subscribe&stream=public:local&access_token=${MASTODON_ACCESS_TOKEN}`
);
socket.onopen = () => {
log.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());
if (data.event !== 'update') { if (data.event !== 'update') {
log.log('Ignoring ES event', data.event);
return; return;
} }
const post: Post = JSON.parse(data.payload); const post: Post = JSON.parse(data.payload);
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 urls: string[] = URL_FILTER.split(','); const songs = await TimelineReader.getSongInfoInPost(post);
const found_urls = urls.filter(t => post.content.includes(t));
// 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 (found_urls.length === 0 && if (songs.length === 0 && found_tags.length === 0) {
found_tags.length === 0 && log.log('Ignoring post', post.url);
!await TimelineReader.checkYoutubeMatches(post.content)) {
return; return;
} }
await savePost(post);
await savePost(post, songs);
await TimelineReader.saveAvatar(post.account);
await TimelineReader.saveSongThumbnails(songs);
log.debug('Saved post', post.url);
const posts = await getPosts(null, null, 100); const posts = await getPosts(null, null, 100);
await saveAtomFeed(createFeed(posts)); await saveAtomFeed(createFeed(posts));
} catch (e) { } catch (e) {
console.error("error message", event, event.data, e) log.error('error message', event, event.data, e);
} }
};
});
socket.onclose = (event) => { socket.onclose = (event) => {
console.log("Closed", event, event.code, event.reason) log.warn(
`Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`,
event
);
setTimeout(() => {
log.info(`Attempting to reconenct to WS`);
this.startWebsocket();
}, 10000);
}; };
socket.onerror = (event) => { socket.onerror = (event) => {
console.log("error", event, event.message, event.error) log.error(
`Websocket connection to ${MASTODON_INSTANCE} failed. ${event.type}: ${event.error}, message: '${event.message}'`
);
}; };
}
private constructor() {
log.log('Constructing timeline object');
this.startWebsocket();
} }
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();
} }
@ -108,4 +426,4 @@ export class TimelineReader {
TimelineReader.init(); TimelineReader.init();
return this._instance; return this._instance;
} }
} }

5
src/lib/sleep.ts Normal file
View File

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

7
src/lib/truthyString.ts Normal file
View File

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

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import FooterComponent from '$lib/components/FooterComponent.svelte' import FooterComponent from '$lib/components/FooterComponent.svelte';
import { SvelteToast } from '@zerodevx/svelte-toast'; import { SvelteToast } from '@zerodevx/svelte-toast';
const options = { const options = {
@ -7,6 +7,7 @@
classes: ['toast'] classes: ['toast']
}; };
</script> </script>
<slot /> <slot />
<SvelteToast {options} /> <SvelteToast {options} />
<div class="footer"> <div class="footer">
@ -33,4 +34,9 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
</style> @media only screen and (max-width: 620px) {
.footer {
width: 100%;
}
}
</style>

View File

@ -1,174 +1,184 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { Post } from '$lib/mastodon/response'; import type { Post } from '$lib/mastodon/response';
import { PUBLIC_REFRESH_INTERVAL, PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public'; import {
import PostComponent from '$lib/components/PostComponent.svelte'; PUBLIC_REFRESH_INTERVAL,
import LoadMoreComponent from '$lib/components/LoadMoreComponent.svelte'; PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME
import { fly, type FlyParams } from 'svelte/transition'; } from '$env/static/public';
import { cubicInOut } from 'svelte/easing'; import PostComponent from '$lib/components/PostComponent.svelte';
import { errorToast } from '$lib/errorToast' import LoadMoreComponent from '$lib/components/LoadMoreComponent.svelte';
import { fly, type FlyParams } from 'svelte/transition';
import { cubicInOut } from 'svelte/easing';
import { errorToast } from '$lib/errorToast';
export let data: PageData;
export let data: PageData; interface FetchOptions {
since?: string;
interface FetchOptions { before?: string;
since?: string, count?: number;
before?: string,
count?: number
}
interface EdgeFlyParams extends FlyParams {
created_at: string
}
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
let interval: NodeJS.Timer | null = null;
let moreOlderPostsAvailable = true;
let loadingOlderPosts = false;
// Needed, so that edgeFly() can do its thing:
// To determine whether a newly loaded post is older than the existing ones, is required to know what the oldest
// post was, before the fetch happened.
let oldestBeforeLastFetch: number | null = null;
/**
* Animate either from the top, or the bottom of the window, depending if the post is
* newer than the existing ones or older.
*/
function edgeFly(node: Element, opts: EdgeFlyParams) {
const createdAt = new Date(opts.created_at).getTime();
const diffNewest = Math.abs(new Date(data.posts[0].created_at).getTime() - createdAt);
const oldest = oldestBeforeLastFetch !== null
? oldestBeforeLastFetch
: new Date(data.posts[data.posts.length - 1].created_at).getTime();
const diffOldest = Math.abs(oldest - createdAt);
const fromTop = diffNewest <= diffOldest;
const rect = node.getBoundingClientRect();
const paramY = +`${opts.y}`;
let offset = isNaN(paramY) ? 0 : paramY + rect.height;
opts.y = fromTop ? -offset : window.innerHeight + offset;
return fly(node, opts);
}
async function fetchPosts(options: FetchOptions): Promise<Post[]> {
const params = new URLSearchParams();
if (options?.since !== undefined) {
params.set('since', options.since);
}
if (options?.before !== undefined) {
params.set('before', options.before);
}
if (options?.count !== undefined) {
params.set('count', options.count.toFixed(0));
} }
const response = await fetch(`/api/posts?${params}`); interface EdgeFlyParams extends FlyParams {
return await response.json(); created_at: string;
}
function filterDuplicates(posts: Post[]): Post[] {
return posts.filter((obj, index, arr) => {
return arr.map(mapObj => mapObj.url).indexOf(obj.url) === index;
});
}
function refresh() {
let filter: FetchOptions = {};
if (data.posts.length > 0) {
filter = { since: data.posts[0].created_at };
} }
fetchPosts(filter).then(resp => {
if (resp.length > 0) { const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
// Prepend new posts, filter dupes let interval: ReturnType<typeof setTimeout> | null = null;
// There shouldn't be any duplicates, but better be safe than sorry let moreOlderPostsAvailable = true;
data.posts = filterDuplicates(resp.concat(data.posts)); let loadingOlderPosts = false;
// Needed, so that edgeFly() can do its thing:
// To determine whether a newly loaded post is older than the existing ones, is required to know what the oldest
// post was, before the fetch happened.
let oldestBeforeLastFetch: number | null = null;
/**
* Animate either from the top, or the bottom of the window, depending if the post is
* newer than the existing ones or older.
*/
function edgeFly(node: Element, opts: EdgeFlyParams) {
const createdAt = new Date(opts.created_at).getTime();
const diffNewest = Math.abs(new Date(data.posts[0].created_at).getTime() - createdAt);
const oldest =
oldestBeforeLastFetch !== null
? oldestBeforeLastFetch
: new Date(data.posts[data.posts.length - 1].created_at).getTime();
const diffOldest = Math.abs(oldest - createdAt);
const fromTop = diffNewest <= diffOldest;
const rect = node.getBoundingClientRect();
const paramY = +`${opts.y}`;
let offset = isNaN(paramY) ? 0 : paramY + rect.height;
opts.y = fromTop ? -offset : window.innerHeight + offset;
return fly(node, opts);
}
async function fetchPosts(options: FetchOptions): Promise<Post[]> {
const params = new URLSearchParams();
if (options?.since !== undefined) {
params.set('since', options.since);
} }
}) if (options?.before !== undefined) {
.catch((e: Error) => { params.set('before', options.before);
errorToast('Error loading newest posts: ' + e.message);
});
}
onMount(async () => {
if (data.posts.length > 0) {
oldestBeforeLastFetch = new Date(data.posts[data.posts.length - 1].created_at).getTime();
}
interval = setInterval(refresh, refreshInterval);
// - If the page is hidden, slow down refresh rate
// - If the page is shown, bump up refresh rate
document.addEventListener('visibilitychange', () => {
const delay = document.hidden ? refreshInterval * 10 : refreshInterval;
if (interval) {
clearInterval(interval);
} }
interval = setInterval(refresh, delay); if (options?.count !== undefined) {
params.set('count', options.count.toFixed(0));
}
const response = await fetch(`/api/posts?${params}`);
return await response.json();
}
function filterDuplicates(posts: Post[]): Post[] {
return posts.filter((obj, index, arr) => {
return arr.map((mapObj) => mapObj.url).indexOf(obj.url) === index;
});
}
function refresh() {
let filter: FetchOptions = {};
if (data.posts.length > 0) {
filter = { since: data.posts[0].created_at };
}
fetchPosts(filter)
.then((resp) => {
if (resp.length > 0) {
// Prepend new posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry
data.posts = filterDuplicates(resp.concat(data.posts));
}
})
.catch((e: Error) => {
errorToast('Error loading newest posts: ' + e.message);
});
}
onMount(async () => {
if (data.posts.length > 0) {
oldestBeforeLastFetch = new Date(data.posts[data.posts.length - 1].created_at).getTime();
}
interval = setInterval(refresh, refreshInterval);
// - If the page is hidden, slow down refresh rate
// - If the page is shown, bump up refresh rate
document.addEventListener('visibilitychange', () => {
const delay = document.hidden ? refreshInterval * 10 : refreshInterval;
if (interval) {
clearInterval(interval);
}
interval = setInterval(refresh, delay);
});
return () => {
if (interval !== null) {
clearInterval(interval);
}
};
}); });
return () => { function loadOlderPosts() {
if (interval !== null) { loadingOlderPosts = true;
clearInterval(interval) const filter: FetchOptions = { count: 20 };
if (data.posts.length > 0) {
const before = data.posts[data.posts.length - 1].created_at;
filter.before = before;
oldestBeforeLastFetch = new Date(before).getTime();
} }
fetchPosts(filter)
.then((resp) => {
if (resp.length > 0) {
// Append old posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry
data.posts = filterDuplicates(data.posts.concat(resp));
// If we got less than we expected, there are no older posts available
moreOlderPostsAvailable = resp.length >= (filter.count ?? 20);
} else {
moreOlderPostsAvailable = false;
}
loadingOlderPosts = false;
})
.catch((e) => {
loadingOlderPosts = false;
errorToast('Error loading older posts: ' + e.message);
});
} }
});
function loadOlderPosts() {
loadingOlderPosts = true;
const filter: FetchOptions = { count: 20 };
if (data.posts.length > 0) {
const before = data.posts[data.posts.length - 1].created_at;
filter.before = before;
oldestBeforeLastFetch = new Date(before).getTime();
}
fetchPosts(filter).then(resp => {
if (resp.length > 0) {
// Append old posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry
data.posts = filterDuplicates(data.posts.concat(resp));
// If we got less than we expected, there are no older posts available
moreOlderPostsAvailable = resp.length >= (filter.count ?? 20);
} else {
moreOlderPostsAvailable = false;
}
loadingOlderPosts = false;
})
.catch(e => {
loadingOlderPosts = false;
errorToast('Error loading older posts: ' + e.message);
});
}
</script> </script>
<svelte:head> <svelte:head>
<title>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</title> <title>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</title>
</svelte:head> </svelte:head>
<h2>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</h2> <h2>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</h2>
<div class="wrapper"> <div class="wrapper">
<div></div> <div />
<div class="posts"> <div class="posts">
{#if data.posts.length === 0} {#if data.posts.length === 0}
Sorry, no posts recommending music aave been found yet Sorry, no posts recommending music have been found yet
{/if} {/if}
{#each data.posts as post (post.url)} {#each data.posts as post (post.url)}
<div <div
class="post" class="post"
transition:edgeFly="{{ y: 10, created_at: post.created_at, duration: 300, easing: cubicInOut }}" transition:edgeFly|global={{
y: 10,
created_at: post.created_at,
duration: 300,
easing: cubicInOut
}}
> >
<PostComponent {post} /> <PostComponent {post} />
</div> </div>
{/each} {/each}
<LoadMoreComponent <LoadMoreComponent
on:loadOlderPosts={loadOlderPosts} on:loadOlderPosts={loadOlderPosts}
moreAvailable={moreOlderPostsAvailable} moreAvailable={moreOlderPostsAvailable}
isLoading={loadingOlderPosts}/> isLoading={loadingOlderPosts}
</div> />
<div></div>
</div> </div>
<div />
</div>
<style> <style>
.posts { .posts {
display: flex; display: flex;
@ -177,7 +187,7 @@ function loadOlderPosts() {
} }
.post { .post {
width: 100%; width: 100%;
max-width: 600px; max-width: min(800px, 80vw);
margin-bottom: 1em; margin-bottom: 1em;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
padding: 1em; padding: 1em;
@ -191,4 +201,11 @@ function loadOlderPosts() {
text-align: center; text-align: center;
z-index: 100; z-index: 100;
} }
</style>
@media only screen and (max-width: 650px) {
.post {
max-width: calc(100vw - 16px);
padding: 1em 0;
}
}
</style>

View File

@ -2,8 +2,8 @@ import type { Post } from '$lib/mastodon/response';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => { export const load = (async ({ fetch }) => {
const p = await fetch('/'); const p = await fetch('/');
return { return {
posts: await p.json() as Post[] posts: (await p.json()) as Post[]
}; };
}) satisfies PageLoad; }) satisfies PageLoad;

View File

@ -2,4 +2,4 @@ import type { RequestHandler } from './$types';
export const GET = (async ({ fetch }) => { export const GET = (async ({ fetch }) => {
return await fetch('api/posts'); return await fetch('api/posts');
}) satisfies RequestHandler; }) satisfies RequestHandler;

View File

@ -13,4 +13,4 @@ export const GET = (async ({ url }) => {
count = Math.min(count, 100); count = Math.min(count, 100);
const posts = await getPosts(since, before, count); const posts = await getPosts(since, before, count);
return json(posts); return json(posts);
}) satisfies RequestHandler; }) satisfies RequestHandler;

View File

@ -1,19 +1,19 @@
{ {
"name": "Moshing Mammut", "name": "Moshing Mammut",
"short_name": "Moshing Mammut", "short_name": "Moshing Mammut",
"icons": [ "icons": [
{ {
"src": "/android-chrome-192x192.png", "src": "/android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/android-chrome-512x512.png", "src": "/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"theme_color": "#2e0b78", "theme_color": "#2e0b78",
"background_color": "#2e0b78", "background_color": "#2e0b78",
"display": "standalone" "display": "standalone"
} }

View File

@ -1,58 +0,0 @@
body {
--color-blue: hsl(259, 82%, 26%);
--color-blue-dark: hsl(259, 82%, 10%);
--color-lavender: hsl(273, 43%, 65%);
--color-mauve: hsl(286, 73%, 81%);
--color-grey: hsl(44, 7%, 41%);
--color-grey-translucent: hsla(44, 7%, 41%, 0.2);
--color-grey-light: hsl(42, 7%, 72%);
--color-red: hsl(7, 100%, 56%);
--color-red-light: hsl(7, 100%, 61%);
--color-red-lighter: hsl(7, 100%, 68%);
--color-red-dark: hsl(7, 100%, 48%);
--color-red-desat: hsl(7, 20%, 56%);
--color-red-desat-dark: hsl(7, 20%, 30%);
--color-red-desat-desat: hsl(7, 8%, 56%);
--color-text: var(--color-blue);
--color-border: var(--color-grey);
--color-link: var(--color-mauve);
--color-link-visited: var(--color-lavender);
--color-bg: var(--color-grey-light);
--color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat-dark);
--color-button-hover: var(--color-red);
--color-button-deactivated: var(--color-red-desat-desat);
--color-button-text: var(--color-blue-dark);
color: var(--color-text);
background-color: var(--color-bg);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans,
Ubuntu, Cantarell, "Helvetica Neue", Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
a {
color: var(--color-link);
}
a:visited {
color: var(--color-link-visited);
}
@media (prefers-color-scheme: dark) {
body {
--color-text: var(--color-grey-light);
--color-border: var(--color-grey-light);
--color-link: var(--color-mauve);
--color-link-visited: var(--color-lavender);
--color-bg: var(--color-blue);
--color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat);
--color-button-hover: var(--color-red);
--color-button-deactivated: var(--color-red-desat-desat);
--color-button-text: var(--color-blue-dark);
}
}

View File

@ -1,5 +1,5 @@
import adapter from '@sveltejs/adapter-node'; import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
@ -12,7 +12,15 @@ const config = {
// If your environment is not supported or you settled on a specific environment, switch out the adapter. // If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(), adapter: adapter(),
},
csp: {
directives: {
'script-src': ['self', 'unsafe-inline'],
'base-uri': ['self'],
'object-src': ['none']
}
}
}
}; };
export default config; export default config;

View File

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