diff --git a/.gitignore b/.gitignore index de716d9..7465f0a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ config.json test/ downloads/ utt.lock +thumbnails/ diff --git a/config.ts b/config.ts index 67cff92..122c62b 100644 --- a/config.ts +++ b/config.ts @@ -20,6 +20,7 @@ export type ConfigType = { meili_update_api_key?: string; ffmpeg_path: string; thumbnail_method: ThumbnailMethod; + thumbnail_dir: string; }; export enum ThumbnailMethod { @@ -124,6 +125,9 @@ export class Config { if (n < 0 || n > 1) return ThumbnailMethod.FFMPEG_BINARY; return n as ThumbnailMethod; } + get thumbnail_dir() { + return this._return_string("thumbnail_dir") || "./thumbnails"; + } to_json(): ConfigType { return { cookies: typeof this.cookies === "string", @@ -144,6 +148,7 @@ export class Config { meili_update_api_key: this.meili_update_api_key, ffmpeg_path: this.ffmpeg_path, thumbnail_method: this.thumbnail_method, + thumbnail_dir: this.thumbnail_dir, }; } } diff --git a/db.ts b/db.ts index 68b60a0..4211626 100644 --- a/db.ts +++ b/db.ts @@ -1,5 +1,9 @@ import { DB } from "sqlite/mod.ts"; -import { compare as compare_ver, parse as parse_ver } from "std/semver/mod.ts"; +import { + compare as compare_ver, + format as format_ver, + parse as parse_ver, +} from "std/semver/mod.ts"; import { unescape } from "std/html/mod.ts"; import { join, resolve } from "std/path/mod.ts"; import { SqliteError } from "sqlite/mod.ts"; @@ -401,7 +405,7 @@ export class EhDb { this.db.transaction(() => { this.db.query("INSERT OR REPLACE INTO version VALUES (?, ?);", [ "eh", - this.version.toString(), + format_ver(this.version), ]); }); } diff --git a/fresh.gen.ts b/fresh.gen.ts index f9ed9e7..8f6e270 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -12,8 +12,10 @@ import * as $6 from "./routes/api/file/random.ts"; import * as $7 from "./routes/api/filemeta.ts"; import * as $8 from "./routes/api/filemeta/[token].ts"; import * as $9 from "./routes/api/gallery/[gid].ts"; -import * as $10 from "./routes/api/task.ts"; -import * as $11 from "./routes/index.tsx"; +import * as $10 from "./routes/api/status.ts"; +import * as $11 from "./routes/api/task.ts"; +import * as $12 from "./routes/api/thumbnail/[id].ts"; +import * as $13 from "./routes/index.tsx"; import * as $$0 from "./islands/Container.tsx"; import * as $$1 from "./islands/Settings.tsx"; import * as $$2 from "./islands/TaskManager.tsx"; @@ -30,8 +32,10 @@ const manifest = { "./routes/api/filemeta.ts": $7, "./routes/api/filemeta/[token].ts": $8, "./routes/api/gallery/[gid].ts": $9, - "./routes/api/task.ts": $10, - "./routes/index.tsx": $11, + "./routes/api/status.ts": $10, + "./routes/api/task.ts": $11, + "./routes/api/thumbnail/[id].ts": $12, + "./routes/index.tsx": $13, }, islands: { "./islands/Container.tsx": $$0, diff --git a/islands/Settings.tsx b/islands/Settings.tsx index b6844ca..40ed4ac 100644 --- a/islands/Settings.tsx +++ b/islands/Settings.tsx @@ -98,7 +98,7 @@ export default class Settings extends Component { set_changed={set_changed} set_settings={set_settings} > -
+
{ description={t("settings.export_zip_jpn_title")} />
-
+
+ + { type="text" outlined={true} /> - - - - +
{ helpertext={t("settings.db_path_help")} outlined={true} /> - { type="text" outlined={true} /> -
diff --git a/meilisearch.ts b/meilisearch.ts index 3fe0d55..620a41a 100644 --- a/meilisearch.ts +++ b/meilisearch.ts @@ -55,7 +55,9 @@ export class MeiliSearchServer { } #gallery_update(e: Event) { const ev = e as CustomEvent; - this.updateGallery(ev.detail); + this.updateGallery(ev.detail).catch((e) => { + console.log(e); + }); } async #updateGMetaSettings() { if (this.#gmeta) { diff --git a/page/GalleryPage.ts b/page/GalleryPage.ts index 9bcacf9..0a2abdf 100644 --- a/page/GalleryPage.ts +++ b/page/GalleryPage.ts @@ -1,6 +1,7 @@ import { DOMParser, Element } from "deno_dom/deno-dom-wasm-noinit.ts"; import { Client } from "../client.ts"; -import { initDOMParser, parse_bool } from "../utils.ts"; +import { initDOMParser, map, parse_bool } from "../utils.ts"; +import { parseUrl, UrlType } from "../url.ts"; class GalleryPage { dom; @@ -11,6 +12,7 @@ class GalleryPage { #meta_script: string | undefined = undefined; #gid: number | undefined = undefined; #token: string | undefined = undefined; + #new_version: Array<{ gid: number; token: string }> | undefined = undefined; constructor(html: string, client: Client) { const dom = (new DOMParser()).parseFromString(html, "text/html"); if (!dom) { @@ -86,6 +88,24 @@ class GalleryPage { if (!ele) throw Error("Failed to find gallery's name."); return ele.innerText; } + get new_version() { + if (this.#new_version === undefined) { + const eles = this.doc.querySelectorAll("#gnd > a"); + const d = <{ gid: number; token: string }[]> map(eles, (e) => { + const b = e as Element; + const u = b.getAttribute("href"); + if (!u) return null; + const d = parseUrl(u); + if (d?.type === UrlType.Gallery) { + return { gid: d.gid, token: d.token }; + } else { + return null; + } + }).filter((d) => d !== null); + this.#new_version = d; + return d; + } else return this.#new_version; + } get japanese_name() { return this.doc.getElementById("gj")?.innerText; } diff --git a/page/GalleryPage_test.ts b/page/GalleryPage_test.ts index 78e009e..9e23ada 100644 --- a/page/GalleryPage_test.ts +++ b/page/GalleryPage_test.ts @@ -36,4 +36,18 @@ Deno.test({ assertEquals(re.language, "Chinese"); assertEquals(re.gid, 2552611); assertEquals(re.token, "3132307627"); + assertEquals(re.new_version.length, 0); +}); + +Deno.test({ + name: "GalleryPage_test2", + permissions: API_PERMISSION, +}, async () => { + const cfg = await load_settings("./config.json"); + const client = new Client(cfg); + const re = await client.fetchGalleryPage(2209409, "8c8b2b1fc3"); + assertEquals(re.name, "[Fanbox] houk1se1 (2022.03.08 - 2022.05.01)"); + assertEquals(re.japanese_name, ""); + assertEquals(re.length, 42); + assertEquals(re.new_version[0], { gid: 2223198, token: "2a5788135e" }); }); diff --git a/routes/api/file/random.ts b/routes/api/file/random.ts index 0c68578..ebfd9de 100644 --- a/routes/api/file/random.ts +++ b/routes/api/file/random.ts @@ -8,8 +8,10 @@ export const handler: Handlers = { const u = new URL(req.url); const is_nsfw = await parse_bool(u.searchParams.get("is_nsfw"), null); const is_ad = await parse_bool(u.searchParams.get("is_ad"), null); + const thumb = await parse_bool(u.searchParams.get("thumb"), false); const f = m.db.get_random_file(is_nsfw, is_ad); if (!f) return new Response("File not found.", { status: 404 }); - return Response.redirect(`${u.origin}/api/file/${f.id}`); + const t = thumb ? "thumbnail" : "file"; + return Response.redirect(`${u.origin}/api/${t}/${f.id}`); }, }; diff --git a/routes/api/status.ts b/routes/api/status.ts new file mode 100644 index 0000000..56d2815 --- /dev/null +++ b/routes/api/status.ts @@ -0,0 +1,28 @@ +import { Handlers } from "$fresh/server.ts"; +import { get_task_manager } from "../../server.ts"; +import { StatusData } from "../../server/status.ts"; +import { return_data } from "../../server/utils.ts"; +import { check_ffmpeg_binary } from "../../thumbnail/ffmpeg_binary.ts"; + +export const handler: Handlers = { + async GET(_req, _ctx) { + const m = get_task_manager(); + const ffmpeg_binary_enabled = await check_ffmpeg_binary( + m.cfg.ffmpeg_path, + ); + const meilisearch_enabled = m.meilisearch !== undefined; + const meilisearch = meilisearch_enabled && m.cfg.meili_host && + m.cfg.meili_update_api_key + ? { + host: m.cfg.meili_host, + key: m.cfg.meili_search_api_key || + m.cfg.meili_update_api_key, + } + : undefined; + return return_data({ + ffmpeg_binary_enabled, + meilisearch_enabled, + meilisearch, + }); + }, +}; diff --git a/routes/api/thumbnail/[id].ts b/routes/api/thumbnail/[id].ts new file mode 100644 index 0000000..d62dcb6 --- /dev/null +++ b/routes/api/thumbnail/[id].ts @@ -0,0 +1,80 @@ +import { Handlers } from "$fresh/server.ts"; +import { exists } from "std/fs/exists.ts"; +import { get_task_manager } from "../../../server.ts"; +import { parse_bool, parse_int } from "../../../server/parse_form.ts"; +import { generate_filename, ThumbnailConfig } from "../../../thumbnail/base.ts"; +import { sure_dir } from "../../../utils.ts"; +import { ThumbnailMethod } from "../../../config.ts"; +import { fb_generate_thumbnail } from "../../../thumbnail/ffmpeg_binary.ts"; +import { + get_file_response, + GetFileResponseOptions, +} from "../../../server/get_file_response.ts"; + +export const handler: Handlers = { + async GET(req, ctx) { + const id = parseInt(ctx.params.id); + if (isNaN(id)) { + return new Response("Bad Request", { status: 400 }); + } + const m = get_task_manager(); + const b = m.cfg.thumbnail_dir; + const method = m.cfg.thumbnail_method; + await sure_dir(b); + const f = m.db.get_file(id); + if (!f) { + return new Response("File not found.", { status: 404 }); + } + const u = new URL(req.url); + const max = await parse_int(u.searchParams.get("max"), 1200); + const width = await parse_int(u.searchParams.get("width"), null); + const height = await parse_int(u.searchParams.get("height"), null); + const quality = await parse_int(u.searchParams.get("quality"), 1); + const force = await parse_bool(u.searchParams.get("force"), false); + const cfg: ThumbnailConfig = { width: 0, height: 0, quality }; + if (width !== null && height !== null) { + cfg.width = width; + cfg.height = height; + } else if (width !== null) { + cfg.width = width; + cfg.height = Math.round(f.height / f.width * width); + } else if (height !== null) { + cfg.height = height; + cfg.width = Math.round(f.width / f.height * height); + } else { + if (f.width > f.height) { + cfg.width = max; + cfg.height = Math.round(f.height / f.width * max); + } else { + cfg.height = max; + cfg.width = Math.round(f.width / f.height * max); + } + } + if (!force) { + if (cfg.width > f.width || cfg.height > f.height) { + return Response.redirect(`${u.origin}/api/file/${f.id}`); + } + } + const output = generate_filename(b, f, cfg); + if (!(await exists(output))) { + if (method === ThumbnailMethod.FFMPEG_BINARY) { + const re = await fb_generate_thumbnail( + m.cfg.ffmpeg_path, + f.path, + output, + cfg, + ); + if (!re) { + return new Response("Failed to generate thumbnail.", { + status: 500, + }); + } + } + } + const opts: GetFileResponseOptions = {}; + opts.range = req.headers.get("range"); + opts.if_modified_since = req.headers.get("If-Modified-Since"); + opts.if_unmodified_since = req.headers.get("If-Unmodified-Since"); + return await get_file_response(output, opts); + }, +}; diff --git a/server/status.ts b/server/status.ts new file mode 100644 index 0000000..5a88fca --- /dev/null +++ b/server/status.ts @@ -0,0 +1,8 @@ +export type StatusData = { + ffmpeg_binary_enabled: boolean; + meilisearch_enabled: boolean; + meilisearch?: { + host: string; + key: string; + }; +}; diff --git a/static/common.css b/static/common.css index bd7d229..eb6da2c 100644 --- a/static/common.css +++ b/static/common.css @@ -19,6 +19,14 @@ top: 64px; } + +.settings { + margin: 0 18%; + padding-top: 40px; + transition: 0.6s; +} + + .settings div.text { width: 100%; } @@ -60,3 +68,43 @@ .settings div.text.outlined.label { margin-top: 6px; } + +.settings .text-box { + margin: 0 10px; +} + + +.settings .text-box .text { + display: flex; + justify-content: space-between; + align-items: center; +} + + +.settings .text-box .text .mdc-text-field::after{ + width: 0; +} +.settings .text-box .text .mdc-text-field::before{ + width: 0; +} + +.settings .ua { + position: relative; +} +.settings .ua > button { + position: absolute; + top: 30px; + left: -10px; +} + + + +@media (max-width:767px) { + +} + +@media (max-width:1280px) { + .settings { + margin: 0; + } +} \ No newline at end of file diff --git a/thumbnail/base.ts b/thumbnail/base.ts new file mode 100644 index 0000000..9e1f502 --- /dev/null +++ b/thumbnail/base.ts @@ -0,0 +1,22 @@ +import { join } from "std/path/mod.ts"; +import { filterFilename } from "../utils.ts"; +import { EhFile } from "../db.ts"; + +export type ThumbnailConfig = { + width: number; + height: number; + quality: number; +}; + +export function generate_filename( + base: string, + f: EhFile, + cfg: ThumbnailConfig, +) { + return join( + base, + filterFilename( + `${f.id}-${f.token}-${cfg.width}x${cfg.height}-q${cfg.quality}.jpg`, + ), + ); +} diff --git a/thumbnail/ffmpeg_binary.ts b/thumbnail/ffmpeg_binary.ts new file mode 100644 index 0000000..b5f3d82 --- /dev/null +++ b/thumbnail/ffmpeg_binary.ts @@ -0,0 +1,43 @@ +import { ThumbnailConfig } from "./base.ts"; + +export async function check_ffmpeg_binary(p: string) { + const cmd = new Deno.Command(p, { + stdout: "null", + stderr: "null", + args: ["-h"], + }); + const c = cmd.spawn(); + const o = await c.output(); + return o.code === 0; +} + +export async function fb_generate_thumbnail( + p: string, + i: string, + o: string, + cfg: ThumbnailConfig, +) { + const args = [ + "-i", + i, + "-vf", + `scale=${cfg.width}:${cfg.height}`, + "-qmin", + `${cfg.quality}`, + "-qmax", + `${cfg.quality}`, + o, + ]; + const cmd = new Deno.Command(p, { args, stdout: "null", stderr: "piped" }); + const c = cmd.spawn(); + const s = await c.output(); + if (s.code !== 0) { + try { + const d = (new TextDecoder()).decode(s.stderr); + console.log(d); + } catch (_) { + console.log(s.stderr); + } + } + return s.code === 0; +} diff --git a/translation/en/settings.jsonc b/translation/en/settings.jsonc index 6d4165d..10bc257 100644 --- a/translation/en/settings.jsonc +++ b/translation/en/settings.jsonc @@ -27,5 +27,6 @@ "ffmpeg_path": "The path to the ffmpeg binary: ", "thumbnail_method": "The method used to generate thumbnail: ", "thumbnail_method0": "ffmpeg binary", - "thumbnail_method1": "ffmpeg API" + "thumbnail_method1": "ffmpeg API", + "thumbnail_dir": "The folder used to store thumbnails: " } diff --git a/translation/zh-cn/settings.jsonc b/translation/zh-cn/settings.jsonc index f3e5943..4a5ffa9 100644 --- a/translation/zh-cn/settings.jsonc +++ b/translation/zh-cn/settings.jsonc @@ -27,5 +27,6 @@ "ffmpeg_path": "FFMPEG二进制的位置:", "thumbnail_method": "生成缩略图的方式:", "thumbnail_method0": "FFMPEG二进制", - "thumbnail_method1": "FFMPEG API" + "thumbnail_method1": "FFMPEG API", + "thumbnail_dir": "存放缩略图的文件夹:" }