Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,11 @@ After downloading and extracting a new version, copy the current `data` director
- Backend: Go + Gin + GORM + SQLite
- Frontend: React + Vite + Tailwind + Zustand
- Media probing: `ffprobe`
- Screenshot generation: `mpv`
- Screenshot generation: `ffmpeg` on macOS, `mpv` on other platforms

### Common Commands

Download dependencies (`ffprobe` + `mpv`):
Download dependencies (`ffprobe` + `mpv`, plus `ffmpeg` on macOS):

```bash
./scripts/cli.sh download linux-x86_64
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,11 @@ porn manager, jav manager, av manager, jav scraper, jav metadata, adult video ma
- Backend: Go + Gin + GORM + SQLite
- Frontend: React + Vite + Tailwind + Zustand
- 媒体探测: `ffprobe`
- 截图生成: `mpv`
- 截图生成: macOS 使用 `ffmpeg`,其他平台使用 `mpv`

### 常用命令

下载依赖(`ffprobe` + `mpv`):
下载依赖(`ffprobe` + `mpv`,macOS 额外下载 `ffmpeg`):

```bash
./scripts/cli.sh download linux-x86_64
Expand Down
94 changes: 76 additions & 18 deletions internal/manager/screenshot_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,30 +242,29 @@ func (m *ScreenshotManager) capture(ctx context.Context, videoPath string, secon
return fmt.Errorf("ensure screenshot dir: %w", err)
}

mpvPath, pathErr := util.ResolveMPVPath()
if pathErr != nil {
return fmt.Errorf("resolve mpv path: %w", pathErr)
}
tempDir, err := os.MkdirTemp(filepath.Dir(outputPath), ".mpv-shot-*")
tempDir, err := os.MkdirTemp(filepath.Dir(outputPath), ".screenshot-*")
if err != nil {
return fmt.Errorf("create mpv temp dir: %w", err)
return fmt.Errorf("create screenshot temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
shotPath := filepath.Join(tempDir, "00000001.jpg")

args := []string{
"--no-config",
"--really-quiet",
"--msg-level=all=error",
"--ao=null",
"--hr-seek=yes",
"--start=" + strconv.Itoa(second),
"--frames=1",
"--vo=image",
"--vo-image-format=jpg",
"--vo-image-outdir=" + tempDir,
videoPath,
if runtime.GOOS == "darwin" {
ffmpegPath, err := util.ResolveFFmpegPath()
if err != nil {
return fmt.Errorf("resolve ffmpeg path: %w", err)
}
if err := runFFmpegScreenshot(ctx, ffmpegPath, videoPath, second, shotPath); err != nil {
return err
}
return moveScreenshot(shotPath, outputPath)
}

mpvPath, pathErr := util.ResolveMPVPath()
if pathErr != nil {
return fmt.Errorf("resolve mpv path: %w", pathErr)
}
args := buildMPVScreenshotArgs(second, tempDir, videoPath)

cmd := exec.CommandContext(ctx, mpvPath, args...)
out, err := cmd.CombinedOutput()
Expand All @@ -290,12 +289,71 @@ func (m *ScreenshotManager) capture(ctx context.Context, videoPath string, secon
return errors.New("mpv produced empty screenshot file")
}

return moveScreenshot(shotPath, outputPath)
}

func runFFmpegScreenshot(ctx context.Context, ffmpegPath string, videoPath string, second int, outputPath string) error {
args := buildFFmpegScreenshotArgs(second, outputPath, videoPath)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
out, err := cmd.CombinedOutput()
if err != nil {
_ = os.Remove(outputPath)
lastOut := strings.TrimSpace(string(out))
if lastOut != "" {
return fmt.Errorf("ffmpeg screenshot failed: %w: %s", err, lastOut)
}
return fmt.Errorf("ffmpeg screenshot failed: %w", err)
}

info, err := os.Stat(outputPath)
if err != nil {
return errors.New("ffmpeg produced no screenshot file")
}
if info.Size() == 0 {
_ = os.Remove(outputPath)
return errors.New("ffmpeg produced empty screenshot file")
}
return nil
}

func moveScreenshot(shotPath string, outputPath string) error {
if err := os.Rename(shotPath, outputPath); err != nil {
return fmt.Errorf("rename screenshot: %w", err)
}
return nil
}

func buildFFmpegScreenshotArgs(second int, outputPath string, videoPath string) []string {
return []string{
"-nostdin",
"-hide_banner",
"-loglevel", "error",
"-y",
"-ss", strconv.Itoa(second),
"-i", videoPath,
"-map", "0:v:0",
"-frames:v", "1",
"-q:v", "2",
outputPath,
}
}

func buildMPVScreenshotArgs(second int, tempDir string, videoPath string) []string {
return []string{
"--no-config",
"--really-quiet",
"--msg-level=all=error",
"--ao=null",
"--hr-seek=yes",
"--start=" + strconv.Itoa(second),
"--frames=1",
"--vo=image",
"--vo-image-format=jpg",
"--vo-image-outdir=" + tempDir,
videoPath,
}
}

func resolveVideoPath(video *models.Video) (string, error) {
if video == nil {
return "", errors.New("video is nil")
Expand Down
54 changes: 54 additions & 0 deletions internal/manager/screenshot_manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package manager

import (
"slices"
"testing"
)

func TestBuildFFmpegScreenshotArgsIncludesHeadlessImageOptions(t *testing.T) {
args := buildFFmpegScreenshotArgs(12, "/tmp/screenshots/00000001.jpg", "/videos/example.mp4")

required := []string{
"-nostdin",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-ss",
"12",
"-i",
"/videos/example.mp4",
"-map",
"0:v:0",
"-frames:v",
"1",
"-q:v",
"2",
"/tmp/screenshots/00000001.jpg",
}
for _, option := range required {
if !slices.Contains(args, option) {
t.Fatalf("expected ffmpeg screenshot args to include %q, got %v", option, args)
}
}
}

func TestBuildMPVScreenshotArgsIncludesImageOutputOptions(t *testing.T) {
args := buildMPVScreenshotArgs(12, "/tmp/screenshots", "/videos/example.mp4")

required := []string{
"--no-config",
"--ao=null",
"--start=12",
"--frames=1",
"--vo=image",
"--vo-image-format=jpg",
"--vo-image-outdir=/tmp/screenshots",
"/videos/example.mp4",
}
for _, option := range required {
if !slices.Contains(args, option) {
t.Fatalf("expected mpv screenshot args to include %q, got %v", option, args)
}
}
}
13 changes: 8 additions & 5 deletions internal/mpv/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,23 +329,26 @@ func buildConfigContent() (string, error) {
}

lines := []string{
"auto-window-resize=no",
"keep-open=yes",
fmt.Sprintf("ontop=%s", mpvBool(ontop)),
}
if useAutofit {
lines = append(lines, fmt.Sprintf("autofit=%d%%x%d%%", windowWidth, windowHeight))
} else {
lines = append(lines,
fmt.Sprintf("autofit=%d%%x%d%%", windowWidth, windowHeight),
"geometry=50%:50%",
"auto-window-resize=no",
"geometry="+centeredWindowGeometry(windowWidth, windowHeight),
)
} else {
lines = append(lines, fmt.Sprintf("geometry=%d%%x%d%%", windowWidth, windowHeight))
}
lines = append(lines, fmt.Sprintf("volume=%d", volume))

return strings.Join(lines, "\n") + "\n", nil
}

func centeredWindowGeometry(width, height int) string {
return fmt.Sprintf("%d%%x%d%%+50%%+50%%", width, height)
}

func loadConfiguredPlayerBaseSettings() (int, int, bool, int, bool, error) {
if common.DB == nil {
return defaultWindowWidth, defaultWindowHeight, false, defaultVolume, defaultOntop, nil
Expand Down
49 changes: 49 additions & 0 deletions internal/mpv/input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ func TestBuildConfigContentIncludesRequiredDefaults(t *testing.T) {
if !strings.Contains(content, "keep-open=yes\n") {
t.Fatalf("expected keep-open=yes in mpv config, got %q", content)
}
if !strings.Contains(content, "auto-window-resize=no\n") {
t.Fatalf("expected auto-window-resize=no in fixed-size mpv config, got %q", content)
}
if !strings.Contains(content, "ontop=yes\n") {
t.Fatalf("expected ontop=yes in mpv config, got %q", content)
}
if !strings.Contains(content, "geometry=70%x70%+50%+50%\n") {
t.Fatalf("expected centered default geometry in mpv config, got %q", content)
}
}

func TestBuildConfigContentRespectsConfiguredOntop(t *testing.T) {
Expand All @@ -47,6 +53,49 @@ func TestBuildConfigContentRespectsConfiguredOntop(t *testing.T) {
}
}

func TestBuildConfigContentCentersConfiguredWindowSize(t *testing.T) {
openConfigTestDB(t)
if err := dbpkg.UpsertConfig(context.Background(), map[string]string{
playerWindowWidthConfigKey: "80",
playerWindowHeightConfigKey: "60",
}); err != nil {
t.Fatalf("upsert config: %v", err)
}

content, err := buildConfigContent()
if err != nil {
t.Fatalf("buildConfigContent returned error: %v", err)
}

if !strings.Contains(content, "geometry=80%x60%+50%+50%\n") {
t.Fatalf("expected centered configured geometry in mpv config, got %q", content)
}
}

func TestBuildConfigContentUsesOnlyAutofitForAutomaticWindowSize(t *testing.T) {
openConfigTestDB(t)
if err := dbpkg.UpsertConfig(context.Background(), map[string]string{
playerWindowUseAutofitConfigKey: "true",
}); err != nil {
t.Fatalf("upsert config: %v", err)
}

content, err := buildConfigContent()
if err != nil {
t.Fatalf("buildConfigContent returned error: %v", err)
}

if !strings.Contains(content, "autofit=70%x70%\n") {
t.Fatalf("expected default autofit size in mpv config, got %q", content)
}
if strings.Contains(content, "auto-window-resize=no\n") {
t.Fatalf("expected autofit mpv config to leave automatic window resize enabled, got %q", content)
}
if strings.Contains(content, "geometry=") {
t.Fatalf("expected autofit mpv config to omit fixed geometry, got %q", content)
}
}

func openConfigTestDB(t *testing.T) {
t.Helper()

Expand Down
28 changes: 24 additions & 4 deletions internal/util/video.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ var (
ffprobeOnce sync.Once
ffprobePath string
ffprobeErr error

ffmpegOnce sync.Once
ffmpegPath string
ffmpegErr error
)

// ResolveFFprobePath resolves the ffprobe binary location.
Expand All @@ -131,15 +135,31 @@ func ResolveFFprobePath() (string, error) {
return ffprobePath, ffprobeErr
}

// ResolveFFmpegPath resolves the ffmpeg binary location.
func ResolveFFmpegPath() (string, error) {
ffmpegOnce.Do(func() {
ffmpegPath, ffmpegErr = findFFmpegPath()
})
return ffmpegPath, ffmpegErr
}

func findFFprobePath() (string, error) {
return findFFBinaryPath("FFPROBE_PATH", "ffprobe")
}

func findFFmpegPath() (string, error) {
return findFFBinaryPath("FFMPEG_PATH", "ffmpeg")
}

func findFFBinaryPath(envKey, name string) (string, error) {
var candidates []string
if env := strings.TrimSpace(os.Getenv("FFPROBE_PATH")); env != "" {
if env := strings.TrimSpace(os.Getenv(envKey)); env != "" {
candidates = append(candidates, env)
}

binName := "ffprobe"
binName := name
if runtime.GOOS == "windows" {
binName = "ffprobe.exe"
binName = name + ".exe"
}

if wd, err := os.Getwd(); err == nil {
Expand All @@ -159,7 +179,7 @@ func findFFprobePath() (string, error) {
return resolved, nil
}
}
return "", errors.New("ffprobe not found; set FFPROBE_PATH or place binary at internal/bin/ffprobe")
return "", fmt.Errorf("%s not found; set %s or place binary at internal/bin/%s", name, envKey, binName)
}

// ProbeVideo extracts codec/resolution/fps/duration using ffprobe.
Expand Down
Loading
Loading