Compare commits
17 Commits
3dd752cb34
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e36e53805 | ||
|
|
9de277a879 | ||
|
|
eb12586a40 | ||
|
|
03565e4fbc | ||
|
|
9bda977a29 | ||
|
|
6a8445fbd0 | ||
|
|
0e4da7e436 | ||
|
|
5b67d5c5f0 | ||
|
|
93aecf47c6 | ||
|
|
69e2789b9b | ||
|
|
3e6d2874d5 | ||
|
|
e377a4f5db | ||
|
|
2668cbed7d | ||
|
|
bea85bc736 | ||
|
|
b5c146bd1d | ||
|
|
c761ea38b8 | ||
|
|
236b1354ac |
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/cmd/prune/prune
|
||||||
|
/prune
|
||||||
|
/cmd/ui/ui
|
||||||
|
/ui
|
||||||
|
**/*.sw*
|
||||||
|
**/testdata
|
||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ffmpeg.d/pkg/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -66,7 +68,17 @@ func Run(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
if seriesHasMovement, err := seriesHasMovement(ctx, seriesFiles, movementInterval); err != nil {
|
if seriesHasMovement, err := seriesHasMovement(ctx, seriesFiles, movementInterval); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if seriesHasMovement {
|
} else if seriesHasMovement > -1 {
|
||||||
|
outd := strings.ReplaceAll(cam, "record", "movement")
|
||||||
|
os.MkdirAll(outd, os.ModePerm)
|
||||||
|
|
||||||
|
fileWithMovement := seriesFiles[seriesHasMovement]
|
||||||
|
if b, err := os.ReadFile(fileWithMovement); err != nil {
|
||||||
|
return err
|
||||||
|
} else if err := os.WriteFile(path.Join(outd, series+path.Ext(fileWithMovement)), b, os.ModePerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
firstFileInfo, err := os.Stat(seriesFiles[0])
|
firstFileInfo, err := os.Stat(seriesFiles[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -81,8 +93,6 @@ func Run(ctx context.Context, args []string) error {
|
|||||||
|
|
||||||
log.Println("found movement in", series, "over", durationSeconds, "seconds at", fps, "fps")
|
log.Println("found movement in", series, "over", durationSeconds, "seconds at", fps, "fps")
|
||||||
|
|
||||||
outd := strings.ReplaceAll(cam, "record", "movement")
|
|
||||||
os.MkdirAll(outd, os.ModePerm)
|
|
||||||
cmd := exec.CommandContext(ctx,
|
cmd := exec.CommandContext(ctx,
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
@@ -105,22 +115,67 @@ func Run(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}(); err != nil {
|
}(); err != nil {
|
||||||
return err
|
log.Println("failed series", series, ":", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, cam := range cams {
|
||||||
|
if err := func() error {
|
||||||
|
camMovementD := strings.ReplaceAll(cam, "record", "movement")
|
||||||
|
|
||||||
|
movementFiles, err := lsf(camMovementD)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sort.Strings(movementFiles)
|
||||||
|
|
||||||
|
var sizeOfCamDMiB int64
|
||||||
|
for _, movementFile := range movementFiles {
|
||||||
|
stat, err := os.Stat(movementFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fileSizeMiB := stat.Size() / 1024 / 1024
|
||||||
|
sizeOfCamDMiB += fileSizeMiB
|
||||||
|
}
|
||||||
|
|
||||||
|
GiB100 := int64(100 * 1024)
|
||||||
|
for sizeOfCamDMiB > GiB100 && len(movementFiles) > 0 {
|
||||||
|
oldestFile := movementFiles[0]
|
||||||
|
|
||||||
|
oldestFileStat, err := os.Stat(oldestFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
oldestFileSizeMiB := oldestFileStat.Size() / 1024 / 1024
|
||||||
|
|
||||||
|
log.Println("deleting old file", oldestFile)
|
||||||
|
if err := os.Remove(oldestFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeOfCamDMiB -= oldestFileSizeMiB
|
||||||
|
movementFiles = movementFiles[1:]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}(); err != nil {
|
||||||
|
log.Println("failed to cap size of cam", cam, " dir:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func seriesHasMovement(ctx context.Context, files []string, movementInterval time.Duration) (bool, error) {
|
func seriesHasMovement(ctx context.Context, files []string, movementInterval time.Duration) (int, error) {
|
||||||
if len(files) < 1 {
|
if len(files) < 1 {
|
||||||
return false, nil
|
return -1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
f := files[0]
|
f := files[0]
|
||||||
fStat, err := os.Stat(f)
|
fStat, err := os.Stat(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return -1, err
|
||||||
}
|
}
|
||||||
fTime := fStat.ModTime()
|
fTime := fStat.ModTime()
|
||||||
|
|
||||||
@@ -128,7 +183,7 @@ func seriesHasMovement(ctx context.Context, files []string, movementInterval tim
|
|||||||
g := files[i]
|
g := files[i]
|
||||||
gStat, err := os.Stat(g)
|
gStat, err := os.Stat(g)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return -1, err
|
||||||
}
|
}
|
||||||
gTime := gStat.ModTime()
|
gTime := gStat.ModTime()
|
||||||
|
|
||||||
@@ -137,15 +192,15 @@ func seriesHasMovement(ctx context.Context, files []string, movementInterval tim
|
|||||||
}
|
}
|
||||||
|
|
||||||
if hasMovement, err := hasMovement(ctx, f, g); err != nil {
|
if hasMovement, err := hasMovement(ctx, f, g); err != nil {
|
||||||
return false, fmt.Errorf("failed to check for movement between %s and %s: %w", f, g, err)
|
return -1, fmt.Errorf("failed to check for movement between %s and %s: %w", f, g, err)
|
||||||
} else if hasMovement {
|
} else if hasMovement {
|
||||||
return true, nil
|
return i, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
f = g
|
f = g
|
||||||
fTime = gTime
|
fTime = gTime
|
||||||
}
|
}
|
||||||
return false, nil
|
return -1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasMovement(ctx context.Context, a, b string) (bool, error) {
|
func hasMovement(ctx context.Context, a, b string) (bool, error) {
|
||||||
@@ -204,23 +259,9 @@ func mv(wPath, rPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func lsd(d string) ([]string, error) {
|
func lsd(d string) ([]string, error) {
|
||||||
return ls(d, true)
|
return fs.LsD(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
func lsf(d string) ([]string, error) {
|
func lsf(d string) ([]string, error) {
|
||||||
return ls(d, false)
|
return fs.LsF(d)
|
||||||
}
|
|
||||||
|
|
||||||
func ls(d string, dirs bool) ([]string, error) {
|
|
||||||
entries, err := os.ReadDir(d)
|
|
||||||
|
|
||||||
results := make([]string, 0, len(entries))
|
|
||||||
for i := range entries {
|
|
||||||
if dirs == entries[i].IsDir() {
|
|
||||||
results = append(results, path.Join(d, entries[i].Name()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(results)
|
|
||||||
|
|
||||||
return results, err
|
|
||||||
}
|
}
|
||||||
@@ -57,9 +57,11 @@ func TestRun(t *testing.T) {
|
|||||||
|
|
||||||
if results, err := lsf(path.Join(path.Dir(d), "movement")); err != nil {
|
if results, err := lsf(path.Join(path.Dir(d), "movement")); err != nil {
|
||||||
t.Fatal("failed to lsf for results:", err)
|
t.Fatal("failed to lsf for results:", err)
|
||||||
} else if len(results) != 1 {
|
} else if len(results) != 2 {
|
||||||
t.Fatal(results)
|
t.Fatal(results)
|
||||||
} else if path.Base(results[0]) != seriesA+".mp4" {
|
} else if path.Base(results[0]) != seriesA+".jpg" {
|
||||||
|
t.Fatal(results)
|
||||||
|
} else if path.Base(results[1]) != seriesA+".mp4" {
|
||||||
t.Fatal(results)
|
t.Fatal(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.webm
vendored
Normal file
|
Before Width: | Height: | Size: 9.1 MiB After Width: | Height: | Size: 9.1 MiB |
|
Before Width: | Height: | Size: 8.3 MiB After Width: | Height: | Size: 8.3 MiB |
49
cmd/rmdir/main.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"ffmpeg.d/pkg/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := Run(context.Background(), os.Args[1:]); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, args []string) error {
|
||||||
|
cams, err := lsd(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to lsd %s: %w", args[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cam := range cams {
|
||||||
|
files, err := lsf(cam)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to lsf %s: %w", cam, err)
|
||||||
|
} else if len(files) < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
log.Println("rm", file)
|
||||||
|
if os.Getenv("DRY_RUN") == "false" {
|
||||||
|
os.Remove(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lsd(d string) ([]string, error) {
|
||||||
|
return fs.LsD(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lsf(d string) ([]string, error) {
|
||||||
|
return fs.LsF(d)
|
||||||
|
}
|
||||||
21
cmd/ui/index.html.tmpl
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<header>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
|
||||||
|
</header>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
{{ range .Series }}
|
||||||
|
<div style="display: inline-block; width: 12em; height: 9em; margin-bottom: .7em;">
|
||||||
|
<a href="/media/{{ .HREF }}">
|
||||||
|
<img src="/media/{{ .Thumbnail }}" alt="{{ .HREF }}"/>
|
||||||
|
<span>{{ .Thumbnail }}</span>
|
||||||
|
<br>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<footer>
|
||||||
|
</footer>
|
||||||
|
</html>
|
||||||
125
cmd/ui/main.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
_ "embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"ffmpeg.d/pkg/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed *.tmpl
|
||||||
|
var TMPL embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
|
||||||
|
defer can()
|
||||||
|
|
||||||
|
if err := Run(ctx, os.Args[1:]); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, args []string) error {
|
||||||
|
flags := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||||
|
d := flags.String("d", "./testdata/", "directory containing directories of (x.jpg,x.mp4)")
|
||||||
|
p := flags.Int("p", 38080, "port to listen on")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFS(TMPL, "*.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", *p),
|
||||||
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/media/") {
|
||||||
|
http.StripPrefix("/media/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=31536000")
|
||||||
|
w.Header().Add("Cache-Control", "immutable")
|
||||||
|
http.FileServer(http.Dir(*d)).ServeHTTP(w, r)
|
||||||
|
})).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type Series struct {
|
||||||
|
HREF string
|
||||||
|
Thumbnail string
|
||||||
|
}
|
||||||
|
|
||||||
|
ds, err := fs.LsD(*d)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seriesByKey := map[string]Series{}
|
||||||
|
for _, d := range ds {
|
||||||
|
files, err := fs.LsF(d)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
key := strings.Split(path.Base(f), ".")[0]
|
||||||
|
v := seriesByKey[key]
|
||||||
|
switch path.Ext(f) {
|
||||||
|
case ".jpg":
|
||||||
|
v.Thumbnail = path.Join(path.Base(d), path.Base(f))
|
||||||
|
case ".mp4":
|
||||||
|
v.HREF = path.Join(path.Base(d), path.Base(f))
|
||||||
|
}
|
||||||
|
seriesByKey[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
series := []Series{}
|
||||||
|
for _, v := range seriesByKey {
|
||||||
|
series = append(series, v)
|
||||||
|
}
|
||||||
|
slices.SortFunc(series, func(a, b Series) int {
|
||||||
|
return -1 * strings.Compare(path.Base(a.HREF), path.Base(b.HREF))
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, map[string]any{
|
||||||
|
"Series": series,
|
||||||
|
}); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
BaseContext: func(net.Listener) context.Context {
|
||||||
|
return ctx
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer s.Shutdown(ctx)
|
||||||
|
|
||||||
|
errs := make(chan error)
|
||||||
|
go func() {
|
||||||
|
defer close(errs)
|
||||||
|
|
||||||
|
errs <- s.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errs:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.Shutdown(ctx)
|
||||||
|
for range errs {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
1
cmd/ui/testdata/cam/1.jpg
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.jpg
|
||||||
1
cmd/ui/testdata/cam/1.mp4
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.mp4
|
||||||
1
cmd/ui/testdata/cam/2.jpg
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.jpg
|
||||||
1
cmd/ui/testdata/cam/2.mp4
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.mp4
|
||||||
1
cmd/ui/testdata/cam/3.jpg
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.jpg
|
||||||
1
cmd/ui/testdata/cam/3.mp4
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.mp4
|
||||||
1
cmd/ui/testdata/cam/4.jpg
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.jpg
|
||||||
1
cmd/ui/testdata/cam/5.mp4
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.mp4
|
||||||
1
cmd/ui/testdata/cam/6.jpg
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.jpg
|
||||||
1
cmd/ui/testdata/cam/6.mp4
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.mp4
|
||||||
BIN
cmd/ui/testdata/cam/LAdN7KVw-Ig.jpg
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
cmd/ui/testdata/cam/LAdN7KVw-Ig.mp4
vendored
Normal file
29
pkg/fs/ls.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package fs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LsD(d string) ([]string, error) {
|
||||||
|
return ls(d, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LsF(d string) ([]string, error) {
|
||||||
|
return ls(d, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ls(d string, dirs bool) ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(d)
|
||||||
|
|
||||||
|
results := make([]string, 0, len(entries))
|
||||||
|
for i := range entries {
|
||||||
|
if dirs == entries[i].IsDir() {
|
||||||
|
results = append(results, path.Join(d, entries[i].Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(results)
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
3
testdata/ffmpeg.d/.gitignore
vendored
@@ -1,3 +0,0 @@
|
|||||||
**/*.sw*
|
|
||||||
/cmd/prune/prune
|
|
||||||
/cmd/prune/testdata/**
|
|
||||||