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
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
75 changes: 71 additions & 4 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package config

import (
"encoding/base64" // Uncommented
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)

// Config holds the application configuration.
Expand All @@ -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 {
Expand Down
64 changes: 59 additions & 5 deletions pkg/handlers/handlers.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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, "<pre>%s</pre>", 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) {
Expand Down
2 changes: 1 addition & 1 deletion pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading