8 Commits

Author SHA1 Message Date
5b6dbd327d Update dependencies 2023-06-24 10:10:52 +02:00
b960d35a58 Fix #32 2023-06-24 10:06:52 +02:00
87b8317c90 Fix #34 2023-06-20 15:47:00 +02:00
e103bef84c Fix #33 2023-06-20 15:45:09 +02:00
6d13aed0f0 Fix CSP config 2023-06-20 15:30:30 +02:00
185d28c295 Fix CSP config being in the wrong section 2023-06-20 15:09:48 +02:00
d57888678d Fix #31 2023-06-20 08:20:30 +02:00
db80b929ca Fix #25 2023-06-16 15:51:57 +02:00
11 changed files with 466 additions and 332 deletions

View File

@ -88,6 +88,8 @@ Copy `apache2.conf.EXAMPLE` to `/etc/apache2/sites-available/moshingmammut.conf`
Domain. If you do not need or want SSL support, remove the whole `<IfModule mod_ssl.c>` block. Domain. If you do not need or want SSL support, remove the whole `<IfModule mod_ssl.c>` block.
If you do, add the path to your SSLCertificateFile and SSLCertificateKeyFile. If you do, add the path to your SSLCertificateFile and SSLCertificateKeyFile.
Modify DocumentRoot and the two Alias and Directory statements, so that thumbnails and avatars are served directly by apache.
Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service` Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service`
and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly. and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
@ -96,7 +98,8 @@ and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY` and `ODESLI_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_. _API key_.
If `YOUTUBE_API_KEY` is unset, no playlist will be updated. If `YOUTUBE_API_KEY` is unset, no playlist will be updated. Also, _all_ YouTube links will be treated as music videos,
because the API is the only way to check if a YouTube link leads to music or something else.
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.

View File

@ -15,6 +15,23 @@
Include /etc/letsencrypt/options-ssl-apache.conf Include /etc/letsencrypt/options-ssl-apache.conf
DocumentRoot /home/moshing-mammut/app/
ProxyPass /avatars/ !
ProxyPass /thumbnails/ !
Alias /avatars/ /home/moshing-mammut/app/avatars/
Alias /thumbnails/ /home/moshing-mammut/app/thumbnails/
<Directory "/home/moshing-mammut/app/avatars/">
Require all granted
Header set Cache-Control "public,max-age=31536000,immutable"
</Directory>
<Directory "/home/moshing-mammut/app/thumbnails/">
Require all granted
Header set Cache-Control "public,max-age=31536000,immutable"
</Directory>
ProxyPass / http://localhost:3000/ ProxyPass / http://localhost:3000/
ProxyPassReverse / http://localhost:3000/ ProxyPassReverse / http://localhost:3000/

571
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "moshing-mammut", "name": "moshing-mammut",
"version": "1.3.1", "version": "1.4.1",
"private": true, "private": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"scripts": { "scripts": {

View File

@ -10,6 +10,10 @@
<meta name="apple-mobile-web-app-title" content="Moshing Mammut" /> <meta name="apple-mobile-web-app-title" content="Moshing Mammut" />
<meta name="application-name" content="Moshing Mammut" /> <meta name="application-name" content="Moshing Mammut" />
<meta name="msapplication-TileColor" content="#2e0b78" /> <meta name="msapplication-TileColor" content="#2e0b78" />
<meta
name="description"
content="A collection of music recommendations and now-listenings by the users of metalhead.club"
/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#17063b" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#17063b" media="(prefers-color-scheme: dark)" />

View File

@ -39,7 +39,7 @@
<picture> <picture>
{@html sourceSetHtml} {@html sourceSetHtml}
<img src={account.avatar} alt={avatarDescription} loading="lazy" /> <img src={account.avatar} alt={avatarDescription} loading="lazy" width="50" height="50" />
</picture> </picture>
<style> <style>

View File

@ -15,11 +15,39 @@
dateCreated = relativeTime($timePassed) ?? absoluteDate; dateCreated = relativeTime($timePassed) ?? absoluteDate;
} }
const songs = filterDuplicates(post.songs ?? []);
function filterDuplicates(songs: SongInfo[]): SongInfo[] {
return songs.filter((obj, index, arr) => {
return arr.map((mapObj) => mapObj.pageUrl).indexOf(obj.pageUrl) === index;
});
}
function getThumbnailSize(song: SongInfo): {
width?: number;
height?: number;
widthSmall?: number;
heightSmall?: number;
} {
if (song.thumbnailWidth === undefined || song.thumbnailHeight === undefined) {
return { width: undefined, height: undefined, widthSmall: undefined, heightSmall: undefined };
}
const factor = 200 / song.thumbnailWidth;
const smallFactor = 60 / song.thumbnailHeight;
const height = song.thumbnailHeight * factor;
return {
width: 200,
height: height,
widthSmall: smallFactor * song.thumbnailWidth,
heightSmall: 60
};
}
// Blurred thumbs aren't generated (yet, unclear of they ever will) // Blurred thumbs aren't generated (yet, unclear of they ever will)
// So blurred forces using the small one, by skipping the others and removing its media query. // So blurred forces using the small one, by skipping the others and removing its media query.
// This is technically unnecessary - the blurred one will only show if it matches the small media query, // This is technically unnecessary - the blurred one will only show if it matches the small media query,
// but this makes it more explicit // but this makes it more explicit
function getSourceSetHtml(song: SongInfo, isBlurred: boolean = false): string { function getSourceSetHtml(song: SongInfo, isBlurred = false): string {
const small = new Map<string, string[]>(); const small = new Map<string, string[]>();
const large = new Map<string, string[]>(); const large = new Map<string, string[]>();
@ -58,15 +86,16 @@
} }
} }
let html = ''; let html = '';
const { width, height, widthSmall, heightSmall } = getThumbnailSize(song);
const mediaAttribute = isBlurred ? '' : 'media="(max-width: 650px)"'; const mediaAttribute = isBlurred ? '' : 'media="(max-width: 650px)"';
for (const entry of small.entries()) { for (const entry of small.entries()) {
const srcset = entry[1].join(', '); const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" ${mediaAttribute} />`; html += `<source srcset="${srcset}" type="${entry[0]}" ${mediaAttribute} width="${widthSmall}" height="${heightSmall}" />`;
} }
html += '\n'; html += '\n';
for (const entry of large.entries()) { for (const entry of large.entries()) {
const srcset = entry[1].join(', '); const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" />`; html += `<source srcset="${srcset}" type="${entry[0]}" width="${width}" height="${height}"/>`;
} }
return html; return html;
} }
@ -88,7 +117,7 @@
<div class="content">{@html post.content}</div> <div class="content">{@html post.content}</div>
<div class="song"> <div class="song">
{#if post.songs} {#if post.songs}
{#each post.songs as song (song.pageUrl)} {#each songs as song (song.pageUrl)}
<div class="info-wrapper"> <div class="info-wrapper">
<picture> <picture>
{@html getSourceSetHtml(song)} {@html getSourceSetHtml(song)}
@ -103,6 +132,8 @@
class="cover" class="cover"
alt="Cover for {song.artistName} - {song.title}" alt="Cover for {song.artistName} - {song.title}"
loading="lazy" loading="lazy"
width={song.thumbnailWidth}
height={song.thumbnailHeight}
/> />
</picture> </picture>
<span class="text">{song.artistName} - {song.title}</span> <span class="text">{song.artistName} - {song.title}</span>

View File

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

View File

@ -40,6 +40,8 @@ type SongRow = {
title?: string; title?: string;
artistName?: string; artistName?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
thumbnailWidth?: number;
thumbnailHeight?: number;
}; };
type AccountAvatarRow = { type AccountAvatarRow = {
@ -91,8 +93,8 @@ if (enableVerboseLog) {
}); });
} }
async function applyDbMigration(migration: Migration): Promise<void> { function applyDbMigration(migration: Migration): Promise<void> {
return new Promise(async (resolve, reject) => { return new Promise((resolve, reject) => {
db.exec(migration.statement, (err) => { db.exec(migration.statement, (err) => {
if (err !== null) { if (err !== null) {
log.error(`Failed to apply migration ${migration.name}`, err); log.error(`Failed to apply migration ${migration.name}`, err);
@ -110,7 +112,7 @@ async function applyMigration(migration: Migration) {
// so filtering won't help // so filtering won't help
const posts = await getPostsInternal(null, null, 10000); const posts = await getPostsInternal(null, null, 10000);
let current = 0; let current = 0;
let total = posts.length.toString().padStart(4, '0'); const total = posts.length.toString().padStart(4, '0');
for (const post of posts) { for (const post of posts) {
current++; current++;
if (post.songs && post.songs.length) { if (post.songs && post.songs.length) {
@ -304,6 +306,13 @@ function getMigrations(): Migration[] {
kind INTEGER NOT NULL, kind INTEGER NOT NULL,
FOREIGN KEY (song_thumbnailUrl) REFERENCES songs(thumbnailUrl) FOREIGN KEY (song_thumbnailUrl) REFERENCES songs(thumbnailUrl)
);` );`
},
{
id: 7,
name: 'song thumbnail size',
statement: `
ALTER TABLE songs ADD COLUMN thumbnailWidth INTEGER NULL;
ALTER TABLE songs ADD COLUMN thumbnailHeight INTEGER NULL;`
} }
]; ];
} }
@ -435,8 +444,8 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
for (const song of songs) { for (const song of songs) {
db.run( db.run(
` `
INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, title, artistName, thumbnailUrl, post_url) INSERT INTO songs (postedUrl, overviewUrl, type, youtubeUrl, title, artistName, thumbnailUrl, post_url, thumbnailWidth, thumbnailHeight)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[ [
song.postedUrl, song.postedUrl,
@ -446,7 +455,9 @@ function saveSongInfoData(postUrl: string, songs: SongInfo[]): Promise<void> {
song.title, song.title,
song.artistName, song.artistName,
song.thumbnailUrl, song.thumbnailUrl,
postUrl postUrl,
song.thumbnailWidth,
song.thumbnailHeight
], ],
(songErr) => { (songErr) => {
if (songErr !== null) { if (songErr !== null) {
@ -481,7 +492,10 @@ export async function savePost(post: Post, songs: SongInfo[]) {
await savePostTagData(post); await savePostTagData(post);
log.debug(`Saved ${post.tags.length} tag data ${post.url}`); log.debug(`Saved ${post.tags.length} tag data ${post.url}`);
await saveSongInfoData(post.url, songs); await saveSongInfoData(post.url, songs);
log.debug(`Saved ${songs.length} song info data ${post.url}`); log.debug(
`Saved ${songs.length} song info data ${post.url}`,
songs.map((s) => s.thumbnailHeight)
);
} }
function getPostData(filterQuery: string, params: FilterParameter): Promise<PostRow[]> { function getPostData(filterQuery: string, params: FilterParameter): Promise<PostRow[]> {
@ -534,17 +548,17 @@ function getTagData(postIdsParams: string, postIds: string[]): Promise<Map<strin
}); });
} }
function getSongData(postIdsParams: String, postIds: string[]): Promise<Map<string, SongInfo[]>> { function getSongData(postIdsParams: string, postIds: string[]): Promise<Map<string, SongInfo[]>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.all( db.all(
`SELECT post_url, songs.postedUrl, songs.overviewUrl, songs.type, songs.youtubeUrl, `SELECT post_url, songs.postedUrl, songs.overviewUrl, songs.type, songs.youtubeUrl,
songs.title, songs.artistName, songs.thumbnailUrl, songs.post_url songs.title, songs.artistName, songs.thumbnailUrl, songs.post_url, songs.thumbnailWidth, songs.thumbnailHeight
FROM songs FROM songs
WHERE post_url IN (${postIdsParams});`, WHERE post_url IN (${postIdsParams});`,
postIds, postIds,
(tagErr, tagRows: SongRow[]) => { (tagErr, tagRows: SongRow[]) => {
if (tagErr != null) { if (tagErr != null) {
log.error('Error loading post tags', tagErr); log.error('Error loading post songs', tagErr);
reject(tagErr); reject(tagErr);
return; return;
} }
@ -557,13 +571,16 @@ function getSongData(postIdsParams: String, postIds: string[]): Promise<Map<stri
title: item.title, title: item.title,
artistName: item.artistName, artistName: item.artistName,
thumbnailUrl: item.thumbnailUrl, thumbnailUrl: item.thumbnailUrl,
postedUrl: item.postedUrl postedUrl: item.postedUrl,
thumbnailHeight: item.thumbnailHeight,
thumbnailWidth: item.thumbnailWidth
} as SongInfo; } as SongInfo;
result.set(item.post_url, [...(result.get(item.post_url) || []), info]); result.set(item.post_url, [...(result.get(item.post_url) || []), info]);
return result; return result;
}, },
new Map() new Map()
); );
log.verbose('songMap', songMap);
resolve(songMap); resolve(songMap);
} }
); );

View File

@ -1,4 +1,9 @@
import { HASHTAG_FILTER, MASTODON_INSTANCE, ODESLI_API_KEY } from '$env/static/private'; import {
HASHTAG_FILTER,
MASTODON_INSTANCE,
ODESLI_API_KEY,
YOUTUBE_API_KEY
} from '$env/static/private';
import { log } from '$lib/log'; import { log } from '$lib/log';
import type { import type {
Account, Account,
@ -27,10 +32,55 @@ import sharp from 'sharp';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
const URL_REGEX = new RegExp(/href="(?<postUrl>[^>]+?)" target="_blank"/gm); const URL_REGEX = new RegExp(/href="(?<postUrl>[^>]+?)" target="_blank"/gm);
const INVIDIOUS_REGEX = new RegExp(/invidious.*?watch.*?v=(?<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;
private static async isMusicVideo(videoId: string) {
if (!YOUTUBE_API_KEY || YOUTUBE_API_KEY === 'CHANGE_ME') {
// Assume that it *is* a music link when no YT API key is provided
return true;
}
const searchParams = new URLSearchParams([
['part', 'snippet'],
['id', videoId],
['key', YOUTUBE_API_KEY]
]);
const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`);
const resp = await fetch(youtubeVideoUrl);
const respObj = await resp.json();
if (!respObj.items.length) {
console.warn('Could not find video with id', videoId);
return false;
}
const item = respObj.items[0];
if (!item.snippet) {
console.warn('Could not load snippet for video', videoId, item);
return false;
}
if (item.snippet.tags?.includes('music')) {
return true;
}
const categorySearchParams = new URLSearchParams([
['part', 'snippet'],
['id', item.snippet.categoryId],
['key', YOUTUBE_API_KEY]
]);
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]?.snippet?.title);
return categoryTitle === 'Music';
}
public static async getSongInfoInPost(post: Post): Promise<SongInfo[]> { public static async getSongInfoInPost(post: Post): Promise<SongInfo[]> {
const urlMatches = post.content.matchAll(URL_REGEX); const urlMatches = post.content.matchAll(URL_REGEX);
const songs: SongInfo[] = []; const songs: SongInfo[] = [];
@ -71,8 +121,12 @@ export class TimelineReader {
return null; return null;
} }
const videoId = INVIDIOUS_REGEX.exec(url.href)?.groups?.videoId;
const urlString =
videoId !== undefined ? `https://youtube.com/watch?v=${videoId}` : url.toString();
const odesliParams = new URLSearchParams(); const odesliParams = new URLSearchParams();
odesliParams.append('url', url.toString()); odesliParams.append('url', urlString);
odesliParams.append('userCountry', 'DE'); odesliParams.append('userCountry', 'DE');
odesliParams.append('songIfSingle', 'true'); odesliParams.append('songIfSingle', 'true');
if (ODESLI_API_KEY && ODESLI_API_KEY !== 'CHANGE_ME') { if (ODESLI_API_KEY && ODESLI_API_KEY !== 'CHANGE_ME') {
@ -80,23 +134,37 @@ 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 {
return fetch(odesliApiUrl).then(async (response) => { const response = await fetch(odesliApiUrl);
if (response.status === 429) { if (response.status === 429) {
throw new Error('Rate limit reached', { cause: 429 }); throw new Error('Rate limit reached', { cause: 429 });
} }
const odesliInfo: OdesliResponse = await response.json(); const odesliInfo: OdesliResponse = await response.json();
if (!odesliInfo || !odesliInfo.entitiesByUniqueId || !odesliInfo.entityUniqueId) { if (!odesliInfo || !odesliInfo.entitiesByUniqueId || !odesliInfo.entityUniqueId) {
return null;
}
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
const platform: Platform = 'youtube';
if (info.platforms.includes(platform)) {
const youtubeId =
videoId ??
YOUTUBE_REGEX.exec(url.href)?.groups?.videoId ??
new URL(odesliInfo.pageUrl).pathname.split('/y/').pop();
if (youtubeId === undefined) {
log.warn('Looks like a youtube video, but could not extract a video id', url, odesliInfo);
return null; return null;
} }
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId]; const isMusic = await TimelineReader.isMusicVideo(youtubeId);
const platform: Platform = 'youtube'; if (!isMusic) {
return { log.debug('Probably not a music video', url);
...info, return null;
pageUrl: odesliInfo.pageUrl, }
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url, }
postedUrl: url.toString() return {
} as SongInfo; ...info,
}); pageUrl: odesliInfo.pageUrl,
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
postedUrl: url.toString()
} as SongInfo;
} catch (e) { } catch (e) {
if (e instanceof Error && e.cause === 429) { if (e instanceof Error && e.cause === 429) {
log.warn('song.link rate limit reached. Trying again in 10 seconds'); log.warn('song.link rate limit reached. Trying again in 10 seconds');

View File

@ -11,15 +11,14 @@ const config = {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter. // If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter() adapter: adapter(),
},
csp: { csp: {
directives: { directives: {
'script-src': ['self'] 'script-src': ['self', 'unsafe-inline'],
}, 'base-uri': ['self'],
reportOnly: { 'object-src': ['none']
'script-src': ['self'] }
} }
} }
}; };