Compare commits

...

26 Commits
v1.3.0 ... main

Author SHA1 Message Date
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
22 changed files with 2579 additions and 905 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

@ -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/

2299
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.0", "version": "1.3.2",
"private": true, "private": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"scripts": { "scripts": {
@ -14,8 +14,10 @@
"format": "prettier --plugin-search-dir . --write ." "format": "prettier --plugin-search-dir . --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^1.2.3", "@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/kit": "^1.5.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/node": "^18.16.3",
"@types/sqlite3": "^3.1.8", "@types/sqlite3": "^3.1.8",
"@types/ws": "^8.5.4", "@types/ws": "^8.5.4",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/eslint-plugin": "^5.45.0",
@ -23,20 +25,24 @@
"@zerodevx/svelte-toast": "^0.9.3", "@zerodevx/svelte-toast": "^0.9.3",
"eslint": "^8.28.0", "eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte3": "^4.0.0", "eslint-plugin-svelte": "^2.35.1",
"prettier": "^2.8.0", "prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1", "prettier-plugin-svelte": "^2.10.1",
"svelte": "^3.54.0", "svelte": "^4.0.0",
"svelte-check": "^3.0.1", "svelte-check": "^3.4.3",
"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",
"sharp": "^0.32.0",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"ws": "^8.13.0" "ws": "^8.13.0"
},
"engines": {
"node": ">=18.0.0"
} }
} }

View File

@ -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,40 @@
%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 +68,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,6 +1,7 @@
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 { HandleServerError } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import fs from 'fs/promises'; import fs from 'fs/promises';
TimelineReader.init(); TimelineReader.init();
@ -27,6 +28,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

@ -3,17 +3,51 @@
export let account: Account; export let account: Account;
let avatarDescription: string; let avatarDescription: string;
let sourceSetHtml: string;
$: avatarDescription = `Avatar for ${account.acct}`; $: 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: 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

@ -18,7 +18,9 @@
$: 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');

View File

@ -1,5 +1,6 @@
<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';
@ -14,6 +15,91 @@
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,
@ -31,16 +117,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">
<img <picture>
src={song.thumbnailUrl} {@html getSourceSetHtml(song)}
class="cover" <img
alt="Cover for {song.artistName} - {song.title}" 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> <span class="text">{song.artistName} - {song.title}</span>
</div> </div>
</a> </a>
@ -128,7 +223,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,13 +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 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;
@ -41,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 = {
@ -79,6 +93,45 @@ if (enableVerboseLog) {
}); });
} }
function applyDbMigration(migration: Migration): Promise<void> {
return new Promise((resolve, reject) => {
db.exec(migration.statement, (err) => {
if (err !== null) {
log.error(`Failed to apply migration ${migration.name}`, err);
reject(err);
return;
}
resolve();
});
});
}
async function applyMigration(migration: Migration) {
if (migration.id === 4) {
// When this is run, no posts will have added song data,
// so filtering won't help
const posts = await getPostsInternal(null, null, 10000);
let current = 0;
const total = posts.length.toString().padStart(4, '0');
for (const post of posts) {
current++;
if (post.songs && post.songs.length) {
continue;
}
log.info(
`Fetching songs for existing post ${current.toString().padStart(4, '0')} of ${total}`,
post.url
);
const songs = await TimelineReader.getSongInfoInPost(post);
await saveSongInfoData(post.url, songs);
log.debug(`Fetched ${songs.length} songs for existing post`, post.url);
}
log.debug(`Finished fetching songs`);
} else {
await applyDbMigration(migration);
}
}
db.on('open', () => { db.on('open', () => {
log.info('Opened database'); log.info('Opened database');
db.serialize(); db.serialize();
@ -98,7 +151,7 @@ db.on('open', () => {
return; return;
} }
for (const migration of toApply) { for (const migration of toApply) {
db.exec(migration.statement, (err) => { applyMigration(migration).then(() => {
remaining--; remaining--;
// This will set databaseReady to true before the migration has been inserted as applies, // This will set databaseReady to true before the migration has been inserted as applies,
// but that doesn't matter. It's only important that is has been applied // but that doesn't matter. It's only important that is has been applied
@ -225,30 +278,61 @@ function getMigrations(): Migration[] {
post_url TEXT, post_url TEXT,
FOREIGN KEY (post_url) REFERENCES posts(url) FOREIGN KEY (post_url) REFERENCES posts(url)
);` );`
},
{
id: 4,
name: 'song info for existing posts',
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;`
} }
]; ];
} }
async function waitReady(): Promise<undefined> { 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(undefined); resolve();
} }
}, 100); }, 100);
}); });
} }
function saveAccountData(account: Account): Promise<undefined> { function saveAccountData(account: Account): Promise<void> {
return new Promise<undefined>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
db.run( db.run(
` `
INSERT INTO accounts (id, acct, username, display_name, url, avatar) INSERT INTO accounts (id, acct, username, display_name, url, avatar)
@ -274,14 +358,14 @@ function saveAccountData(account: Account): Promise<undefined> {
reject(err); reject(err);
return; return;
} }
resolve(undefined); resolve();
} }
); );
}); });
} }
function savePostData(post: Post): Promise<undefined> { function savePostData(post: Post): Promise<void> {
return new Promise<undefined>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
db.run( db.run(
` `
INSERT INTO posts (id, content, created_at, url, account_id) INSERT INTO posts (id, content, created_at, url, account_id)
@ -297,16 +381,16 @@ function savePostData(post: Post): Promise<undefined> {
reject(postErr); reject(postErr);
return; return;
} }
resolve(undefined); resolve();
} }
); );
}); });
} }
function savePostTagData(post: Post): Promise<undefined> { function savePostTagData(post: Post): Promise<void> {
return new Promise<undefined>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (!post.tags.length) { if (!post.tags.length) {
resolve(undefined); resolve();
return; return;
} }
@ -338,7 +422,7 @@ function savePostTagData(post: Post): Promise<undefined> {
remaining--; remaining--;
// Only resolve after all have been inserted // Only resolve after all have been inserted
if (remaining === 0) { if (remaining === 0) {
resolve(undefined); resolve();
} }
} }
); );
@ -349,10 +433,10 @@ function savePostTagData(post: Post): Promise<undefined> {
}); });
} }
function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<undefined> { function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
return new Promise<undefined>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (songs.length === 0) { if (songs.length === 0) {
resolve(undefined); resolve();
return; return;
} }
db.parallelize(() => { db.parallelize(() => {
@ -360,8 +444,8 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<undefined
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,
@ -371,7 +455,9 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<undefined
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) {
@ -383,7 +469,7 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<undefined
remaining--; remaining--;
// Only resolve after all have been inserted // Only resolve after all have been inserted
if (remaining === 0) { if (remaining === 0) {
resolve(undefined); resolve();
} }
} }
); );
@ -406,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[]> {
@ -431,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
@ -459,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;
} }
@ -482,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,
@ -503,7 +667,14 @@ export async function getPosts(
if (!databaseReady) { if (!databaseReady) {
await waitReady(); await waitReady();
} }
return await getPostsInternal(since, before, limit);
}
async function getPostsInternal(
since: string | null,
before: string | null,
limit: number
): Promise<Post[]> {
let filterQuery = ''; let filterQuery = '';
const params: FilterParameter = { $limit: limit }; const params: FilterParameter = { $limit: limit };
if (since === null && before === null) { if (since === null && before === null) {
@ -537,6 +708,17 @@ export async function getPosts(
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,
@ -550,10 +732,134 @@ export async function getPosts(
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,115 @@
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[]> {
const urlMatches = post.content.matchAll(URL_REGEX);
const songs: SongInfo[] = [];
for (const match of urlMatches) {
if (match === undefined || match.groups === undefined) {
log.warn('Match listed in allMatches, but either it or its groups are undefined', match);
continue;
}
const urlMatch = match.groups.postUrl.toString();
let url: URL;
try {
url = new URL(urlMatch);
} catch (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 songs;
}
private static async getSongInfo(url: URL, remainingTries = 6): Promise<SongInfo | null> { private static async getSongInfo(url: URL, remainingTries = 6): Promise<SongInfo | null> {
if (remainingTries === 0) { if (remainingTries === 0) {
log.error('No tries remaining. Lookup failed!'); log.error('No tries remaining. Lookup failed!');
@ -24,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') {
@ -33,23 +135,37 @@ 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 });
} }
const odesliInfo: OdesliResponse = await response.json(); const odesliInfo: OdesliResponse = await response.json();
if (!odesliInfo || !odesliInfo.entitiesByUniqueId || !odesliInfo.entityUniqueId) { 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; return null;
} }
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId]; const isMusic = await TimelineReader.isMusicVideo(youtubeId);
const platform: Platform = 'youtube'; if (!isMusic) {
return { log.debug('Probably not a music video', url);
...info, return null;
pageUrl: odesliInfo.pageUrl, }
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url, }
postedUrl: url.toString() return {
} as SongInfo; ...info,
}); pageUrl: odesliInfo.pageUrl,
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
postedUrl: url.toString()
} 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');
@ -61,11 +177,187 @@ export class TimelineReader {
} }
} }
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() { private startWebsocket() {
const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`); const socket = new WebSocket(
`wss://${MASTODON_INSTANCE}/api/v1/streaming?type=subscribe&stream=public:local&access_token=${MASTODON_ACCESS_TOKEN}`
);
socket.onopen = () => { socket.onopen = () => {
log.log('Connected to WS'); log.log('Connected to WS');
socket.send('{ "type": "subscribe", "stream": "public:local"}');
}; };
socket.onmessage = async (event) => { socket.onmessage = async (event) => {
try { try {
@ -74,36 +366,11 @@ export class TimelineReader {
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 urlMatches = post.content.matchAll(URL_REGEX); const songs = await TimelineReader.getSongInfoInPost(post);
const songs: SongInfo[] = [];
for (const match of urlMatches) {
if (match === undefined || match.groups === undefined) {
log.warn(
'Match listed in allMatches, but either it or its groups are undefined',
match
);
continue;
}
const urlMatch = match.groups.postUrl.toString();
let url: URL;
try {
url = new URL(urlMatch);
} catch (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);
}
}
// 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
@ -113,6 +380,10 @@ 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);
@ -123,7 +394,8 @@ export class TimelineReader {
}; };
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`);

View File

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

View File

@ -160,7 +160,7 @@
{#each data.posts as post (post.url)} {#each data.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,

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