29 Commits
v1.3.1 ... main

Author SHA1 Message Date
3c6e742e43 check for missed posts 2025-06-15 07:01:45 +02:00
7296582b0d updated dependencies, fixed sorting 2025-03-26 08:36:20 +01:00
66f09cf5a3 update to svelte 5 2024-10-29 16:26:07 +01:00
d39ccba927 minor refactors and additional logs 2024-09-24 14:47:50 +02:00
498b1d82d9 Update dependencies 2024-09-24 12:05:36 +02:00
79405cd08c Update dependencies & version 2024-01-22 17:33:31 +01:00
39c9689af4 Format 2024-01-22 17:26:49 +01:00
ad7c8af9de Migrate to Sveltekit 2.0 2024-01-22 17:26:36 +01:00
f1cb0b2159 Migrate to Svelte 4 2024-01-22 16:26:06 +01:00
049cd86ae0 Update dependencies 2024-01-22 16:06:43 +01:00
aab4433a55 Add oauth token to websocket connection 2023-10-15 19:39:36 +02:00
d3b599738e Update version tag 2023-06-24 10:53:02 +02:00
ba89182791 Revert "Update dependencies"
This reverts commit 5b6dbd327d.
2023-06-24 10:49:57 +02:00
5b6dbd327d Update dependencies 2023-06-24 10:10:52 +02:00
b960d35a58 Fix #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
24 changed files with 4654 additions and 2312 deletions

View File

@ -2,6 +2,7 @@ HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening
YOUTUBE_API_KEY = CHANGE_ME YOUTUBE_API_KEY = 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'
BASE_URL = 'https://moshingmammut.phlaym.net' BASE_URL = 'https://moshingmammut.phlaym.net'
VERBOSE = false VERBOSE = false
IGNORE_USERS = @moshhead@metalhead.club IGNORE_USERS = @moshhead@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

@ -65,7 +65,7 @@ Set up NVM:
``` ```
$ cd $ cd
$ curl https://raw.github.com/creationix/nvm/master/install.sh | sh $ curl https://raw.githubusercontent.com/nvm-sh/nvm/refs/heads/master/install.sh | bash
$ source ~/.nvm/nvm.sh $ source ~/.nvm/nvm.sh
$ nvm install --lts $ nvm install --lts
``` ```
@ -88,6 +88,8 @@ 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.
@ -96,10 +98,18 @@ 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. 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. 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.
#### On your server again #### On your server again

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/

5672
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "moshing-mammut", "name": "moshing-mammut",
"version": "1.3.1", "version": "1.3.2",
"private": true, "private": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"scripts": { "scripts": {
@ -14,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.2.12",
"@sveltejs/kit": "^1.5.0", "@sveltejs/kit": "^2.21.5",
"@types/sqlite3": "^3.1.8", "@sveltejs/vite-plugin-svelte": "^5.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": "^10.0.0",
"eslint-plugin-svelte3": "^4.0.0", "eslint-plugin-svelte": "^3.0.0",
"prettier": "^2.8.0", "prettier": "^3.1.0",
"prettier-plugin-svelte": "^2.8.1", "prettier-plugin-svelte": "^3.2.6",
"svelte": "^3.54.0", "svelte": "^5",
"svelte-check": "^3.0.1", "svelte-check": "^4.0.0",
"tslib": "^2.4.1", "tslib": "^2.0.0",
"typescript": "^4.9.3", "typescript": "^5.0.0",
"vite": "^4.0.0" "vite": "^6.0.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"feed": "^4.2.2", "feed": "^5.1.0",
"sqlite3": "^5.1.6", "sharp": "^0.34.2",
"ws": "^8.13.0" "sqlite3": "^5.0.0",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
} }
} }

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -10,7 +10,11 @@
<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" />
<link rel="stylesheet" href="%sveltekit.assets%/style.css" /> <meta
name="description"
content="A collection of music recommendations and now-listenings by the users of metalhead.club"
/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#17063b" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#17063b" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#BCB9B2" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#BCB9B2" media="(prefers-color-scheme: light)" />
@ -19,14 +23,53 @@
%sveltekit.head% %sveltekit.head%
<style> <style>
body { body {
--color-text: #2f0c7a; --color-blue: hsl(259, 82%, 26%);
--color-bg: white; --color-blue-dark: hsl(259, 82%, 13%);
--color-border: #17063b; --color-lavender: hsl(253, 82%, 33%);
--color-link: #563acc; --color-mauve: hsl(273, 82%, 38%);
--color-link-visited: #858afa;
--color-grey: hsl(44, 7%, 41%);
--color-grey-translucent: hsla(44, 7%, 41%, 0.2);
--color-grey-light: hsl(0, 0%, 98%);
--color-red: hsl(7, 100%, 56%);
--color-red-light: hsl(7, 100%, 61%);
--color-red-lighter: hsl(7, 100%, 68%);
--color-red-dark: hsl(7, 100%, 48%);
--color-red-desat: hsl(7, 20%, 56%);
--color-red-desat-dark: hsl(7, 20%, 30%);
--color-red-desat-desat: hsl(7, 8%, 56%);
--color-text: var(--color-blue-dark);
--color-border: var(--color-grey);
--color-link: var(--color-mauve);
--color-link-visited: var(--color-lavender);
--color-bg: var(--color-grey-light);
--color-bg-translucent: hsla(42, 7%, 72%, 0.5);
--color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat-dark);
--color-button-hover: var(--color-red);
--color-button-deactivated: var(--color-red-desat-desat);
--color-button-text: var(--color-blue-dark);
color: var(--color-text); color: var(--color-text);
background-color: var(--color-bg); 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 { a {
@ -38,11 +81,20 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
--color-text: white; --color-lavender: hsl(273, 43%, 65%);
--color-bg: #17063b; --color-mauve: hsl(286, 73%, 81%);
--color-border: white;
--color-link: #8a9bf0; --color-text: var(--color-grey-light);
--color-link-visited: #c384fb; --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> </style>

View File

@ -1,8 +1,10 @@
import { log } from '$lib/log'; 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 }) => {
@ -15,8 +17,6 @@ export const handleError = (({ error }) => {
}; };
}) 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') {
@ -27,6 +27,35 @@ export const handle = (async ({ event, resolve }) => {
return new Response(f, { headers: [['Content-Type', 'application/atom+xml']] }); return new Response(f, { headers: [['Content-Type', 'application/atom+xml']] });
} }
// Ideally, this would be served by apache
if (event.url.pathname.startsWith('/avatars/')) {
const fileName = event.url.pathname.split('/').pop() ?? 'unknown.jpeg';
const suffix = fileName.split('.').pop() ?? 'jpeg';
try {
//This should work, but doesn't yet. See: https://github.com/nodejs/node/issues/45853
/*
const stat = await fs.stat('avatars/' + fileName);
const fd = await fs.open('avatars/' + fileName);
const readStream = fd
.readableWebStream()
.getReader({ mode: 'byob' }) as ReadableStream<Uint8Array>;
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

@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { Account } from '$lib/mastodon/response'; import type { Account } from '$lib/mastodon/response';
export let account: Account; interface Props {
account: Account;
}
let { account }: Props = $props();
</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

@ -1,19 +1,55 @@
<script lang="ts"> <script lang="ts">
import type { Account } from '$lib/mastodon/response'; import type { Account } from '$lib/mastodon/response';
export let account: Account; interface Props {
let avatarDescription: string; account: Account;
$: avatarDescription = `Avatar for ${account.acct}`; }
let { account }: Props = $props();
let avatarDescription: string = $derived(`Avatar for ${account.acct}`);
let sourceSetHtml: string = $derived.by(() => {
// Sort thumbnails by file type. This is important, because the order of the srcset entries matter.
// We need the best format to be first
const formatPriority = new Map<string, number>([
['avif', 0],
['webp', 1],
['jpg', 99],
['jpeg', 99]
]);
const resizedAvatars = (account.resizedAvatars ?? []).toSorted((a, b) => {
const extensionA = a.file.split('.').pop() ?? '';
const extensionB = b.file.split('.').pop() ?? '';
const prioA = formatPriority.get(extensionA) ?? 3;
const prioB = formatPriority.get(extensionB) ?? 3;
return prioA - prioB;
});
const m = new Map<string, string[]>();
for (const resizedAvatar of resizedAvatars) {
const extension = resizedAvatar.file.split('.').pop();
const mime = extension ? `image/${extension}` : 'application/octet-stream';
const sourceSetEntry = `${resizedAvatar.file} ${resizedAvatar.sizeDescriptor}`;
m.set(mime, [...(m.get(mime) || []), sourceSetEntry]);
}
let html = '';
for (const entry of m.entries()) {
const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" />`;
}
return html;
});
</script> </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: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
width: auto; width: 50px;
height: auto; height: 50px;
object-fit: contain; object-fit: contain;
border-radius: 3px; border-radius: 3px;
} }

View File

@ -49,6 +49,7 @@
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 {

View File

@ -1,31 +1,34 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte';
import LoadingSpinnerComponent from '$lib/components/LoadingSpinnerComponent.svelte'; import LoadingSpinnerComponent from '$lib/components/LoadingSpinnerComponent.svelte';
export let moreAvailable = false; interface Props {
export let isLoading = false; moreAvailable?: boolean;
let displayText = ''; isLoading?: boolean;
let title = ''; loadOlderPosts: any;
let disabled: boolean;
$: if (isLoading) {
displayText = 'Loading...';
} else if (!moreAvailable) {
displayText = 'You reached the end';
} else {
displayText = 'Load More';
} }
$: disabled = !moreAvailable || isLoading;
$: title = moreAvailable ? 'Load More' : 'There be dragons!';
const dispatch = createEventDispatcher(); let { moreAvailable = false, isLoading = false, loadOlderPosts }: Props = $props();
let displayText = $derived.by(() => {
if (isLoading) {
return 'Loading...';
} else if (!moreAvailable) {
return 'You reached the end';
}
return 'Load More';
});
let title = $derived(moreAvailable ? 'Load More' : 'There be dragons!');
let disabled: boolean = $derived(!moreAvailable || isLoading);
/*const dispatch = createEventDispatcher<{
loadOlderPosts: string;
}>();
function loadOlderPosts() { function loadOlderPosts() {
dispatch('loadOlderPosts'); dispatch('loadOlderPosts');
} }*/
</script> </script>
<button on:click={loadOlderPosts} {disabled} {title}> <button onclick={() => 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>

View File

@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
export let size = '64px'; interface Props {
export let thickness = '6px'; size?: string;
thickness?: string;
}
let { size = '64px', thickness = '6px' }: Props = $props();
</script> </script>
<div class="lds-dual-ring" style="--size: {size}; --thickness: {thickness}" /> <div class="lds-dual-ring" style="--size: {size}; --thickness: {thickness}"></div>
<style> <style>
.lds-dual-ring { .lds-dual-ring {

View File

@ -1,17 +1,109 @@
<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; interface Props {
let displayRelativeTime = false; post: Post;
}
let { post }: Props = $props();
let displayRelativeTime = $state(false);
const absoluteDate = new Date(post.created_at).toLocaleString(); const absoluteDate = new Date(post.created_at).toLocaleString();
let dateCreated = absoluteDate;
const timePassed = secondsSince(new Date(post.created_at)); const timePassed = secondsSince(new Date(post.created_at));
$: if (displayRelativeTime) { let dateCreated = $derived.by(() => {
dateCreated = relativeTime($timePassed) ?? absoluteDate; if (displayRelativeTime) {
return relativeTime($timePassed) ?? absoluteDate;
}
return absoluteDate;
});
const songs = filterDuplicates(post.songs ?? []);
function filterDuplicates(songs: SongInfo[]): SongInfo[] {
return songs.filter((obj, index, arr) => {
return arr.map((mapObj) => mapObj.pageUrl).indexOf(obj.pageUrl) === index;
});
}
function getThumbnailSize(song: SongInfo): {
width?: number;
height?: number;
widthSmall?: number;
heightSmall?: number;
} {
if (song.thumbnailWidth === undefined || song.thumbnailHeight === undefined) {
return { width: undefined, height: undefined, widthSmall: undefined, heightSmall: undefined };
}
const factor = 200 / song.thumbnailWidth;
const smallFactor = 60 / song.thumbnailHeight;
const height = song.thumbnailHeight * factor;
return {
width: 200,
height: height,
widthSmall: smallFactor * song.thumbnailWidth,
heightSmall: 60
};
}
// Blurred thumbs aren't generated (yet, unclear of they ever will)
// So blurred forces using the small one, by skipping the others and removing its media query.
// This is technically unnecessary - the blurred one will only show if it matches the small media query,
// but this makes it more explicit
function getSourceSetHtml(song: SongInfo, isBlurred = false): string {
const small = new Map<string, string[]>();
const large = new Map<string, string[]>();
// Sort thumbnails by file type. This is important, because the order of the srcset entries matter.
// We need the best format to be first
const formatPriority = new Map<string, number>([
['avif', 0],
['webp', 1],
['jpg', 99],
['jpeg', 99]
]);
const thumbs = (song.resizedThumbnails ?? []).toSorted((a, b) => {
const extensionA = a.file.split('.').pop() ?? '';
const extensionB = b.file.split('.').pop() ?? '';
const prioA = formatPriority.get(extensionA) ?? 3;
const prioB = formatPriority.get(extensionB) ?? 3;
return prioA - prioB;
});
for (const resizedThumb of thumbs) {
if (isBlurred && resizedThumb.kind !== SongThumbnailImageKind.Small) {
continue;
}
const extension = resizedThumb.file.split('.').pop();
const mime = extension ? `image/${extension}` : 'application/octet-stream';
const sourceSetEntry = `${resizedThumb.file} ${resizedThumb.sizeDescriptor}`;
switch (resizedThumb.kind) {
case SongThumbnailImageKind.Big:
large.set(mime, [...(large.get(mime) || []), sourceSetEntry]);
break;
case SongThumbnailImageKind.Small:
small.set(mime, [...(small.get(mime) || []), sourceSetEntry]);
break;
case SongThumbnailImageKind.Blurred: // currently not generated
break;
}
}
let html = '';
const { width, height, widthSmall, heightSmall } = getThumbnailSize(song);
const mediaAttribute = isBlurred ? '' : 'media="(max-width: 650px)"';
for (const entry of small.entries()) {
const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" ${mediaAttribute} width="${widthSmall}" height="${heightSmall}" />`;
}
html += '\n';
for (const entry of large.entries()) {
const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" width="${width}" height="${height}"/>`;
}
return html;
} }
onMount(() => { onMount(() => {
@ -31,16 +123,25 @@
<div class="content">{@html post.content}</div> <div class="content">{@html post.content}</div>
<div class="song"> <div class="song">
{#if post.songs} {#if post.songs}
{#each post.songs as song (song.pageUrl)} {#each songs as song (song.pageUrl)}
<div class="info-wrapper"> <div class="info-wrapper">
<div class="bgimage" style="background-image: url({song.thumbnailUrl});" /> <picture>
{@html getSourceSetHtml(song)}
<img class="bgimage" src={song.thumbnailUrl} loading="lazy" alt="Blurred cover" />
</picture>
<a href={song.pageUrl ?? song.postedUrl} target="_blank"> <a href={song.pageUrl ?? song.postedUrl} target="_blank">
<div class="info"> <div class="info">
<picture>
{@html getSourceSetHtml(song)}
<img <img
src={song.thumbnailUrl} src={song.thumbnailUrl}
class="cover" class="cover"
alt="Cover for {song.artistName} - {song.title}" alt="Cover for {song.artistName} - {song.title}"
loading="lazy"
width={song.thumbnailWidth}
height={song.thumbnailHeight}
/> />
</picture>
<span class="text">{song.artistName} - {song.title}</span> <span class="text">{song.artistName} - {song.title}</span>
</div> </div>
</a> </a>
@ -128,7 +229,6 @@
} }
.cover:not(.background) { .cover:not(.background) {
z-index: 1; z-index: 1;
width: 60px;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
} }

View File

@ -37,4 +37,24 @@ export interface Account {
display_name: string; display_name: string;
url: string; url: string;
avatar: string; avatar: string;
resizedAvatars?: AccountAvatar[];
} }
export type AccountAvatar = {
accountUrl: string;
file: string;
sizeDescriptor: string;
};
export enum SongThumbnailImageKind {
Big = 1,
Small,
Blurred
}
export type SongThumbnailImage = {
songThumbnailUrl: string;
file: string;
sizeDescriptor: string;
kind: SongThumbnailImageKind;
};

View File

@ -1,3 +1,5 @@
import type { SongThumbnailImage } from '$lib/mastodon/response';
export type SongInfo = { export type SongInfo = {
pageUrl: string; pageUrl: string;
youtubeUrl?: string; youtubeUrl?: string;
@ -6,6 +8,9 @@ export type SongInfo = {
artistName?: string; artistName?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
postedUrl: string; postedUrl: string;
resizedThumbnails?: SongThumbnailImage[];
thumbnailWidth?: number;
thumbnailHeight?: number;
}; };
export type SongwhipReponse = { export type SongwhipReponse = {

View File

@ -1,14 +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, log } from '$lib/log';
import type { Account, Post, 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 { DEV } = import.meta.env;
type FilterParameter = { type FilterParameter = {
$limit: number | undefined | null; $limit?: number | undefined | null;
$since?: string | undefined | null; $since?: string | undefined | null;
$before?: string | undefined | null; $before?: string | undefined | null;
[x: string]: string | number | undefined | null; [x: string]: string | number | undefined | null;
@ -42,6 +40,21 @@ type SongRow = {
title?: string; title?: string;
artistName?: string; artistName?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
thumbnailWidth?: number;
thumbnailHeight?: number;
};
type AccountAvatarRow = {
account_url: string;
file: string;
sizeDescriptor: string;
};
type SongThumbnailAvatarRow = {
song_thumbnailUrl: string;
file: string;
sizeDescriptor: string;
kind: number;
}; };
type Migration = { type Migration = {
@ -80,8 +93,8 @@ if (enableVerboseLog) {
}); });
} }
async function applyDbMigration(migration: Migration): Promise<void> { function applyDbMigration(migration: Migration): Promise<void> {
return new Promise(async (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); log.error(`Failed to apply migration ${migration.name}`, err);
@ -99,7 +112,7 @@ async function applyMigration(migration: Migration) {
// so filtering won't help // so filtering won't help
const posts = await getPostsInternal(null, null, 10000); const posts = await getPostsInternal(null, null, 10000);
let current = 0; let current = 0;
let total = posts.length.toString().padStart(4, '0'); const total = posts.length.toString().padStart(4, '0');
for (const post of posts) { for (const post of posts) {
current++; current++;
if (post.songs && post.songs.length) { if (post.songs && post.songs.length) {
@ -270,6 +283,36 @@ function getMigrations(): Migration[] {
id: 4, id: 4,
name: 'song info for existing posts', name: 'song info for existing posts',
statement: `` statement: ``
},
{
id: 5,
name: 'resized avatars',
statement: `
CREATE TABLE accountsavatars (
file TEXT NOT NULL PRIMARY KEY,
account_url TEXT NOT NULL,
sizeDescriptor TEXT NOT NULL,
FOREIGN KEY (account_url) REFERENCES accounts(url)
);`
},
{
id: 6,
name: 'resized song thumbnails',
statement: `
CREATE TABLE songsthumbnails (
file TEXT NOT NULL PRIMARY KEY,
song_thumbnailUrl TEXT NOT NULL,
sizeDescriptor TEXT NOT NULL,
kind INTEGER NOT NULL,
FOREIGN KEY (song_thumbnailUrl) REFERENCES songs(thumbnailUrl)
);`
},
{
id: 7,
name: 'song thumbnail size',
statement: `
ALTER TABLE songs ADD COLUMN thumbnailWidth INTEGER NULL;
ALTER TABLE songs ADD COLUMN thumbnailHeight INTEGER NULL;`
} }
]; ];
} }
@ -278,13 +321,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(() => {
if (DEV) { log.verbose('Waiting for database to be ready');
log.debug('Waiting for database to be ready');
}
if (databaseReady) { if (databaseReady) {
if (DEV) { log.verbose('DB is ready');
log.debug('DB is ready');
}
clearInterval(interval); clearInterval(interval);
resolve(); resolve();
} }
@ -405,8 +444,8 @@ 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) INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, title, artistName, thumbnailUrl, post_url, thumbnailWidth, thumbnailHeight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[ [
song.postedUrl, song.postedUrl,
@ -416,7 +455,9 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
song.title, song.title,
song.artistName, song.artistName,
song.thumbnailUrl, song.thumbnailUrl,
postUrl postUrl,
song.thumbnailWidth,
song.thumbnailHeight
], ],
(songErr) => { (songErr) => {
if (songErr !== null) { if (songErr !== null) {
@ -438,11 +479,11 @@ 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}`);
if (!databaseReady) { if (!databaseReady) {
await waitReady(); await waitReady();
} }
log.debug(`Saving post ${post.url}`);
const account = post.account; const account = post.account;
await saveAccountData(account); await saveAccountData(account);
log.debug(`Saved account data ${post.url}`); log.debug(`Saved account data ${post.url}`);
@ -451,7 +492,10 @@ export async function savePost(post: Post, songs: SongInfo[]) {
await savePostTagData(post); await savePostTagData(post);
log.debug(`Saved ${post.tags.length} tag data ${post.url}`); log.debug(`Saved ${post.tags.length} tag data ${post.url}`);
await saveSongInfoData(post.url, songs); await saveSongInfoData(post.url, songs);
log.debug(`Saved ${songs.length} song info data ${post.url}`); log.debug(
`Saved ${songs.length} song info data ${post.url}`,
songs.map((s) => s.thumbnailHeight)
);
} }
function getPostData(filterQuery: string, params: FilterParameter): Promise<PostRow[]> { function getPostData(filterQuery: string, params: FilterParameter): Promise<PostRow[]> {
@ -476,7 +520,7 @@ function getPostData(filterQuery: string, params: FilterParameter): Promise<Post
}); });
} }
function getTagData(postIdsParams: String, postIds: string[]): Promise<Map<string, Tag[]>> { function getTagData(postIdsParams: string, postIds: string[]): Promise<Map<string, Tag[]>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.all( db.all(
`SELECT post_id, tags.url, tags.tag `SELECT post_id, tags.url, tags.tag
@ -504,17 +548,17 @@ 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.title, songs.artistName, songs.thumbnailUrl, songs.post_url 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 tags', tagErr); log.error('Error loading post songs', tagErr);
reject(tagErr); reject(tagErr);
return; return;
} }
@ -527,19 +571,94 @@ function getSongData(postIdsParams: String, postIds: string[]): Promise<Map<stri
title: item.title, title: item.title,
artistName: item.artistName, artistName: item.artistName,
thumbnailUrl: item.thumbnailUrl, thumbnailUrl: item.thumbnailUrl,
postedUrl: item.postedUrl postedUrl: item.postedUrl,
thumbnailHeight: item.thumbnailHeight,
thumbnailWidth: item.thumbnailWidth
} as SongInfo; } as SongInfo;
result.set(item.post_url, [...(result.get(item.post_url) || []), info]); result.set(item.post_url, [...(result.get(item.post_url) || []), info]);
return result; return result;
}, },
new Map() new Map()
); );
log.verbose('songMap', songMap);
resolve(songMap); resolve(songMap);
} }
); );
}); });
} }
function getAvatarData(
accountUrlsParams: string,
accountUrls: string[]
): Promise<Map<string, AccountAvatar[]>> {
return new Promise((resolve, reject) => {
db.all(
`SELECT account_url, file, sizeDescriptor
FROM accountsavatars
WHERE account_url IN (${accountUrlsParams});`,
accountUrls,
(err, rows: AccountAvatarRow[]) => {
if (err != null) {
log.error('Error loading avatars', err);
reject(err);
return;
}
const avatarMap: Map<string, AccountAvatar[]> = rows.reduce(
(result: Map<string, AccountAvatar[]>, item) => {
const info: AccountAvatar = {
accountUrl: item.account_url,
file: item.file,
sizeDescriptor: item.sizeDescriptor
};
result.set(item.account_url, [...(result.get(item.account_url) || []), info]);
return result;
},
new Map()
);
resolve(avatarMap);
}
);
});
}
function getSongThumbnailData(
thumbUrlsParams: string,
thumbUrls: string[]
): Promise<Map<string, SongThumbnailImage[]>> {
return new Promise((resolve, reject) => {
db.all(
`SELECT song_thumbnailUrl, file, sizeDescriptor, kind
FROM songsthumbnails
WHERE song_thumbnailUrl IN (${thumbUrlsParams});`,
thumbUrls,
(err, rows: SongThumbnailAvatarRow[]) => {
if (err != null) {
log.error('Error loading avatars', err);
reject(err);
return;
}
const thumbnailMap: Map<string, SongThumbnailImage[]> = rows.reduce(
(result: Map<string, SongThumbnailImage[]>, item) => {
const info: SongThumbnailImage = {
songThumbnailUrl: item.song_thumbnailUrl,
file: item.file,
sizeDescriptor: item.sizeDescriptor,
kind: item.kind
};
result.set(item.song_thumbnailUrl, [
...(result.get(item.song_thumbnailUrl) || []),
info
]);
return result;
},
new Map()
);
resolve(thumbnailMap);
}
);
});
}
export async function getPosts( export async function getPosts(
since: string | null, since: string | null,
before: string | null, before: string | null,
@ -589,6 +708,17 @@ async function getPostsInternal(
const postIds = rows.map((r: PostRow) => r.url); const postIds = rows.map((r: PostRow) => r.url);
const tagMap = await getTagData(postIdsParams, postIds); const tagMap = await getTagData(postIdsParams, postIds);
const songMap = await getSongData(postIdsParams, postIds); const songMap = await getSongData(postIdsParams, postIds);
for (const entry of songMap) {
for (const songInfo of entry[1]) {
const thumbs = await getSongThumbnails(songInfo);
songInfo.resizedThumbnails = thumbs;
}
}
const accountUrls = [...new Set(rows.map((r: PostRow) => r.account_url))];
const accountUrlsParams = accountUrls.map(() => '?').join(', ');
const avatars = await getAvatarData(accountUrlsParams, accountUrls);
const posts = rows.map((row) => { const posts = rows.map((row) => {
return { return {
id: row.id, id: row.id,
@ -602,10 +732,134 @@ async function getPostsInternal(
username: row.username, username: row.username,
display_name: row.display_name, display_name: row.display_name,
url: row.account_url, url: row.account_url,
avatar: row.avatar avatar: row.avatar,
resizedAvatars: avatars.get(row.account_url) || []
} as Account, } as Account,
songs: songMap.get(row.url) || [] songs: songMap.get(row.url) || []
} as Post; } as Post;
}); });
return posts; return posts;
} }
export async function removeAvatars(accountUrl: string): Promise<void> {
const params: FilterParameter = { $account: accountUrl };
const sql = `
DELETE
FROM accountsavatars
WHERE account_url = $account`;
await waitReady();
return new Promise((resolve, reject) => {
db.run(sql, params, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
export async function saveSongThumbnail(thumb: SongThumbnailImage): Promise<void> {
// Will be null if file already existed
if (thumb.file === null) {
return;
}
const params: FilterParameter = {
$songId: thumb.songThumbnailUrl,
$file: thumb.file,
$sizeDescriptor: thumb.sizeDescriptor,
$kind: thumb.kind.valueOf()
};
const sql = `
INSERT INTO songsthumbnails
(song_thumbnailUrl, file, sizeDescriptor, kind) VALUES ($songId, $file, $sizeDescriptor, $kind)
ON CONFLICT(file) DO UPDATE SET
song_thumbnailUrl=excluded.song_thumbnailUrl,
sizeDescriptor=excluded.sizeDescriptor,
kind=excluded.kind;`;
await waitReady();
return new Promise((resolve, reject) => {
db.run(sql, params, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
export async function saveAvatar(avatar: AccountAvatar): Promise<void> {
// Will be null if file already existed
if (avatar.file === null) {
return;
}
const params: FilterParameter = {
$accountUrl: avatar.accountUrl,
$file: avatar.file,
$sizeDescriptor: avatar.sizeDescriptor
};
const sql = `
INSERT INTO accountsavatars
(account_url, file, sizeDescriptor) VALUES ($accountUrl, $file, $sizeDescriptor)
ON CONFLICT(file) DO UPDATE SET
account_url=excluded.account_url,
sizeDescriptor=excluded.sizeDescriptor;`;
await waitReady();
return new Promise((resolve, reject) => {
db.run(sql, params, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
export async function getAvatars(
accountUrl: string,
limit: number | undefined
): Promise<AccountAvatar[]> {
// TODO: Refactor to use `getAvatarData`
await waitReady();
let limitFilter = '';
const params: FilterParameter = {
$account: accountUrl,
$limit: 100
};
if (limit !== undefined) {
limitFilter = 'LIMIT $limit';
params.$limit = limit;
}
const sql = `
SELECT account_url, file, sizeDescriptor
FROM accountsavatars
WHERE account_url = $account
${limitFilter};`;
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows: AccountAvatarRow[]) => {
if (err) {
reject(err);
return;
}
resolve(
rows.map((r) => {
return {
accountUrl: r.account_url,
file: r.file,
sizeDescriptor: r.sizeDescriptor
} as AccountAvatar;
})
);
});
});
}
export async function getSongThumbnails(song: SongInfo): Promise<SongThumbnailImage[]> {
if (!song.thumbnailUrl) {
return [];
}
const rows = await getSongThumbnailData('?', [song.thumbnailUrl]);
return rows.get(song.thumbnailUrl) ?? [];
}

View File

@ -1,17 +1,87 @@
import { HASHTAG_FILTER, MASTODON_INSTANCE, ODESLI_API_KEY } from '$env/static/private'; import {
HASHTAG_FILTER,
MASTODON_ACCESS_TOKEN,
MASTODON_INSTANCE,
ODESLI_API_KEY,
YOUTUBE_API_KEY
} from '$env/static/private';
import { log } from '$lib/log'; import { log } from '$lib/log';
import type { Post, Tag, TimelineEvent } from '$lib/mastodon/response'; 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 type { OdesliResponse, Platform, SongInfo } from '$lib/odesliResponse';
import { getPosts, savePost } from '$lib/server/db'; 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 { sleep } from '$lib/sleep'; import { sleep } from '$lib/sleep';
import crypto from 'crypto';
import fs from 'fs/promises';
import sharp from 'sharp';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
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 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) {
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([
['part', 'snippet'],
['id', videoId],
['key', YOUTUBE_API_KEY]
]);
const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`);
const resp = await fetch(youtubeVideoUrl);
const respObj = await resp.json();
if (!respObj.items.length) {
console.warn('Could not find video with id', videoId);
return false;
}
const item = respObj.items[0];
if (!item.snippet) {
console.warn('Could not load snippet for video', videoId, item);
return false;
}
if (item.snippet.tags?.includes('music')) {
return true;
}
const categorySearchParams = new URLSearchParams([
['part', 'snippet'],
['id', item.snippet.categoryId],
['key', YOUTUBE_API_KEY]
]);
const youtubeCategoryUrl = new URL(
`https://www.googleapis.com/youtube/v3/videoCategories?${categorySearchParams}`
);
const categoryTitle: string = await fetch(youtubeCategoryUrl)
.then((r) => r.json())
.then((r) => r.items[0]?.snippet?.title);
return categoryTitle === 'Music';
}
public static async getSongInfoInPost(post: Post): Promise<SongInfo[]> { public static 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[] = [];
@ -52,8 +122,12 @@ export class TimelineReader {
return null; 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(); const odesliParams = new URLSearchParams();
odesliParams.append('url', url.toString()); odesliParams.append('url', urlString);
odesliParams.append('userCountry', 'DE'); odesliParams.append('userCountry', 'DE');
odesliParams.append('songIfSingle', 'true'); odesliParams.append('songIfSingle', 'true');
if (ODESLI_API_KEY && ODESLI_API_KEY !== 'CHANGE_ME') { if (ODESLI_API_KEY && ODESLI_API_KEY !== 'CHANGE_ME') {
@ -61,7 +135,7 @@ export class TimelineReader {
} }
const odesliApiUrl = `https://api.song.link/v1-alpha.1/links?${odesliParams}`; const odesliApiUrl = `https://api.song.link/v1-alpha.1/links?${odesliParams}`;
try { try {
return fetch(odesliApiUrl).then(async (response) => { const response = await fetch(odesliApiUrl);
if (response.status === 429) { if (response.status === 429) {
throw new Error('Rate limit reached', { cause: 429 }); throw new Error('Rate limit reached', { cause: 429 });
} }
@ -71,13 +145,27 @@ export class TimelineReader {
} }
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId]; const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
const platform: Platform = 'youtube'; 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 { return {
...info, ...info,
pageUrl: odesliInfo.pageUrl, pageUrl: odesliInfo.pageUrl,
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url, youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
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'); log.warn('song.link rate limit reached. Trying again in 10 seconds');
@ -89,19 +177,182 @@ export class TimelineReader {
} }
} }
private startWebsocket() { private static async resizeAvatar(
const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`); baseName: string,
socket.onopen = () => { size: number,
log.log('Connected to WS'); suffix: string,
socket.send('{ "type": "subscribe", "stream": "public:local"}'); folder: string,
}; sharpAvatar: sharp.Sharp
socket.onmessage = async (event) => { ): Promise<string | null> {
try { const fileName = `${folder}/${baseName}_${suffix}`;
const data: TimelineEvent = JSON.parse(event.data.toString()); const exists = await fs
if (data.event !== 'update') { .access(fileName, fs.constants.F_OK)
return; .then(() => true)
.catch(() => false);
if (exists) {
log.debug('File already exists', fileName);
return null;
} }
const post: Post = JSON.parse(data.payload); 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 async checkAndSavePost(post: Post) {
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));
@ -115,17 +366,40 @@ export class TimelineReader {
} }
await savePost(post, songs); await savePost(post, songs);
await TimelineReader.saveAvatar(post.account);
await TimelineReader.saveSongThumbnails(songs);
log.debug('Saved post', post.url); 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));
}
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) => {
try {
const data: TimelineEvent = JSON.parse(event.data.toString());
if (data.event !== 'update') {
log.log('Ignoring ES event', data.event);
return;
}
const post: Post = JSON.parse(data.payload);
await this.checkAndSavePost(post);
} catch (e) { } catch (e) {
log.error('error message', event, event.data, e); log.error('error message', event, event.data, e);
} }
}; };
socket.onclose = (event) => { socket.onclose = (event) => {
log.warn( log.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
); );
setTimeout(() => { setTimeout(() => {
log.info(`Attempting to reconenct to WS`); log.info(`Attempting to reconenct to WS`);
@ -139,11 +413,42 @@ export class TimelineReader {
}; };
} }
private async loadPostsSinceLastRun() {
const now = new Date().toISOString();
let latestPost = await getPosts(null, now, 1);
log.log('Last post in DB since', now, latestPost);
let u = new URL(`https://${MASTODON_INSTANCE}/api/v1/timelines/public?local=true&limit=40`);
if (latestPost.length > 0) {
u.searchParams.append('since_id', latestPost[0].id);
}
for (let tag of HASHTAG_FILTER.split(',')) {
u.searchParams.append('q', '#' + tag);
}
const headers = {
Authorization: `Bearer ${MASTODON_ACCESS_TOKEN}`
};
const latestPosts: Post[] = await fetch(u, { headers }).then((r) => r.json());
log.info('searched posts', latestPosts);
for (const post of latestPosts) {
await this.checkAndSavePost(post);
}
}
private constructor() { private constructor() {
log.log('Constructing timeline object');
this.startWebsocket(); this.startWebsocket();
this.loadPostsSinceLastRun()
.then((_) => {
log.info('loaded posts since last run');
})
.catch((e) => {
log.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

@ -1,6 +1,11 @@
<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';
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
const options = { const options = {
pausable: true, pausable: true,
@ -8,7 +13,7 @@
}; };
</script> </script>
<slot /> {@render children?.()}
<SvelteToast {options} /> <SvelteToast {options} />
<div class="footer"> <div class="footer">
<FooterComponent /> <FooterComponent />

View File

@ -12,7 +12,12 @@
import { cubicInOut } from 'svelte/easing'; import { cubicInOut } from 'svelte/easing';
import { errorToast } from '$lib/errorToast'; import { errorToast } from '$lib/errorToast';
export let data: PageData; interface Props {
data: PageData;
}
let { data = $bindable() }: Props = $props();
let posts: Post[] = $state(data.posts);
interface FetchOptions { interface FetchOptions {
since?: string; since?: string;
@ -26,8 +31,8 @@
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL); const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
let interval: ReturnType<typeof setTimeout> | null = null; let interval: ReturnType<typeof setTimeout> | null = null;
let moreOlderPostsAvailable = true; let moreOlderPostsAvailable = $state(true);
let loadingOlderPosts = false; let loadingOlderPosts = $state(false);
// Needed, so that edgeFly() can do its thing: // 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 // To determine whether a newly loaded post is older than the existing ones, is required to know what the oldest
@ -40,11 +45,11 @@
*/ */
function edgeFly(node: Element, opts: EdgeFlyParams) { function edgeFly(node: Element, opts: EdgeFlyParams) {
const createdAt = new Date(opts.created_at).getTime(); const createdAt = new Date(opts.created_at).getTime();
const diffNewest = Math.abs(new Date(data.posts[0].created_at).getTime() - createdAt); const diffNewest = Math.abs(new Date(posts[0].created_at).getTime() - createdAt);
const oldest = const oldest =
oldestBeforeLastFetch !== null oldestBeforeLastFetch !== null
? oldestBeforeLastFetch ? oldestBeforeLastFetch
: new Date(data.posts[data.posts.length - 1].created_at).getTime(); : new Date(posts[posts.length - 1].created_at).getTime();
const diffOldest = Math.abs(oldest - createdAt); const diffOldest = Math.abs(oldest - createdAt);
const fromTop = diffNewest <= diffOldest; const fromTop = diffNewest <= diffOldest;
@ -79,15 +84,15 @@
function refresh() { function refresh() {
let filter: FetchOptions = {}; let filter: FetchOptions = {};
if (data.posts.length > 0) { if (posts.length > 0) {
filter = { since: data.posts[0].created_at }; filter = { since: posts[0].created_at };
} }
fetchPosts(filter) fetchPosts(filter)
.then((resp) => { .then((resp) => {
if (resp.length > 0) { if (resp.length > 0) {
// Prepend new posts, filter dupes // Prepend new posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry // There shouldn't be any duplicates, but better be safe than sorry
data.posts = filterDuplicates(resp.concat(data.posts)); posts = filterDuplicates(resp.concat(posts));
} }
}) })
.catch((e: Error) => { .catch((e: Error) => {
@ -96,8 +101,9 @@
} }
onMount(async () => { onMount(async () => {
if (data.posts.length > 0) { posts = data.posts;
oldestBeforeLastFetch = new Date(data.posts[data.posts.length - 1].created_at).getTime(); if (posts.length > 0) {
oldestBeforeLastFetch = new Date(posts[posts.length - 1].created_at).getTime();
} }
interval = setInterval(refresh, refreshInterval); interval = setInterval(refresh, refreshInterval);
@ -121,8 +127,8 @@
function loadOlderPosts() { function loadOlderPosts() {
loadingOlderPosts = true; loadingOlderPosts = true;
const filter: FetchOptions = { count: 20 }; const filter: FetchOptions = { count: 20 };
if (data.posts.length > 0) { if (posts.length > 0) {
const before = data.posts[data.posts.length - 1].created_at; const before = posts[posts.length - 1].created_at;
filter.before = before; filter.before = before;
oldestBeforeLastFetch = new Date(before).getTime(); oldestBeforeLastFetch = new Date(before).getTime();
} }
@ -132,7 +138,7 @@
if (resp.length > 0) { if (resp.length > 0) {
// Append old posts, filter dupes // Append old posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry // There shouldn't be any duplicates, but better be safe than sorry
data.posts = filterDuplicates(data.posts.concat(resp)); posts = filterDuplicates(posts.concat(resp));
// If we got less than we expected, there are no older posts available // If we got less than we expected, there are no older posts available
moreOlderPostsAvailable = resp.length >= (filter.count ?? 20); moreOlderPostsAvailable = resp.length >= (filter.count ?? 20);
} else { } else {
@ -152,15 +158,15 @@
</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 posts.length === 0}
Sorry, no posts recommending music have been found yet Sorry, no posts recommending music have been found yet
{/if} {/if}
{#each data.posts as post (post.url)} {#each posts as post (post.url)}
<div <div
class="post" class="post"
transition:edgeFly={{ transition:edgeFly|global={{
y: 10, y: 10,
created_at: post.created_at, created_at: post.created_at,
duration: 300, duration: 300,
@ -171,12 +177,12 @@
</div> </div>
{/each} {/each}
<LoadMoreComponent <LoadMoreComponent
on:loadOlderPosts={loadOlderPosts} {loadOlderPosts}
moreAvailable={moreOlderPostsAvailable} moreAvailable={moreOlderPostsAvailable}
isLoading={loadingOlderPosts} isLoading={loadingOlderPosts}
/> />
</div> </div>
<div /> <div></div>
</div> </div>
<style> <style>

View File

@ -1,59 +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-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-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-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);
}
}

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 = {
@ -11,7 +11,15 @@ const config = {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter. // 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']
}
}
} }
}; };

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'
]
}
}
}); });