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