Files
dcf/pkg/dcf/dcf.go
T
clawbot acb108a82e get cards with GetStores and GetStore, read them through fs.FS (closes #4)
GetStores replaces GetDCFStores: it takes a context, has no count
argument and returns a plain slice. GetStore returns the one mounted
card, or ErrNoStore or ErrMultipleStores.

Each Object keeps the fs.FS its card was read through, and Hash reads
through it, so tests can use fstest.MapFS. A cancelled context stops
the read before the next directory. Library errors wrap with %w.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-11-03 20:54:19 +01:00

285 lines
8.1 KiB
Go

// Package dcf reads DCF cards: memory cards laid out to the "Design rule for
// Camera File system" specification (JEITA CP-3461). It finds the cards among
// the mounted filesystems and parses the DCF directories and files on each one
// into their directory numbers and file numbers. It lists the images and videos
// on each card, and everything under DCIM that is not a DCF directory or file. It
// groups the files in each DCF directory that share a file number.
package dcf
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"github.com/dustin/go-humanize"
)
var (
// ErrNoStore is the error from GetStore when no DCF store is mounted.
ErrNoStore = errors.New("no DCF store is mounted")
// ErrMultipleStores is the error from GetStore when more than one DCF store
// is mounted.
ErrMultipleStores = errors.New("more than one DCF store is mounted")
)
// Store represents a DCF store, which is a directory structure that contains
// images and videos in the root of a mounted filesystem.
type Store struct {
RootDirectory string
// Directories holds the DCF directories under DCIM.
Directories []*Directory
// Images and Videos hold the images and videos among the objects in
// Directories, and those under PRIVATE/M4ROOT.
Images []*Image
Videos []*Video
// NonConforming holds the path, relative to RootDirectory, of everything
// under DCIM that is not a DCF directory or a DCF file. It includes every
// symlink, and everything inside a directory that breaks the DCF naming
// rules. A directory whose name is not valid UTF-8 is listed without what is
// inside it, because fs.FS refuses to open one.
NonConforming []string
}
// Directory represents a DCF directory: a directory directly under DCIM whose
// name follows the DCF naming rules.
type Directory struct {
// Number is the directory number, from 100 to 999.
Number int
// Name is the name of the directory as it appears on the card.
Name string
// Objects holds every DCF file in the directory, including those that are
// neither images nor videos.
Objects []*Object
// FileGroups holds the same files, grouped by file number, in file number
// order.
FileGroups []*FileGroup
}
// FileGroup represents one file group: the files in one DCF directory that share
// a file number. The DCF specification calls this a DCF object. A file that
// shares its number with no other file is a group of one.
type FileGroup struct {
DirectoryNumber int
FileNumber int
// Members holds every file in the group, including Primary, in name order.
// Two members can have the same extension.
Members []*Object
// Primary is the member that stands for the whole group.
Primary *Object
}
// Object represents a file in a DCF store. It is embedded in Image and Video
// types.
type Object struct {
DCFStoreRoot string
Path string
Size int64
Extension string
// DirectoryNumber and FileNumber are the DCF numbers of a file in a DCF
// directory. Both are 0 for a file under PRIVATE/M4ROOT.
DirectoryNumber int
FileNumber int
// fsys is the fs.FS the store was read through. Hash reads the file through
// it.
fsys fs.FS
}
// Image represents an image file on a DCF store.
type Image struct {
Object
}
// Video represents a video file on a DCF store.
type Video struct {
Object
}
// FullFilePath returns the path of the file on the local filesystem.
func (d *Object) FullFilePath() string {
return filepath.Join(d.DCFStoreRoot, d.Path)
}
// VideosCount returns the number of videos in the DCF store.
func (d *Store) VideosCount() int {
return len(d.Videos)
}
// ImagesCount returns the number of images in the DCF store.
func (d *Store) ImagesCount() int {
return len(d.Images)
}
// TotalImageSize returns the total size in bytes of the images in the DCF
// store.
func (d *Store) TotalImageSize() int64 {
totalSize := int64(0)
for _, image := range d.Images {
totalSize += image.Size
}
return totalSize
}
// TotalVideoSize returns the total size in bytes of the videos in the DCF
// store.
func (d *Store) TotalVideoSize() int64 {
totalSize := int64(0)
for _, video := range d.Videos {
totalSize += video.Size
}
return totalSize
}
// TotalSize returns the total size in bytes of the images and videos in the
// DCF store.
func (d *Store) TotalSize() int64 {
return d.TotalImageSize() + d.TotalVideoSize()
}
// MembersCount returns the number of files in the file group.
func (g *FileGroup) MembersCount() int {
return len(g.Members)
}
// TotalSize returns the total size in bytes of the files in the file group.
func (g *FileGroup) TotalSize() int64 {
totalSize := int64(0)
for _, member := range g.Members {
totalSize += member.Size
}
return totalSize
}
func (d *Store) String() string {
// A caller can set a negative Size, and uint64 would make that huge.
totalSize := uint64(max(d.TotalSize(), 0))
return fmt.Sprintf(
"Store{RootDirectory: %s, Images: %d, Videos: %d, TotalSize: %s}",
d.RootDirectory,
d.ImagesCount(),
d.VideosCount(),
humanize.Bytes(totalSize),
)
}
func (v *Video) String() string {
return fmt.Sprintf(
"Video{Path: %s, Size: %d, Extension: %s}",
v.Path, v.Size, v.Extension,
)
}
func (i *Image) String() string {
return fmt.Sprintf(
"Image{Path: %s, Size: %d, Extension: %s}",
i.Path, i.Size, i.Extension,
)
}
// Hash returns the SHA256 hash of the file as a hex string. It reads the file
// through the fs.FS of its store. An Object built by hand has none, so Hash
// returns an error that matches fs.ErrInvalid.
func (d *Object) Hash() (string, error) {
if d.fsys == nil {
return "", fmt.Errorf("hashing %s: %w", d.FullFilePath(), fs.ErrInvalid)
}
file, err := d.fsys.Open(d.Path)
if err != nil {
return "", fmt.Errorf("hashing %s: %w", d.FullFilePath(), err)
}
defer func() { _ = file.Close() }()
hash := sha256.New()
_, err = io.Copy(hash, file)
if err != nil {
return "", fmt.Errorf("hashing %s: %w", d.FullFilePath(), err)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GetStores finds the DCF stores in the root of the mounted filesystems and reads
// each one. It does not mount filesystems or search outside of filesystem root
// directories. It reads the DCIM and PRIVATE/M4ROOT directories of each
// filesystem to populate the returned Stores. It never follows a symlink. If a
// card cannot be read, it returns an error that names the card's mount point.
// When ctx is cancelled, the read stops before the next directory, and the error
// matches ctx.Err().
func GetStores(ctx context.Context) ([]*Store, error) {
dcfStorePaths, err := findDCFMountPoints(ctx)
if err != nil {
return nil, err
}
return parseStores(ctx, dcfStorePaths)
}
// GetStore returns the one DCF store when exactly one is mounted. It returns
// ErrNoStore when none is mounted, and ErrMultipleStores when more than one is.
// It reads every card first, the way GetStores does, so a card that cannot be
// read returns that error instead.
func GetStore(ctx context.Context) (*Store, error) {
stores, err := GetStores(ctx)
if err != nil {
return nil, err
}
return onlyStore(stores)
}
// onlyStore returns the store when stores holds exactly one.
func onlyStore(stores []*Store) (*Store, error) {
switch len(stores) {
case 0:
return nil, ErrNoStore
case 1:
return stores[0], nil
default:
return nil, ErrMultipleStores
}
}
// parseStores reads the card at each of the given mount points.
func parseStores(ctx context.Context, mountpoints []string) ([]*Store, error) {
dcfStores := []*Store{}
for _, mountpoint := range mountpoints {
dcfStore, err := parseStore(ctx, os.DirFS(mountpoint), mountpoint)
if err != nil {
return nil, err
}
dcfStores = append(dcfStores, dcfStore)
}
return dcfStores, nil
}
func isImageFile(path string) bool {
imageExtensions := []string{
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff", ".raw", ".arw",
}
return slices.Contains(imageExtensions, strings.ToLower(filepath.Ext(path)))
}
func isVideoFile(path string) bool {
videoExtensions := []string{".mp4", ".mov", ".avi", ".mkv", ".wmv"}
return slices.Contains(videoExtensions, strings.ToLower(filepath.Ext(path)))
}