Compare commits
6 Commits
v1.0.0
...
a3751c985b
Author | SHA1 | Date | |
---|---|---|---|
a3751c985b
|
|||
5dd20cd6a0
|
|||
8ed804a922
|
|||
02a352a122
|
|||
e8e864bdfc
|
|||
2eddb77b74
|
@ -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'
|
46
README.md
46
README.md
@ -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
|
||||||
|
|
||||||
|
@ -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
|
@ -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;
|
||||||
|
40
src/lib/components/LoadMoreComponent.svelte
Normal file
40
src/lib/components/LoadMoreComponent.svelte
Normal 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>
|
@ -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,
|
||||||
|
@ -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"}');
|
||||||
};
|
};
|
||||||
|
@ -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%;
|
||||||
|
@ -5,11 +5,12 @@ 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');
|
||||||
|
const before = url.searchParams.get('before');
|
||||||
let count = Number.parseInt(url.searchParams.get('count') || '');
|
let count = Number.parseInt(url.searchParams.get('count') || '');
|
||||||
if (isNaN(count)) {
|
if (isNaN(count)) {
|
||||||
count = 20;
|
count = 20;
|
||||||
}
|
}
|
||||||
count = Math.min(count, 100);
|
count = Math.min(count, 100);
|
||||||
const posts = await getPosts(since, count);
|
const posts = await getPosts(since, before, count);
|
||||||
return json(posts);
|
return json(posts);
|
||||||
}) satisfies RequestHandler;
|
}) satisfies RequestHandler;
|
@ -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
|
Reference in New Issue
Block a user