Compare commits

...

5 commits

Author SHA1 Message Date
Amelia Yukii
4ade52ce71 merge: Built-in video thumbnail generator (for remote video thumbnails without needing "Cache remote media" enabled) (!310)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/310
2024-04-08 00:57:23 +00:00
ShittyKopper
634259d600 upd: add config option to enable builtin video thumbnail generator 2024-01-05 13:35:53 +03:00
ShittyKopper
a3c302e756 upd: introduce a separate ./cache dir and cache video thumbnails there 2024-01-05 13:35:53 +03:00
ShittyKopper
bc24e6a294 upd: use builtin thumbnail generator if no thumbnail generator is set 2024-01-05 13:35:42 +03:00
ShittyKopper
1cd59c1ee3 feat: initial builtin video thumbnail generator implementation
when you disable "cache external media", video thumbnails off of remote
instances do not get generated. misskey has a videoThumbnailGenerator config
option to point to an external service to make that happen, but they do
not provide any kind of implementation (or any documentation beyond a
comment on the config file)

this provides a video thumbnail generator that uses the same thumbnail
generation code path used in local files, providing a quick and dirty
solution to instances that want video thumbnails without the need to store
external media permanently

the eventual goal of this is to be the fallback implementation when that
config option is unset.

the current implementation is extremely bare bones and performs no caching
or any other optimizations whatsoever
2024-01-04 20:55:12 +03:00
8 changed files with 120 additions and 3 deletions

View file

@ -255,6 +255,11 @@ proxyRemoteFiles: true
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
#videoThumbnailGenerator: https://example.com
# Enables the built-in thumbnail generator for remote videos. (default: false)
# Only useful if "Cache remote files" is disabled, and "videoThumbnailGenerator" is unset.
# Without it, remote video files that are not cached will not have any thumbnails.
#enableBuiltinVideoThumbnailGenerator: false
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true
# check that inbound ActivityPub GET requests are signed ("authorized fetch")

View file

@ -270,6 +270,11 @@ proxyRemoteFiles: true
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
#videoThumbnailGenerator: https://example.com
# Enables the built-in thumbnail generator for remote videos. (default: false)
# Only useful if "Cache remote files" is disabled, and "videoThumbnailGenerator" is unset.
# Without it, remote video files that are not cached will not have any thumbnails.
#enableBuiltinVideoThumbnailGenerator: false
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true
# check that inbound ActivityPub GET requests are signed ("authorized fetch")

1
.gitignore vendored
View file

@ -55,6 +55,7 @@ api-docs.json
*.code-workspace
.DS_Store
/files
/cache
ormconfig.json
temp
/packages/frontend/src/**/*.stories.ts

View file

@ -21,6 +21,7 @@ services:
- shonk
volumes:
- ./files:/sharkey/files
- ./cache:/sharkey/cache
- ./.config:/sharkey/.config:ro
redis:

View file

@ -88,6 +88,7 @@ type Source = {
mediaProxy?: string;
proxyRemoteFiles?: boolean;
videoThumbnailGenerator?: string;
enableBuiltinVideoThumbnailGenerator?: boolean;
customMOTD?: string[];
@ -170,6 +171,7 @@ export type Config = {
mediaProxy: string;
externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null;
enableBuiltinVideoThumbnailGenerator: boolean;
redis: RedisOptions & RedisOptionsSource;
redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource;
@ -276,6 +278,7 @@ export function loadConfig(): Config {
videoThumbnailGenerator: config.videoThumbnailGenerator ?
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
: null,
enableBuiltinVideoThumbnailGenerator: config.enableBuiltinVideoThumbnailGenerator ?? false,
userAgent: `Misskey/${version} (${config.url})`,
clientEntry: clientManifest['src/_boot_.ts'],
clientManifestExists: clientManifestExists,

View file

@ -16,6 +16,7 @@ const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const path = Path.resolve(_dirname, '../../../../files');
const cachePath = Path.resolve(_dirname, '../../../../cache');
@Injectable()
export class InternalStorageService {
@ -30,11 +31,26 @@ export class InternalStorageService {
return Path.resolve(path, key);
}
@bindThis
public resolveCachePath(key: string) {
return Path.resolve(cachePath, key);
}
@bindThis
public existsCache(key: string) {
return fs.existsSync(this.resolveCachePath(key));
}
@bindThis
public read(key: string) {
return fs.createReadStream(this.resolvePath(key));
}
@bindThis
public readCache(key: string) {
return fs.createReadStream(this.resolveCachePath(key));
}
@bindThis
public saveFromPath(key: string, srcPath: string) {
fs.mkdirSync(path, { recursive: true });
@ -49,8 +65,19 @@ export class InternalStorageService {
return `${this.config.url}/files/${key}`;
}
@bindThis
public saveCacheFromBuffer(key: string, data: Buffer) {
fs.mkdirSync(cachePath, { recursive: true });
fs.writeFileSync(this.resolveCachePath(key), data);
}
@bindThis
public del(key: string) {
fs.unlink(this.resolvePath(key), () => {});
}
@bindThis
public delCache(key: string) {
fs.unlink(this.resolveCachePath(key), () => {});
}
}

View file

@ -50,7 +50,16 @@ export class VideoProcessingService {
@bindThis
public getExternalVideoThumbnailUrl(url: string): string | null {
if (this.config.videoThumbnailGenerator == null) return null;
if (this.config.videoThumbnailGenerator == null) {
if (this.config.enableBuiltinVideoThumbnailGenerator) {
return appendQuery(
`${this.config.url}/proxy/thumbnail.webp`,
query({ url }),
);
}
return null;
}
return appendQuery(
`${this.config.videoThumbnailGenerator}/thumbnail.webp`,

View file

@ -4,6 +4,7 @@
*/
import * as fs from 'node:fs';
import * as crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common';
@ -34,6 +35,7 @@ const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const assets = `${_dirname}/../../server/file/assets/`;
const cacheDir = `${_dirname}/../../../../cache/`;
@Injectable()
export class FileServerService {
@ -85,6 +87,15 @@ export class FileServerService {
done();
});
if (this.config.enableBuiltinVideoThumbnailGenerator) {
fastify.get<{
Querystring: { url: string; };
}>('/proxy/thumbnail.webp', async (request, reply) => {
return await this.videoThumbnailHandler(request, reply)
.catch(err => this.errorHandler(request, reply, err));
});
}
fastify.get<{
Params: { url: string; };
Querystring: { url?: string; };
@ -466,6 +477,61 @@ export class FileServerService {
}
}
@bindThis
private async videoThumbnailHandler(request: FastifyRequest<{ Querystring: { url: string; }; }>, reply: FastifyReply) {
const cacheKey = crypto.createHash('md5').update(request.query.url).digest('base64url');
const cacheFile = `videoThumbnail-${cacheKey}.webp`;
if (this.internalStorageService.existsCache(cacheFile)) {
reply.header('Content-Type', 'image/webp');
reply.header('Cache-Control', 'max-age=31536000, immutable');
return reply.sendFile(cacheFile, cacheDir);
}
const file = await this.getStreamAndTypeFromUrl(request.query.url);
if (file === '404') {
reply.code(404);
reply.header('Cache-Control', 'max-age=86400');
return reply.sendFile('/dummy.png', assets);
}
if (file === '204') {
reply.code(204);
reply.header('Cache-Control', 'max-age=86400');
return;
}
if (file.file?.thumbnailUrl) {
return await reply.redirect(301, file.file.thumbnailUrl);
}
if (!file.mime.startsWith('video/')) {
if ('cleanup' in file) {
file.cleanup();
}
reply.code(400);
return;
}
try {
const image = await this.videoProcessingService.generateVideoThumbnail(file.path);
if ('cleanup' in file) {
file.cleanup();
}
this.internalStorageService.saveCacheFromBuffer(cacheFile, image.data);
reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable');
return image.data;
} catch (e) {
if ('cleanup' in file) file.cleanup();
throw e;
}
}
@bindThis
private async getStreamAndTypeFromUrl(url: string): Promise<
{ state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }