Compare commits

..

No commits in common. "main" and "v1.2.1" have entirely different histories.
main ... v1.2.1

26 changed files with 1061 additions and 3627 deletions

View File

@ -1,8 +1,8 @@
HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening
URL_FILTER = song.link,album.link,spotify.com,music.apple.com,bandcamp.com
YOUTUBE_API_KEY = CHANGE_ME YOUTUBE_API_KEY = CHANGE_ME
ODESLI_API_KEY = CHANGE_ME YOUTUBE_DISABLE = false
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,25 +1,16 @@
module.exports = { module.exports = {
root: true, root: true,
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
extends: ['plugin:svelte/recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['@typescript-eslint'], plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'], ignorePatterns: ['*.cjs'],
overrides: [ overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
{
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,8 +4,6 @@ playbook.yml
inventory.yml inventory.yml
ansible.cfg ansible.cfg
avatars/*
thumbnails/*
node_modules node_modules
/build /build
/.svelte-kit /.svelte-kit

View File

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

View File

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

View File

@ -11,8 +11,8 @@ Having a quick overview over what is being posted can be a great way to discover
This is fairly simple from a technical point of view! metalhead.club's local timeline is being watched using the This is fairly simple from a technical point of view! metalhead.club's local timeline is being watched using the
Mastodon Streaming API over a Websocket. Every time a new post arrives, it is checked if it contains any music by Mastodon Streaming API over a Websocket. Every time a new post arrives, it is checked if it contains any music by
checking included hashtags and URLs. A list of tags can be found in [the configuration](.env.EXAMPLE). checking included hashtags and URLs. A list of tags and URLs can be found in [the configuration](.env.EXAMPLE).
Additionally, links are vetted if they are music by checking if https://song.link finds info on them. Additionally, lins to YouTube are queried, if they are music or other videos using the YouTube API.
If a post passes this check it is saved to a SQLite database. If a post passes this check it is saved to a SQLite database.
@ -88,27 +88,16 @@ Copy `apache2.conf.EXAMPLE` to `/etc/apache2/sites-available/moshingmammut.conf`
Domain. If you do not need or want SSL support, remove the whole `<IfModule mod_ssl.c>` block. Domain. If you do not need or want SSL support, remove the whole `<IfModule mod_ssl.c>` block.
If you do, add the path to your SSLCertificateFile and SSLCertificateKeyFile. If you do, add the path to your SSLCertificateFile and SSLCertificateKeyFile.
Modify DocumentRoot and the two Alias and Directory statements, so that thumbnails and avatars are served directly by apache.
Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service` Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service`
and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly. and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
#### On your development machine #### On your development machine
Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY` and `ODESLI_API_KEY`. Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY`.
To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an
_API key_. _API key_.
If `YOUTUBE_API_KEY` is unset, no playlist will be updated. Also, _all_ YouTube links will be treated as music videos, If `YOUTUBE_API_KEY` is unset, all YouTube videos will be assumed to contain music links.
because the API is the only way to check if a YouTube link leads to music or something else. If this is unwanted, set `YOUTUBE_DISABLE` to `true`).
If `ODESLI_API_KEY` is unset, your rate limit to the song.link API will be lower.
Add `MASTODON_ACCESS_TOKEN` as well, see [Creating our application
](https://docs.joinmastodon.org/client/token/#app) in the Mastodon documentation.
`read:statuses` is the only required scope. An access token will be displayed in your settings. Use that!
There are currently no plans to implement an actual authentication flow.
Run `npm run build` and copy the output folder, usually `build` to `$APP_DIR` on your server. Run `npm run build` and copy the output folder, usually `build` to `$APP_DIR` on your server.

View File

@ -15,23 +15,6 @@
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/

2722
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.2", "version": "1.1.0",
"private": true, "private": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"scripts": { "scripts": {
@ -14,10 +14,8 @@
"format": "prettier --plugin-search-dir . --write ." "format": "prettier --plugin-search-dir . --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^2.0.0", "@sveltejs/adapter-node": "^1.2.3",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^1.5.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",
@ -25,24 +23,20 @@
"@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-svelte": "^2.35.1", "eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.8.0", "prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1", "prettier-plugin-svelte": "^2.8.1",
"svelte": "^4.0.0", "svelte": "^3.54.0",
"svelte-check": "^3.4.3", "svelte-check": "^3.0.1",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^4.9.3",
"vite": "^5.0.0" "vite": "^4.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,11 +10,7 @@
<meta name="apple-mobile-web-app-title" content="Moshing Mammut" /> <meta name="apple-mobile-web-app-title" content="Moshing Mammut" />
<meta name="application-name" content="Moshing Mammut" /> <meta name="application-name" content="Moshing Mammut" />
<meta name="msapplication-TileColor" content="#2e0b78" /> <meta name="msapplication-TileColor" content="#2e0b78" />
<meta <link rel="stylesheet" href="%sveltekit.assets%/style.css" />
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)" />
@ -23,40 +19,14 @@
%sveltekit.head% %sveltekit.head%
<style> <style>
body { body {
--color-blue: hsl(259, 82%, 26%); --color-text: #2f0c7a;
--color-blue-dark: hsl(259, 82%, 13%); --color-bg: white;
--color-lavender: hsl(253, 82%, 33%); --color-border: #17063b;
--color-mauve: hsl(273, 82%, 38%); --color-link: #563acc;
--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 {
@ -68,20 +38,11 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
--color-lavender: hsl(273, 43%, 65%); --color-text: white;
--color-mauve: hsl(286, 73%, 81%); --color-bg: #17063b;
--color-border: white;
--color-text: var(--color-grey-light); --color-link: #8a9bf0;
--color-border: var(--color-grey-light); --color-link-visited: #c384fb;
--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,18 +1,17 @@
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();
export const handleError = (({ error }) => { export const handleError = (({ error }) => {
if (error instanceof Error) { if (error instanceof Error) {
log.error('Something went wrong: ', error.name, error.message); console.error('Something went wrong: ', error.name, error.message);
} }
return { return {
message: `Something went wrong! ${error}` message: 'Whoops!',
code: (error as any)?.code ?? 'UNKNOWN'
}; };
}) satisfies HandleServerError; }) satisfies HandleServerError;
@ -28,35 +27,6 @@ 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,51 +3,17 @@
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>
<picture> <img src={account.avatar} alt={avatarDescription} />
{@html sourceSetHtml}
<img src={account.avatar} alt={avatarDescription} loading="lazy" width="50" height="50" />
</picture>
<style> <style>
img { img {
max-width: 100%; max-width: 50px;
max-height: 100%; max-height: 50px;
width: 50px; width: auto;
height: 50px; height: auto;
object-fit: contain; object-fit: contain;
border-radius: 3px; border-radius: 3px;
} }

View File

@ -49,7 +49,6 @@
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,9 +18,7 @@
$: 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,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { type Post, SongThumbnailImageKind } from '$lib/mastodon/response'; import type { Post } 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';
@ -15,91 +14,6 @@
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,
@ -110,147 +24,34 @@
<div class="wrapper"> <div class="wrapper">
<div class="avatar"><AvatarComponent account={post.account} /></div> <div class="avatar"><AvatarComponent account={post.account} /></div>
<div class="account"><AccountComponent account={post.account} /></div> <div class="post">
<div class="meta"> <div class="meta">
<AccountComponent account={post.account} />
<small><a href={post.url} target="_blank" title={absoluteDate}>{dateCreated}</a></small> <small><a href={post.url} target="_blank" title={absoluteDate}>{dateCreated}</a></small>
</div> </div>
<div class="content">{@html post.content}</div> <div class="content">{@html post.content}</div>
<div class="song">
{#if post.songs}
{#each songs as song (song.pageUrl)}
<div class="info-wrapper">
<picture>
{@html getSourceSetHtml(song)}
<img class="bgimage" src={song.thumbnailUrl} loading="lazy" alt="Blurred cover" />
</picture>
<a href={song.pageUrl ?? song.postedUrl} target="_blank">
<div class="info">
<picture>
{@html getSourceSetHtml(song)}
<img
src={song.thumbnailUrl}
class="cover"
alt="Cover for {song.artistName} - {song.title}"
loading="lazy"
width={song.thumbnailWidth}
height={song.thumbnailHeight}
/>
</picture>
<span class="text">{song.artistName} - {song.title}</span>
</div>
</a>
</div>
{/each}
{/if}
</div> </div>
</div> </div>
<style> <style>
.wrapper { .wrapper {
display: grid; display: flex;
grid-template-columns: 50px 1fr auto auto;
grid-template-rows: auto 1fr auto;
grid-template-areas:
'avatar account account meta'
'avatar content content song'
'. content content song';
grid-column-gap: 6px;
column-gap: 6px;
grid-row-gap: 6px;
row-gap: 6px;
} }
.avatar { .post {
grid-area: avatar;
max-width: 50px;
max-height: 50px;
}
.account {
grid-area: account;
}
.meta {
grid-area: meta;
justify-self: end;
}
.content {
grid-area: content;
word-break: break-word;
translate: 0 -0.5em;
}
.song {
grid-area: song;
align-self: center;
justify-self: center;
max-width: 200px;
}
.cover {
max-width: 200px;
display: block;
border-radius: 3px;
margin-bottom: 3px;
}
.bgimage {
display: none;
background-color: var(--color-bg);
}
.info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; flex-grow: 2;
gap: 0.5em; word-break: break-word;
z-index: 1;
} }
.info * { .meta {
z-index: inherit; display: flex;
justify-content: space-between;
} }
@media only screen and (max-width: 650px) { .avatar {
.wrapper { margin-right: 1em;
grid-template-areas:
'avatar account account meta'
'content content content content'
'song song song song';
grid-row-gap: 3px;
row-gap: 3px;
}
.song {
width: 100%;
}
.song,
.cover {
max-width: 100%;
}
.cover {
height: 60px;
}
.cover:not(.background) {
z-index: 1;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.bgimage {
display: block;
width: 100%;
height: 60px;
z-index: 0;
filter: blur(10px);
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.info {
position: relative;
top: -60px;
flex-direction: row;
}
.info-wrapper {
margin-bottom: -50px;
}
.text {
padding: 3px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 3px;
background-color: var(--color-bg-translucent);
color: var(--color-text);
} }
.content {
max-width: calc(600px - 1em - 50px);
overflow-x: auto;
} }
</style> </style>

View File

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

View File

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

View File

@ -1,67 +1,9 @@
import { env } from '$env/dynamic/private';
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 type { Account, Post, Tag } from '$lib/mastodon/response';
import type { Account, AccountAvatar, Post, SongThumbnailImage, Tag } from '$lib/mastodon/response'; import { isTruthy } from '$lib/truthyString';
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 = {
$limit?: number | undefined | null;
$since?: string | undefined | null;
$before?: string | undefined | null;
[x: string]: string | number | undefined | null;
};
type PostRow = {
id: string;
content: string;
created_at: string;
url: string;
account_id: string;
acct: string;
username: string;
display_name: string;
account_url: string;
avatar: string;
};
type PostTagRow = {
post_id: string;
tag: string;
url: string;
};
type SongRow = {
post_url: string;
postedUrl: string;
overviewUrl?: string;
type: 'album' | 'song';
youtubeUrl?: string;
title?: string;
artistName?: 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 = {
id: number;
name: string;
statement: string;
};
const db: sqlite3.Database = new sqlite3.Database('moshingmammut.db'); const db: sqlite3.Database = new sqlite3.Database('moshingmammut.db');
// for the local masto instance, the instance name is *not* saved // for the local masto instance, the instance name is *not* saved
@ -78,72 +20,39 @@ const ignoredUsers: string[] =
); );
let databaseReady = false; let databaseReady = false;
if (enableVerboseLog) { if (DEV && isTruthy(env.VERBOSE)) {
sqlite3.verbose(); sqlite3.verbose();
db.on('change', (t, d, table, rowid) => { db.on('change', (t, d, table, rowid) => {
log.verbose('DB change event', t, d, table, rowid); console.debug('DB change event', t, d, table, rowid);
}); });
db.on('trace', (sql) => { db.on('trace', (sql) => {
log.verbose('Running', sql); console.debug('Running', sql);
}); });
db.on('profile', (sql) => { db.on('profile', (sql) => {
log.verbose('Finished', sql); console.debug('Finished', sql);
}); });
} }
function applyDbMigration(migration: Migration): Promise<void> { interface Migration {
return new Promise((resolve, reject) => { id: number;
db.exec(migration.statement, (err) => { name: string;
if (err !== null) { statement: string;
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'); console.log('Opened database');
db.serialize(); db.serialize();
db.run('CREATE TABLE IF NOT EXISTS "migrations" ("id" integer,"name" TEXT, PRIMARY KEY (id))'); db.run('CREATE TABLE IF NOT EXISTS "migrations" ("id" integer,"name" TEXT, PRIMARY KEY (id))');
db.all('SELECT id FROM migrations', (err, rows: Migration[]) => { db.all('SELECT id FROM migrations', (err, rows) => {
if (err !== null) { if (err !== null) {
log.error('Could not fetch existing migrations', err); console.error('Could not fetch existing migrations', err);
databaseReady = true; databaseReady = true;
return; return;
} }
log.debug('Already applied migrations', rows); console.debug('Already applied migrations', rows);
const appliedMigrations: Set<number> = new Set(rows.map((row) => row['id'])); const appliedMigrations: Set<number> = new Set(rows.map((row: any) => row['id']));
const toApply = getMigrations().filter((m) => !appliedMigrations.has(m.id)); const toApply = getMigrations().filter((m) => !appliedMigrations.has(m.id));
let remaining = toApply.length; let remaining = toApply.length;
if (remaining === 0) { if (remaining === 0) {
@ -151,7 +60,7 @@ db.on('open', () => {
return; return;
} }
for (const migration of toApply) { for (const migration of toApply) {
applyMigration(migration).then(() => { db.exec(migration.statement, (err) => {
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
@ -159,7 +68,7 @@ db.on('open', () => {
databaseReady = true; databaseReady = true;
} }
if (err !== null) { if (err !== null) {
log.error(`Failed to apply migration ${migration.name}`, err); console.error(`Failed to apply migration ${migration.name}`, err);
return; return;
} }
db.run( db.run(
@ -167,10 +76,10 @@ db.on('open', () => {
[migration.id, migration.name], [migration.id, migration.name],
(e: Error) => { (e: Error) => {
if (e !== null) { if (e !== null) {
log.error(`Failed to mark migration ${migration.name} as applied`, e); console.error(`Failed to mark migration ${migration.name} as applied`, e);
return; return;
} }
log.info(`Applied migration ${migration.name}`); console.info(`Applied migration ${migration.name}`);
} }
); );
}); });
@ -178,7 +87,7 @@ db.on('open', () => {
}); });
}); });
db.on('error', (err) => { db.on('error', (err) => {
log.error('Error opening database', err); console.error('Error opening database', err);
}); });
function getMigrations(): Migration[] { function getMigrations(): Migration[] {
@ -261,78 +170,35 @@ function getMigrations(): Migration[] {
DROP TABLE poststags; DROP TABLE poststags;
ALTER TABLE poststags_new RENAME TO poststags; ALTER TABLE poststags_new RENAME TO poststags;
` `
},
{
id: 3,
name: 'song info for posts',
statement: `
CREATE TABLE songs (
id integer PRIMARY KEY,
postedUrl TEXT NOT NULL,
overviewUrl TEXT,
type TEXT CHECK ( type in ('album', 'song') ),
youtubeUrl TEXT,
title TEXT,
artistName TEXT,
thumbnailUrl TEXT,
post_url TEXT,
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<void> { async function waitReady(): Promise<undefined> {
// Simpler than a semaphore and is really only needed on startup // Simpler than a semaphore and is really only needed on startup
return new Promise((resolve) => { return new Promise((resolve) => {
const interval = setInterval(() => { const interval = setInterval(() => {
log.verbose('Waiting for database to be ready'); if (DEV) {
console.debug('Waiting for database to be ready');
}
if (databaseReady) { if (databaseReady) {
log.verbose('DB is ready'); if (DEV) {
console.debug('DB is ready');
}
clearInterval(interval); clearInterval(interval);
resolve(); resolve(undefined);
} }
}, 100); }, 100);
}); });
} }
function saveAccountData(account: Account): Promise<void> { export async function savePost(post: Post): Promise<undefined> {
return new Promise<void>((resolve, reject) => { if (!databaseReady) {
await waitReady();
}
return await new Promise<undefined>((resolve, reject) => {
console.debug(`Saving post ${post.url}`);
const account = post.account;
db.run( db.run(
` `
INSERT INTO accounts (id, acct, username, display_name, url, avatar) INSERT INTO accounts (id, acct, username, display_name, url, avatar)
@ -354,18 +220,10 @@ function saveAccountData(account: Account): Promise<void> {
], ],
(err) => { (err) => {
if (err !== null) { if (err !== null) {
log.error(`Could not insert/update account ${account.id}`, err); console.error(`Could not insert/update account ${account.id}`, err);
reject(err); reject(err);
return; return;
} }
resolve();
}
);
});
}
function savePostData(post: Post): Promise<void> {
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)
@ -377,22 +235,10 @@ function savePostData(post: Post): Promise<void> {
[post.id, post.content, post.created_at, post.url, post.account.url], [post.id, post.content, post.created_at, post.url, post.account.url],
(postErr) => { (postErr) => {
if (postErr !== null) { if (postErr !== null) {
log.error(`Could not insert post ${post.url}`, postErr); console.error(`Could not insert post ${post.url}`, postErr);
reject(postErr); reject(postErr);
return; return;
} }
resolve();
}
);
});
}
function savePostTagData(post: Post): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!post.tags.length) {
resolve();
return;
}
db.parallelize(() => { db.parallelize(() => {
let remaining = post.tags.length; let remaining = post.tags.length;
@ -405,7 +251,7 @@ function savePostTagData(post: Post): Promise<void> {
[tag.url, tag.name], [tag.url, tag.name],
(tagErr) => { (tagErr) => {
if (tagErr !== null) { if (tagErr !== null) {
log.error(`Could not insert/update tag ${tag.url}`, tagErr); console.error(`Could not insert/update tag ${tag.url}`, tagErr);
reject(tagErr); reject(tagErr);
return; return;
} }
@ -414,7 +260,10 @@ function savePostTagData(post: Post): Promise<void> {
[post.url, tag.url], [post.url, tag.url],
(posttagserr) => { (posttagserr) => {
if (posttagserr !== null) { if (posttagserr !== null) {
log.error(`Could not insert poststags ${tag.url}, ${post.url}`, posttagserr); console.error(
`Could not insert poststags ${tag.url}, ${post.url}`,
posttagserr
);
reject(posttagserr); reject(posttagserr);
return; return;
} }
@ -422,7 +271,7 @@ function savePostTagData(post: Post): Promise<void> {
remaining--; remaining--;
// Only resolve after all have been inserted // Only resolve after all have been inserted
if (remaining === 0) { if (remaining === 0) {
resolve(); resolve(undefined);
} }
} }
); );
@ -430,107 +279,70 @@ function savePostTagData(post: Post): Promise<void> {
); );
} }
}); });
}
);
}
);
}); });
} }
function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> { export async function getPosts(since: string | null, before: string | null, limit: number) {
return new Promise<void>((resolve, reject) => {
if (songs.length === 0) {
resolve();
return;
}
db.parallelize(() => {
let remaining = songs.length;
for (const song of songs) {
db.run(
`
INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, title, artistName, thumbnailUrl, post_url, thumbnailWidth, thumbnailHeight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
song.postedUrl,
song.pageUrl,
song.type,
song.youtubeUrl,
song.title,
song.artistName,
song.thumbnailUrl,
postUrl,
song.thumbnailWidth,
song.thumbnailHeight
],
(songErr) => {
if (songErr !== null) {
log.error(`Could not insert song ${song.postedUrl}`, songErr);
reject(songErr);
return;
}
// Don't decrease on fail
remaining--;
// Only resolve after all have been inserted
if (remaining === 0) {
resolve();
}
}
);
}
});
});
}
export async function savePost(post: Post, songs: SongInfo[]) {
if (!databaseReady) { if (!databaseReady) {
await waitReady(); await waitReady();
} }
const promise = await new Promise<Post[]>((resolve, reject) => {
let filter_query = '';
const params: any = { $limit: limit };
if (since === null && before === null) {
filter_query = '';
} else if (since !== null) {
filter_query = 'WHERE posts.created_at > $since';
params.$since = since;
} else if (before !== null) {
// Setting both, before and since doesn't make sense, so this case is not explicitly handled
filter_query = 'WHERE posts.created_at < $before';
params.$before = before;
}
log.debug(`Saving post ${post.url}`); ignoredUsers.forEach((ignoredUser, index) => {
const account = post.account; const userParam = `$user_${index}`;
await saveAccountData(account); const acctParam = userParam + 'a';
log.debug(`Saved account data ${post.url}`); const usernameParam = userParam + 'u';
await savePostData(post); const prefix = filter_query === '' ? ' WHERE' : ' AND';
log.debug(`Saved post data ${post.url}`); filter_query += `${prefix} acct != ${acctParam} AND username != ${usernameParam} `;
await savePostTagData(post); params[acctParam] = ignoredUser;
log.debug(`Saved ${post.tags.length} tag data ${post.url}`); params[usernameParam] = ignoredUser;
await saveSongInfoData(post.url, songs); });
log.debug(
`Saved ${songs.length} song info data ${post.url}`,
songs.map((s) => s.thumbnailHeight)
);
}
function getPostData(filterQuery: string, params: FilterParameter): Promise<PostRow[]> {
const sql = `SELECT posts.id, posts.content, posts.created_at, posts.url, const sql = `SELECT posts.id, posts.content, posts.created_at, posts.url,
accounts.id AS account_id, accounts.acct, accounts.username, accounts.display_name, accounts.id AS account_id, accounts.acct, accounts.username, accounts.display_name,
accounts.url AS account_url, accounts.avatar accounts.url AS account_url, accounts.avatar
FROM posts FROM posts
JOIN accounts ON posts.account_id = accounts.url JOIN accounts ON posts.account_id = accounts.url
${filterQuery} ${filter_query}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $limit`; LIMIT $limit`;
db.all(sql, params, (err, rows: any[]) => {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows: PostRow[]) => {
if (err != null) { if (err != null) {
log.error('Error loading posts', err); console.error('Error loading posts', err);
reject(err); reject(err);
return; return;
} }
resolve(rows); if (rows.length === 0) {
}); // No need to check for tags
}); resolve([]);
} return;
}
function getTagData(postIdsParams: string, postIds: string[]): Promise<Map<string, Tag[]>> { const postIdsParams = rows.map(() => '?').join(', ');
return new Promise((resolve, reject) => {
db.all( db.all(
`SELECT post_id, tags.url, tags.tag `SELECT post_id, tags.url, tags.tag
FROM poststags FROM poststags
JOIN tags ON poststags.tag_url = tags.url JOIN tags ON poststags.tag_url = tags.url
WHERE post_id IN (${postIdsParams});`, WHERE post_id IN (${postIdsParams});`,
postIds, rows.map((r: any) => r.url),
(tagErr, tagRows: PostTagRow[]) => { (tagErr, tagRows: any[]) => {
if (tagErr != null) { if (tagErr != null) {
log.error('Error loading post tags', tagErr); console.error('Error loading post tags', tagErr);
reject(tagErr); reject(tagErr);
return; return;
} }
@ -542,324 +354,27 @@ function getTagData(postIdsParams: string, postIds: string[]): Promise<Map<strin
result.set(item.post_id, [...(result.get(item.post_id) || []), tag]); result.set(item.post_id, [...(result.get(item.post_id) || []), tag]);
return result; return result;
}, new Map()); }, new Map());
resolve(tagMap);
}
);
});
}
function getSongData(postIdsParams: string, postIds: string[]): Promise<Map<string, SongInfo[]>> {
return new Promise((resolve, reject) => {
db.all(
`SELECT post_url, songs.postedUrl, songs.overviewUrl, songs.type, songs.youtubeUrl,
songs.title, songs.artistName, songs.thumbnailUrl, songs.post_url, songs.thumbnailWidth, songs.thumbnailHeight
FROM songs
WHERE post_url IN (${postIdsParams});`,
postIds,
(tagErr, tagRows: SongRow[]) => {
if (tagErr != null) {
log.error('Error loading post songs', tagErr);
reject(tagErr);
return;
}
const songMap: Map<string, SongInfo[]> = tagRows.reduce(
(result: Map<string, SongInfo[]>, item) => {
const info = {
pageUrl: item.overviewUrl,
youtubeUrl: item.youtubeUrl,
type: item.type,
title: item.title,
artistName: item.artistName,
thumbnailUrl: item.thumbnailUrl,
postedUrl: item.postedUrl,
thumbnailHeight: item.thumbnailHeight,
thumbnailWidth: item.thumbnailWidth
} as SongInfo;
result.set(item.post_url, [...(result.get(item.post_url) || []), info]);
return result;
},
new Map()
);
log.verbose('songMap', 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(
since: string | null,
before: string | null,
limit: number
): Promise<Post[]> {
if (!databaseReady) {
await waitReady();
}
return await getPostsInternal(since, before, limit);
}
async function getPostsInternal(
since: string | null,
before: string | null,
limit: number
): Promise<Post[]> {
let filterQuery = '';
const params: FilterParameter = { $limit: limit };
if (since === null && before === null) {
filterQuery = '';
} else if (since !== null) {
filterQuery = 'WHERE posts.created_at > $since';
params.$since = since;
} else if (before !== null) {
// Setting both, before and since doesn't make sense, so this case is not explicitly handled
filterQuery = 'WHERE posts.created_at < $before';
params.$before = before;
}
ignoredUsers.forEach((ignoredUser, index) => {
const userParam = `$user_${index}`;
const acctParam = userParam + 'a';
const usernameParam = userParam + 'u';
const prefix = filterQuery === '' ? ' WHERE' : ' AND';
filterQuery += `${prefix} acct != ${acctParam} AND username != ${usernameParam} `;
params[acctParam] = ignoredUser;
params[usernameParam] = ignoredUser;
});
const rows = await getPostData(filterQuery, params);
if (rows.length === 0) {
// No need to check for tags and songs
return [];
}
const postIdsParams = rows.map(() => '?').join(', ');
const postIds = rows.map((r: PostRow) => r.url);
const tagMap = await getTagData(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,
content: row.content, content: row.content,
created_at: row.created_at, created_at: row.created_at,
url: row.url, url: row.url,
tags: tagMap.get(row.url) || [], tags: tagMap.get(row.id) || [],
account: { account: {
id: row.account_id, id: row.account_id,
acct: row.acct, acct: row.acct,
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) || []
} as Post; } as Post;
}); });
return posts; resolve(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;
})
); );
}); });
}); });
} return promise;
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,7 +1,6 @@
import { BASE_URL, WEBSUB_HUB } from '$env/static/private'; import { BASE_URL, WEBSUB_HUB } from '$env/static/private';
import { PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public'; import { PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public';
import type { Post } from '$lib//mastodon/response'; import type { Post } from '$lib//mastodon/response';
import { log } from '$lib/log';
import { Feed } from 'feed'; import { Feed } from 'feed';
import fs from 'fs/promises'; import fs from 'fs/promises';
@ -60,6 +59,6 @@ export async function saveAtomFeed(feed: Feed) {
body: params body: params
}); });
} catch (e) { } catch (e) {
log.error('Failed to update WebSub hub', e); console.error('Failed to update WebSub hub', e);
} }
} }

View File

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

View File

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

View File

@ -25,7 +25,7 @@
} }
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL); const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
let interval: ReturnType<typeof setTimeout> | null = null; let interval: NodeJS.Timer | null = null;
let moreOlderPostsAvailable = true; let moreOlderPostsAvailable = true;
let loadingOlderPosts = false; let loadingOlderPosts = false;
@ -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|global={{ transition:edgeFly={{
y: 10, y: 10,
created_at: post.created_at, created_at: post.created_at,
duration: 300, duration: 300,
@ -187,7 +187,7 @@
} }
.post { .post {
width: 100%; width: 100%;
max-width: min(800px, 80vw); max-width: 600px;
margin-bottom: 1em; margin-bottom: 1em;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
padding: 1em; padding: 1em;

57
static/style.css Normal file
View File

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

View File

@ -1,5 +1,5 @@
import adapter from '@sveltejs/adapter-node'; import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
@ -11,15 +11,7 @@ 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,16 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig, searchForWorkspaceRoot } from 'vite'; import { defineConfig } 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'
]
}
}
}); });