Compare commits

..

1 Commits

Author SHA1 Message Date
45678d7e6a
Fixed detection if youtube video is music or not 2023-06-24 03:39:42 +02:00
19 changed files with 2115 additions and 3161 deletions

View File

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

View File

@ -88,8 +88,6 @@ 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.
@ -103,13 +101,6 @@ 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,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/

4896
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.3.1",
"private": true, "private": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"scripts": { "scripts": {
@ -14,35 +14,34 @@
"format": "prettier --plugin-search-dir . --write ." "format": "prettier --plugin-search-dir . --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.0.0", "@sveltejs/adapter-node": "^1.2.3",
"@sveltejs/kit": "^2.7.0", "@sveltejs/kit": "^1.5.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@types/node": "^18.16.3",
"@types/node": "^22.6.1", "@types/sqlite3": "^3.1.8",
"@types/sqlite3": "^3.0.0", "@types/ws": "^8.5.4",
"@types/ws": "^8.5.0", "@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^5.45.0",
"@typescript-eslint/parser": "^8.0.0",
"@zerodevx/svelte-toast": "^0.9.3", "@zerodevx/svelte-toast": "^0.9.3",
"eslint": "^9.11.1", "eslint": "^8.28.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.45.1", "eslint-plugin-svelte3": "^4.0.0",
"prettier": "^3.1.0", "prettier": "^2.8.0",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^2.8.1",
"svelte": "^5", "svelte": "^3.54.0",
"svelte-check": "^4.0.0", "svelte-check": "^3.0.1",
"tslib": "^2.0.0", "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.33.0", "sharp": "^0.32.0",
"sqlite3": "^5.0.0", "sqlite3": "^5.1.6",
"ws": "^8.18.0" "ws": "^8.13.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=18.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,10 +10,6 @@
<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)" />
@ -54,22 +50,9 @@
color: var(--color-text); color: var(--color-text);
background-color: var(--color-bg); background-color: var(--color-bg);
font-family: font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans,
system-ui, Ubuntu, Cantarell, 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji',
-apple-system, 'Segoe UI Emoji', 'Segoe UI Symbol';
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,10 +1,9 @@
import { log } from '$lib/log'; import { log } from '$lib/log';
import { TimelineReader } from '$lib/server/timeline'; import { TimelineReader } from '$lib/server/timeline';
import type { Handle, HandleServerError } from '@sveltejs/kit'; import type { 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 }) => {
@ -17,6 +16,8 @@ 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') {
@ -52,7 +53,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);
error(404); throw error(404);
} }
} }

View File

@ -1,11 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Account } from '$lib/mastodon/response'; import type { Account } from '$lib/mastodon/response';
interface Props { export let account: Account;
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,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { Account } from '$lib/mastodon/response'; import type { Account } from '$lib/mastodon/response';
interface Props { export let account: Account;
account: Account; let avatarDescription: string;
} 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>([
@ -35,13 +33,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]}" />`;
} }
return html; sourceSetHtml = html;
}); }
</script> </script>
<picture> <picture>
{@html sourceSetHtml} {@html sourceSetHtml}
<img src={account.avatar} alt={avatarDescription} loading="lazy" width="50" height="50" /> <img src={account.avatar} alt={avatarDescription} loading="lazy" />
</picture> </picture>
<style> <style>

View File

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

View File

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

View File

@ -6,54 +6,20 @@
import { secondsSince, relativeTime } from '$lib/relativeTime'; import { secondsSince, relativeTime } from '$lib/relativeTime';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
interface Props { export let post: Post;
post: Post; let displayRelativeTime = false;
}
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));
let dateCreated = $derived.by(() => { $: if (displayRelativeTime) {
if (displayRelativeTime) { dateCreated = relativeTime($timePassed) ?? absoluteDate;
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 = false): string { function getSourceSetHtml(song: SongInfo, isBlurred: boolean = 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[]>();
@ -92,16 +58,15 @@
} }
} }
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} width="${widthSmall}" height="${heightSmall}" />`; html += `<source srcset="${srcset}" type="${entry[0]}" ${mediaAttribute} />`;
} }
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]}" width="${width}" height="${height}"/>`; html += `<source srcset="${srcset}" type="${entry[0]}" />`;
} }
return html; return html;
} }
@ -123,7 +88,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 songs as song (song.pageUrl)} {#each post.songs as song (song.pageUrl)}
<div class="info-wrapper"> <div class="info-wrapper">
<picture> <picture>
{@html getSourceSetHtml(song)} {@html getSourceSetHtml(song)}
@ -138,8 +103,6 @@
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,8 +9,6 @@ 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,8 +40,6 @@ type SongRow = {
title?: string; title?: string;
artistName?: string; artistName?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
thumbnailWidth?: number;
thumbnailHeight?: number;
}; };
type AccountAvatarRow = { type AccountAvatarRow = {
@ -93,8 +91,8 @@ if (enableVerboseLog) {
}); });
} }
function applyDbMigration(migration: Migration): Promise<void> { async function applyDbMigration(migration: Migration): Promise<void> {
return new Promise((resolve, reject) => { return new Promise(async (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);
@ -112,7 +110,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;
const total = posts.length.toString().padStart(4, '0'); let 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) {
@ -306,13 +304,6 @@ 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;`
} }
]; ];
} }
@ -444,8 +435,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, thumbnailWidth, thumbnailHeight) INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, title, artistName, thumbnailUrl, post_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[ [
song.postedUrl, song.postedUrl,
@ -455,9 +446,7 @@ 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) {
@ -479,11 +468,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}`);
@ -492,10 +481,7 @@ 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( log.debug(`Saved ${songs.length} song info data ${post.url}`);
`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[]> {
@ -548,17 +534,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.thumbnailWidth, songs.thumbnailHeight songs.title, songs.artistName, songs.thumbnailUrl, songs.post_url
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 songs', tagErr); log.error('Error loading post tags', tagErr);
reject(tagErr); reject(tagErr);
return; return;
} }
@ -571,16 +557,13 @@ 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,11 +1,10 @@
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
} from '$env/static/private'; } from '$env/static/private';
import { log } from '$lib/log'; import { enableVerboseLog, log } from '$lib/log';
import type { import type {
Account, Account,
AccountAvatar, AccountAvatar,
@ -79,6 +78,24 @@ 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]?.snippet?.title);
if (enableVerboseLog) {
log.verbose(
'Video',
videoId,
'category',
categoryTitle,
'tags',
item.snippet.tags,
'category id',
item.snippet.categoryId,
'response',
respObj,
'snippet',
item.snippet
);
} else {
log.debug('Video', videoId, 'category', categoryTitle);
}
return categoryTitle === 'Music'; return categoryTitle === 'Music';
} }
@ -136,6 +153,7 @@ export class TimelineReader {
const odesliApiUrl = `https://api.song.link/v1-alpha.1/links?${odesliParams}`; const odesliApiUrl = `https://api.song.link/v1-alpha.1/links?${odesliParams}`;
try { try {
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 });
} }
@ -145,8 +163,9 @@ 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)) {
const youtubeId = let 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();
@ -156,7 +175,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); log.debug('Probably not a music video', url, odesliInfo);
return null; return null;
} }
} }
@ -224,7 +243,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)
) )
@ -261,7 +280,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)
) )
@ -353,17 +372,15 @@ export class TimelineReader {
} }
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'); 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);
@ -395,8 +412,7 @@ export class TimelineReader {
}; };
socket.onclose = (event) => { socket.onclose = (event) => {
log.warn( log.warn(
`Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`, `Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`
event
); );
setTimeout(() => { setTimeout(() => {
log.info(`Attempting to reconenct to WS`); log.info(`Attempting to reconenct to WS`);
@ -411,12 +427,10 @@ export class TimelineReader {
} }
private constructor() { private constructor() {
log.log('Constructing timeline object');
this.startWebsocket(); this.startWebsocket();
} }
public static init() { public static init() {
log.log('Timeline object init');
if (this._instance === undefined) { if (this._instance === undefined) {
this._instance = new TimelineReader(); this._instance = new TimelineReader();
} }

View File

@ -1,11 +1,6 @@
<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,
@ -13,7 +8,7 @@
}; };
</script> </script>
{@render children?.()} <slot />
<SvelteToast {options} /> <SvelteToast {options} />
<div class="footer"> <div class="footer">
<FooterComponent /> <FooterComponent />

View File

@ -12,11 +12,7 @@
import { cubicInOut } from 'svelte/easing'; import { cubicInOut } from 'svelte/easing';
import { errorToast } from '$lib/errorToast'; import { errorToast } from '$lib/errorToast';
interface Props { export let data: PageData;
data: PageData;
}
let { data = $bindable() }: Props = $props();
interface FetchOptions { interface FetchOptions {
since?: string; since?: string;
@ -30,8 +26,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 = $state(true); let moreOlderPostsAvailable = true;
let loadingOlderPosts = $state(false); let loadingOlderPosts = 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
@ -156,7 +152,7 @@
</svelte:head> </svelte:head>
<h2>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</h2> <h2>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</h2>
<div class="wrapper"> <div class="wrapper">
<div></div> <div />
<div class="posts"> <div class="posts">
{#if data.posts.length === 0} {#if data.posts.length === 0}
Sorry, no posts recommending music have been found yet Sorry, no posts recommending music have been found yet
@ -164,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,
@ -175,12 +171,12 @@
</div> </div>
{/each} {/each}
<LoadMoreComponent <LoadMoreComponent
{loadOlderPosts} on: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/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
@ -11,14 +11,15 @@ const config = {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter. // If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(), adapter: adapter()
},
csp: { csp: {
directives: { directives: {
'script-src': ['self', 'unsafe-inline'], 'script-src': ['self']
'base-uri': ['self'], },
'object-src': ['none'] reportOnly: {
} 'script-src': ['self']
} }
} }
}; };