diff --git a/README.en.md b/README.en.md index 5d12d97..1f0c925 100644 --- a/README.en.md +++ b/README.en.md @@ -80,6 +80,12 @@ Go to the [Releases](https://github.com/JavBoss/pornboss/releases) page, downloa After launch, Pornboss will try to open your browser automatically. If it does not, open the local address shown in the terminal manually. Keep the terminal window open while Pornboss is running. +The release package includes a `config.toml` file in its root directory. By default `port = 0`, so Pornboss uses a random startup port. To use a fixed port, set it like this: + +```text +port = 17654 +``` + ### 3. Add Your Media Folders Open `Global Settings` -> `Directory Management`, then add the local folders that store your videos. Scanning runs in the background, and indexed videos are available immediately without waiting for the full scan to finish. diff --git a/README.md b/README.md index cca7891..d50f083 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,12 @@ Pornboss 集成 mpv 播放能力,点击视频即可调用轻量、高性能的 启动成功后,程序会自动尝试打开浏览器。如果没有自动打开,可以手动访问终端里显示的本地地址。运行过程中请不要关闭终端窗口。 +发布包根目录会包含 `config.toml` 文件。默认 `port = 0`,启动时使用随机端口;如果需要固定端口,改成例如: + +```text +port = 17654 +``` + ### 3. 添加资源目录 进入“全局设置” -> “目录管理”,添加存放视频的本地文件夹。扫描任务会在后台运行,已入库的视频可以直接使用,不需要等待全部扫描完成。 diff --git a/cmd/server/main.go b/cmd/server/main.go index df52f0f..1c53858 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "os" "os/signal" "path/filepath" + "strconv" "syscall" "time" @@ -27,6 +28,7 @@ import ( "pornboss/internal/manager" "github.com/gin-gonic/gin" + "github.com/pelletier/go-toml/v2" "gopkg.in/natefinch/lumberjack.v2" ) @@ -143,7 +145,10 @@ func main() { }() if gin.Mode() == gin.ReleaseMode { - listenAddr := releaseListenAddr(*addr) + listenAddr, err := releaseListenAddr(*addr, baseDir) + if err != nil { + logger.Fatalf("resolve release listen address: %v", err) + } listener, err := net.Listen("tcp", listenAddr) if err != nil { logger.Fatalf("listen on %s: %v", listenAddr, err) @@ -204,15 +209,54 @@ func buildLogger(baseDir string) (*log.Logger, func(), error) { return logger, func() { _ = rotator.Close() }, nil } -func releaseListenAddr(addr string) string { +func releaseListenAddr(addr string, baseDir string) (string, error) { host, _, err := net.SplitHostPort(addr) if err != nil { - return ":0" + host = "" + } + + port, configured, err := releaseConfigPort(baseDir) + if err != nil { + return "", err } + if configured { + if host == "" { + return net.JoinHostPort("", strconv.Itoa(port)), nil + } + return net.JoinHostPort(host, strconv.Itoa(port)), nil + } + if host == "" { - return ":0" + return ":0", nil + } + return net.JoinHostPort(host, "0"), nil +} + +func releaseConfigPort(baseDir string) (int, bool, error) { + if baseDir == "" { + return 0, false, nil + } + data, err := os.ReadFile(filepath.Join(baseDir, "config.toml")) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return 0, false, nil + } + return 0, false, fmt.Errorf("read config: %w", err) + } + + var cfg struct { + Port int `toml:"port"` + } + if err := toml.Unmarshal(data, &cfg); err != nil { + return 0, false, fmt.Errorf("parse config TOML: %w", err) + } + if cfg.Port == 0 { + return 0, false, nil + } + if cfg.Port < 1 || cfg.Port > 65535 { + return 0, false, fmt.Errorf("invalid config port %d", cfg.Port) } - return net.JoinHostPort(host, "0") + return cfg.Port, true, nil } func resolveBaseDir() (string, error) { diff --git a/go.mod b/go.mod index 06fc3ef..a6bd0c4 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/h2non/filetype v1.1.3 github.com/mattn/go-ieproxy v0.0.12 github.com/mattn/go-sqlite3 v1.14.22 + github.com/pelletier/go-toml/v2 v2.2.4 golang.org/x/net v0.42.0 golang.org/x/sys v0.35.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -36,7 +37,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/scripts/cli/cli.mjs b/scripts/cli/cli.mjs index a9673a1..135566f 100755 --- a/scripts/cli/cli.mjs +++ b/scripts/cli/cli.mjs @@ -204,7 +204,14 @@ async function isBundledFfmpegReady(choice) { } async function isBundledMpvReady(choice) { - return exists(binMpvPath(choice)); + if (!(await exists(binMpvPath(choice)))) return false; + if (choice.goos === "linux") { + return ( + (await exists(path.join(binMpvDir(choice), "bin", "mpv-bin"))) && + (await exists(path.join(binMpvDir(choice), "lib", "ld-linux-x86-64.so.2"))) + ); + } + return true; } function runCommand(cmd, args, options = {}) { @@ -221,6 +228,28 @@ function runCommand(cmd, args, options = {}) { }); } +function runCommandCapture(cmd, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...options }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(`${cmd} exited with code ${code}: ${stderr || stdout}`)); + } + }); + }); +} + function commandExists(cmd) { return new Promise((resolve) => { const probe = process.platform === "win32" ? "where" : "which"; @@ -445,6 +474,19 @@ async function createMacCommandLauncher(outDir) { await fsp.chmod(launcherPath, 0o755); } +async function createReleaseConfig(outDir) { + const configPath = path.join(outDir, "config.toml"); + const configContent = [ + "# Pornboss release config", + "# This file uses TOML format.", + "# Set port to 0 to use a random startup port.", + "# Example: port = 17654", + "port = 0", + "", + ].join("\n"); + await fsp.writeFile(configPath, configContent); +} + async function createZip(outDir, zipPath) { const hasZip = await commandExists("zip"); if (!hasZip) { @@ -500,6 +542,8 @@ async function runRelease(choice, version) { console.log("[release] 复制 mpv"); await copyBundledMpv(choice, outDir); } + console.log("[release] 生成默认配置文件"); + await createReleaseConfig(outDir); if (choice.goos === "darwin") { console.log("[release] 生成 macOS .command 启动器"); await createMacCommandLauncher(outDir); @@ -687,6 +731,176 @@ async function findDirectory(dir, dirname) { return null; } +async function indexLibraryFiles(dir, index = new Map()) { + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return index; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await indexLibraryFiles(fullPath, index); + continue; + } + if ((entry.isFile() || entry.isSymbolicLink()) && !index.has(entry.name)) { + index.set(entry.name, fullPath); + } + } + return index; +} + +async function readElfNeeded(filePath) { + const { stdout } = await runCommandCapture("readelf", ["-d", filePath]); + return Array.from(stdout.matchAll(/Shared library: \[([^\]]+)\]/g), (match) => match[1]); +} + +async function rewriteAbsoluteNeeded(binaryPath, neededPath) { + if (!path.isAbsolute(neededPath)) return; + + const replacementName = path.basename(neededPath); + const needle = Buffer.from(`${neededPath}\0`); + if (Buffer.byteLength(replacementName) + 1 > needle.length) { + throw new Error(`[mpv] 无法重写绝对依赖路径:${neededPath}`); + } + + const replacement = Buffer.alloc(needle.length); + replacement.write(`${replacementName}\0`); + const content = await fsp.readFile(binaryPath); + let offset = content.indexOf(needle); + if (offset < 0) return; + while (offset >= 0) { + replacement.copy(content, offset); + offset = content.indexOf(needle, offset + needle.length); + } + await fsp.writeFile(binaryPath, content); +} + +async function copyLinuxMpvDependency({ libraryRoot, libraryIndex, destLibDir, needed }) { + const filename = path.basename(needed); + const dest = path.join(destLibDir, filename); + if (await exists(dest)) return dest; + + const direct = path.join(libraryRoot, filename); + const src = (await exists(direct)) ? direct : libraryIndex.get(filename); + if (!src) { + throw new Error(`[mpv] AppImage 内缺少运行库:${needed}`); + } + + const realSrc = await fsp.realpath(src).catch(() => src); + await fsp.copyFile(realSrc, dest); + return dest; +} + +async function collectLinuxMpvDependencies({ + queue, + seen, + libraryRoot, + libraryIndex, + destLibDir, +}) { + while (queue.length) { + const current = queue.shift(); + if (seen.has(current)) continue; + seen.add(current); + + const neededLibraries = await readElfNeeded(current); + for (const needed of neededLibraries) { + await rewriteAbsoluteNeeded(current, needed); + const copied = await copyLinuxMpvDependency({ + libraryRoot, + libraryIndex, + destLibDir, + needed, + }); + if (!seen.has(copied)) queue.push(copied); + } + } +} + +async function installLinuxMpvFromAppImage(archive, choice, tmpBase) { + if (!(await commandExists("readelf"))) { + throw new Error("[mpv] 精简 Linux AppImage 需要 readelf,请先安装 binutils"); + } + + await fsp.chmod(archive, 0o755).catch(() => {}); + + const extractDir = path.join(tmpBase, "appimage-extract"); + await fsp.rm(extractDir, { recursive: true, force: true }); + await fsp.mkdir(extractDir, { recursive: true }); + await runCommand(archive, ["--appimage-extract"], { cwd: extractDir }); + + const appRoot = path.join(extractDir, "squashfs-root"); + const junestRoot = path.join(appRoot, ".junest"); + const sourceMpv = path.join(junestRoot, "usr", "bin", "mpv"); + const sourceLibDir = path.join(junestRoot, "usr", "lib"); + const sourceLoader = path.join(sourceLibDir, "ld-linux-x86-64.so.2"); + if (!(await exists(sourceMpv)) || !(await exists(sourceLoader))) { + throw new Error("[mpv] AppImage 结构不符合预期,未找到 .junest/usr/bin/mpv"); + } + + const installDir = path.join(tmpBase, "mpv-slim"); + const installBinDir = path.join(installDir, "bin"); + const installLibDir = path.join(installDir, "lib"); + await fsp.rm(installDir, { recursive: true, force: true }); + await fsp.mkdir(installBinDir, { recursive: true }); + await fsp.mkdir(installLibDir, { recursive: true }); + + const bundledMpv = path.join(installBinDir, "mpv-bin"); + const bundledLoader = path.join(installLibDir, "ld-linux-x86-64.so.2"); + await fsp.copyFile(sourceMpv, bundledMpv); + await fsp.copyFile(sourceLoader, bundledLoader); + await fsp.chmod(bundledMpv, 0o755); + await fsp.chmod(bundledLoader, 0o755); + + const libraryIndex = await indexLibraryFiles(sourceLibDir); + const queue = [bundledMpv, bundledLoader]; + const seen = new Set(); + const extraRuntimeLibs = ["libSDL3.so.0"]; + + await collectLinuxMpvDependencies({ + queue, + seen, + libraryRoot: sourceLibDir, + libraryIndex, + destLibDir: installLibDir, + }); + + for (const needed of extraRuntimeLibs) { + const copied = await copyLinuxMpvDependency({ + libraryRoot: sourceLibDir, + libraryIndex, + destLibDir: installLibDir, + needed, + }); + if (!seen.has(copied)) queue.push(copied); + await collectLinuxMpvDependencies({ + queue, + seen, + libraryRoot: sourceLibDir, + libraryIndex, + destLibDir: installLibDir, + }); + } + + const wrapper = [ + "#!/bin/sh", + 'HERE=$(CDPATH= cd "$(dirname "$0")" && pwd -P)', + 'LIB="$HERE/lib"', + 'export LD_LIBRARY_PATH="$LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"', + 'exec "$LIB/ld-linux-x86-64.so.2" --library-path "$LD_LIBRARY_PATH" "$HERE/bin/mpv-bin" "$@"', + "", + ].join("\n"); + await fsp.writeFile(path.join(installDir, "mpv"), wrapper); + await fsp.chmod(path.join(installDir, "mpv"), 0o755); + + await fsp.rm(binMpvDir(choice), { recursive: true, force: true }); + await copyDir(installDir, binMpvDir(choice)); + await fsp.chmod(binMpvPath(choice), 0o755); +} + function isArchiveName(name) { return ( name.endsWith(".zip") || @@ -851,7 +1065,7 @@ async function downloadMpv(choice) { let installed = false; for (const url of urls) { console.log(`[mpv] 下载 ${choice.label}:${url}`); - const archive = path.join(tmpBase, path.basename(url)); + const archive = path.join(tmpBase, downloadFilename(url, "mpv-download")); const extractDir = path.join(tmpBase, "extract"); await fsp.rm(extractDir, { recursive: true, force: true }); await fsp.mkdir(extractDir, { recursive: true }); @@ -863,10 +1077,13 @@ async function downloadMpv(choice) { continue; } - if (archive.endsWith(".AppImage")) { - await fsp.rm(binMpvDir(choice), { recursive: true, force: true }); - await fsp.mkdir(binMpvDir(choice), { recursive: true }); - await fsp.copyFile(archive, binMpvPath(choice)); + if (archive.toLowerCase().endsWith(".appimage")) { + try { + await installLinuxMpvFromAppImage(archive, choice, tmpBase); + } catch (err) { + console.warn(err?.message || "[mpv] AppImage 精简失败,尝试下一个来源"); + continue; + } } else { try { await extractArchive(archive, extractDir);