Convert and resize avatars to fit the displayed images
This commit is contained in:
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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));
|
||||
|
||||
|
Reference in New Issue
Block a user