19 Commits
v1.4.0 ... main

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

View File

@ -65,7 +65,7 @@ Set up NVM:
``` ```
$ cd $ cd
$ curl https://raw.github.com/creationix/nvm/master/install.sh | sh $ curl https://raw.githubusercontent.com/nvm-sh/nvm/refs/heads/master/install.sh | bash
$ source ~/.nvm/nvm.sh $ source ~/.nvm/nvm.sh
$ nvm install --lts $ nvm install --lts
``` ```
@ -88,6 +88,8 @@ Copy `apache2.conf.EXAMPLE` to `/etc/apache2/sites-available/moshingmammut.conf`
Domain. If you do not need or want SSL support, remove the whole `<IfModule mod_ssl.c>` block. Domain. If you do not need or want SSL support, remove the whole `<IfModule mod_ssl.c>` block.
If you do, add the path to your SSLCertificateFile and SSLCertificateKeyFile. If you do, add the path to your SSLCertificateFile and SSLCertificateKeyFile.
Modify DocumentRoot and the two Alias and Directory statements, so that thumbnails and avatars are served directly by apache.
Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service` Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service`
and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly. and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
@ -101,6 +103,13 @@ because the API is the only way to check if a YouTube link leads to music or som
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/

5562
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "moshing-mammut", "name": "moshing-mammut",
"version": "1.3.1", "version": "1.3.2",
"private": true, "private": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"scripts": { "scripts": {
@ -14,34 +14,35 @@
"format": "prettier --plugin-search-dir . --write ." "format": "prettier --plugin-search-dir . --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^1.2.3", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^1.5.0", "@sveltejs/kit": "^2.21.5",
"@types/node": "^18.16.3", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/sqlite3": "^3.1.8", "@types/node": "^22.6.1",
"@types/ws": "^8.5.4", "@types/sqlite3": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@types/ws": "^8.5.0",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@zerodevx/svelte-toast": "^0.9.3", "@zerodevx/svelte-toast": "^0.9.3",
"eslint": "^8.28.0", "eslint": "^9.11.1",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte3": "^4.0.0", "eslint-plugin-svelte": "^3.0.0",
"prettier": "^2.8.0", "prettier": "^3.1.0",
"prettier-plugin-svelte": "^2.8.1", "prettier-plugin-svelte": "^3.2.6",
"svelte": "^3.54.0", "svelte": "^5",
"svelte-check": "^3.0.1", "svelte-check": "^4.0.0",
"tslib": "^2.4.1", "tslib": "^2.0.0",
"typescript": "^4.9.3", "typescript": "^5.0.0",
"vite": "^4.0.0" "vite": "^6.0.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"feed": "^4.2.2", "feed": "^5.1.0",
"sharp": "^0.32.0", "sharp": "^0.34.2",
"sqlite3": "^5.1.6", "sqlite3": "^5.0.0",
"ws": "^8.13.0" "ws": "^8.18.0"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=20.0.0"
} }
} }

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -10,6 +10,10 @@
<meta name="apple-mobile-web-app-title" content="Moshing Mammut" /> <meta name="apple-mobile-web-app-title" content="Moshing Mammut" />
<meta name="application-name" content="Moshing Mammut" /> <meta name="application-name" content="Moshing Mammut" />
<meta name="msapplication-TileColor" content="#2e0b78" /> <meta name="msapplication-TileColor" content="#2e0b78" />
<meta
name="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)" />
@ -50,9 +54,22 @@
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, font-family:
Ubuntu, Cantarell, 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji', system-ui,
'Segoe UI Emoji', 'Segoe UI Symbol'; -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 {

View File

@ -1,9 +1,10 @@
import { log } from '$lib/log'; import { log } from '$lib/log';
import { TimelineReader } from '$lib/server/timeline'; import { TimelineReader } from '$lib/server/timeline';
import type { HandleServerError } from '@sveltejs/kit'; import type { Handle, HandleServerError } from '@sveltejs/kit';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import fs from 'fs/promises'; import fs from 'fs/promises';
log.log('App startup');
TimelineReader.init(); TimelineReader.init();
export const handleError = (({ error }) => { export const handleError = (({ error }) => {
@ -16,8 +17,6 @@ export const handleError = (({ error }) => {
}; };
}) satisfies HandleServerError; }) satisfies HandleServerError;
import type { Handle } from '@sveltejs/kit';
export const handle = (async ({ event, resolve }) => { export const handle = (async ({ event, resolve }) => {
// Reeder *insists* on checking /feed instead of /feed.xml // Reeder *insists* on checking /feed instead of /feed.xml
if (event.url.pathname === '/feed') { if (event.url.pathname === '/feed') {
@ -53,7 +52,7 @@ export const handle = (async ({ event, resolve }) => {
return new Response(f, { headers: [['Content-Type', 'image/' + suffix]] }); return new Response(f, { headers: [['Content-Type', 'image/' + suffix]] });
} catch (e) { } catch (e) {
log.error('no stream', e); log.error('no stream', e);
throw error(404); error(404);
} }
} }

View File

@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { Account } from '$lib/mastodon/response'; import type { Account } from '$lib/mastodon/response';
export let account: Account; interface Props {
account: Account;
}
let { account }: Props = $props();
</script> </script>
<a href={account.url} target="_blank">{account.display_name} @{account.acct}</a> <a href={account.url} target="_blank">{account.display_name} @{account.acct}</a>

View File

@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { Account } from '$lib/mastodon/response'; import type { Account } from '$lib/mastodon/response';
export let account: Account; interface Props {
let avatarDescription: string; account: Account;
let sourceSetHtml: string; }
$: avatarDescription = `Avatar for ${account.acct}`;
$: { let { account }: Props = $props();
let avatarDescription: string = $derived(`Avatar for ${account.acct}`);
let sourceSetHtml: string = $derived.by(() => {
// Sort thumbnails by file type. This is important, because the order of the srcset entries matter. // Sort thumbnails by file type. This is important, because the order of the srcset entries matter.
// We need the best format to be first // We need the best format to be first
const formatPriority = new Map<string, number>([ const formatPriority = new Map<string, number>([
@ -14,7 +16,7 @@
['jpg', 99], ['jpg', 99],
['jpeg', 99] ['jpeg', 99]
]); ]);
const resizedAvatars = (account.resizedAvatars ?? []).sort((a, b) => { const resizedAvatars = (account.resizedAvatars ?? []).toSorted((a, b) => {
const extensionA = a.file.split('.').pop() ?? ''; const extensionA = a.file.split('.').pop() ?? '';
const extensionB = b.file.split('.').pop() ?? ''; const extensionB = b.file.split('.').pop() ?? '';
const prioA = formatPriority.get(extensionA) ?? 3; const prioA = formatPriority.get(extensionA) ?? 3;
@ -33,13 +35,13 @@
const srcset = entry[1].join(', '); const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" />`; html += `<source srcset="${srcset}" type="${entry[0]}" />`;
} }
sourceSetHtml = html; return html;
} });
</script> </script>
<picture> <picture>
{@html sourceSetHtml} {@html sourceSetHtml}
<img src={account.avatar} alt={avatarDescription} loading="lazy" /> <img src={account.avatar} alt={avatarDescription} loading="lazy" width="50" height="50" />
</picture> </picture>
<style> <style>

View File

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

View File

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

View File

@ -6,20 +6,54 @@
import { secondsSince, relativeTime } from '$lib/relativeTime'; import { secondsSince, relativeTime } from '$lib/relativeTime';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
export let post: Post; interface Props {
let displayRelativeTime = false; post: Post;
}
let { post }: Props = $props();
let displayRelativeTime = $state(false);
const absoluteDate = new Date(post.created_at).toLocaleString(); const absoluteDate = new Date(post.created_at).toLocaleString();
let dateCreated = absoluteDate;
const timePassed = secondsSince(new Date(post.created_at)); const timePassed = secondsSince(new Date(post.created_at));
$: if (displayRelativeTime) { let dateCreated = $derived.by(() => {
dateCreated = relativeTime($timePassed) ?? absoluteDate; if (displayRelativeTime) {
return relativeTime($timePassed) ?? absoluteDate;
}
return absoluteDate;
});
const songs = filterDuplicates(post.songs ?? []);
function filterDuplicates(songs: SongInfo[]): SongInfo[] {
return songs.filter((obj, index, arr) => {
return arr.map((mapObj) => mapObj.pageUrl).indexOf(obj.pageUrl) === index;
});
}
function getThumbnailSize(song: SongInfo): {
width?: number;
height?: number;
widthSmall?: number;
heightSmall?: number;
} {
if (song.thumbnailWidth === undefined || song.thumbnailHeight === undefined) {
return { width: undefined, height: undefined, widthSmall: undefined, heightSmall: undefined };
}
const factor = 200 / song.thumbnailWidth;
const smallFactor = 60 / song.thumbnailHeight;
const height = song.thumbnailHeight * factor;
return {
width: 200,
height: height,
widthSmall: smallFactor * song.thumbnailWidth,
heightSmall: 60
};
} }
// Blurred thumbs aren't generated (yet, unclear of they ever will) // 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. // 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, // This is technically unnecessary - the blurred one will only show if it matches the small media query,
// but this makes it more explicit // but this makes it more explicit
function getSourceSetHtml(song: SongInfo, isBlurred: boolean = false): string { function getSourceSetHtml(song: SongInfo, isBlurred = false): string {
const small = new Map<string, string[]>(); const small = new Map<string, string[]>();
const large = new Map<string, string[]>(); const large = new Map<string, string[]>();
@ -31,7 +65,7 @@
['jpg', 99], ['jpg', 99],
['jpeg', 99] ['jpeg', 99]
]); ]);
const thumbs = (song.resizedThumbnails ?? []).sort((a, b) => { const thumbs = (song.resizedThumbnails ?? []).toSorted((a, b) => {
const extensionA = a.file.split('.').pop() ?? ''; const extensionA = a.file.split('.').pop() ?? '';
const extensionB = b.file.split('.').pop() ?? ''; const extensionB = b.file.split('.').pop() ?? '';
const prioA = formatPriority.get(extensionA) ?? 3; const prioA = formatPriority.get(extensionA) ?? 3;
@ -58,15 +92,16 @@
} }
} }
let html = ''; let html = '';
const { width, height, widthSmall, heightSmall } = getThumbnailSize(song);
const mediaAttribute = isBlurred ? '' : 'media="(max-width: 650px)"'; const mediaAttribute = isBlurred ? '' : 'media="(max-width: 650px)"';
for (const entry of small.entries()) { for (const entry of small.entries()) {
const srcset = entry[1].join(', '); const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" ${mediaAttribute} />`; html += `<source srcset="${srcset}" type="${entry[0]}" ${mediaAttribute} width="${widthSmall}" height="${heightSmall}" />`;
} }
html += '\n'; html += '\n';
for (const entry of large.entries()) { for (const entry of large.entries()) {
const srcset = entry[1].join(', '); const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" />`; html += `<source srcset="${srcset}" type="${entry[0]}" width="${width}" height="${height}"/>`;
} }
return html; return html;
} }
@ -88,7 +123,7 @@
<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">
<picture> <picture>
{@html getSourceSetHtml(song)} {@html getSourceSetHtml(song)}
@ -103,6 +138,8 @@
class="cover" class="cover"
alt="Cover for {song.artistName} - {song.title}" alt="Cover for {song.artistName} - {song.title}"
loading="lazy" loading="lazy"
width={song.thumbnailWidth}
height={song.thumbnailHeight}
/> />
</picture> </picture>
<span class="text">{song.artistName} - {song.title}</span> <span class="text">{song.artistName} - {song.title}</span>

View File

@ -9,6 +9,8 @@ export type SongInfo = {
thumbnailUrl?: string; thumbnailUrl?: string;
postedUrl: string; postedUrl: string;
resizedThumbnails?: SongThumbnailImage[]; resizedThumbnails?: SongThumbnailImage[];
thumbnailWidth?: number;
thumbnailHeight?: number;
}; };
export type SongwhipReponse = { export type SongwhipReponse = {

View File

@ -40,6 +40,8 @@ type SongRow = {
title?: string; title?: string;
artistName?: string; artistName?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
thumbnailWidth?: number;
thumbnailHeight?: number;
}; };
type AccountAvatarRow = { type AccountAvatarRow = {
@ -91,8 +93,8 @@ if (enableVerboseLog) {
}); });
} }
async function applyDbMigration(migration: Migration): Promise<void> { function applyDbMigration(migration: Migration): Promise<void> {
return new Promise(async (resolve, reject) => { return new Promise((resolve, reject) => {
db.exec(migration.statement, (err) => { db.exec(migration.statement, (err) => {
if (err !== null) { if (err !== null) {
log.error(`Failed to apply migration ${migration.name}`, err); log.error(`Failed to apply migration ${migration.name}`, err);
@ -110,7 +112,7 @@ async function applyMigration(migration: Migration) {
// so filtering won't help // so filtering won't help
const posts = await getPostsInternal(null, null, 10000); const posts = await getPostsInternal(null, null, 10000);
let current = 0; let current = 0;
let total = posts.length.toString().padStart(4, '0'); const total = posts.length.toString().padStart(4, '0');
for (const post of posts) { for (const post of posts) {
current++; current++;
if (post.songs && post.songs.length) { if (post.songs && post.songs.length) {
@ -304,6 +306,13 @@ function getMigrations(): Migration[] {
kind INTEGER NOT NULL, kind INTEGER NOT NULL,
FOREIGN KEY (song_thumbnailUrl) REFERENCES songs(thumbnailUrl) 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;`
} }
]; ];
} }
@ -435,8 +444,8 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
for (const song of songs) { for (const song of songs) {
db.run( db.run(
` `
INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, title, artistName, thumbnailUrl, post_url) INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, title, artistName, thumbnailUrl, post_url, thumbnailWidth, thumbnailHeight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[ [
song.postedUrl, song.postedUrl,
@ -446,7 +455,9 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
song.title, song.title,
song.artistName, song.artistName,
song.thumbnailUrl, song.thumbnailUrl,
postUrl postUrl,
song.thumbnailWidth,
song.thumbnailHeight
], ],
(songErr) => { (songErr) => {
if (songErr !== null) { if (songErr !== null) {
@ -468,11 +479,11 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
} }
export async function savePost(post: Post, songs: SongInfo[]) { export async function savePost(post: Post, songs: SongInfo[]) {
log.debug(`Saving post ${post.url}`);
if (!databaseReady) { if (!databaseReady) {
await waitReady(); await waitReady();
} }
log.debug(`Saving post ${post.url}`);
const account = post.account; const account = post.account;
await saveAccountData(account); await saveAccountData(account);
log.debug(`Saved account data ${post.url}`); log.debug(`Saved account data ${post.url}`);
@ -481,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[]> {
@ -534,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;
} }
@ -557,13 +571,16 @@ 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);
} }
); );

View File

@ -1,5 +1,6 @@
import { import {
HASHTAG_FILTER, HASHTAG_FILTER,
MASTODON_ACCESS_TOKEN,
MASTODON_INSTANCE, MASTODON_INSTANCE,
ODESLI_API_KEY, ODESLI_API_KEY,
YOUTUBE_API_KEY YOUTUBE_API_KEY
@ -59,13 +60,17 @@ export class TimelineReader {
} }
const item = respObj.items[0]; const item = respObj.items[0];
if (item.tags?.includes('music')) { if (!item.snippet) {
console.warn('Could not load snippet for video', videoId, item);
return false;
}
if (item.snippet.tags?.includes('music')) {
return true; return true;
} }
const categorySearchParams = new URLSearchParams([ const categorySearchParams = new URLSearchParams([
['part', 'snippet'], ['part', 'snippet'],
['id', item.categoryId], ['id', item.snippet.categoryId],
['key', YOUTUBE_API_KEY] ['key', YOUTUBE_API_KEY]
]); ]);
const youtubeCategoryUrl = new URL( const youtubeCategoryUrl = new URL(
@ -73,7 +78,7 @@ 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]?.title); .then((r) => r.items[0]?.snippet?.title);
return categoryTitle === 'Music'; return categoryTitle === 'Music';
} }
@ -131,7 +136,6 @@ 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 {
const response = await fetch(odesliApiUrl); const response = await fetch(odesliApiUrl);
log.debug('received odesli response', response.status);
if (response.status === 429) { if (response.status === 429) {
throw new Error('Rate limit reached', { cause: 429 }); throw new Error('Rate limit reached', { cause: 429 });
} }
@ -141,9 +145,8 @@ export class TimelineReader {
} }
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId]; const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
const platform: Platform = 'youtube'; const platform: Platform = 'youtube';
log.debug(url, 'odesli response', info, 'YT URL', odesliInfo.linksByPlatform[platform]?.url);
if (info.platforms.includes(platform)) { if (info.platforms.includes(platform)) {
let youtubeId = const youtubeId =
videoId ?? videoId ??
YOUTUBE_REGEX.exec(url.href)?.groups?.videoId ?? YOUTUBE_REGEX.exec(url.href)?.groups?.videoId ??
new URL(odesliInfo.pageUrl).pathname.split('/y/').pop(); new URL(odesliInfo.pageUrl).pathname.split('/y/').pop();
@ -153,7 +156,7 @@ export class TimelineReader {
} }
const isMusic = await TimelineReader.isMusicVideo(youtubeId); const isMusic = await TimelineReader.isMusicVideo(youtubeId);
if (!isMusic) { if (!isMusic) {
log.debug('Probably not a music video', url, odesliInfo); log.debug('Probably not a music video', url);
return null; return null;
} }
} }
@ -221,7 +224,7 @@ export class TimelineReader {
accountUrl: accountUrl, accountUrl: accountUrl,
file: fn, file: fn,
sizeDescriptor: `${i}x` sizeDescriptor: `${i}x`
} as AccountAvatar) }) as AccountAvatar
) )
.then(saveAvatar) .then(saveAvatar)
) )
@ -258,7 +261,7 @@ export class TimelineReader {
file: fn, file: fn,
sizeDescriptor: `${i}x`, sizeDescriptor: `${i}x`,
kind: kind kind: kind
} as SongThumbnailImage) }) as SongThumbnailImage
) )
.then(saveSongThumbnail) .then(saveSongThumbnail)
) )
@ -349,48 +352,54 @@ export class TimelineReader {
} }
} }
private async checkAndSavePost(post: Post) {
const hashttags: string[] = HASHTAG_FILTER.split(',');
const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name));
const songs = await TimelineReader.getSongInfoInPost(post);
// 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
if (songs.length === 0 && found_tags.length === 0) {
log.log('Ignoring post', post.url);
return;
}
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);
await saveAtomFeed(createFeed(posts));
}
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 {
const data: TimelineEvent = JSON.parse(event.data.toString()); const data: TimelineEvent = JSON.parse(event.data.toString());
if (data.event !== 'update') { if (data.event !== 'update') {
log.log('Ignoring ES event', data.event);
return; return;
} }
const post: Post = JSON.parse(data.payload); const post: Post = JSON.parse(data.payload);
await this.checkAndSavePost(post);
const hashttags: string[] = HASHTAG_FILTER.split(',');
const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name));
const songs = await TimelineReader.getSongInfoInPost(post);
// 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
if (songs.length === 0 && found_tags.length === 0) {
log.log('Ignoring post', post.url);
return;
}
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);
await saveAtomFeed(createFeed(posts));
} catch (e) { } catch (e) {
log.error('error message', event, event.data, e); log.error('error message', event, event.data, e);
} }
}; };
socket.onclose = (event) => { socket.onclose = (event) => {
log.warn( log.warn(
`Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'` `Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`,
event
); );
setTimeout(() => { setTimeout(() => {
log.info(`Attempting to reconenct to WS`); log.info(`Attempting to reconenct to WS`);
@ -404,11 +413,42 @@ export class TimelineReader {
}; };
} }
private async loadPostsSinceLastRun() {
const now = new Date().toISOString();
let latestPost = await getPosts(null, now, 1);
log.log('Last post in DB since', now, latestPost);
let u = new URL(`https://${MASTODON_INSTANCE}/api/v1/timelines/public?local=true&limit=40`);
if (latestPost.length > 0) {
u.searchParams.append('since_id', latestPost[0].id);
}
for (let tag of HASHTAG_FILTER.split(',')) {
u.searchParams.append('q', '#' + tag);
}
const headers = {
Authorization: `Bearer ${MASTODON_ACCESS_TOKEN}`
};
const latestPosts: Post[] = await fetch(u, { headers }).then((r) => r.json());
log.info('searched posts', latestPosts);
for (const post of latestPosts) {
await this.checkAndSavePost(post);
}
}
private constructor() { private constructor() {
log.log('Constructing timeline object');
this.startWebsocket(); this.startWebsocket();
this.loadPostsSinceLastRun()
.then((_) => {
log.info('loaded posts since last run');
})
.catch((e) => {
log.error('cannot fetch latest posts', e);
});
} }
public static init() { public static init() {
log.log('Timeline object init');
if (this._instance === undefined) { if (this._instance === undefined) {
this._instance = new TimelineReader(); this._instance = new TimelineReader();
} }

View File

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

View File

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

View File

@ -1,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,15 +11,14 @@ 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: { csp: {
directives: { directives: {
'script-src': ['self'] 'script-src': ['self', 'unsafe-inline'],
}, 'base-uri': ['self'],
reportOnly: { 'object-src': ['none']
'script-src': ['self'] }
} }
} }
}; };