6 Commits

10 changed files with 153 additions and 44 deletions

View File

@ -1,6 +1,8 @@
HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening
URL_FILTER = song.link,album.link,spotify.com,music.apple.com,bandcamp.com URL_FILTER = song.link,album.link,spotify.com,music.apple.com,bandcamp.com
YOUTUBE_API_KEY = CHANGE_ME YOUTUBE_API_KEY = CHANGE_ME
MASTODON_INSTANCE = 'metalhead.club'
VERBOSE = false VERBOSE = false
PUBLIC_REFRESH_INTERVAL = 10000 PUBLIC_REFRESH_INTERVAL = 10000
PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME = 'Metalhead.club'

View File

@ -46,32 +46,50 @@ This might not be the ideal setup to run this, but here's how I am doing it. Ins
or Debian derivative, using Apache as HTTP Proxy. Other setups are possible, but not covered here. or Debian derivative, using Apache as HTTP Proxy. Other setups are possible, but not covered here.
By default, NVM is used to install NodeJS, but you can install it any way you want. By default, NVM is used to install NodeJS, but you can install it any way you want.
This is based on [SvelteKit's instructions](https://kit.svelte.dev/docs/adapter-node#deploying) This is based on [SvelteKit's instructions](https://kit.svelte.dev/docs/adapter-node#deploying) and [How To Deploy Node.js Applications Using Systemd and Nginx](https://www.digitalocean.com/community/tutorials/how-to-deploy-node-js-applications-using-systemd-and-nginx)
On your server, install the requirements:
- Apache2 HTTP Server
- NodeJS (via [NVM](https://github.com/nvm-sh/nvm))
#### On your server #### On your server
Create a directory for the app. This will be called `$APP_DIR` from now on. Install Apache2 if not already installed.
Place `package.json`, `apache2.conf.EXAMPLE`, `moshing-mammut.service.EXAMPLE` and `start.sh.EXAMPLE` in this directory. Copy `apache2.conf.EXAMPLE` and `moshing-mammut.service.EXAMPLE` to your server.
Set up a user for the app: `useradd -mrU moshing-mammut`
Switch to your newly created user: `su moshing-mammut`
Set up NVM:
```
$ cd
$ curl https://raw.github.com/creationix/nvm/master/install.sh | sh
$ source ~/.nvm/nvm.sh
$ nvm install --lts
```
Create a directory for the app. This will be called `$APP_DIR` from now on. I use `/home/moshing-mammut/app`.
Enter `$APP_DIR`.
Place `package-lock.json` and `start.sh.EXAMPLE` in this directory.
Run `npm ci --omit dev` to install the dependencies.
Rename `start.sh.EXAMPLE` to `start.sh` and set the path to your NVM.
If you do not have NVM installed, simply remove the line and make sure your node executable can be found either by
specifying the full path or by adding it to your $PATH.
Exit out of your `moshing-mammut` user shell.
Copy `apache2.conf.EXAMPLE` to `/etc/apache2/sites-available/moshingmammut.conf` and replace `ServerName` with your Copy `apache2.conf.EXAMPLE` to `/etc/apache2/sites-available/moshingmammut.conf` and replace `ServerName` with your
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.
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 replace `/PATH_TO_MOSHING_MAMMUT` with your `$APP_DIR`. Also replace `MOSHING_MAMMUT_USER` with the user you want and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
to run the app as.
Rename `start.sh.EXAMPLE` to `start.sh` and replace `/PATH_TO_YOUR_NVM/.nvm/nvm.sh` with the path to your NVM
installation.
If you do not have NVM installed, simply remove the line and make sure your node executable can be found either by
specifying the full path or by adding it to your $PATH.
Run `npm ci --omit dev` to install the dependencies.
#### On your development machine #### On your development machine

View File

@ -2,14 +2,13 @@
Description=Moshing Mammut Description=Moshing Mammut
[Service] [Service]
ExecStart=/PATH_TO_MOSHING_MAMMUT/start.sh ExecStart=/home/moshing-mammut/app/start.sh
Restart=always Restart=always
User=MOSHING_MAMMUT_USER User=moshing-mammut
Group=nogroup Group=moshing-mammut
Environment=PATH=/usr/bin:/usr/local/bin Environment=PATH=/usr/bin:/usr/local/bin
Environment=NODE_ENV=production Environment=NODE_ENV=production
WorkingDirectory=/PATH_TO_MOSHING_MAMMUT/ WorkingDirectory=/home/moshing-mammut/app
KillMode=process
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -26,7 +26,7 @@
gap: 10px; gap: 10px;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
background-color: #54546788; background-color: #bbbbcd73;
padding: 0.3em 1em; padding: 0.3em 1em;
margin: 0 -8px; margin: 0 -8px;
border-radius: 3px; border-radius: 3px;

View File

@ -0,0 +1,40 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let moreAvailable: boolean = false;
export let isLoading: boolean = false;
let displayText = '';
let title = '';
let disabled: boolean;
$: displayText = isLoading ? 'Loading...' : 'Load More';
$: disabled = !moreAvailable || isLoading;
$: title = moreAvailable ? 'Load More' : 'No more posts available';
const dispatch = createEventDispatcher();
function loadOlderPosts() {
dispatch('loadOlderPosts');
}
</script>
<button on:click={loadOlderPosts} {disabled} {title}>{displayText}</button>
<style>
button {
padding: 0.75em;
border-radius: 3px;
border: none;
background-color: var(--color-link);
color: white;
cursor: grab;
transition: all 0.2s ease-in-out;
}
button:hover:not(:disabled) {
background-color: var(--color-link-visited);
}
button:disabled {
cursor: not-allowed;
background-color: grey;
}
</style>

View File

@ -172,15 +172,19 @@ export function savePost(post: Post): void {
}); });
} }
export async function getPosts(since: string | null, limit: number) { export async function getPosts(since: string | null, before: string | null, limit: number) {
let promise = await new Promise<Post[]>((resolve, reject) => { let promise = await new Promise<Post[]>((resolve, reject) => {
let filter_query; let filter_query;
let params: any = { $limit: limit }; let params: any = { $limit: limit };
if (since === null) { if (since === null && before === null) {
filter_query = ''; filter_query = '';
} else { } else if (since !== null) {
filter_query = 'WHERE posts.created_at > $since'; filter_query = 'WHERE posts.created_at > $since';
params.$since = 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;
} }
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,

View File

@ -1,9 +1,9 @@
import { HASHTAG_FILTER, URL_FILTER, YOUTUBE_API_KEY } from '$env/static/private'; import { HASHTAG_FILTER, MASTODON_INSTANCE, URL_FILTER, YOUTUBE_API_KEY } from '$env/static/private';
import type { Post, Tag, TimelineEvent } from '$lib/mastodon/response'; import type { Post, Tag, TimelineEvent } from '$lib/mastodon/response';
import { savePost } from '$lib/server/db'; import { savePost } from '$lib/server/db';
import { WebSocket } from "ws"; import { WebSocket } from "ws";
const YOUTUBE_REGEX = new RegExp(/https?:\/\/(www\.)?youtu((be.com\/.*v=)|(\.be\/))(?<videoId>[a-zA-Z_0-9-]+)/gm); const YOUTUBE_REGEX = new RegExp(/https?:\/\/(www\.)?youtu((be.com\/.*?v=)|(\.be\/))(?<videoId>[a-zA-Z_0-9-]+)/gm);
export class TimelineReader { export class TimelineReader {
private static _instance: TimelineReader; private static _instance: TimelineReader;
@ -55,7 +55,7 @@ export class TimelineReader {
} }
private constructor() { private constructor() {
const socket = new WebSocket("wss://metalhead.club/api/v1/streaming") const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`);
socket.onopen = (_event) => { socket.onopen = (_event) => {
socket.send('{ "type": "subscribe", "stream": "public:local"}'); socket.send('{ "type": "subscribe", "stream": "public:local"}');
}; };

View File

@ -2,12 +2,15 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { Post } from '$lib/mastodon/response'; import type { Post } from '$lib/mastodon/response';
import { PUBLIC_REFRESH_INTERVAL } from '$env/static/public'; import { PUBLIC_REFRESH_INTERVAL, PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public';
import PostComponent from '$lib/components/PostComponent.svelte'; import PostComponent from '$lib/components/PostComponent.svelte';
import LoadMoreComponent from '$lib/components/LoadMoreComponent.svelte';
export let data: PageData; export let data: PageData;
let interval: NodeJS.Timer | null = null; let interval: NodeJS.Timer | null = null;
let moreOlderPostsAvailable = true;
let loadingOlderPosts = false;
onMount(async () => { onMount(async () => {
interval = setInterval(async () => { interval = setInterval(async () => {
@ -19,8 +22,13 @@ onMount(async () => {
.then(r => r.json()) .then(r => r.json())
.then((resp: Post[]) => { .then((resp: Post[]) => {
if (resp.length > 0) { if (resp.length > 0) {
data.posts = resp.concat(data.posts); // Prepend new posts, filter dupes
console.log('updated data', resp, data.posts); // There shouldn't be any duplicates, but better be safe than sorry
const combined = resp.concat(data.posts);
const filteredPosts = combined.filter((obj, index, arr) => {
return arr.map(mapObj => mapObj.url).indexOf(obj.url) === index;
});
data.posts = filteredPosts;
} }
}) })
.catch(e => { .catch(e => {
@ -33,21 +41,57 @@ onMount(async () => {
clearInterval(interval) clearInterval(interval)
} }
} }
}) });
async function loadOlderPosts() {
loadingOlderPosts = true;
const params = new URLSearchParams();
if (data.posts.length > 0) {
params.set('before', data.posts[data.posts.length - 1].created_at);
}
await fetch(`/api/posts?${params}`)
.then(r => r.json())
.then((resp: Post[]) => {
if (resp.length > 0) {
// Append old posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry
const combined = data.posts.concat(resp);
const filteredPosts = combined.filter((obj, index, arr) => {
return arr.map(mapObj => mapObj.url).indexOf(obj.url) === index;
});
data.posts = filteredPosts;
moreOlderPostsAvailable = true;
} else {
moreOlderPostsAvailable = false;
}
loadingOlderPosts = false;
})
.catch(e => {
loadingOlderPosts = false;
// TODO: Show error in UI
console.error('Error loading older posts', e);
});
}
</script> </script>
<svelte:head> <svelte:head>
<title>Metalhead.club music list</title> <title>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</title>
</svelte:head> </svelte:head>
<h2>Metalhead.club music list</h2> <h2>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</h2>
<div class="wrapper"> <div class="wrapper">
<div></div> <div></div>
<div class="posts"> <div class="posts">
{#if data.posts.length === 0} {#if data.posts.length === 0}
Sorry, no posts recommending music aave been found yet Sorry, no posts recommending music aave been found yet
{/if} {/if}
{#each data.posts as post (post.id)} {#each data.posts as post (post.url)}
<div class="post"><PostComponent {post} /></div> <div class="post"><PostComponent {post} /></div>
{/each} {/each}
<LoadMoreComponent
on:loadOlderPosts={loadOlderPosts}
moreAvailable={moreOlderPostsAvailable}
isLoading={loadingOlderPosts}/>
</div> </div>
<div></div> <div></div>
</div> </div>
@ -55,6 +99,7 @@ onMount(async () => {
.posts { .posts {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
} }
.post { .post {
width: 100%; width: 100%;

View File

@ -4,12 +4,13 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const GET = (async ({ url }) => { export const GET = (async ({ url }) => {
const since = url.searchParams.get('since'); const since = url.searchParams.get('since');
let count = Number.parseInt(url.searchParams.get('count') || ''); const before = url.searchParams.get('before');
if (isNaN(count)) { let count = Number.parseInt(url.searchParams.get('count') || '');
count = 20; if (isNaN(count)) {
} count = 20;
count = Math.min(count, 100); }
const posts = await getPosts(since, count); count = Math.min(count, 100);
return json(posts); const posts = await getPosts(since, before, count);
return json(posts);
}) satisfies RequestHandler; }) satisfies RequestHandler;

View File

@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
. /PATH_TO_YOUR_NVM/.nvm/nvm.sh . /home/moshing-mammut/.nvm/nvm.sh
node -r dotenv/config build node -r dotenv/config build