feat: make author clickable
This commit is contained in:
95
lib/cache/image.ts
vendored
95
lib/cache/image.ts
vendored
@ -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 },
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -37,6 +37,7 @@ export type Recipe = {
|
||||
rating?: number;
|
||||
portion?: number;
|
||||
author?: string;
|
||||
average?: string;
|
||||
thumbnail?: string;
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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");
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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) {
|
||||
|
12
lib/types.ts
12
lib/types.ts
@ -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;
|
||||
|
Reference in New Issue
Block a user