15 changed files with 600 additions and 5 deletions
@ -0,0 +1,104 @@ |
|||||
|
package footage |
||||
|
|
||||
|
import ( |
||||
|
"sort" |
||||
|
"sync" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
type Index struct { |
||||
|
root string |
||||
|
interval time.Duration |
||||
|
|
||||
|
mu sync.RWMutex |
||||
|
days map[string]DayEntry |
||||
|
|
||||
|
done chan struct{} |
||||
|
once sync.Once |
||||
|
} |
||||
|
|
||||
|
func NewIndex(root string, interval time.Duration) *Index { |
||||
|
index := &Index{ |
||||
|
root: root, |
||||
|
interval: interval, |
||||
|
days: make(map[string]DayEntry), |
||||
|
done: make(chan struct{}), |
||||
|
} |
||||
|
index.rescan() |
||||
|
|
||||
|
if interval > 0 { |
||||
|
go index.run() |
||||
|
} |
||||
|
|
||||
|
return index |
||||
|
} |
||||
|
|
||||
|
func (i *Index) DayList() []string { |
||||
|
i.mu.RLock() |
||||
|
defer i.mu.RUnlock() |
||||
|
|
||||
|
days := make([]string, 0, len(i.days)) |
||||
|
for day := range i.days { |
||||
|
days = append(days, day) |
||||
|
} |
||||
|
sort.Sort(sort.Reverse(sort.StringSlice(days))) |
||||
|
return days |
||||
|
} |
||||
|
|
||||
|
func (i *Index) Day(date string) (*DayEntry, bool) { |
||||
|
i.mu.RLock() |
||||
|
defer i.mu.RUnlock() |
||||
|
|
||||
|
entry, ok := i.days[date] |
||||
|
if !ok { |
||||
|
return nil, false |
||||
|
} |
||||
|
|
||||
|
copied := copyDayEntry(entry) |
||||
|
return &copied, true |
||||
|
} |
||||
|
|
||||
|
func (i *Index) Close() { |
||||
|
i.once.Do(func() { |
||||
|
close(i.done) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func (i *Index) run() { |
||||
|
ticker := time.NewTicker(i.interval) |
||||
|
defer ticker.Stop() |
||||
|
|
||||
|
for { |
||||
|
select { |
||||
|
case <-ticker.C: |
||||
|
i.rescan() |
||||
|
case <-i.done: |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (i *Index) rescan() { |
||||
|
days, err := Scan(i.root) |
||||
|
if err != nil { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
next := make(map[string]DayEntry, len(days)) |
||||
|
for _, day := range days { |
||||
|
next[day.Date] = copyDayEntry(day) |
||||
|
} |
||||
|
|
||||
|
i.mu.Lock() |
||||
|
i.days = next |
||||
|
i.mu.Unlock() |
||||
|
} |
||||
|
|
||||
|
func copyDayEntry(entry DayEntry) DayEntry { |
||||
|
copied := DayEntry{ |
||||
|
Date: entry.Date, |
||||
|
Images: append([]ImageFile(nil), entry.Images...), |
||||
|
Videos: append([]VideoFile(nil), entry.Videos...), |
||||
|
} |
||||
|
return copied |
||||
|
} |
||||
@ -0,0 +1,96 @@ |
|||||
|
package footage |
||||
|
|
||||
|
import ( |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"sync" |
||||
|
"testing" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
func TestIndexListsDaysNewestFirstAndReturnsCopies(t *testing.T) { |
||||
|
root := filepath.Join("..", "..", "testdata", "footage") |
||||
|
|
||||
|
index := NewIndex(root, time.Hour) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
days := index.DayList() |
||||
|
if len(days) != 2 { |
||||
|
t.Fatalf("expected 2 days, got %d", len(days)) |
||||
|
} |
||||
|
if days[0] != "20260102" || days[1] != "20260101" { |
||||
|
t.Fatalf("expected days sorted newest first, got %#v", days) |
||||
|
} |
||||
|
|
||||
|
entry, ok := index.Day("20260101") |
||||
|
if !ok { |
||||
|
t.Fatalf("expected day 20260101") |
||||
|
} |
||||
|
entry.Images = nil |
||||
|
|
||||
|
entryAgain, ok := index.Day("20260101") |
||||
|
if !ok { |
||||
|
t.Fatalf("expected day 20260101 on second lookup") |
||||
|
} |
||||
|
if len(entryAgain.Images) != 2 { |
||||
|
t.Fatalf("expected index day copy to preserve images, got %d", len(entryAgain.Images)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestIndexPeriodicRescan(t *testing.T) { |
||||
|
root := t.TempDir() |
||||
|
writeFixtureFile(t, root, filepath.Join("20260101", "images", "A26010112000001.jpg")) |
||||
|
|
||||
|
index := NewIndex(root, 10*time.Millisecond) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
if days := index.DayList(); len(days) != 1 || days[0] != "20260101" { |
||||
|
t.Fatalf("expected initial day 20260101, got %#v", days) |
||||
|
} |
||||
|
|
||||
|
writeFixtureFile(t, root, filepath.Join("20260102", "record", "A260102_130000_130015.265")) |
||||
|
|
||||
|
deadline := time.Now().Add(time.Second) |
||||
|
for time.Now().Before(deadline) { |
||||
|
days := index.DayList() |
||||
|
if len(days) == 2 && days[0] == "20260102" && days[1] == "20260101" { |
||||
|
return |
||||
|
} |
||||
|
time.Sleep(10 * time.Millisecond) |
||||
|
} |
||||
|
|
||||
|
t.Fatalf("timed out waiting for rescan to include new day, got %#v", index.DayList()) |
||||
|
} |
||||
|
|
||||
|
func TestIndexConcurrentAccessDuringRescan(t *testing.T) { |
||||
|
root := t.TempDir() |
||||
|
writeFixtureFile(t, root, filepath.Join("20260101", "images", "A26010112000001.jpg")) |
||||
|
|
||||
|
index := NewIndex(root, time.Millisecond) |
||||
|
t.Cleanup(index.Close) |
||||
|
|
||||
|
var wg sync.WaitGroup |
||||
|
for worker := 0; worker < 8; worker++ { |
||||
|
wg.Add(1) |
||||
|
go func() { |
||||
|
defer wg.Done() |
||||
|
for i := 0; i < 100; i++ { |
||||
|
_ = index.DayList() |
||||
|
_, _ = index.Day("20260101") |
||||
|
} |
||||
|
}() |
||||
|
} |
||||
|
wg.Wait() |
||||
|
} |
||||
|
|
||||
|
func writeFixtureFile(t *testing.T, root, relPath string) { |
||||
|
t.Helper() |
||||
|
|
||||
|
path := filepath.Join(root, relPath) |
||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
||||
|
t.Fatalf("create fixture dir: %v", err) |
||||
|
} |
||||
|
if err := os.WriteFile(path, []byte("fixture"), 0o644); err != nil { |
||||
|
t.Fatalf("write fixture file: %v", err) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,212 @@ |
|||||
|
package footage |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"regexp" |
||||
|
"sort" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
var ( |
||||
|
imageFilenamePattern = regexp.MustCompile(`^A(\d{6})(\d{6})\d+\.jpg$`) |
||||
|
videoFilenamePattern = regexp.MustCompile(`^A(\d{6})_(\d{6})_(\d{6})\.265$`) |
||||
|
) |
||||
|
|
||||
|
type DayEntry struct { |
||||
|
Date string |
||||
|
Images []ImageFile |
||||
|
Videos []VideoFile |
||||
|
} |
||||
|
|
||||
|
type ImageFile struct { |
||||
|
RelPath string |
||||
|
Filename string |
||||
|
Timestamp time.Time |
||||
|
} |
||||
|
|
||||
|
type VideoFile struct { |
||||
|
RelPath string |
||||
|
Filename string |
||||
|
StartTime time.Time |
||||
|
EndTime time.Time |
||||
|
Duration time.Duration |
||||
|
} |
||||
|
|
||||
|
func Scan(root string) ([]DayEntry, error) { |
||||
|
dayDirs, err := os.ReadDir(root) |
||||
|
if err != nil { |
||||
|
if os.IsNotExist(err) { |
||||
|
return nil, nil |
||||
|
} |
||||
|
return nil, fmt.Errorf("read footage root: %w", err) |
||||
|
} |
||||
|
|
||||
|
days := make([]DayEntry, 0, len(dayDirs)) |
||||
|
for _, dayDir := range dayDirs { |
||||
|
if !dayDir.IsDir() { |
||||
|
continue |
||||
|
} |
||||
|
if _, err := time.ParseInLocation("20060102", dayDir.Name(), time.Local); err != nil { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
entry := DayEntry{Date: dayDir.Name()} |
||||
|
dayPath := filepath.Join(root, dayDir.Name()) |
||||
|
|
||||
|
images, err := scanImages(root, dayPath) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
entry.Images = images |
||||
|
|
||||
|
videos, err := scanVideos(root, dayPath) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
entry.Videos = videos |
||||
|
|
||||
|
days = append(days, entry) |
||||
|
} |
||||
|
|
||||
|
sort.Slice(days, func(i, j int) bool { |
||||
|
return days[i].Date > days[j].Date |
||||
|
}) |
||||
|
|
||||
|
return days, nil |
||||
|
} |
||||
|
|
||||
|
func scanImages(root, dayPath string) ([]ImageFile, error) { |
||||
|
imageDir := filepath.Join(dayPath, "images") |
||||
|
entries, err := os.ReadDir(imageDir) |
||||
|
if err != nil { |
||||
|
if os.IsNotExist(err) { |
||||
|
return nil, nil |
||||
|
} |
||||
|
return nil, fmt.Errorf("read image dir %s: %w", imageDir, err) |
||||
|
} |
||||
|
|
||||
|
images := make([]ImageFile, 0, len(entries)) |
||||
|
for _, entry := range entries { |
||||
|
if entry.IsDir() { |
||||
|
continue |
||||
|
} |
||||
|
info, err := entry.Info() |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("read image info %s: %w", entry.Name(), err) |
||||
|
} |
||||
|
if !info.Mode().Type().IsRegular() { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
image, err := parseImageFilename(entry.Name()) |
||||
|
if err != nil { |
||||
|
continue |
||||
|
} |
||||
|
image.RelPath = mustRel(root, filepath.Join(imageDir, entry.Name())) |
||||
|
images = append(images, image) |
||||
|
} |
||||
|
|
||||
|
sort.Slice(images, func(i, j int) bool { |
||||
|
if images[i].Timestamp.Equal(images[j].Timestamp) { |
||||
|
return images[i].Filename < images[j].Filename |
||||
|
} |
||||
|
return images[i].Timestamp.Before(images[j].Timestamp) |
||||
|
}) |
||||
|
|
||||
|
return images, nil |
||||
|
} |
||||
|
|
||||
|
func scanVideos(root, dayPath string) ([]VideoFile, error) { |
||||
|
recordDir := filepath.Join(dayPath, "record") |
||||
|
entries, err := os.ReadDir(recordDir) |
||||
|
if err != nil { |
||||
|
if os.IsNotExist(err) { |
||||
|
return nil, nil |
||||
|
} |
||||
|
return nil, fmt.Errorf("read record dir %s: %w", recordDir, err) |
||||
|
} |
||||
|
|
||||
|
videos := make([]VideoFile, 0, len(entries)) |
||||
|
for _, entry := range entries { |
||||
|
if entry.IsDir() { |
||||
|
continue |
||||
|
} |
||||
|
info, err := entry.Info() |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("read video info %s: %w", entry.Name(), err) |
||||
|
} |
||||
|
if !info.Mode().Type().IsRegular() { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
video, err := parseVideoFilename(entry.Name()) |
||||
|
if err != nil { |
||||
|
continue |
||||
|
} |
||||
|
video.RelPath = mustRel(root, filepath.Join(recordDir, entry.Name())) |
||||
|
videos = append(videos, video) |
||||
|
} |
||||
|
|
||||
|
sort.Slice(videos, func(i, j int) bool { |
||||
|
if videos[i].StartTime.Equal(videos[j].StartTime) { |
||||
|
return videos[i].Filename < videos[j].Filename |
||||
|
} |
||||
|
return videos[i].StartTime.Before(videos[j].StartTime) |
||||
|
}) |
||||
|
|
||||
|
return videos, nil |
||||
|
} |
||||
|
|
||||
|
func parseImageFilename(name string) (ImageFile, error) { |
||||
|
matches := imageFilenamePattern.FindStringSubmatch(name) |
||||
|
if matches == nil { |
||||
|
return ImageFile{}, fmt.Errorf("invalid image filename %q", name) |
||||
|
} |
||||
|
|
||||
|
timestamp, err := time.ParseInLocation("060102150405", matches[1]+matches[2], time.Local) |
||||
|
if err != nil { |
||||
|
return ImageFile{}, fmt.Errorf("parse image timestamp %q: %w", name, err) |
||||
|
} |
||||
|
|
||||
|
return ImageFile{ |
||||
|
Filename: name, |
||||
|
Timestamp: timestamp, |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func parseVideoFilename(name string) (VideoFile, error) { |
||||
|
matches := videoFilenamePattern.FindStringSubmatch(name) |
||||
|
if matches == nil { |
||||
|
return VideoFile{}, fmt.Errorf("invalid video filename %q", name) |
||||
|
} |
||||
|
|
||||
|
start, err := time.ParseInLocation("060102150405", matches[1]+matches[2], time.Local) |
||||
|
if err != nil { |
||||
|
return VideoFile{}, fmt.Errorf("parse video start timestamp %q: %w", name, err) |
||||
|
} |
||||
|
|
||||
|
end, err := time.ParseInLocation("060102150405", matches[1]+matches[3], time.Local) |
||||
|
if err != nil { |
||||
|
return VideoFile{}, fmt.Errorf("parse video end timestamp %q: %w", name, err) |
||||
|
} |
||||
|
if end.Before(start) { |
||||
|
end = end.Add(24 * time.Hour) |
||||
|
} |
||||
|
|
||||
|
return VideoFile{ |
||||
|
Filename: name, |
||||
|
StartTime: start, |
||||
|
EndTime: end, |
||||
|
Duration: end.Sub(start), |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func mustRel(root, path string) string { |
||||
|
relPath, err := filepath.Rel(root, path) |
||||
|
if err != nil { |
||||
|
return path |
||||
|
} |
||||
|
return relPath |
||||
|
} |
||||
@ -0,0 +1,89 @@ |
|||||
|
package footage |
||||
|
|
||||
|
import ( |
||||
|
"path/filepath" |
||||
|
"testing" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
func TestScanIndexesFixtureFootage(t *testing.T) { |
||||
|
root := filepath.Join("..", "..", "testdata", "footage") |
||||
|
|
||||
|
days, err := Scan(root) |
||||
|
if err != nil { |
||||
|
t.Fatalf("scan fixture footage: %v", err) |
||||
|
} |
||||
|
|
||||
|
if len(days) != 2 { |
||||
|
t.Fatalf("expected 2 days, got %d", len(days)) |
||||
|
} |
||||
|
if days[0].Date != "20260102" || days[1].Date != "20260101" { |
||||
|
t.Fatalf("expected days sorted newest first, got %#v", []string{days[0].Date, days[1].Date}) |
||||
|
} |
||||
|
|
||||
|
day := days[1] |
||||
|
if len(day.Images) != 2 { |
||||
|
t.Fatalf("expected 2 images for %s, got %d", day.Date, len(day.Images)) |
||||
|
} |
||||
|
if got := day.Images[0].RelPath; got != filepath.Join("20260101", "images", "A26010112000001.jpg") { |
||||
|
t.Fatalf("unexpected first image path %q", got) |
||||
|
} |
||||
|
if got := day.Images[1].RelPath; got != filepath.Join("20260101", "images", "A26010112050001.jpg") { |
||||
|
t.Fatalf("unexpected second image path %q", got) |
||||
|
} |
||||
|
if !day.Images[0].Timestamp.Before(day.Images[1].Timestamp) { |
||||
|
t.Fatalf("expected images sorted by timestamp ascending") |
||||
|
} |
||||
|
|
||||
|
if len(day.Videos) != 2 { |
||||
|
t.Fatalf("expected 2 videos for %s, got %d", day.Date, len(day.Videos)) |
||||
|
} |
||||
|
if got := day.Videos[0].RelPath; got != filepath.Join("20260101", "record", "A260101_120000_120015.265") { |
||||
|
t.Fatalf("unexpected first video path %q", got) |
||||
|
} |
||||
|
if got := day.Videos[0].Duration; got != 15*time.Second { |
||||
|
t.Fatalf("expected first video duration 15s, got %s", got) |
||||
|
} |
||||
|
if !day.Videos[0].StartTime.Before(day.Videos[1].StartTime) { |
||||
|
t.Fatalf("expected videos sorted by start time ascending") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestParseImageFilename(t *testing.T) { |
||||
|
image, err := parseImageFilename("A26010112000001.jpg") |
||||
|
if err != nil { |
||||
|
t.Fatalf("parse image filename: %v", err) |
||||
|
} |
||||
|
|
||||
|
if image.Filename != "A26010112000001.jpg" { |
||||
|
t.Fatalf("unexpected filename %q", image.Filename) |
||||
|
} |
||||
|
|
||||
|
want := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.Local) |
||||
|
if !image.Timestamp.Equal(want) { |
||||
|
t.Fatalf("expected timestamp %s, got %s", want, image.Timestamp) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestParseVideoFilename(t *testing.T) { |
||||
|
video, err := parseVideoFilename("A260101_120000_120015.265") |
||||
|
if err != nil { |
||||
|
t.Fatalf("parse video filename: %v", err) |
||||
|
} |
||||
|
|
||||
|
if video.Filename != "A260101_120000_120015.265" { |
||||
|
t.Fatalf("unexpected filename %q", video.Filename) |
||||
|
} |
||||
|
if video.Duration != 15*time.Second { |
||||
|
t.Fatalf("expected duration 15s, got %s", video.Duration) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestParseFilenameRejectsUnknownPattern(t *testing.T) { |
||||
|
if _, err := parseImageFilename("camera.jpg"); err == nil { |
||||
|
t.Fatalf("expected invalid image filename to fail") |
||||
|
} |
||||
|
if _, err := parseVideoFilename("camera.265"); err == nil { |
||||
|
t.Fatalf("expected invalid video filename to fail") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
fixture |
||||
@ -0,0 +1 @@ |
|||||
|
fixture |
||||
@ -0,0 +1 @@ |
|||||
|
fixture |
||||
@ -0,0 +1 @@ |
|||||
|
fixture |
||||
@ -0,0 +1 @@ |
|||||
|
fixture |
||||
Loading…
Reference in new issue