diff --git a/Dockerfile b/Dockerfile index 0a6673f..8b5e446 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,7 @@ COPY web/templates/index.html . COPY web/templates/admin.html . COPY web/templates/login.html . COPY web/templates/error.html . +COPY web/templates/log.html . RUN mkdir -p /app/data diff --git a/README.md b/README.md index f814284..72c77a7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,15 @@ UniFi Time-Machine is a Go application that creates beautiful timelapse videos f There are several ways to run UniFi Time-Machine. The easiest way is to use Docker. +### Versions +Versions are linked to Git Tags on this repo such as `v0.0.1` and pushed to `Dockerhub`. Other tags of interest; + +- dev ( latest build on dev branches ) +- latest ( builds off main branch ) +- tags eg: `v1.2.3` ( recommended ) + +From the initial release, a security update was made to the container `user` which is now `appuser` with a UID/GID of `1000`, this may break your existing database on disk and require a `chown` to fix permissions. See release notes for details. + ### Running with Docker Compose (Recommended) This is the recommended way to run the application, as it simplifies the management of the container and its configuration. diff --git a/pkg/config/config.go b/pkg/config/config.go index ca04fdd..15ebd4d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,11 +2,13 @@ package config import ( "encoding/base64" // Uncommented + "fmt" "log" "os" "path/filepath" "strconv" "strings" + "time" ) // Config holds the application configuration. @@ -29,59 +31,124 @@ type Config struct { // AppConfig is the global application configuration. var AppConfig Config + + +// GetFFmpegLogPath returns the path to the ffmpeg log file for the current day. + +func GetFFmpegLogPath() string { + + today := time.Now().Format("2006-01-02") + + logFileName := fmt.Sprintf("ffmpeg_log_%s.txt", today) + + return filepath.Join(AppConfig.DataDir, logFileName) + +} + + + // GetCRFValue returns the CRF value based on the configured video quality. + func (c *Config) GetCRFValue() string { + switch strings.ToLower(c.VideoQuality) { + case "low": + return "35" + case "medium": + return "28" + case "high": + return "20" + case "ultra": + return "15" + default: + return "28" // Default to medium + } + } + + // LoadConfig loads the configuration from environment variables. + func LoadConfig() { + AppConfig = Config{ + UFPAPIKey: getEnv("UFP_API_KEY", ""), + TargetCameraID: getEnv("TARGET_CAMERA_ID", ""), + DataDir: getEnv("DATA_DIR", "data"), + SnapshotIntervalSec: getEnvAsInt("TIMELAPSE_INTERVAL", 3600), + VideoCronIntervalSec: getEnvAsInt("VIDEO_CRON_INTERVAL", 300), + VideoArchivesToKeep: getEnvAsInt("VIDEO_ARCHIVES_TO_KEEP", 3), + AppKey: getEnv("APP_KEY", ""), + AdminPassword: getEnv("ADMIN_PASSWORD", ""), + VideoQuality: getEnv("VIDEO_QUALITY", "medium"), + SnapshotsDir: getEnv("SNAPSHOTS_DIR", "snapshots"), + GalleryDir: getEnv("GALLERY_DIR", "gallery"), - FFmpegLogPath: getEnv("FFMPEG_LOG_PATH", "ffmpeg_log.txt"), + } + + // Validate APP_KEY + if AppConfig.AppKey == "" { + log.Fatal("FATAL: APP_KEY environment variable must be set.") + } - _, err := base64.StdEncoding.DecodeString(AppConfig.AppKey) + + _, err := base64.StdEncoding.DecodeString(AppConfig.AppKey) + if err != nil { - log.Fatalf("FATAL: APP_KEY is not a valid base64 encoded string: %v", err) + + log.Fatalf("FATAL: APP_KEY is not a valid base64 encoded string: %v", err) + } + + AppConfig.SnapshotsDir = filepath.Join(AppConfig.DataDir, AppConfig.SnapshotsDir) + AppConfig.GalleryDir = filepath.Join(AppConfig.DataDir, AppConfig.GalleryDir) - AppConfig.FFmpegLogPath = filepath.Join(AppConfig.DataDir, AppConfig.FFmpegLogPath) + + // Ensure UFP_HOST has a protocol scheme + AppConfig.UFPHost = getEnv("UFP_HOST", "") + if AppConfig.UFPHost != "" && !strings.Contains(AppConfig.UFPHost, "://") { + AppConfig.UFPHost = "https://" + AppConfig.UFPHost + } + + log.Printf("UFP Host set to: %s", AppConfig.UFPHost) + } func getEnv(key, defaultValue string) string { diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index 6f81a90..b49bd8e 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/handlers/handlers.go @@ -1,15 +1,27 @@ package handlers import ( + "fmt" + "net/http" + "os" + "path/filepath" + + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "time-machine/pkg/cachedstats" "time-machine/pkg/config" "time-machine/pkg/database" @@ -129,18 +141,60 @@ func HandleForceGenerate(c *gin.Context) { } func HandleLog(c *gin.Context) { - content, err := os.ReadFile(config.AppConfig.FFmpegLogPath) + logFiles, err := filepath.Glob(filepath.Join(config.AppConfig.DataDir, "ffmpeg_log_*.txt")) + if err != nil { + c.String(http.StatusInternalServerError, "Error finding log files: %v", err) + return + } + + if len(logFiles) == 0 { + c.HTML(http.StatusOK, "log.html", gin.H{ + "Message": "No log files found.", + }) + return + } + + // Sort files by name to get the most recent one last + sort.Sort(sort.Reverse(sort.StringSlice(logFiles))) + + var logDates []string + for _, file := range logFiles { + // Extract YYYY-MM-DD from the filename + name := filepath.Base(file) + dateStr := strings.TrimSuffix(strings.TrimPrefix(name, "ffmpeg_log_"), ".txt") + logDates = append(logDates, dateStr) + } + + // Determine which log to display + selectedDate := c.Query("date") + var logToShowPath string + if selectedDate != "" { + logToShowPath = filepath.Join(config.AppConfig.DataDir, fmt.Sprintf("ffmpeg_log_%s.txt", selectedDate)) + } else { + logToShowPath = logFiles[0] // Default to the latest + selectedDate = logDates[0] + } + + content, err := os.ReadFile(logToShowPath) if err != nil { - // Attempt to create an empty log file if it doesn't exist if os.IsNotExist(err) { - c.String(http.StatusOK, "FFmpeg log file does not exist yet.") + c.HTML(http.StatusNotFound, "log.html", gin.H{ + "Message": fmt.Sprintf("Log file for date %s not found.", selectedDate), + "AvailableDates": logDates, + }) return } c.String(http.StatusInternalServerError, "Error reading log file: %v", err) return } - // Use pre-formatted text for log output - c.String(http.StatusOK, "
%s", string(content)) + + user, _ := c.Get("user") + c.HTML(http.StatusOK, "log.html", gin.H{ + "User": user.(*models.User), + "LogContent": string(content), + "AvailableDates": logDates, + "SelectedDate": selectedDate, + }) } func HandleSystemStats(c *gin.Context) { diff --git a/pkg/server/server.go b/pkg/server/server.go index 8bd78e3..0f12f58 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -22,7 +22,7 @@ func SetupRouter() *gin.Engine { return template.JS(j), err }, }) - r.LoadHTMLFiles("index.html", "admin.html", "login.html", "error.html") + r.LoadHTMLFiles("index.html", "admin.html", "login.html", "error.html", "log.html") // Login page route (GET) - serves the login HTML r.GET("/login", handlers.HandleLoginGet) diff --git a/pkg/services/video/video.go b/pkg/services/video/video.go index 8ff6754..639f599 100644 --- a/pkg/services/video/video.go +++ b/pkg/services/video/video.go @@ -72,6 +72,7 @@ func createVideoSegment(imagePath, segmentPath string) error { if PreferredVideoCodec == "libsvtav1" { cmd = exec.Command("ffmpeg", "-loop", "1", + "-color_range", "2", // Specify full range for input JPEG "-i", imagePath, "-t", "0.0333", // Duration for a single frame at 30 FPS "-r", "30", // Set segment framerate to 30 @@ -82,6 +83,7 @@ func createVideoSegment(imagePath, segmentPath string) error { "-keyint_min", "1", "-crf", config.AppConfig.GetCRFValue(), // Matched with regenerateFullTimelapse "-pix_fmt", "yuv420p", // Ensure consistent pixel format + "-color_range", "1", // Explicitly set limited range for output "-an", "-f", "webm", "-y", segmentPath, @@ -89,6 +91,7 @@ func createVideoSegment(imagePath, segmentPath string) error { } else { cmd = exec.Command("ffmpeg", "-loop", "1", + "-color_range", "2", // Specify full range for input JPEG "-i", imagePath, "-t", "0.0333", // Duration for a single frame at 30 FPS "-r", "30", // Set segment framerate to 30 @@ -98,13 +101,14 @@ func createVideoSegment(imagePath, segmentPath string) error { "-keyint_min", "1", "-crf", config.AppConfig.GetCRFValue(), // Matched with regenerateFullTimelapse "-pix_fmt", "yuv420p", // Ensure consistent pixel format + "-color_range", "1", // Explicitly set limited range for output "-an", "-f", "webm", "-y", segmentPath, ) } - logFile, err := os.OpenFile(config.AppConfig.FFmpegLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + logFile, err := os.OpenFile(config.GetFFmpegLogPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("failed to open FFmpeg log file: %w", err) } @@ -157,11 +161,12 @@ func concatenateVideos(existingVideoPath, newSegmentPath, outputVideoPath string "-crf", config.AppConfig.GetCRFValue(), "-pix_fmt", "yuv420p", "-r", "30", // Set output framerate to 30 FPS + "-color_range", "1", // Explicitly set limited range for output "-y", tempOutput, ) cmd.Dir = config.AppConfig.DataDir - logFile, err := os.OpenFile(config.AppConfig.FFmpegLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + logFile, err := os.OpenFile(config.GetFFmpegLogPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("failed to open FFmpeg log file: %w", err) } @@ -230,6 +235,9 @@ func EnqueueTimelapseJobs() { if _, err := jobs.CreateJob("cleanup_videos", nil); err != nil { log.Printf("Error enqueuing cleanup_videos job: %v", err) } + if _, err := jobs.CreateJob("cleanup_logs", nil); err != nil { + log.Printf("Error enqueuing cleanup_logs job: %v", err) + } } @@ -428,18 +436,20 @@ func regenerateFullTimelapse(snapshotFiles []string, outputFileName string) erro cmd := exec.Command("ffmpeg", "-f", "concat", "-safe", "0", + "-color_range", "2", // Specify full range for input JPEGs "-i", listFileName, "-r", "30", // Set output framerate to 30 FPS "-c:v", PreferredVideoCodec, // Use the detected preferred codec "-b:v", "0", // Use CRF for quality "-crf", config.AppConfig.GetCRFValue(), // Good balance of quality and size "-pix_fmt", "yuv420p", + "-color_range", "1", // Explicitly set limited range for output "-y", "temp_"+outputFileName, ) cmd.Dir = config.AppConfig.DataDir // Capture FFmpeg output to main log - logFile, err := os.OpenFile(config.AppConfig.FFmpegLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + logFile, err := os.OpenFile(config.GetFFmpegLogPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } @@ -553,6 +563,44 @@ func CleanOldVideos() { log.Printf("Error removing old video archive %s: %v", fileName, err) } } - log.Printf("Finished cleanup for %s. Removed %d archive(s).", cfg.Name, len(filesToDelete)) - } -} \ No newline at end of file + log.Printf("Finished cleanup for %s. Removed %d archive(s).", cfg.Name, len(filesToDelete)) + } + } + + func CleanupLogFiles() { + log.Println("Starting log file cleanup...") + files, err := filepath.Glob(filepath.Join(config.AppConfig.DataDir, "ffmpeg_log_*.txt")) + if err != nil { + log.Printf("Error finding log files for cleanup: %v", err) + return + } + + retentionDuration := 7 * 24 * time.Hour + cutoff := time.Now().Add(-retentionDuration) + filesToDelete := 0 + + for _, file := range files { + name := filepath.Base(file) + dateStr := strings.TrimSuffix(strings.TrimPrefix(name, "ffmpeg_log_"), ".txt") + fileDate, err := time.Parse("2006-01-02", dateStr) + if err != nil { + log.Printf("Warning: could not parse date from log file %s: %v", name, err) + continue + } + + if fileDate.Before(cutoff) { + if err := os.Remove(file); err != nil { + log.Printf("Warning: failed to remove log file %s: %v", file, err) + } else { + filesToDelete++ + } + } + } + + if filesToDelete > 0 { + log.Printf("Log file cleanup complete. Removed %d old log(s).", filesToDelete) + } else { + log.Println("No old log files to clean up.") + } + } + \ No newline at end of file diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go index 69c20c2..728bb53 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -51,6 +51,8 @@ func Start() { video.CleanupSnapshots() case "cleanup_videos": video.CleanOldVideos() + case "cleanup_logs": + video.CleanupLogFiles() default: jobErr = fmt.Errorf("unknown job type: %s", job.JobType) log.Println(jobErr) diff --git a/web/templates/log.html b/web/templates/log.html new file mode 100644 index 0000000..24ac34f --- /dev/null +++ b/web/templates/log.html @@ -0,0 +1,65 @@ + + + + + +
{{ .LogContent }}
+