add: SQLite-backed structured logging with retention
Replace JSON/file logging with a logs.db (WAL) store. Thread the logger through the encoder and metadata client for debug instrumentation of every ffmpeg/ffprobe/opusenc invocation and OMDb/TVmaze request. API keys are redacted before request URLs are logged. Retention defaults to 7 days, overridable via log_retention_days in config.
This commit is contained in:
+7
-3
@@ -46,21 +46,21 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
log, err := logger.New(".")
|
log, err := logger.New(".", cfg.LogRetentionDays)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer log.Close()
|
defer log.Close()
|
||||||
|
|
||||||
enc := encoder.New()
|
enc := encoder.New(log)
|
||||||
if err := enc.CheckDeps(); err != nil {
|
if err := enc.CheckDeps(); err != nil {
|
||||||
log.Error("Dependency check failed", err.Error())
|
log.Error("Dependency check failed", err.Error())
|
||||||
fmt.Fprintf(os.Stderr, "Dependency check failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Dependency check failed: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
metaClient := metadata.NewClient(cfg.OMDBAPIKey)
|
metaClient := metadata.NewClient(cfg.OMDBAPIKey, log)
|
||||||
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -128,11 +128,15 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *
|
|||||||
meta, err = metaClient.FetchSeriesMetadata(ctx, tvmazeID, season, episode)
|
meta, err = metaClient.FetchSeriesMetadata(ctx, tvmazeID, season, episode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.ErrorFile(inputPath, "Fetching series metadata", err.Error())
|
log.ErrorFile(inputPath, "Fetching series metadata", err.Error())
|
||||||
|
} else if meta != nil {
|
||||||
|
log.Info(fmt.Sprintf("TVmaze hit: %s S%sE%s - %s", meta.Collection, meta.Season, meta.Episode, meta.Title))
|
||||||
}
|
}
|
||||||
} else if imdbID != "" {
|
} else if imdbID != "" {
|
||||||
meta, err = metaClient.FetchMovieMetadata(ctx, imdbID)
|
meta, err = metaClient.FetchMovieMetadata(ctx, imdbID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.ErrorFile(inputPath, "Fetching movie metadata", err.Error())
|
log.ErrorFile(inputPath, "Fetching movie metadata", err.Error())
|
||||||
|
} else if meta != nil {
|
||||||
|
log.Info(fmt.Sprintf("OMDb hit: %s (%s)", meta.Title, meta.IMDBID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ module videnc-vibe
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
||||||
|
require github.com/mattn/go-sqlite3 v1.14.44 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ func Load(configPath string) (*types.Config, error) {
|
|||||||
if cfg.Encoding.TVRip.Preset == 0 {
|
if cfg.Encoding.TVRip.Preset == 0 {
|
||||||
cfg.Encoding.TVRip.Preset = 2
|
cfg.Encoding.TVRip.Preset = 2
|
||||||
}
|
}
|
||||||
|
if cfg.LogRetentionDays == 0 {
|
||||||
|
cfg.LogRetentionDays = 7
|
||||||
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-53
@@ -11,6 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"videnc-vibe/internal/logger"
|
||||||
"videnc-vibe/pkg/types"
|
"videnc-vibe/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ type Encoder struct {
|
|||||||
ffmpegPath string
|
ffmpegPath string
|
||||||
ffprobePath string
|
ffprobePath string
|
||||||
opusencPath string
|
opusencPath string
|
||||||
|
log *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamInfo struct {
|
type StreamInfo struct {
|
||||||
@@ -43,9 +45,48 @@ type StreamMetadata struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func New(log *logger.Logger) *Encoder {
|
||||||
|
return &Encoder{
|
||||||
|
ffmpegPath: "ffmpeg",
|
||||||
|
ffprobePath: "ffprobe",
|
||||||
|
opusencPath: "opusenc",
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCmd executes the command and emits a debug entry containing the full
|
||||||
|
// command line and its combined output. file is the source file the command
|
||||||
|
// is acting on (may be empty).
|
||||||
|
func (e *Encoder) runCmd(ctx context.Context, label, file, name string, args []string) ([]byte, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
extra, _ := json.Marshal(struct {
|
||||||
|
Cmd string `json:"cmd"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
}{
|
||||||
|
Cmd: name + " " + strings.Join(args, " "),
|
||||||
|
Output: string(out),
|
||||||
|
})
|
||||||
|
e.log.Debug(label, file, string(extra))
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Encoder) CheckDeps() error {
|
||||||
|
if _, err := exec.LookPath(e.ffmpegPath); err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg not found")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(e.ffprobePath); err != nil {
|
||||||
|
return fmt.Errorf("ffprobe not found")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(e.opusencPath); err != nil {
|
||||||
|
return fmt.Errorf("opusenc not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Encoder) GetStreamLanguages(ctx context.Context, path string) ([]StreamMetadata, error) {
|
func (e *Encoder) GetStreamLanguages(ctx context.Context, path string) ([]StreamMetadata, error) {
|
||||||
cmd := exec.CommandContext(ctx, e.ffprobePath, "-v", "error", "-show_streams", "-print_format", "json", path)
|
args := []string{"-v", "error", "-show_streams", "-print_format", "json", path}
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := e.runCmd(ctx, "ffprobe stream languages", path, e.ffprobePath, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ffprobe error: %w", err)
|
return nil, fmt.Errorf("ffprobe error: %w", err)
|
||||||
}
|
}
|
||||||
@@ -79,40 +120,18 @@ func (e *Encoder) GetStreamLanguages(ctx context.Context, path string) ([]Stream
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("DEBUG stream languages: %+v\n", streams)
|
streamsJSON, _ := json.Marshal(streams)
|
||||||
|
e.log.Debug("parsed stream languages", path, string(streamsJSON))
|
||||||
return streams, nil
|
return streams, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Encoder {
|
|
||||||
return &Encoder{
|
|
||||||
ffmpegPath: "ffmpeg",
|
|
||||||
ffprobePath: "ffprobe",
|
|
||||||
opusencPath: "opusenc",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Encoder) CheckDeps() error {
|
|
||||||
if _, err := exec.LookPath(e.ffmpegPath); err != nil {
|
|
||||||
return fmt.Errorf("ffmpeg not found")
|
|
||||||
}
|
|
||||||
if _, err := exec.LookPath(e.ffprobePath); err != nil {
|
|
||||||
return fmt.Errorf("ffprobe not found")
|
|
||||||
}
|
|
||||||
if _, err := exec.LookPath(e.opusencPath); err != nil {
|
|
||||||
return fmt.Errorf("opusenc not found")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height int, interlaced bool, err error) {
|
func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height int, interlaced bool, err error) {
|
||||||
cmd := exec.CommandContext(ctx, e.ffprobePath, "-v", "error", "-show_streams", "-print_format", "json", path)
|
probeArgs := []string{"-v", "error", "-show_streams", "-print_format", "json", path}
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := e.runCmd(ctx, "ffprobe media info", path, e.ffprobePath, probeArgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, false, fmt.Errorf("ffprobe error: %w", err)
|
return 0, 0, false, fmt.Errorf("ffprobe error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("DEBUG ffprobe output: %s\n", string(output))
|
|
||||||
|
|
||||||
var result ProbeResult
|
var result ProbeResult
|
||||||
if err := json.Unmarshal(output, &result); err != nil {
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
return 0, 0, false, fmt.Errorf("parsing ffprobe output: %w", err)
|
return 0, 0, false, fmt.Errorf("parsing ffprobe output: %w", err)
|
||||||
@@ -124,7 +143,10 @@ func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height
|
|||||||
width = stream.Width
|
width = stream.Width
|
||||||
height = stream.Height
|
height = stream.Height
|
||||||
foundVideo = true
|
foundVideo = true
|
||||||
fmt.Printf("DEBUG detected video: %dx%d, codec=%s, SAR=%s\n", width, height, stream.CodecName, stream.SampleAspectRatio)
|
info, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"width": width, "height": height, "codec": stream.CodecName, "sar": stream.SampleAspectRatio,
|
||||||
|
})
|
||||||
|
e.log.Debug("detected video stream", path, string(info))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +154,7 @@ func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height
|
|||||||
return 0, 0, false, fmt.Errorf("no video stream found")
|
return 0, 0, false, fmt.Errorf("no video stream found")
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd = exec.CommandContext(ctx, e.ffmpegPath,
|
idetArgs := []string{
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-nostats",
|
"-nostats",
|
||||||
"-i", path,
|
"-i", path,
|
||||||
@@ -140,11 +162,12 @@ func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height
|
|||||||
"-frames:v", "400",
|
"-frames:v", "400",
|
||||||
"-an", "-sn",
|
"-an", "-sn",
|
||||||
"-f", "null", "-",
|
"-f", "null", "-",
|
||||||
)
|
}
|
||||||
idetOut, _ := cmd.CombinedOutput()
|
idetOut, _ := e.runCmd(ctx, "ffmpeg idet", path, e.ffmpegPath, idetArgs)
|
||||||
interlaced = detectInterlaced(string(idetOut))
|
interlaced = detectInterlaced(string(idetOut))
|
||||||
|
|
||||||
fmt.Printf("DEBUG interlaced: %v\n", interlaced)
|
info, _ := json.Marshal(map[string]bool{"interlaced": interlaced})
|
||||||
|
e.log.Debug("interlace detection", path, string(info))
|
||||||
|
|
||||||
return width, height, interlaced, nil
|
return width, height, interlaced, nil
|
||||||
}
|
}
|
||||||
@@ -170,14 +193,12 @@ func detectInterlaced(idetOutput string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, originalHeight int) (string, int, error) {
|
func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, originalHeight int) (string, int, error) {
|
||||||
cmd := exec.CommandContext(ctx, e.ffprobePath, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,sample_aspect_ratio", "-print_format", "json", path)
|
args := []string{"-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,sample_aspect_ratio", "-print_format", "json", path}
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := e.runCmd(ctx, "ffprobe zscale info", path, e.ffprobePath, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("ffprobe error: %w", err)
|
return "", 0, fmt.Errorf("ffprobe error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("DEBUG ffprobe for zscale: %s\n", string(output))
|
|
||||||
|
|
||||||
var result ProbeResult
|
var result ProbeResult
|
||||||
if err := json.Unmarshal(output, &result); err != nil {
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
return "", 0, fmt.Errorf("parsing ffprobe: %w", err)
|
return "", 0, fmt.Errorf("parsing ffprobe: %w", err)
|
||||||
@@ -190,9 +211,7 @@ func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, origina
|
|||||||
stream := result.Streams[0]
|
stream := result.Streams[0]
|
||||||
width := stream.Width
|
width := stream.Width
|
||||||
height := stream.Height
|
height := stream.Height
|
||||||
|
|
||||||
sar := stream.SampleAspectRatio
|
sar := stream.SampleAspectRatio
|
||||||
fmt.Printf("DEBUG stream: width=%d, height=%d, sar=%s\n", width, height, sar)
|
|
||||||
|
|
||||||
newWidth := width
|
newWidth := width
|
||||||
|
|
||||||
@@ -212,7 +231,11 @@ func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, origina
|
|||||||
|
|
||||||
// Round to nearest, then snap down to even (mod-2 widths preferred by AV1/H.264).
|
// Round to nearest, then snap down to even (mod-2 widths preferred by AV1/H.264).
|
||||||
newWidth = ((width*num + den/2) / den) &^ 1
|
newWidth = ((width*num + den/2) / den) &^ 1
|
||||||
fmt.Printf("DEBUG calculated new width: %d (sar=%s)\n", newWidth, sar)
|
|
||||||
|
info, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"width": width, "height": height, "sar": sar, "new_width": newWidth,
|
||||||
|
})
|
||||||
|
e.log.Debug("zscale calculation", path, string(info))
|
||||||
|
|
||||||
if newWidth == width {
|
if newWidth == width {
|
||||||
return "", newWidth, nil
|
return "", newWidth, nil
|
||||||
@@ -263,11 +286,14 @@ func (e *Encoder) extractAudio(ctx context.Context, input, workDir string, strea
|
|||||||
wavs := make([]string, 0, len(audio))
|
wavs := make([]string, 0, len(audio))
|
||||||
for srcAudioIndex := range audio {
|
for srcAudioIndex := range audio {
|
||||||
wavPath := filepath.Join(workDir, fmt.Sprintf("audio.%d.wav", srcAudioIndex))
|
wavPath := filepath.Join(workDir, fmt.Sprintf("audio.%d.wav", srcAudioIndex))
|
||||||
cmd := exec.CommandContext(ctx, e.ffmpegPath, "-i", input,
|
args := []string{
|
||||||
|
"-i", input,
|
||||||
"-map", fmt.Sprintf("0:a:%d", srcAudioIndex),
|
"-map", fmt.Sprintf("0:a:%d", srcAudioIndex),
|
||||||
"-vn", "-c:a", "pcm_s16le", "-ar", "48000",
|
"-vn", "-c:a", "pcm_s16le", "-ar", "48000",
|
||||||
"-f", "wav", wavPath)
|
"-f", "wav", wavPath,
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
}
|
||||||
|
out, err := e.runCmd(ctx, "ffmpeg extract audio", input, e.ffmpegPath, args)
|
||||||
|
if err != nil {
|
||||||
return wavs, fmt.Errorf("ffmpeg extract a:%d: %s %w", srcAudioIndex, out, err)
|
return wavs, fmt.Errorf("ffmpeg extract a:%d: %s %w", srcAudioIndex, out, err)
|
||||||
}
|
}
|
||||||
wavs = append(wavs, wavPath)
|
wavs = append(wavs, wavPath)
|
||||||
@@ -280,8 +306,9 @@ func (e *Encoder) encodeOpus(ctx context.Context, wavs []string, workDir string)
|
|||||||
for _, wav := range wavs {
|
for _, wav := range wavs {
|
||||||
base := filepath.Base(strings.Replace(wav, ".wav", ".opus", 1))
|
base := filepath.Base(strings.Replace(wav, ".wav", ".opus", 1))
|
||||||
out := filepath.Join(workDir, base)
|
out := filepath.Join(workDir, base)
|
||||||
cmd := exec.CommandContext(ctx, e.opusencPath, "--bitrate", "128k", wav, out)
|
args := []string{"--bitrate", "128k", wav, out}
|
||||||
if logOut, err := cmd.CombinedOutput(); err != nil {
|
logOut, err := e.runCmd(ctx, "opusenc", wav, e.opusencPath, args)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opusenc: %s %w", logOut, err)
|
return nil, fmt.Errorf("opusenc: %s %w", logOut, err)
|
||||||
}
|
}
|
||||||
opusFiles = append(opusFiles, out)
|
opusFiles = append(opusFiles, out)
|
||||||
@@ -299,8 +326,6 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
|
|||||||
return fmt.Errorf("calculating zscale: %w", err)
|
return fmt.Errorf("calculating zscale: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("DEBUG zscale: %s (newWidth=%d)\n", zscaleStr, newWidth)
|
|
||||||
|
|
||||||
var filters []string
|
var filters []string
|
||||||
if interlaced {
|
if interlaced {
|
||||||
filters = append(filters, "bwdif=mode=0:par=-1:-1")
|
filters = append(filters, "bwdif=mode=0:par=-1:-1")
|
||||||
@@ -309,7 +334,10 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
|
|||||||
filters = append(filters, zscaleStr)
|
filters = append(filters, zscaleStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("DEBUG final vf: %s\n", strings.Join(filters, ","))
|
filterInfo, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"zscale": zscaleStr, "new_width": newWidth, "interlaced": interlaced, "vf": strings.Join(filters, ","),
|
||||||
|
})
|
||||||
|
e.log.Debug("video filter chain", input, string(filterInfo))
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"-y",
|
"-y",
|
||||||
@@ -354,7 +382,6 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("language=%s", lang))
|
args = append(args, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("language=%s", lang))
|
||||||
fmt.Printf("DEBUG setting audio language: stream %d -> language=%s\n", i, lang)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, stream := range streamLangs {
|
for _, stream := range streamLangs {
|
||||||
@@ -366,7 +393,6 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-metadata:s:s:%d", idx), fmt.Sprintf("language=%s", stream.Language))
|
args = append(args, fmt.Sprintf("-metadata:s:s:%d", idx), fmt.Sprintf("language=%s", stream.Language))
|
||||||
fmt.Printf("DEBUG setting subtitle language: stream %d -> language=%s\n", idx, stream.Language)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,9 +413,8 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
|
|||||||
args = append(args, outFile)
|
args = append(args, outFile)
|
||||||
|
|
||||||
args = append([]string{"-hide_banner", "-v", "error"}, args...)
|
args = append([]string{"-hide_banner", "-v", "error"}, args...)
|
||||||
cmd := exec.CommandContext(ctx, e.ffmpegPath, args...)
|
out, err := e.runCmd(ctx, "ffmpeg encode", input, e.ffmpegPath, args)
|
||||||
fmt.Printf("DEBUG FFmpeg command: ffmpeg %s\n", strings.Join(args, " "))
|
if err != nil {
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("ffmpeg encode: %s %w", out, err)
|
return fmt.Errorf("ffmpeg encode: %s %w", out, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+80
-58
@@ -1,94 +1,116 @@
|
|||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"videnc-vibe/pkg/types"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
LevelDebug = "debug"
|
||||||
|
LevelInfo = "info"
|
||||||
|
LevelError = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
infoPath string
|
db *sql.DB
|
||||||
errorPath string
|
dbPath string
|
||||||
structPath string
|
retentionDays int
|
||||||
infoFile *os.File
|
|
||||||
errorFile *os.File
|
|
||||||
structFile *os.File
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logDir string) (*Logger, error) {
|
// New opens (or creates) logs.db inside logDir, applies the schema, and runs
|
||||||
|
// an initial retention purge. retentionDays <= 0 falls back to 7.
|
||||||
|
func New(logDir string, retentionDays int) (*Logger, error) {
|
||||||
|
if retentionDays <= 0 {
|
||||||
|
retentionDays = 7
|
||||||
|
}
|
||||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("creating log directory: %w", err)
|
return nil, fmt.Errorf("creating log directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Format("2006-01-02")
|
dbPath := filepath.Join(logDir, "logs.db")
|
||||||
infoPath := filepath.Join(logDir, fmt.Sprintf("info_%s.log", now))
|
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||||
errorPath := filepath.Join(logDir, fmt.Sprintf("error_%s.log", now))
|
|
||||||
structPath := filepath.Join(logDir, "structured.json")
|
|
||||||
|
|
||||||
infoFile, err := os.OpenFile(infoPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening info log: %w", err)
|
return nil, fmt.Errorf("opening log db: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
errorFile, err := os.OpenFile(errorPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
schema := `
|
||||||
if err != nil {
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
return nil, fmt.Errorf("opening error log: %w", err)
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
file TEXT,
|
||||||
|
extra TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_ts_level ON logs(ts, level);
|
||||||
|
`
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("applying log schema: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
structFile, err := os.OpenFile(structPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
l := &Logger{db: db, dbPath: dbPath, retentionDays: retentionDays}
|
||||||
if err != nil {
|
l.purge()
|
||||||
return nil, fmt.Errorf("opening structured log: %w", err)
|
return l, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Logger{
|
func (l *Logger) purge() {
|
||||||
infoPath: infoPath,
|
cutoff := fmt.Sprintf("-%d days", l.retentionDays)
|
||||||
errorPath: errorPath,
|
_, _ = l.db.Exec(`DELETE FROM logs WHERE ts < datetime('now', ?)`, cutoff)
|
||||||
structPath: structPath,
|
}
|
||||||
infoFile: infoFile,
|
|
||||||
errorFile: errorFile,
|
func (l *Logger) write(level, message, file, extra string) {
|
||||||
structFile: structFile,
|
_, _ = l.db.Exec(
|
||||||
}, nil
|
`INSERT INTO logs(level, message, file, extra) VALUES(?, ?, ?, ?)`,
|
||||||
|
level, message, nullIfEmpty(file), nullIfEmpty(extra),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullIfEmpty(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func stamp() string {
|
||||||
|
return time.Now().Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Info(message string) {
|
func (l *Logger) Info(message string) {
|
||||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
fmt.Fprintf(os.Stdout, "[%s] INFO: %s\n", stamp(), message)
|
||||||
entry := fmt.Sprintf("[%s] INFO: %s\n", timestamp, message)
|
l.write(LevelInfo, message, "", "")
|
||||||
l.infoFile.WriteString(entry)
|
|
||||||
l.writeStructured("info", message, "", "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Error(message, err string) {
|
func (l *Logger) Error(message, errStr string) {
|
||||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
msg := fmt.Sprintf("%s: %s", message, errStr)
|
||||||
entry := fmt.Sprintf("[%s] ERROR: %s - %s\n", timestamp, message, err)
|
fmt.Fprintf(os.Stderr, "[%s] ERROR: %s\n", stamp(), msg)
|
||||||
l.errorFile.WriteString(entry)
|
l.write(LevelError, msg, "", "")
|
||||||
l.writeStructured("error", message, err, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) ErrorFile(file string, message, err string) {
|
func (l *Logger) ErrorFile(file, message, errStr string) {
|
||||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
msg := fmt.Sprintf("%s: %s", message, errStr)
|
||||||
entry := fmt.Sprintf("[%s] ERROR: %s - %s (file: %s)\n", timestamp, message, err, file)
|
fmt.Fprintf(os.Stderr, "[%s] ERROR: %s (file: %s)\n", stamp(), msg, file)
|
||||||
l.errorFile.WriteString(entry)
|
l.write(LevelError, msg, file, "")
|
||||||
l.writeStructured("error", message, err, file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) writeStructured(level, message, err, file string) {
|
// Debug records a debug entry to the database only. extra is an opaque string,
|
||||||
entry := types.LogEntry{
|
// typically JSON, used for ffprobe output, API response bodies, or command
|
||||||
Timestamp: time.Now(),
|
// stdout/stderr captures. Pass "" if not applicable.
|
||||||
Level: level,
|
func (l *Logger) Debug(message, file, extra string) {
|
||||||
Message: message,
|
l.write(LevelDebug, message, file, extra)
|
||||||
Error: err,
|
|
||||||
File: file,
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(entry)
|
|
||||||
l.structFile.WriteString(string(data) + "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) Close() {
|
func (l *Logger) Close() {
|
||||||
l.infoFile.Close()
|
if l.db == nil {
|
||||||
l.errorFile.Close()
|
return
|
||||||
l.structFile.Close()
|
}
|
||||||
|
l.purge()
|
||||||
|
l.db.Close()
|
||||||
|
l.db = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"videnc-vibe/internal/logger"
|
||||||
"videnc-vibe/pkg/types"
|
"videnc-vibe/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,15 +53,49 @@ type TVMazeEpisode struct {
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
omdbAPIKey string
|
omdbAPIKey string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
log *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(apiKey string) *Client {
|
func NewClient(apiKey string, log *logger.Logger) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
omdbAPIKey: apiKey,
|
omdbAPIKey: apiKey,
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redactURL strips secret query parameters (e.g. apikey) before logging.
|
||||||
|
func redactURL(rawURL string) string {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
for _, k := range []string{"apikey", "api_key"} {
|
||||||
|
if q.Has(k) {
|
||||||
|
q.Set(k, "REDACTED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) logHTTP(label, rawURL string, status int, body []byte) {
|
||||||
|
if c.log == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
extra, _ := json.Marshal(struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}{
|
||||||
|
URL: redactURL(rawURL),
|
||||||
|
Status: status,
|
||||||
|
Body: string(body),
|
||||||
|
})
|
||||||
|
c.log.Debug(label, "", string(extra))
|
||||||
|
}
|
||||||
|
|
||||||
func ParseFilename(filename string) (isSeries bool, imdbID, tvmazeID, season, episode string) {
|
func ParseFilename(filename string) (isSeries bool, imdbID, tvmazeID, season, episode string) {
|
||||||
filename = strings.TrimSuffix(filename, ".mkv")
|
filename = strings.TrimSuffix(filename, ".mkv")
|
||||||
|
|
||||||
@@ -111,9 +147,9 @@ func ParseMediaType(filename string) types.MediaType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) FetchMovieMetadata(ctx context.Context, imdbID string) (*types.Metadata, error) {
|
func (c *Client) FetchMovieMetadata(ctx context.Context, imdbID string) (*types.Metadata, error) {
|
||||||
url := fmt.Sprintf("%s?i=%s&apikey=%s", omdbAPIURL, imdbID, c.omdbAPIKey)
|
reqURL := fmt.Sprintf("%s?i=%s&apikey=%s", omdbAPIURL, imdbID, c.omdbAPIKey)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("building OMDb request: %w", err)
|
return nil, fmt.Errorf("building OMDb request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -128,6 +164,8 @@ func (c *Client) FetchMovieMetadata(ctx context.Context, imdbID string) (*types.
|
|||||||
return nil, fmt.Errorf("reading OMDb body: %w", err)
|
return nil, fmt.Errorf("reading OMDb body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.logHTTP("OMDb GET", reqURL, resp.StatusCode, body)
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("OMDb HTTP %d: %s", resp.StatusCode, snippet(body))
|
return nil, fmt.Errorf("OMDb HTTP %d: %s", resp.StatusCode, snippet(body))
|
||||||
}
|
}
|
||||||
@@ -197,8 +235,8 @@ func (c *Client) FetchSeriesMetadata(ctx context.Context, tvmazeID, season, epis
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) fetchTVMazeJSON(ctx context.Context, url string, v interface{}) error {
|
func (c *Client) fetchTVMazeJSON(ctx context.Context, reqURL string, v interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build request: %w", err)
|
return fmt.Errorf("build request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -213,6 +251,8 @@ func (c *Client) fetchTVMazeJSON(ctx context.Context, url string, v interface{})
|
|||||||
return fmt.Errorf("read body: %w", err)
|
return fmt.Errorf("read body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.logHTTP("TVmaze GET", reqURL, resp.StatusCode, body)
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet(body))
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet(body))
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-12
@@ -1,9 +1,5 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MediaType string
|
type MediaType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -37,6 +33,7 @@ type Metadata struct {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
OMDBAPIKey string `yaml:"omdb_api_key"`
|
OMDBAPIKey string `yaml:"omdb_api_key"`
|
||||||
|
LogRetentionDays int `yaml:"log_retention_days"`
|
||||||
Encoding EncodingConfig
|
Encoding EncodingConfig
|
||||||
Paths PathsConfig
|
Paths PathsConfig
|
||||||
}
|
}
|
||||||
@@ -60,11 +57,3 @@ type PathsConfig struct {
|
|||||||
Failed string `yaml:"failed"`
|
Failed string `yaml:"failed"`
|
||||||
Work string `yaml:"work"`
|
Work string `yaml:"work"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogEntry struct {
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
Level string `json:"level"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
File string `json:"file,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user