Convert and resize avatars to fit the displayed images

This commit is contained in:
2023-05-02 17:31:16 +02:00
parent fbaedaf45b
commit 736b8498af
10 changed files with 797 additions and 84 deletions

View File

@ -1,6 +1,7 @@
import { log } from '$lib/log';
import { TimelineReader } from '$lib/server/timeline';
import type { HandleServerError } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import fs from 'fs/promises';
TimelineReader.init();
@ -27,6 +28,35 @@ export const handle = (async ({ event, resolve }) => {
return new Response(f, { headers: [['Content-Type', 'application/atom+xml']] });
}
// Ideally, this would be served by apache
if (event.url.pathname.startsWith('/avatars/')) {
const fileName = event.url.pathname.split('/').pop() ?? 'unknown.jpeg';
const suffix = fileName.split('.').pop() ?? 'jpeg';
try {
//This should work, but doesn't yet. See: https://github.com/nodejs/node/issues/45853
/*
const stat = await fs.stat('avatars/' + fileName);
const fd = await fs.open('avatars/' + fileName);
const readStream = fd
.readableWebStream()
.getReader({ mode: 'byob' }) as ReadableStream<Uint8Array>;
log.info('sending. size: ', stat.size);
return new Response(readStream, {
headers: [
['Content-Type', 'image/' + suffix],
['Content-Length', stat.size.toString()]
]
});
*/
const f = await fs.readFile('avatars/' + fileName);
return new Response(f, { headers: [['Content-Type', 'image/' + suffix]] });
} catch (e) {
log.error('no stream', e);
throw error(404);
}
}
const response = await resolve(event);
return response;
}) satisfies Handle;

View File

@ -3,10 +3,29 @@
export let account: Account;
let avatarDescription: string;
let sourceSetHtml: string;
$: avatarDescription = `Avatar for ${account.acct}`;
$: {
const m = new Map<string, string[]>();
for (const resizedAvatar of account.resizedAvatars ?? []) {
const extension = resizedAvatar.file.split('.').pop();
const mime = extension ? `image/${extension}` : 'application/octet-stream';
const sourceSetEntry = `${resizedAvatar.file} ${resizedAvatar.sizeDescriptor}`;
m.set(mime, [...(m.get(mime) || []), sourceSetEntry]);
}
let html = '';
for (const entry of m.entries()) {
const srcset = entry[1].join(', ');
html += `<source srcset="${srcset}" type="${entry[0]}" />`;
}
sourceSetHtml = html;
}
</script>
<img src={account.avatar} alt={avatarDescription} />
<picture>
{@html sourceSetHtml}
<img src={account.avatar} alt={avatarDescription} loading="lazy" />
</picture>
<style>
img {

View File

@ -33,13 +33,14 @@
{#if post.songs}
{#each post.songs as song (song.pageUrl)}
<div class="info-wrapper">
<div class="bgimage" style="background-image: url({song.thumbnailUrl});" />
<img class="bgimage" src={song.thumbnailUrl} loading="lazy" alt="Blurred cover" />
<a href={song.pageUrl ?? song.postedUrl} target="_blank">
<div class="info">
<img
src={song.thumbnailUrl}
class="cover"
alt="Cover for {song.artistName} - {song.title}"
loading="lazy"
/>
<span class="text">{song.artistName} - {song.title}</span>
</div>

View File

@ -37,4 +37,11 @@ export interface Account {
display_name: string;
url: string;
avatar: string;
resizedAvatars?: AccountAvatar[];
}
export type AccountAvatar = {
accountUrl: string;
file: string;
sizeDescriptor: string;
};

View File

@ -1,14 +1,12 @@
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, AccountAvatar, Post, Tag } from '$lib/mastodon/response';
import type { SongInfo } from '$lib/odesliResponse';
import { TimelineReader } from '$lib/server/timeline';
import sqlite3 from 'sqlite3';
const { DEV } = import.meta.env;
type FilterParameter = {
$limit: number | undefined | null;
$limit?: number | undefined | null;
$since?: string | undefined | null;
$before?: string | undefined | null;
[x: string]: string | number | undefined | null;
@ -44,6 +42,12 @@ type SongRow = {
thumbnailUrl?: string;
};
type AccountAvatarRow = {
account_url: string;
file: string;
sizeDescriptor: string;
};
type Migration = {
id: number;
name: string;
@ -270,6 +274,17 @@ function getMigrations(): Migration[] {
id: 4,
name: 'song info for existing posts',
statement: ``
},
{
id: 5,
name: 'resized avatars',
statement: `
CREATE TABLE accountsavatars (
file TEXT NOT NULL PRIMARY KEY,
account_url TEXT NOT NULL,
sizeDescriptor TEXT NOT NULL,
FOREIGN KEY (account_url) REFERENCES accounts(url)
);`
}
];
}
@ -278,13 +293,9 @@ async function waitReady(): Promise<void> {
// Simpler than a semaphore and is really only needed on startup
return new Promise((resolve) => {
const interval = setInterval(() => {
if (DEV) {
log.debug('Waiting for database to be ready');
}
log.verbose('Waiting for database to be ready');
if (databaseReady) {
if (DEV) {
log.debug('DB is ready');
}
log.verbose('DB is ready');
clearInterval(interval);
resolve();
}
@ -540,6 +551,40 @@ function getSongData(postIdsParams: String, postIds: string[]): Promise<Map<stri
});
}
function getAvatarData(
accountUrlsParams: String,
accountUrls: string[]
): Promise<Map<string, AccountAvatar[]>> {
return new Promise((resolve, reject) => {
db.all(
`SELECT account_url, file, sizeDescriptor
FROM accountsavatars
WHERE account_url IN (${accountUrlsParams});`,
accountUrls,
(err, rows: AccountAvatarRow[]) => {
if (err != null) {
log.error('Error loading avatars', err);
reject(err);
return;
}
const avatarMap: Map<string, AccountAvatar[]> = rows.reduce(
(result: Map<string, AccountAvatar[]>, item) => {
const info: AccountAvatar = {
accountUrl: item.account_url,
file: item.file,
sizeDescriptor: item.sizeDescriptor
};
result.set(item.account_url, [...(result.get(item.account_url) || []), info]);
return result;
},
new Map()
);
resolve(avatarMap);
}
);
});
}
export async function getPosts(
since: string | null,
before: string | null,
@ -589,6 +634,11 @@ async function getPostsInternal(
const postIds = rows.map((r: PostRow) => r.url);
const tagMap = await getTagData(postIdsParams, postIds);
const songMap = await getSongData(postIdsParams, postIds);
const accountUrls = [...new Set(rows.map((r: PostRow) => r.account_url))];
const accountUrlsParams = accountUrls.map(() => '?').join(', ');
const avatars = await getAvatarData(accountUrlsParams, accountUrls);
const posts = rows.map((row) => {
return {
id: row.id,
@ -602,10 +652,96 @@ async function getPostsInternal(
username: row.username,
display_name: row.display_name,
url: row.account_url,
avatar: row.avatar
avatar: row.avatar,
resizedAvatars: avatars.get(row.account_url) || []
} as Account,
songs: songMap.get(row.url) || []
} as Post;
});
return posts;
}
export async function removeAvatars(accountUrl: string): Promise<void> {
const params: FilterParameter = { $account: accountUrl };
const sql = `
DELETE
FROM accountsavatars
WHERE account_url = $account`;
await waitReady();
return new Promise((resolve, reject) => {
db.run(sql, params, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
export async function saveAvatar(avatar: AccountAvatar): Promise<void> {
// Will be null if file already existed
if (avatar.file === null) {
return;
}
const params: FilterParameter = {
$accountUrl: avatar.accountUrl,
$file: avatar.file,
$sizeDescriptor: avatar.sizeDescriptor
};
const sql = `
INSERT INTO accountsavatars
(account_url, file, sizeDescriptor) VALUES ($accountUrl, $file, $sizeDescriptor)
ON CONFLICT(file) DO UPDATE SET
account_url=excluded.account_url,
sizeDescriptor=excluded.sizeDescriptor;`;
await waitReady();
return new Promise((resolve, reject) => {
db.run(sql, params, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
export async function getAvatars(
accountUrl: string,
limit: number | undefined
): Promise<AccountAvatar[]> {
// TODO: Refactor to use `getAvatarData`
await waitReady();
let limitFilter = '';
const params: FilterParameter = {
$account: accountUrl,
$limit: 100
};
if (limit !== undefined) {
limitFilter = 'LIMIT $limit';
params.$limit = limit;
}
const sql = `
SELECT account_url, file, sizeDescriptor
FROM accountsavatars
WHERE account_url = $account
${limitFilter};`;
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows: AccountAvatarRow[]) => {
if (err) {
reject(err);
return;
}
resolve(
rows.map((r) => {
return {
accountUrl: r.account_url,
file: r.file,
sizeDescriptor: r.sizeDescriptor
} as AccountAvatar;
})
);
});
});
}

View File

@ -1,10 +1,12 @@
import { HASHTAG_FILTER, MASTODON_INSTANCE, ODESLI_API_KEY } from '$env/static/private';
import { log } from '$lib/log';
import type { Post, Tag, TimelineEvent } from '$lib/mastodon/response';
import type { Account, AccountAvatar, Post, Tag, TimelineEvent } from '$lib/mastodon/response';
import type { OdesliResponse, Platform, SongInfo } from '$lib/odesliResponse';
import { getPosts, savePost } from '$lib/server/db';
import { getAvatars, getPosts, removeAvatars, saveAvatar, savePost } from '$lib/server/db';
import { createFeed, saveAtomFeed } from '$lib/server/rss';
import { sleep } from '$lib/sleep';
import fs from 'fs/promises';
import sharp from 'sharp';
import { WebSocket } from 'ws';
const URL_REGEX = new RegExp(/href="(?<postUrl>[^>]+?)" target="_blank"/gm);
@ -89,6 +91,144 @@ export class TimelineReader {
}
}
private static async resizeAvatar(
baseName: string,
size: number,
suffix: string,
sharpAvatar: sharp.Sharp
): Promise<string | null> {
const fileName = `avatars/${baseName}_${suffix}`;
const exists = await fs
.access(fileName, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
if (exists) {
log.debug('File already exists', fileName);
return null;
}
log.debug('Saving avatar', fileName);
await sharpAvatar.resize(size).toFile(fileName);
return fileName;
}
private static async saveAvatar(account: Account) {
try {
const existingAvatars = await getAvatars(account.url, 1);
const existingAvatarBase = existingAvatars.shift()?.file.split('/').pop()?.split('_').shift();
const avatarFilenameBase =
new URL(account.avatar).pathname.split('/').pop()?.split('.').shift() ?? account.acct;
// User's avatar changed. Remove the old one!
if (existingAvatarBase && existingAvatarBase !== avatarFilenameBase) {
await removeAvatars(account.url);
const avatarsToDelete = (await fs.readdir('avatars'))
.filter((x) => x.startsWith(existingAvatarBase + '_'))
.map((x) => {
log.debug('Removing existing avatar file', x);
return x;
})
.map((x) => fs.unlink('avatars/' + x));
await Promise.allSettled(avatarsToDelete);
}
const avatarResponse = await fetch(account.avatar);
const avatar = await avatarResponse.arrayBuffer();
const sharpAvatar = sharp(avatar);
await Promise.all([
TimelineReader.resizeAvatar(avatarFilenameBase, 50, '1x.webp', sharpAvatar)
.then(
(fn) =>
({
accountUrl: account.url,
file: fn,
sizeDescriptor: '1x'
} as AccountAvatar)
)
.then(saveAvatar),
TimelineReader.resizeAvatar(avatarFilenameBase, 100, '2x.webp', sharpAvatar)
.then(
(fn) =>
({
accountUrl: account.url,
file: fn,
sizeDescriptor: '2x'
} as AccountAvatar)
)
.then(saveAvatar),
TimelineReader.resizeAvatar(avatarFilenameBase, 150, '3x.webp', sharpAvatar)
.then(
(fn) =>
({
accountUrl: account.url,
file: fn,
sizeDescriptor: '3x'
} as AccountAvatar)
)
.then(saveAvatar),
TimelineReader.resizeAvatar(avatarFilenameBase, 50, '1x.avif', sharpAvatar)
.then(
(fn) =>
({
accountUrl: account.url,
file: fn,
sizeDescriptor: '1x'
} as AccountAvatar)
)
.then(saveAvatar),
TimelineReader.resizeAvatar(avatarFilenameBase, 100, '2x.avif', sharpAvatar)
.then(
(fn) =>
({
accountUrl: account.url,
file: fn,
sizeDescriptor: '2x'
} as AccountAvatar)
)
.then(saveAvatar),
TimelineReader.resizeAvatar(avatarFilenameBase, 150, '3x.avif', sharpAvatar)
.then(
(fn) =>
({
accountUrl: account.url,
file: fn,
sizeDescriptor: '3x'
} as AccountAvatar)
)
.then(saveAvatar),
TimelineReader.resizeAvatar(avatarFilenameBase, 50, '1x.jpeg', sharpAvatar)
.then((fn) => {
return {
accountUrl: account.url,
file: fn,
sizeDescriptor: '1x'
} as AccountAvatar;
})
.then(saveAvatar),
TimelineReader.resizeAvatar(avatarFilenameBase, 100, '2x.jpeg', sharpAvatar)
.then(
(fn) =>
({
accountUrl: account.url,
file: fn,
sizeDescriptor: '2x'
} as AccountAvatar)
)
.then(saveAvatar),
TimelineReader.resizeAvatar(avatarFilenameBase, 150, '3x.jpeg', sharpAvatar)
.then(
(fn) =>
({
accountUrl: account.url,
file: fn,
sizeDescriptor: '3x'
} as AccountAvatar)
)
.then(saveAvatar)
]);
} catch (e) {
console.error('Could not resize and save avatar for', account.acct, account.avatar, e);
}
}
private startWebsocket() {
const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`);
socket.onopen = () => {
@ -102,6 +242,10 @@ export class TimelineReader {
return;
}
const post: Post = JSON.parse(data.payload);
// TODO: Move down after post has been confirmed after testing
await TimelineReader.saveAvatar(post.account);
const hashttags: string[] = HASHTAG_FILTER.split(',');
const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name));