hako/internal/archival/archiver/yt_dlp.go
Felipe M. 484b225af7
feat: add user administration API and UI
Add admin-only CRUD endpoints for user management under /api/v1/system/users
with full frontend implementation including create, edit, change password,
and delete operations with self-protection guards.

- Extend AuthDomain interface with ListUsers, GetUser, UpdateUser,
  UpdateUserPassword, DeleteUser methods
- Add List, Update, Delete methods to UserStore with shared scanUser helper
- Create UserHandler with 5 endpoints protected by admin middleware
- Complete Users.vue admin page with modals and error handling
- Make CreateUser accept role parameter for atomic user creation
- Fix auth middleware to use database role as source of truth instead of
  stale JWT claims, preventing privilege persistence after demotion
- Fix pre-existing gofmt issues in yt_dlp.go and config.go
2026-04-02 10:33:36 +02:00

271 lines
6.9 KiB
Go

package archiver
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.nakama.town/fmartingr/hako/internal/model"
"git.nakama.town/fmartingr/hako/internal/storage"
)
// YtDlpConfig defines the configuration for the yt-dlp archiver
type YtDlpConfig struct {
Timeout string `json:"timeout"`
WriteThumbnail bool `json:"write_thumbnail"`
WriteSubs bool `json:"write_subs"` // Download subtitles as separate files
EmbedSubs bool `json:"embed_subs"` // Embed subtitles into the video file
}
// ToMap converts the YtDlpConfig to a map[string]any for use with ApplyConfig
func (c YtDlpConfig) ToMap() map[string]any {
data, err := json.Marshal(c)
if err != nil {
return nil
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
return nil
}
return out
}
// YtDlpArchiver implements video download archival using the yt-dlp binary
type YtDlpArchiver struct {
binaryPath string
config YtDlpConfig
}
// NewYtDlpArchiver creates a new YtDlpArchiver
func NewYtDlpArchiver() *YtDlpArchiver {
return &YtDlpArchiver{
config: YtDlpConfig{
Timeout: "10m",
WriteThumbnail: true,
WriteSubs: true,
EmbedSubs: true,
},
}
}
// Key returns the unique identifier for this archiver
func (a *YtDlpArchiver) Key() string {
return "yt_dlp"
}
// Name returns the human-readable name for this archiver
func (a *YtDlpArchiver) Name() string {
return "yt-dlp"
}
// IsEnabled checks if the yt-dlp binary is available
func (a *YtDlpArchiver) IsEnabled(ctx context.Context) bool {
path, err := exec.LookPath("yt-dlp")
if err != nil {
return false
}
a.binaryPath = path
return true
}
// Init initializes the archiver (resolves binary path)
func (a *YtDlpArchiver) Init() error {
path, err := exec.LookPath("yt-dlp")
if err != nil {
return fmt.Errorf("yt-dlp not found in PATH: %w", err)
}
a.binaryPath = path
return nil
}
// GetDefaultConfig returns the default configuration
func (a *YtDlpArchiver) GetDefaultConfig() any {
return YtDlpConfig{
Timeout: "10m",
WriteThumbnail: true,
WriteSubs: true,
EmbedSubs: true,
}
}
// ApplyConfig applies the provided configuration
func (a *YtDlpArchiver) ApplyConfig(config map[string]any) error {
if timeoutStr, ok := config["timeout"].(string); ok && timeoutStr != "" {
if _, err := time.ParseDuration(timeoutStr); err != nil {
return fmt.Errorf("invalid timeout format: %w", err)
}
a.config.Timeout = timeoutStr
}
if v, ok := config["write_thumbnail"].(bool); ok {
a.config.WriteThumbnail = v
}
if v, ok := config["write_subs"].(bool); ok {
a.config.WriteSubs = v
}
if v, ok := config["embed_subs"].(bool); ok {
a.config.EmbedSubs = v
}
return nil
}
// ytDlpMetadata holds the title field from yt-dlp --dump-json output
type ytDlpMetadata struct {
Title string `json:"title"`
}
func (a *YtDlpArchiver) fetchTitle(ctx context.Context, url string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, a.binaryPath, "--dump-json", "--no-download", "--no-warnings", url)
out, err := cmd.Output()
if err != nil {
return "", err
}
var meta ytDlpMetadata
if err := json.Unmarshal(out, &meta); err != nil {
return "", err
}
return strings.TrimSpace(meta.Title), nil
}
// Archive downloads the video using yt-dlp and saves files to storage
func (a *YtDlpArchiver) Archive(ctx context.Context, link *model.Link, stor storage.Storage) (*ArchiveResult, error) {
if a.binaryPath == "" {
if path, err := exec.LookPath("yt-dlp"); err != nil {
return nil, fmt.Errorf("yt-dlp not found in PATH: %w", err)
} else {
a.binaryPath = path
}
}
timeout := 10 * time.Minute
if a.config.Timeout != "" {
if d, err := time.ParseDuration(a.config.Timeout); err == nil {
timeout = d
}
}
title, _ := a.fetchTitle(ctx, link.URL)
tempDir, err := os.MkdirTemp("", "hako-yt-dlp-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
outputTemplate := filepath.Join(tempDir, "%(title)s.%(ext)s")
args := []string{
"-o", outputTemplate,
"--no-warnings",
"--no-playlist",
}
if a.config.WriteThumbnail {
args = append(args, "--write-thumbnail")
}
if a.config.WriteSubs {
args = append(args, "--write-subs", "--write-auto-subs", "--sub-langs", "en.*")
}
if a.config.EmbedSubs {
args = append(args, "--embed-subs", "--sub-langs", "en.*")
}
args = append(args, link.URL)
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, a.binaryPath, args...)
var combinedOutput strings.Builder
cmd.Stdout = &combinedOutput
cmd.Stderr = &combinedOutput
if err := cmd.Run(); err != nil {
errMsg := err.Error()
if exitErr, ok := err.(*exec.ExitError); ok {
errMsg = fmt.Sprintf("exit code %d", exitErr.ExitCode())
}
return nil, fmt.Errorf("yt-dlp failed (%s): %s", errMsg, strings.TrimSpace(combinedOutput.String()))
}
var files []FileInfo
err = filepath.Walk(tempDir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.IsDir() || path == tempDir {
return nil
}
rel, _ := filepath.Rel(tempDir, path)
if rel == "" || rel == "." {
return nil
}
filename := filepath.Base(path)
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s: %w", filename, err)
}
hash := sha256.New()
tee := io.TeeReader(f, hash)
storagePath, err := stor.Save(ctx, link.ID, filename, tee)
_ = f.Close()
if err != nil {
return fmt.Errorf("save %s: %w", filename, err)
}
mimeType := mimeTypeFromExt(filepath.Ext(filename))
// Add +thumbnail suffix to image files when WriteThumbnail is enabled
if a.config.WriteThumbnail && isImageMimeType(mimeType) {
mimeType = mimeType + "+thumbnail"
}
files = append(files, FileInfo{
Filename: filename,
MimeType: mimeType,
FileSize: info.Size(),
HashSha256: hex.EncodeToString(hash.Sum(nil)),
Path: storagePath,
})
return nil
})
if err != nil {
return nil, fmt.Errorf("processing downloaded files: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("yt-dlp produced no files. output: %s", strings.TrimSpace(combinedOutput.String()))
}
if title == "" {
title = link.URL
}
return &ArchiveResult{
Title: title,
Files: files,
}, nil
}
func mimeTypeFromExt(ext string) string {
ext = strings.ToLower(ext)
m := map[string]string{
".mp4": "video/mp4", ".webm": "video/webm", ".mkv": "video/x-matroska",
".m4a": "audio/mp4", ".opus": "audio/opus",
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp",
".vtt": "text/vtt", ".srt": "text/plain", ".ass": "text/x-ssa",
}
if mt, ok := m[ext]; ok {
return mt
}
return "application/octet-stream"
}
func isImageMimeType(mimeType string) bool {
return strings.HasPrefix(mimeType, "image/")
}