18 Commits

Author SHA1 Message Date
68aade4f1f Fix #24, refactor URL detection 2023-04-24 19:38:13 +02:00
9bbcc843c2 Fix #22, fix #23. Display posts as grid instead of flexbox, add song info 2023-04-23 20:10:45 +02:00
42d91a097f Fix youtube links not being parsed for song info 2023-04-23 13:07:52 +02:00
971c846dd1 Saving song infos to DB, refactor logging 2023-04-23 12:46:14 +02:00
1cd9d83910 Improve type safety 2023-04-22 09:28:42 +02:00
b62936ed54 Extract song info from odesli (song.link) 2023-04-22 08:50:17 +02:00
45eeb550b3 Auto reconnect to Mastodon WebSocket if it fails 2023-04-15 09:56:03 +02:00
52c7922002 Improved layout on smaller devices 2023-04-14 20:32:49 +02:00
5ab1167d38 Fix #9: Add WebSub support 2023-04-14 20:04:46 +02:00
c57828d3e2 Fix savePost() never resolving 2023-04-14 20:00:31 +02:00
4e7196182c Fixed posts not saving correctly after DB migration 2023-04-13 16:18:30 +02:00
8d3a23ee88 Update README with changes to DB tables 2023-04-12 20:47:23 +02:00
77c29bdd8a Fix @user@instance not being filtered correctly for blocked users; Fix #11 use urls as identifiers 2023-04-12 20:44:36 +02:00
e346928d32 Revert DB name 2023-04-12 20:10:18 +02:00
ef4c517ff2 Fix #12, wait for DB migrations to finish before attempting to use the database 2023-04-12 20:08:55 +02:00
052c93d461 Fix #18 add ability to block specific users 2023-04-12 17:08:40 +02:00
d716b3882b Fix #13, make youtube API optional 2023-04-11 18:39:02 +02:00
4fbd9a260f Fix #21 respect safe areas on ios safari 2023-04-11 16:21:15 +02:00
22 changed files with 1584 additions and 530 deletions

View File

@ -1,9 +1,11 @@
HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening
URL_FILTER = song.link,album.link,spotify.com,music.apple.com,bandcamp.com
YOUTUBE_API_KEY = CHANGE_ME YOUTUBE_API_KEY = CHANGE_ME
ODESLI_API_KEY = CHANGE_ME
MASTODON_INSTANCE = 'metalhead.club' MASTODON_INSTANCE = 'metalhead.club'
BASE_URL = 'https://moshingmammut.phlaym.net' BASE_URL = 'https://moshingmammut.phlaym.net'
VERBOSE = false VERBOSE = false
IGNORE_USERS = @moshhead@metalhead.club
WEBSUB_HUB = 'http://pubsubhubbub.superfeedr.com'
PUBLIC_REFRESH_INTERVAL = 10000 PUBLIC_REFRESH_INTERVAL = 10000
PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME = 'Metalhead.club' PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME = 'Metalhead.club'

View File

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

View File

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

View File

@ -11,8 +11,8 @@ Having a quick overview over what is being posted can be a great way to discover
This is fairly simple from a technical point of view! metalhead.club's local timeline is being watched using the This is fairly simple from a technical point of view! metalhead.club's local timeline is being watched using the
Mastodon Streaming API over a Websocket. Every time a new post arrives, it is checked if it contains any music by Mastodon Streaming API over a Websocket. Every time a new post arrives, it is checked if it contains any music by
checking included hashtags and URLs. A list of tags and URLs can be found in [the configuration](.env.EXAMPLE). checking included hashtags and URLs. A list of tags can be found in [the configuration](.env.EXAMPLE).
Additionally, lins to YouTube are queried, if they are music or other videos using the YouTube API. Additionally, links are vetted if they are music by checking if https://song.link finds info on them.
If a post passes this check it is saved to a SQLite database. If a post passes this check it is saved to a SQLite database.
@ -38,7 +38,7 @@ complexity. I'm willing to make this change if people prefer this though.
Additionally, I ended up adding a few things which turned out to be not needed: Additionally, I ended up adding a few things which turned out to be not needed:
The `tags` table (tags are included in the post's content and I don't do anything separately with tags) and The `tags` table (tags are included in the post's content and I don't do anything separately with tags) and
`accounts.username` and `accounts.avatar_static`. I will keep these in until the initial wave of feedback arrives, and ~~`accounts.username`~~ (s being used for #18) ~~and `accounts.avatar_static`~~ (has been removed). I will keep these in until the initial wave of feedback arrives, and
remove it if no new features required them. remove it if no new features required them.
I'll gladly accept any help in coming up with a good solution which doesn't need to store anything at all! I'll gladly accept any help in coming up with a good solution which doesn't need to store anything at all!
@ -93,9 +93,12 @@ and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
#### On your development machine #### On your development machine
Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY`. Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY` and `ODESLI_API_KEY`.
To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an
_API key_. As soon as #13 is implemented, this will be optional! _API key_.
If `YOUTUBE_API_KEY` is unset, no playlist will be updated.
If `ODESLI_API_KEY` is unset, your rate limit to the song.link API will be lower.
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.

903
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
{ {
"name": "moshing-mammut", "name": "moshing-mammut",
"version": "1.1.0", "version": "1.3.0",
"private": true, "private": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"devn": "vite dev --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",

View File

@ -10,12 +10,12 @@
<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="theme-color" content="#2e0b78" />
<link rel="stylesheet" href="%sveltekit.assets%/style.css" /> <link rel="stylesheet" href="%sveltekit.assets%/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#17063b" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#17063b" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#BCB9B2" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#BCB9B2" media="(prefers-color-scheme: light)" />
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Atom Feed" /> <link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Atom Feed" />
<link rel="hub" href="https://pubsubhubbub.superfeedr.com" />
%sveltekit.head% %sveltekit.head%
<style> <style>
body { body {

View File

@ -1,3 +1,4 @@
import { log } from '$lib/log';
import { TimelineReader } from '$lib/server/timeline'; import { TimelineReader } from '$lib/server/timeline';
import type { HandleServerError } from '@sveltejs/kit'; import type { HandleServerError } from '@sveltejs/kit';
import fs from 'fs/promises'; import fs from 'fs/promises';
@ -6,12 +7,11 @@ TimelineReader.init();
export const handleError = (({ error }) => { export const handleError = (({ error }) => {
if (error instanceof Error) { if (error instanceof Error) {
console.error('Something went wrong: ', error.name, error.message); log.error('Something went wrong: ', error.name, error.message);
} }
return { return {
message: 'Whoops!', message: `Something went wrong! ${error}`
code: (error as any)?.code ?? 'UNKNOWN'
}; };
}) satisfies HandleServerError; }) satisfies HandleServerError;

View File

@ -10,8 +10,8 @@
<style> <style>
img { img {
max-width: 50px; max-width: 100%;
max-height: 50px; max-height: 100%;
width: auto; width: auto;
height: auto; height: auto;
object-fit: contain; object-fit: contain;

View File

@ -42,6 +42,7 @@
padding: 0.3em 1em; padding: 0.3em 1em;
margin: 0 -8px; margin: 0 -8px;
border-radius: 3px; border-radius: 3px;
padding-bottom: env(safe-area-inset-bottom);
} }
.icon { .icon {
position: relative; position: relative;
@ -57,7 +58,7 @@
background-color: var(--color-grey-translucent); background-color: var(--color-grey-translucent);
} }
} }
@media only screen and (max-device-width: 620px) { @media only screen and (max-width: 620px) {
.mastodonInstance, .mastodonInstance,
.feedSuffix { .feedSuffix {
display: none; display: none;
@ -67,7 +68,7 @@
} }
} }
@media only screen and (max-device-width: 430px) { @media only screen and (max-width: 430px) {
.mastodonInstance, .mastodonInstance,
.feedSuffix, .feedSuffix,
.secretIngredient { .secretIngredient {
@ -75,7 +76,7 @@
} }
} }
@media only screen and (max-device-width: 370px) { @media only screen and (max-width: 370px) {
.label { .label {
display: none; display: none;
} }

View File

@ -24,33 +24,139 @@
<div class="wrapper"> <div class="wrapper">
<div class="avatar"><AvatarComponent account={post.account} /></div> <div class="avatar"><AvatarComponent account={post.account} /></div>
<div class="post"> <div class="account"><AccountComponent account={post.account} /></div>
<div class="meta"> <div class="meta">
<AccountComponent account={post.account} /> <small><a href={post.url} target="_blank" title={absoluteDate}>{dateCreated}</a></small>
<small><a href={post.url} target="_blank" title={absoluteDate}>{dateCreated}</a></small> </div>
</div> <div class="content">{@html post.content}</div>
<div class="content">{@html post.content}</div> <div class="song">
{#if post.songs}
{#each post.songs as song (song.pageUrl)}
<div class="info-wrapper">
<div class="bgimage" style="background-image: url({song.thumbnailUrl});" />
<a href={song.pageUrl ?? song.postedUrl} target="_blank">
<div class="info">
<img
src={song.thumbnailUrl}
class="cover"
alt="Cover for {song.artistName} - {song.title}"
/>
<span class="text">{song.artistName} - {song.title}</span>
</div>
</a>
</div>
{/each}
{/if}
</div> </div>
</div> </div>
<style> <style>
.wrapper { .wrapper {
display: flex; display: grid;
} grid-template-columns: 50px 1fr auto auto;
.post { grid-template-rows: auto 1fr auto;
display: flex; grid-template-areas:
flex-direction: column; 'avatar account account meta'
flex-grow: 2; 'avatar content content song'
} '. content content song';
.meta { grid-column-gap: 6px;
display: flex; column-gap: 6px;
justify-content: space-between; grid-row-gap: 6px;
row-gap: 6px;
} }
.avatar { .avatar {
margin-right: 1em; grid-area: avatar;
max-width: 50px;
max-height: 50px;
}
.account {
grid-area: account;
}
.meta {
grid-area: meta;
justify-self: end;
} }
.content { .content {
max-width: calc(600px - 1em - 50px); grid-area: content;
overflow-x: auto; word-break: break-word;
translate: 0 -0.5em;
}
.song {
grid-area: song;
align-self: center;
justify-self: center;
max-width: 200px;
}
.cover {
max-width: 200px;
display: block;
border-radius: 3px;
margin-bottom: 3px;
}
.bgimage {
display: none;
background-color: var(--color-bg);
}
.info {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5em;
z-index: 1;
}
.info * {
z-index: inherit;
}
@media only screen and (max-width: 650px) {
.wrapper {
grid-template-areas:
'avatar account account meta'
'content content content content'
'song song song song';
grid-row-gap: 3px;
row-gap: 3px;
}
.song {
width: 100%;
}
.song,
.cover {
max-width: 100%;
}
.cover {
height: 60px;
}
.cover:not(.background) {
z-index: 1;
width: 60px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.bgimage {
display: block;
width: 100%;
height: 60px;
z-index: 0;
filter: blur(10px);
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.info {
position: relative;
top: -60px;
flex-direction: row;
}
.info-wrapper {
margin-bottom: -50px;
}
.text {
padding: 3px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 3px;
background-color: var(--color-bg-translucent);
color: var(--color-text);
}
} }
</style> </style>

32
src/lib/log.ts Normal file
View File

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

View File

@ -1,3 +1,5 @@
import type { SongInfo } from '$lib/odesliResponse';
export interface TimelineEvent { export interface TimelineEvent {
event: string; event: string;
payload: string; payload: string;
@ -10,6 +12,17 @@ export interface Post {
url: string; url: string;
content: string; content: string;
account: Account; account: Account;
card?: PreviewCard;
songs?: SongInfo[];
}
export interface PreviewCard {
url: string;
title: string;
image?: string;
blurhash?: string;
width: number;
height: number;
} }
export interface Tag { export interface Tag {
@ -24,5 +37,4 @@ export interface Account {
display_name: string; display_name: string;
url: string; url: string;
avatar: string; avatar: string;
avatar_static: string;
} }

144
src/lib/odesliResponse.ts Normal file
View File

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

View File

@ -1,47 +1,112 @@
import { env } from '$env/dynamic/private'; import { IGNORE_USERS, MASTODON_INSTANCE } from '$env/static/private';
import { enableVerboseLog, log } from '$lib/log';
import type { Account, Post, Tag } from '$lib/mastodon/response'; import type { Account, Post, Tag } from '$lib/mastodon/response';
import type { SongInfo } from '$lib/odesliResponse';
import sqlite3 from 'sqlite3'; import sqlite3 from 'sqlite3';
const { DEV } = import.meta.env; const { DEV } = import.meta.env;
const db: sqlite3.Database = new sqlite3.Database('moshingmammut.db'); type FilterParameter = {
$limit: number | undefined | null;
$since?: string | undefined | null;
$before?: string | undefined | null;
[x: string]: string | number | undefined | null;
};
if (DEV && env.VERBOSE === 'true') { type PostRow = {
sqlite3.verbose(); id: string;
db.on('change', (t, d, table, rowid) => { content: string;
console.debug('DB change event', t, d, table, rowid); created_at: string;
}); url: string;
account_id: string;
acct: string;
username: string;
display_name: string;
account_url: string;
avatar: string;
};
db.on('trace', (sql) => { type PostTagRow = {
console.debug('Running', sql); post_id: string;
}); tag: string;
url: string;
};
db.on('profile', (sql) => { type SongRow = {
console.debug('Finished', sql); post_url: string;
}); postedUrl: string;
} overviewUrl?: string;
type: 'album' | 'song';
youtubeUrl?: string;
title?: string;
artistName?: string;
thumbnailUrl?: string;
};
interface Migration { type Migration = {
id: number; id: number;
name: string; name: string;
statement: string; statement: string;
};
const db: sqlite3.Database = new sqlite3.Database('moshingmammut.db');
// for the local masto instance, the instance name is *not* saved
// as part of the username or acct, so it needs to be stripped
const ignoredUsers: string[] =
IGNORE_USERS === undefined
? []
: IGNORE_USERS.split(',')
.map((u) => (u.startsWith('@') ? u.substring(1) : u))
.map((u) =>
u.endsWith('@' + MASTODON_INSTANCE)
? u.substring(0, u.length - ('@' + MASTODON_INSTANCE).length)
: u
);
let databaseReady = false;
if (enableVerboseLog) {
sqlite3.verbose();
db.on('change', (t, d, table, rowid) => {
log.verbose('DB change event', t, d, table, rowid);
});
db.on('trace', (sql) => {
log.verbose('Running', sql);
});
db.on('profile', (sql) => {
log.verbose('Finished', sql);
});
} }
db.on('open', () => { db.on('open', () => {
console.log('Opened database'); log.info('Opened database');
db.serialize(); db.serialize();
db.run('CREATE TABLE IF NOT EXISTS "migrations" ("id" integer,"name" TEXT, PRIMARY KEY (id))'); db.run('CREATE TABLE IF NOT EXISTS "migrations" ("id" integer,"name" TEXT, PRIMARY KEY (id))');
db.all('SELECT id FROM migrations', (err, rows) => { db.all('SELECT id FROM migrations', (err, rows: Migration[]) => {
if (err !== null) { if (err !== null) {
console.error('Could not fetch existing migrations', err); log.error('Could not fetch existing migrations', err);
databaseReady = true;
return; return;
} }
console.debug('Already applied migrations', rows); log.debug('Already applied migrations', rows);
const appliedMigrations: Set<number> = new Set(rows.map((row: any) => row['id'])); const appliedMigrations: Set<number> = new Set(rows.map((row) => row['id']));
const toApply = getMigrations().filter((m) => !appliedMigrations.has(m.id)); const toApply = getMigrations().filter((m) => !appliedMigrations.has(m.id));
let remaining = toApply.length;
if (remaining === 0) {
databaseReady = true;
return;
}
for (const migration of toApply) { for (const migration of toApply) {
db.exec(migration.statement, (err) => { db.exec(migration.statement, (err) => {
remaining--;
// This will set databaseReady to true before the migration has been inserted as applies,
// but that doesn't matter. It's only important that is has been applied
if (remaining === 0) {
databaseReady = true;
}
if (err !== null) { if (err !== null) {
console.error(`Failed to apply migration ${migration.name}`, err); log.error(`Failed to apply migration ${migration.name}`, err);
return; return;
} }
db.run( db.run(
@ -49,10 +114,10 @@ db.on('open', () => {
[migration.id, migration.name], [migration.id, migration.name],
(e: Error) => { (e: Error) => {
if (e !== null) { if (e !== null) {
console.error(`Failed to mark migration ${migration.name} as applied`, e); log.error(`Failed to mark migration ${migration.name} as applied`, e);
return; return;
} }
console.info(`Applied migration ${migration.name}`); log.info(`Applied migration ${migration.name}`);
} }
); );
}); });
@ -60,7 +125,7 @@ db.on('open', () => {
}); });
}); });
db.on('error', (err) => { db.on('error', (err) => {
console.error('Error opening database', err); log.error('Error opening database', err);
}); });
function getMigrations(): Migration[] { function getMigrations(): Migration[] {
@ -94,179 +159,401 @@ function getMigrations(): Migration[] {
FOREIGN KEY (post_id) REFERENCES posts(id), FOREIGN KEY (post_id) REFERENCES posts(id),
FOREIGN KEY (tag_url) REFERENCES tags(url) FOREIGN KEY (tag_url) REFERENCES tags(url)
)` )`
},
{
id: 2,
name: 'urls as keys',
statement: `
CREATE TABLE accounts_new (
id TEXT NOT NULL,
acct TEXT,
username TEXT,
display_name TEXT,
url TEXT NOT NULL PRIMARY KEY,
avatar TEXT
);
INSERT INTO accounts_new (id, acct, username, display_name, url, avatar)
SELECT id, acct, username, display_name, url, avatar
FROM accounts;
DROP TABLE accounts;
ALTER TABLE accounts_new RENAME TO accounts;
CREATE TABLE posts_new (
id TEXT NOT NULL,
content TEXT,
created_at TEXT,
url TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts(url)
);
INSERT INTO posts_new (id, content, created_at, url, account_id)
SELECT p.id, p.content, p.created_at, p.url, accounts.url
FROM posts as p
JOIN accounts ON accounts.id = p.account_id;
DROP TABLE posts;
ALTER TABLE posts_new RENAME TO posts;
CREATE TABLE poststags_new (
id integer PRIMARY KEY,
post_id TEXT NOT NULL,
tag_url TEXT NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts(url),
FOREIGN KEY (tag_url) REFERENCES tags(url)
);
INSERT INTO poststags_new (id, post_id, tag_url)
SELECT pt.id, posts.url, pt.tag_url
FROM poststags as pt
JOIN posts ON posts.id = pt.post_id;
DROP TABLE poststags;
ALTER TABLE poststags_new RENAME TO poststags;
`
},
{
id: 3,
name: 'song info for posts',
statement: `
CREATE TABLE songs (
id integer PRIMARY KEY,
postedUrl TEXT NOT NULL,
overviewUrl TEXT,
type TEXT CHECK ( type in ('album', 'song') ),
youtubeUrl TEXT,
title TEXT,
artistName TEXT,
thumbnailUrl TEXT,
post_url TEXT,
FOREIGN KEY (post_url) REFERENCES posts(url)
);`
} }
]; ];
} }
export async function savePost(post: Post): Promise<undefined> { async function waitReady(): Promise<undefined> {
return new Promise((resolve, reject) => { // Simpler than a semaphore and is really only needed on startup
console.debug(`Saving post ${post.url}`); return new Promise((resolve) => {
const account = post.account; const interval = setInterval(() => {
if (DEV) {
log.debug('Waiting for database to be ready');
}
if (databaseReady) {
if (DEV) {
log.debug('DB is ready');
}
clearInterval(interval);
resolve(undefined);
}
}, 100);
});
}
function saveAccountData(account: Account): Promise<undefined> {
return new Promise<undefined>((resolve, reject) => {
db.run( db.run(
` `
INSERT INTO accounts (id, acct, username, display_name, url, avatar, avatar_static) INSERT INTO accounts (id, acct, username, display_name, url, avatar)
VALUES(?, ?, ?, ?, ?, ?, ?) VALUES(?, ?, ?, ?, ?, ?)
ON CONFLICT(id) ON CONFLICT(url)
DO UPDATE SET DO UPDATE SET
acct=excluded.acct, acct=excluded.acct,
username=excluded.username, username=excluded.username,
display_name=excluded.display_name, display_name=excluded.display_name,
url=excluded.url, id=excluded.id,
avatar=excluded.avatar, avatar=excluded.avatar;`,
avatar_static=excluded.avatar_static;`,
[ [
account.id, account.id,
account.acct, account.acct,
account.username, account.username,
account.display_name, account.display_name,
account.url, account.url,
account.avatar, account.avatar
account.avatar_static
], ],
(err) => { (err) => {
if (err !== null) { if (err !== null) {
console.error(`Could not insert/update account ${account.id}`, err); log.error(`Could not insert/update account ${account.id}`, err);
reject(err); reject(err);
return; return;
} }
db.run( resolve(undefined);
`
INSERT INTO posts (id, content, created_at, url, account_id)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET
content=excluded.content,
created_at=excluded.created_at,
url=excluded.url,
account_id=excluded.account_id;`,
[post.id, post.content, post.created_at, post.url, post.account.id],
(postErr) => {
if (postErr !== null) {
console.error(`Could not insert post ${post.url}`, postErr);
reject(postErr);
return;
}
db.parallelize(() => {
let remaining = post.tags.length;
for (const tag of post.tags) {
db.run(
`
INSERT INTO tags (url, tag) VALUES (?, ?)
ON CONFLICT(url) DO UPDATE SET
tag=excluded.tag;`,
[tag.url, tag.name],
(tagErr) => {
if (tagErr !== null) {
console.error(`Could not insert/update tag ${tag.url}`, tagErr);
reject(tagErr);
return;
}
db.run(
'INSERT INTO poststags (post_id, tag_url) VALUES (?, ?)',
[post.id, tag.url],
(posttagserr) => {
if (posttagserr !== null) {
console.error(
`Could not insert poststags ${tag.url}, ${post.url}`,
posttagserr
);
reject(posttagserr);
return;
}
// Don't decrease on fail
remaining--;
// Only resolve after all have been inserted
if (remaining === 0) {
resolve(undefined);
}
}
);
}
);
}
});
}
);
} }
); );
}); });
} }
export async function getPosts(since: string | null, before: string | null, limit: number) { function savePostData(post: Post): Promise<undefined> {
const promise = await new Promise<Post[]>((resolve, reject) => { return new Promise<undefined>((resolve, reject) => {
let filter_query; db.run(
const params: any = { $limit: limit }; `
if (since === null && before === null) { INSERT INTO posts (id, content, created_at, url, account_id)
filter_query = ''; VALUES (?, ?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET
} else if (since !== null) { content=excluded.content,
filter_query = 'WHERE posts.created_at > $since'; created_at=excluded.created_at,
params.$since = since; id=excluded.id,
} else if (before !== null) { account_id=excluded.account_id;`,
// Setting both, before and since doesn't make sense, so this case is not explicitly handled [post.id, post.content, post.created_at, post.url, post.account.url],
filter_query = 'WHERE posts.created_at < $before'; (postErr) => {
params.$before = before; if (postErr !== null) {
log.error(`Could not insert post ${post.url}`, postErr);
reject(postErr);
return;
}
resolve(undefined);
}
);
});
}
function savePostTagData(post: Post): Promise<undefined> {
return new Promise<undefined>((resolve, reject) => {
if (!post.tags.length) {
resolve(undefined);
return;
} }
const sql = `SELECT posts.id, posts.content, posts.created_at, posts.url,
accounts.id AS account_id, accounts.acct, accounts.username, accounts.display_name, db.parallelize(() => {
accounts.url AS account_url, accounts.avatar let remaining = post.tags.length;
FROM posts for (const tag of post.tags) {
JOIN accounts ON posts.account_id = accounts.id db.run(
${filter_query} `
ORDER BY created_at DESC INSERT INTO tags (url, tag) VALUES (?, ?)
LIMIT $limit`; ON CONFLICT(url) DO UPDATE SET
db.all(sql, params, (err, rows: any[]) => { tag=excluded.tag;`,
[tag.url, tag.name],
(tagErr) => {
if (tagErr !== null) {
log.error(`Could not insert/update tag ${tag.url}`, tagErr);
reject(tagErr);
return;
}
db.run(
'INSERT INTO poststags (post_id, tag_url) VALUES (?, ?)',
[post.url, tag.url],
(posttagserr) => {
if (posttagserr !== null) {
log.error(`Could not insert poststags ${tag.url}, ${post.url}`, posttagserr);
reject(posttagserr);
return;
}
// Don't decrease on fail
remaining--;
// Only resolve after all have been inserted
if (remaining === 0) {
resolve(undefined);
}
}
);
}
);
}
});
});
}
function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<undefined> {
return new Promise<undefined>((resolve, reject) => {
if (songs.length === 0) {
resolve(undefined);
return;
}
db.parallelize(() => {
let remaining = songs.length;
for (const song of songs) {
db.run(
`
INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, title, artistName, thumbnailUrl, post_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[
song.postedUrl,
song.pageUrl,
song.type,
song.youtubeUrl,
song.title,
song.artistName,
song.thumbnailUrl,
postUrl
],
(songErr) => {
if (songErr !== null) {
log.error(`Could not insert song ${song.postedUrl}`, songErr);
reject(songErr);
return;
}
// Don't decrease on fail
remaining--;
// Only resolve after all have been inserted
if (remaining === 0) {
resolve(undefined);
}
}
);
}
});
});
}
export async function savePost(post: Post, songs: SongInfo[]) {
if (!databaseReady) {
await waitReady();
}
log.debug(`Saving post ${post.url}`);
const account = post.account;
await saveAccountData(account);
log.debug(`Saved account data ${post.url}`);
await savePostData(post);
log.debug(`Saved post data ${post.url}`);
await savePostTagData(post);
log.debug(`Saved ${post.tags.length} tag data ${post.url}`);
await saveSongInfoData(post.url, songs);
log.debug(`Saved ${songs.length} song info data ${post.url}`);
}
function getPostData(filterQuery: string, params: FilterParameter): Promise<PostRow[]> {
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.url AS account_url, accounts.avatar
FROM posts
JOIN accounts ON posts.account_id = accounts.url
${filterQuery}
ORDER BY created_at DESC
LIMIT $limit`;
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows: PostRow[]) => {
if (err != null) { if (err != null) {
console.error('Error loading posts', err); log.error('Error loading posts', err);
reject(err); reject(err);
return; return;
} }
if (rows.length === 0) { resolve(rows);
// No need to check for tags
resolve([]);
return;
}
const postIdsParams = rows.map(() => '?').join(', ');
db.all(
`SELECT post_id, tags.url, tags.tag
FROM poststags
JOIN tags ON poststags.tag_url = tags.url
WHERE post_id IN (${postIdsParams});`,
rows.map((r: any) => r.id),
(tagErr, tagRows: any[]) => {
if (tagErr != null) {
console.error('Error loading post tags', tagErr);
reject(tagErr);
return;
}
const tagMap: Map<string, Tag[]> = tagRows.reduce((result: Map<string, Tag[]>, item) => {
const tag: Tag = {
url: item.url,
name: item.tag
};
result.set(item.post_id, [...(result.get(item.post_id) || []), tag]);
return result;
}, new Map());
const posts = rows.map((row) => {
return {
id: row.id,
content: row.content,
created_at: row.created_at,
url: row.url,
tags: tagMap.get(row.id) || [],
account: {
id: row.account_id,
acct: row.acct,
username: row.username,
display_name: row.display_name,
url: row.account_url,
avatar: row.avatar,
avatar_static: ''
} as Account
} as Post;
});
resolve(posts);
}
);
}); });
}); });
return promise; }
function getTagData(postIdsParams: String, postIds: string[]): Promise<Map<string, Tag[]>> {
return new Promise((resolve, reject) => {
db.all(
`SELECT post_id, tags.url, tags.tag
FROM poststags
JOIN tags ON poststags.tag_url = tags.url
WHERE post_id IN (${postIdsParams});`,
postIds,
(tagErr, tagRows: PostTagRow[]) => {
if (tagErr != null) {
log.error('Error loading post tags', tagErr);
reject(tagErr);
return;
}
const tagMap: Map<string, Tag[]> = tagRows.reduce((result: Map<string, Tag[]>, item) => {
const tag: Tag = {
url: item.url,
name: item.tag
};
result.set(item.post_id, [...(result.get(item.post_id) || []), tag]);
return result;
}, new Map());
resolve(tagMap);
}
);
});
}
function getSongData(postIdsParams: String, postIds: string[]): Promise<Map<string, SongInfo[]>> {
return new Promise((resolve, reject) => {
db.all(
`SELECT post_url, songs.postedUrl, songs.overviewUrl, songs.type, songs.youtubeUrl,
songs.title, songs.artistName, songs.thumbnailUrl, songs.post_url
FROM songs
WHERE post_url IN (${postIdsParams});`,
postIds,
(tagErr, tagRows: SongRow[]) => {
if (tagErr != null) {
log.error('Error loading post tags', tagErr);
reject(tagErr);
return;
}
const songMap: Map<string, SongInfo[]> = tagRows.reduce(
(result: Map<string, SongInfo[]>, item) => {
const info = {
pageUrl: item.overviewUrl,
youtubeUrl: item.youtubeUrl,
type: item.type,
title: item.title,
artistName: item.artistName,
thumbnailUrl: item.thumbnailUrl,
postedUrl: item.postedUrl
} as SongInfo;
result.set(item.post_url, [...(result.get(item.post_url) || []), info]);
return result;
},
new Map()
);
resolve(songMap);
}
);
});
}
export async function getPosts(
since: string | null,
before: string | null,
limit: number
): Promise<Post[]> {
if (!databaseReady) {
await waitReady();
}
let filterQuery = '';
const params: FilterParameter = { $limit: limit };
if (since === null && before === null) {
filterQuery = '';
} else if (since !== null) {
filterQuery = 'WHERE posts.created_at > $since';
params.$since = since;
} else if (before !== null) {
// Setting both, before and since doesn't make sense, so this case is not explicitly handled
filterQuery = 'WHERE posts.created_at < $before';
params.$before = before;
}
ignoredUsers.forEach((ignoredUser, index) => {
const userParam = `$user_${index}`;
const acctParam = userParam + 'a';
const usernameParam = userParam + 'u';
const prefix = filterQuery === '' ? ' WHERE' : ' AND';
filterQuery += `${prefix} acct != ${acctParam} AND username != ${usernameParam} `;
params[acctParam] = ignoredUser;
params[usernameParam] = ignoredUser;
});
const rows = await getPostData(filterQuery, params);
if (rows.length === 0) {
// No need to check for tags and songs
return [];
}
const postIdsParams = rows.map(() => '?').join(', ');
const postIds = rows.map((r: PostRow) => r.url);
const tagMap = await getTagData(postIdsParams, postIds);
const songMap = await getSongData(postIdsParams, postIds);
const posts = rows.map((row) => {
return {
id: row.id,
content: row.content,
created_at: row.created_at,
url: row.url,
tags: tagMap.get(row.url) || [],
account: {
id: row.account_id,
acct: row.acct,
username: row.username,
display_name: row.display_name,
url: row.account_url,
avatar: row.avatar
} as Account,
songs: songMap.get(row.url) || []
} as Post;
});
return posts;
} }

View File

@ -1,11 +1,13 @@
import { BASE_URL } from '$env/static/private'; import { BASE_URL, WEBSUB_HUB } from '$env/static/private';
import { PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public'; import { PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public';
import type { Post } from '$lib//mastodon/response'; import type { Post } from '$lib//mastodon/response';
import { log } from '$lib/log';
import { Feed } from 'feed'; import { Feed } from 'feed';
import fs from 'fs/promises'; import fs from 'fs/promises';
export function createFeed(posts: Post[]): Feed { export function createFeed(posts: Post[]): Feed {
const baseUrl = BASE_URL.endsWith('/') ? BASE_URL : BASE_URL + '/'; const baseUrl = BASE_URL.endsWith('/') ? BASE_URL : BASE_URL + '/';
const hub = WEBSUB_HUB ? WEBSUB_HUB : undefined;
const feed = new Feed({ const feed = new Feed({
title: `${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music feed`, title: `${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music feed`,
description: `Posts about music on ${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME}`, description: `Posts about music on ${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME}`,
@ -19,6 +21,7 @@ export function createFeed(posts: Post[]): Feed {
feedLinks: { feedLinks: {
atom: `${BASE_URL}/feed.xml` atom: `${BASE_URL}/feed.xml`
}, },
hub: hub,
author: { author: {
name: '@aymm', name: '@aymm',
link: 'https://metalhead.club/@aymm' link: 'https://metalhead.club/@aymm'
@ -40,8 +43,23 @@ export function createFeed(posts: Post[]): Feed {
}); });
}); });
feed.addCategory('Music'); feed.addCategory('Music');
return feed; return feed;
} }
export async function saveAtomFeed(feed: Feed) { export async function saveAtomFeed(feed: Feed) {
await fs.writeFile('feed.xml', feed.atom1(), { encoding: 'utf8' }); await fs.writeFile('feed.xml', feed.atom1(), { encoding: 'utf8' });
if (!WEBSUB_HUB) {
return;
}
try {
const params = new URLSearchParams();
params.append('hub.mode', 'publish');
params.append('hub.url', `${BASE_URL}/feed.xml`);
await fetch(WEBSUB_HUB, {
method: 'POST',
body: params
});
} catch (e) {
log.error('Failed to update WebSub hub', e);
}
} }

View File

@ -1,76 +1,70 @@
import { import { HASHTAG_FILTER, MASTODON_INSTANCE, ODESLI_API_KEY } from '$env/static/private';
HASHTAG_FILTER, import { log } from '$lib/log';
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 type { OdesliResponse, Platform, SongInfo } from '$lib/odesliResponse';
import { getPosts, savePost } from '$lib/server/db'; import { getPosts, savePost } from '$lib/server/db';
import { createFeed, saveAtomFeed } from '$lib/server/rss'; import { createFeed, saveAtomFeed } from '$lib/server/rss';
import { sleep } from '$lib/sleep';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
const YOUTUBE_REGEX = new RegExp( const URL_REGEX = new RegExp(/href="(?<postUrl>[^>]+?)" target="_blank"/gm);
/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;
private static async isMusicVideo(videoId: string) { private static async getSongInfo(url: URL, remainingTries = 6): Promise<SongInfo | null> {
const searchParams = new URLSearchParams([ if (remainingTries === 0) {
['part', 'snippet'], log.error('No tries remaining. Lookup failed!');
['id', videoId], return null;
['key', YOUTUBE_API_KEY] }
]); if (url.hostname === 'songwhip.com') {
const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`); // song.link doesn't support songwhip links and songwhip themselves will provide metadata if you pass in a
const resp = await fetch(youtubeVideoUrl); // Apple Music/Spotify/etc link, but won't when provided with their own link, so no way to extract song info
const respObj = await resp.json(); // except maybe scraping their HTML
if (!respObj.items.length) { return null;
console.warn('Could not find video with id', videoId);
return false;
} }
const item = respObj.items[0]; const odesliParams = new URLSearchParams();
if (item.tags?.includes('music')) { odesliParams.append('url', url.toString());
return true; odesliParams.append('userCountry', 'DE');
odesliParams.append('songIfSingle', 'true');
if (ODESLI_API_KEY && ODESLI_API_KEY !== 'CHANGE_ME') {
odesliParams.append('key', ODESLI_API_KEY);
} }
const odesliApiUrl = `https://api.song.link/v1-alpha.1/links?${odesliParams}`;
const categorySearchParams = new URLSearchParams([ try {
['part', 'snippet'], return fetch(odesliApiUrl).then(async (response) => {
['id', item.categoryId], if (response.status === 429) {
['key', YOUTUBE_API_KEY] throw new Error('Rate limit reached', { cause: 429 });
]);
const youtubeCategoryUrl = new URL(
`https://www.googleapis.com/youtube/v3/videoCategories?${categorySearchParams}`
);
const categoryTitle: string = await fetch(youtubeCategoryUrl)
.then((r) => r.json())
.then((r) => r.items[0]?.title);
return categoryTitle === 'Music';
}
private static async checkYoutubeMatches(postContent: string): Promise<boolean> {
const matches = postContent.matchAll(YOUTUBE_REGEX);
for (const match of matches) {
if (match === undefined || match.groups === undefined) {
continue;
}
const videoId = match.groups.videoId.toString();
try {
const isMusic = await TimelineReader.isMusicVideo(videoId);
if (isMusic) {
return true;
} }
} catch (e) { const odesliInfo: OdesliResponse = await response.json();
console.error('Could not check if', videoId, 'is a music video', e); if (!odesliInfo || !odesliInfo.entitiesByUniqueId || !odesliInfo.entityUniqueId) {
return null;
}
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
const platform: Platform = 'youtube';
return {
...info,
pageUrl: odesliInfo.pageUrl,
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
postedUrl: url.toString()
} as SongInfo;
});
} catch (e) {
if (e instanceof Error && e.cause === 429) {
log.warn('song.link rate limit reached. Trying again in 10 seconds');
await sleep(10_000);
return await this.getSongInfo(url, remainingTries - 1);
} }
log.error(`Failed to load ${url} info from song.link`, e);
return null;
} }
return false;
} }
private constructor() { private startWebsocket() {
const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`); const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`);
socket.onopen = () => { socket.onopen = () => {
log.log('Connected to WS');
socket.send('{ "type": "subscribe", "stream": "public:local"}'); socket.send('{ "type": "subscribe", "stream": "public:local"}');
}; };
socket.onmessage = async (event) => { socket.onmessage = async (event) => {
@ -83,33 +77,70 @@ export class TimelineReader {
const hashttags: string[] = HASHTAG_FILTER.split(','); const hashttags: string[] = HASHTAG_FILTER.split(',');
const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name)); const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name));
const urls: string[] = URL_FILTER.split(','); const urlMatches = post.content.matchAll(URL_REGEX);
const found_urls = urls.filter((t) => post.content.includes(t)); const songs: SongInfo[] = [];
for (const match of urlMatches) {
if (match === undefined || match.groups === undefined) {
log.warn(
'Match listed in allMatches, but either it or its groups are undefined',
match
);
continue;
}
const urlMatch = match.groups.postUrl.toString();
let url: URL;
try {
url = new URL(urlMatch);
} catch (e) {
log.error('URL found via Regex does not seem to be a valud url', urlMatch, e);
continue;
}
// Check *all* found url and let odesli determine if it is music or not
log.debug(`Checking ${url} if it contains song data`);
const info = await TimelineReader.getSongInfo(url);
log.debug(`Found song info for ${url}?`, info);
if (info) {
songs.push(info);
}
}
// If we don't have any tags or non-youtube urls, check youtube // If we don't have any tags or non-youtube urls, check youtube
// YT is handled separately, because it requires an API call and therefore is slower // YT is handled separately, because it requires an API call and therefore is slower
if ( if (songs.length === 0 && found_tags.length === 0) {
found_urls.length === 0 && log.log('Ignoring post', post.url);
found_tags.length === 0 &&
!(await TimelineReader.checkYoutubeMatches(post.content))
) {
return; return;
} }
await savePost(post);
await savePost(post, songs);
log.debug('Saved post', post.url);
const posts = await getPosts(null, null, 100); const posts = await getPosts(null, null, 100);
await saveAtomFeed(createFeed(posts)); await saveAtomFeed(createFeed(posts));
} catch (e) { } catch (e) {
console.error('error message', event, event.data, e); log.error('error message', event, event.data, e);
} }
}; };
socket.onclose = (event) => { socket.onclose = (event) => {
console.log('Closed', event, event.code, event.reason); log.warn(
`Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`
);
setTimeout(() => {
log.info(`Attempting to reconenct to WS`);
this.startWebsocket();
}, 10000);
}; };
socket.onerror = (event) => { socket.onerror = (event) => {
console.log('error', event, event.message, event.error); log.error(
`Websocket connection to ${MASTODON_INSTANCE} failed. ${event.type}: ${event.error}, message: '${event.message}'`
);
}; };
} }
private constructor() {
this.startWebsocket();
}
public static init() { public static init() {
if (this._instance === undefined) { if (this._instance === undefined) {
this._instance = new TimelineReader(); this._instance = new TimelineReader();

5
src/lib/sleep.ts Normal file
View File

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

7
src/lib/truthyString.ts Normal file
View File

@ -0,0 +1,7 @@
export function isTruthy(value: string | number | boolean | null | undefined): boolean {
if (typeof value === 'string') {
return value.toLowerCase() === 'true' || !!+value; // here we parse to number first
}
return !!value;
}

View File

@ -34,9 +34,9 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
@media only screen and (max-device-width: 620px) { @media only screen and (max-width: 620px) {
.footer { .footer {
width: calc(100% + 16px); width: 100%;
} }
} }
</style> </style>

View File

@ -25,7 +25,7 @@
} }
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL); const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
let interval: NodeJS.Timer | null = null; let interval: ReturnType<typeof setTimeout> | null = null;
let moreOlderPostsAvailable = true; let moreOlderPostsAvailable = true;
let loadingOlderPosts = false; let loadingOlderPosts = false;
@ -155,7 +155,7 @@
<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 have been found yet
{/if} {/if}
{#each data.posts as post (post.url)} {#each data.posts as post (post.url)}
<div <div
@ -187,7 +187,7 @@
} }
.post { .post {
width: 100%; width: 100%;
max-width: 600px; max-width: min(800px, 80vw);
margin-bottom: 1em; margin-bottom: 1em;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
padding: 1em; padding: 1em;
@ -202,9 +202,10 @@
z-index: 100; z-index: 100;
} }
@media only screen and (max-device-width: 650px) { @media only screen and (max-width: 650px) {
.post { .post {
max-width: 100vw; max-width: calc(100vw - 16px);
padding: 1em 0;
} }
} }
</style> </style>

View File

@ -21,6 +21,7 @@ body {
--color-link: var(--color-mauve); --color-link: var(--color-mauve);
--color-link-visited: var(--color-lavender); --color-link-visited: var(--color-lavender);
--color-bg: var(--color-grey-light); --color-bg: var(--color-grey-light);
--color-bg-translucent: hsla(42, 7%, 72%, 0.5);
--color-button: var(--color-red-light); --color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat-dark); --color-button-shadow: var(--color-red-desat-dark);
--color-button-hover: var(--color-red); --color-button-hover: var(--color-red);
@ -48,6 +49,7 @@ a:visited {
--color-link: var(--color-mauve); --color-link: var(--color-mauve);
--color-link-visited: var(--color-lavender); --color-link-visited: var(--color-lavender);
--color-bg: var(--color-blue); --color-bg: var(--color-blue);
--color-bg-translucent: hsla(259, 82%, 26%, 0.5);
--color-button: var(--color-red-light); --color-button: var(--color-red-light);
--color-button-shadow: var(--color-red-desat); --color-button-shadow: var(--color-red-desat);
--color-button-hover: var(--color-red); --color-button-hover: var(--color-red);