package video
import (
"fmt"
"html/template"
"net/http"
"net/url"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/footage"
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates"
"github.com/go-chi/chi/v5"
)
type Handler struct {
cfg *config.Config
idx *footage.Index
thumbs *Cache
ffmpegPath string
}
type ShellData struct {
Title string
User *auth.User
Months []MonthGroup
CurrentDate string
ActiveTab string
Content any
}
type MonthGroup struct {
Label string
Days []string
}
type PageData struct {
Date string
Videos []VideoItem
ActiveIndex int
PrevIndex int
NextIndex int
Current VideoItem
}
type VideoItem struct {
Index int
Filename string
RelPath string
StreamURL string
ThumbURL string
DurationLabel string
Active bool
}
func NewHandler(cfg *config.Config, idx *footage.Index) *Handler {
return &Handler{
cfg: cfg,
idx: idx,
ffmpegPath: defaultFFmpegPath,
thumbs: NewCache(defaultCacheEntries, defaultFFmpegPath),
}
}
func (h *Handler) ServeStream(w http.ResponseWriter, r *http.Request) {
absPath, ok := h.resolveRequestPath(w, r)
if !ok {
return
}
if err := Stream(w, r, absPath, h.ffmpegPath); err != nil {
http.Error(w, "stream video failed", http.StatusBadGateway)
}
}
func (h *Handler) ServeThumb(w http.ResponseWriter, r *http.Request) {
absPath, ok := h.resolveRequestPath(w, r)
if !ok {
return
}
thumb, err := h.thumbs.Thumbnail(absPath)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "max-age=3600")
w.Header().Set("Content-Type", "image/jpeg")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(thumb)
}
func (h *Handler) ServePage(w http.ResponseWriter, r *http.Request) {
date := chi.URLParam(r, "date")
day, ok := h.day(date)
if !ok {
http.NotFound(w, r)
return
}
data := h.pageData(date, day.Videos, parseIndex(r.URL.Query().Get("idx"), len(day.Videos)))
h.render(w, r, ShellData{
Title: "CamMonitor " + date + " videos",
CurrentDate: date,
ActiveTab: "videos",
Content: data,
})
}
func (h *Handler) pageData(date string, files []footage.VideoFile, active int) PageData {
items := make([]VideoItem, 0, len(files))
for i, file := range files {
items = append(items, VideoItem{
Index: i,
Filename: file.Filename,
RelPath: filepath.ToSlash(file.RelPath),
StreamURL: "/stream/video/" + pathEscape(file.RelPath),
ThumbURL: "/thumb/video/" + pathEscape(file.RelPath),
DurationLabel: formatDuration(file.Duration),
Active: i == active,
})
}
page := PageData{
Date: date,
Videos: items,
ActiveIndex: active,
}
if len(items) > 0 {
page.PrevIndex = (active - 1 + len(items)) % len(items)
page.NextIndex = (active + 1) % len(items)
page.Current = items[active]
}
return page
}
func (h *Handler) resolveRequestPath(w http.ResponseWriter, r *http.Request) (string, bool) {
relPath := chi.URLParam(r, "*")
if routePattern := chi.RouteContext(r.Context()).RoutePattern(); routePattern != "" {
prefix := strings.TrimSuffix(routePattern, "*")
escaped := strings.TrimPrefix(r.URL.EscapedPath(), prefix)
if unescaped, err := url.PathUnescape(escaped); err == nil && unescaped != "" {
relPath = unescaped
}
}
absPath, err := h.resolveRelPath(relPath)
if err != nil {
http.Error(w, "invalid video path", http.StatusBadRequest)
return "", false
}
return absPath, true
}
func (h *Handler) resolveRelPath(relPath string) (string, error) {
if h.cfg == nil || h.cfg.FootageRoot == "" {
return "", fmt.Errorf("missing footage root")
}
if relPath == "" {
return "", fmt.Errorf("missing path")
}
clean := filepath.Clean(filepath.FromSlash(relPath))
if clean == "." || clean == ".." || filepath.IsAbs(clean) || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("unsafe path")
}
root, err := filepath.Abs(h.cfg.FootageRoot)
if err != nil {
return "", err
}
target, err := filepath.Abs(filepath.Join(root, clean))
if err != nil {
return "", err
}
rel, err := filepath.Rel(root, target)
if err != nil {
return "", err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes footage root")
}
return target, nil
}
func (h *Handler) render(w http.ResponseWriter, r *http.Request, data ShellData) {
data.User, _ = auth.UserFromContext(r.Context())
data.Months = groupDaysByMonth(h.dayList())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := videoTemplate.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "render video page failed", http.StatusInternalServerError)
}
}
func (h *Handler) dayList() []string {
if h.idx == nil {
return nil
}
return h.idx.DayList()
}
func (h *Handler) day(date string) (*footage.DayEntry, bool) {
if h.idx == nil {
return nil, false
}
return h.idx.Day(date)
}
func parseIndex(value string, length int) int {
if length <= 0 {
return 0
}
index, err := strconv.Atoi(value)
if err != nil || index < 0 || index >= length {
return 0
}
return index
}
func pathEscape(relPath string) string {
parts := strings.Split(filepath.ToSlash(relPath), "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func groupDaysByMonth(days []string) []MonthGroup {
groups := make(map[string][]string)
labels := make([]string, 0)
for _, day := range days {
if len(day) != len("20060102") {
continue
}
parsed, err := time.Parse("20060102", day)
if err != nil {
continue
}
label := parsed.Format("2006-01")
if _, ok := groups[label]; !ok {
labels = append(labels, label)
}
groups[label] = append(groups[label], day)
}
sort.Sort(sort.Reverse(sort.StringSlice(labels)))
monthGroups := make([]MonthGroup, 0, len(labels))
for _, label := range labels {
monthGroups = append(monthGroups, MonthGroup{
Label: label,
Days: groups[label],
})
}
return monthGroups
}
func formatDuration(duration time.Duration) string {
totalSeconds := int(duration.Round(time.Second) / time.Second)
if totalSeconds < 0 {
totalSeconds = 0
}
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60
if hours > 0 {
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
return fmt.Sprintf("%02d:%02d", minutes, seconds)
}
var videoTemplate = template.Must(template.ParseFS(
webtemplates.FS,
webtemplates.Base,
webtemplates.Videos,
))