You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
288 lines
6.7 KiB
288 lines
6.7 KiB
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,
|
|
))
|
|
|