feat: make author clickable

This commit is contained in:
2023-08-12 18:32:56 +02:00
parent d2a02fcf34
commit 2b4173d759
27 changed files with 257 additions and 174 deletions

95
lib/cache/image.ts vendored
View File

@ -1,4 +1,4 @@
import { hash, isLocalImage } from "@lib/string.ts";
import { hash, isLocalImage, rgbToHex } from "@lib/string.ts";
import * as cache from "@lib/cache/cache.ts";
import {
ImageMagick,
@ -8,32 +8,45 @@ import { createLogger } from "@lib/log.ts";
import { generateThumbhash } from "@lib/thumbhash.ts";
import { SILVERBULLET_SERVER } from "@lib/env.ts";
type ImageCacheOptions = {
type ImageCacheOptionsBasic = {
url: string;
mediaType?: string;
};
interface ImageCacheOptionsDimensions extends ImageCacheOptionsBasic {
width: number;
height: number;
mediaType?: string;
suffix?: string;
};
}
interface ImageCacheOptionsSuffix extends ImageCacheOptionsBasic {
suffix: string;
}
type ImageCacheOptions = ImageCacheOptionsDimensions | ImageCacheOptionsSuffix;
const CACHE_KEY = "images";
const log = createLogger("cache/image");
function getCacheKey(
{ url: _url, width, height, suffix }: ImageCacheOptions,
opts: ImageCacheOptions,
) {
const isLocal = isLocalImage(_url);
const url = new URL(isLocal ? `${SILVERBULLET_SERVER}/${_url}` : _url);
const isLocal = isLocalImage(opts.url);
const url = new URL(
isLocal ? `${SILVERBULLET_SERVER}/${opts.url}` : opts.url,
);
const _suffix = suffix || `${width}:${height}`;
const _suffix = "suffix" in opts
? opts.suffix
: `${opts.width}:${opts.height}`;
return `${CACHE_KEY}:${url.hostname}:${
const cacheId = `${CACHE_KEY}:${url.hostname}:${
url.pathname.replaceAll("/", ":")
}:${_suffix}`
.replace(
"::",
":",
);
return cacheId;
}
export function createThumbhash(
@ -53,7 +66,21 @@ export function createThumbhash(
"RGBA",
);
if (!bytes) return;
const hash = generateThumbhash(bytes, _image.width, _image.height);
const [hash, average] = generateThumbhash(
bytes,
_image.width,
_image.height,
);
if (average) {
cache.set(
getCacheKey({
url,
suffix: "average",
}),
rgbToHex(average.r, average.g, average.b),
);
}
if (hash) {
const b64 = btoa(String.fromCharCode(...hash));
@ -61,8 +88,6 @@ export function createThumbhash(
getCacheKey({
url,
suffix: "thumbnail",
width: _image.width,
height: _image.height,
}),
b64,
);
@ -91,18 +116,26 @@ function verifyImage(
}
export function getThumbhash({ url }: { url: string }) {
return cache.get<Uint8Array>(
getCacheKey({
url,
suffix: "thumbnail",
width: 200,
height: 200,
}),
return Promise.all(
[
cache.get<Uint8Array>(
getCacheKey({
url,
suffix: "thumbnail",
}),
),
cache.get<string>(
getCacheKey({
url,
suffix: "average",
}),
),
] as const,
);
}
export async function getImage({ url, width, height }: ImageCacheOptions) {
const cacheKey = getCacheKey({ url, width, height });
export async function getImage(opts: ImageCacheOptions) {
const cacheKey = getCacheKey(opts);
const pointerCacheRaw = await cache.get<string>(cacheKey);
if (!pointerCacheRaw) return;
@ -122,31 +155,29 @@ export async function getImage({ url, width, height }: ImageCacheOptions) {
export async function setImage(
buffer: Uint8Array,
{ url, width, height, mediaType }: ImageCacheOptions,
opts: ImageCacheOptions,
) {
const clone = new Uint8Array(buffer);
const imageCorrect = await verifyImage(clone);
if (!imageCorrect) {
log.info("failed to store image", { url });
log.info("failed to store image", { url: opts.url });
return;
}
const cacheKey = getCacheKey({ url, width, height });
const cacheKey = getCacheKey(opts);
const pointerId = await hash(cacheKey);
await cache.set(`image:${pointerId}`, clone);
cache.expire(pointerId, 60 * 60 * 24);
cache.expire(cacheKey, 60 * 60 * 24);
await cache.set(`image:${pointerId}`, clone, { expires: 60 * 60 * 24 });
await cache.set(
cacheKey,
JSON.stringify({
id: pointerId,
url,
width,
height,
mediaType,
...("suffix" in opts
? { suffix: opts.suffix }
: { width: opts.width, height: opts.height }),
}),
{ expires: 60 * 60 * 24 },
);
}

View File

@ -22,12 +22,13 @@ export async function addThumbnailToResource<T = Resource>(
): Promise<T> {
const imageUrl = res?.meta?.image;
if (!imageUrl) return res;
const thumbhash = await getThumbhash({ url: imageUrl });
const [thumbhash, average] = await getThumbhash({ url: imageUrl });
if (!thumbhash) return res;
return {
...res,
meta: {
...res?.meta,
average: average,
thumbnail: thumbhash,
},
};

View File

@ -80,3 +80,26 @@ export class PromiseQueue {
return true;
}
}
export class ConcurrentPromiseQueue {
/**
* Eingereihte Promises.
*/
private queues: PromiseQueue[] = [];
constructor(concurrency: number) {
this.queues = Array.from({ length: concurrency }).map(() => {
return new PromiseQueue();
});
}
private queueIndex = 0;
private getQueue() {
this.queueIndex = (this.queueIndex + 1) % this.queues.length;
return this.queues[this.queueIndex];
}
public enqueue<T = void>(promise: () => Promise<T>): Promise<T> {
return this.getQueue().enqueue(promise);
}
}

View File

@ -17,18 +17,13 @@ export type Article = {
date: Date;
link: string;
thumbnail?: string;
average?: string;
image?: string;
author?: string;
rating?: number;
};
};
const crud = createCrud<Article>({
prefix: "Media/articles/",
parse: parseArticle,
hasThumbnails: true,
});
function renderArticle(article: Article) {
const meta = article.meta;
if ("date" in meta) {
@ -100,9 +95,12 @@ function parseArticle(original: string, id: string): Article {
};
}
const crud = createCrud<Article>({
prefix: "Media/articles/",
parse: parseArticle,
render: renderArticle,
hasThumbnails: true,
});
export const getAllArticles = crud.readAll;
export const getArticle = crud.read;
export const createArticle = (article: Article) => {
const content = renderArticle(article);
return crud.create(article.id, content);
};
export const createArticle = crud.create;

View File

@ -14,6 +14,7 @@ export type Movie = {
date: Date;
image: string;
thumbnail?: string;
average?: string;
author: string;
rating: number;
status: "not-seen" | "watch-again" | "finished";
@ -99,12 +100,10 @@ export function parseMovie(original: string, id: string): Movie {
const crud = createCrud<Movie>({
prefix: "Media/movies/",
parse: parseMovie,
render: renderMovie,
hasThumbnails: true,
});
export const getMovie = crud.read;
export const getAllMovies = crud.readAll;
export const createMovie = (movie: Movie) => {
const content = renderMovie(movie);
return crud.create(movie.id, content);
};
export const createMovie = crud.create;

View File

@ -37,6 +37,7 @@ export type Recipe = {
rating?: number;
portion?: number;
author?: string;
average?: string;
thumbnail?: string;
};
};

View File

@ -16,6 +16,7 @@ export type Series = {
image: string;
author: string;
rating: number;
average?: string;
thumbnail?: string;
status: "not-seen" | "watch-again" | "finished";
};
@ -100,25 +101,10 @@ export function parseSeries(original: string, id: string): Series {
const crud = createCrud<Series>({
prefix: "Media/series/",
parse: parseSeries,
render: renderSeries,
hasThumbnails: true,
});
export const getSeries = (id: string) =>
crud.read(id).then(async (serie) => {
const imageUrl = serie.meta?.image;
if (!imageUrl) return serie;
const thumbhash = await getThumbhash({ url: imageUrl });
if (!thumbhash) return serie;
return {
...serie,
meta: {
...serie.meta,
thumbnail: btoa(String.fromCharCode(...thumbhash)),
},
};
});
export const getSeries = crud.read;
export const getAllSeries = crud.readAll;
export const createSeries = (series: Series) => {
const content = renderSeries(series);
return crud.create(series.id, content);
};
export const createSeries = crud.create;

View File

@ -1,6 +1,6 @@
import { BadRequestError } from "@lib/errors.ts";
import { resources } from "@lib/resources.ts";
import { ResourceStatus } from "@lib/types.ts";
import { ResourceStatus, SearchResult } from "@lib/types.ts";
import { getTypeSenseClient } from "@lib/typesense.ts";
import { extractHashTags } from "@lib/string.ts";
@ -11,6 +11,7 @@ type SearchParams = {
type?: ResourceType;
tags?: string[];
status?: ResourceStatus;
author?: string;
query_by?: string;
};
@ -44,7 +45,7 @@ export function parseResourceUrl(_url: string): SearchParams | undefined {
export async function searchResource(
{ q, query_by = "name,description,author,tags", tags = [], type, status }:
SearchParams,
) {
): Promise<SearchResult> {
const typesenseClient = await getTypeSenseClient();
if (!typesenseClient) {
throw new Error("Query not available");

View File

@ -104,3 +104,15 @@ export const isLocalImage = (src: string) =>
export const isString = (input: string | undefined): input is string => {
return typeof input === "string";
};
function componentToHex(c: number) {
if (c <= 1) {
c = Math.round(c * 255);
}
const hex = c.toString(16);
return hex.length == 1 ? "0" + hex : hex;
}
export function rgbToHex(r: number, g: number, b: number) {
return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b);
}

View File

@ -1,7 +1,8 @@
import * as thumbhash from "https://esm.sh/thumbhash@0.1.1";
export function generateThumbhash(buffer: Uint8Array, w: number, h: number) {
return thumbhash.rgbaToThumbHash(w, h, buffer);
const hash = thumbhash.rgbaToThumbHash(w, h, buffer);
return [hash, thumbhash.thumbHashToAverageRGBA(hash)] as const;
}
export function generateDataURL(hash: string) {

View File

@ -34,6 +34,18 @@ export interface TMDBSeries {
vote_count: number;
}
export type GenericResource = {
name: string;
id: string;
type: keyof typeof resources;
meta?: {
image?: string;
author?: string;
average?: string;
thumbnail?: string;
};
};
export interface GiteaOauthUser {
sub: string;
name: string;