Files
dcf/pkg/dcf/dcf.go
T
clawbot cf7611ea1b group DCF files that share a file number (closes #3)
Files in one DCF directory that share a file number are one file group,
as section 4.3.2.2 of the specification says. `Directory.FileGroups`
lists them, each with its numbers, its members and its primary member. A
lone file is a group of one. A group reports its member count and total
size.

Section 6.2.1.3 gives a DCF basic file priority over a DCF extended image
file. It says nothing about a DCF optional file, so ranking every `JPG`
first is a choice. A `THM` comes last.

`isImageFile` now accepts `.tif`, so a `TIF` counts as an image.
`Store.Images`, `Store.Videos` and the totals still count each file on
its own.

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

249 lines
6.9 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 (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"github.com/dustin/go-humanize"
)
// 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
}
// 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.
func (d *Object) Hash() (string, error) {
return pathToSHA256(d.FullFilePath())
}
func pathToSHA256(path string) (string, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
hash := sha256.New()
_, err = io.Copy(hash, file)
if err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GetDCFStores returns a list of DCF stores found on the system in the root of
// any mounted filesystems. The integer argument is the number of DCF stores to
// return. If the argument is 0, all DCF stores are returned. It does not mount
// filesystems or search outside of filesystem root directories. It does,
// however, read 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.
func GetDCFStores(requestedCount int) (*[]Store, error) {
dcfStorePaths, err := findDCFMountPoints(requestedCount)
if err != nil {
return nil, err
}
dcfStores, err := parseStores(dcfStorePaths)
if err != nil {
return nil, err
}
return &dcfStores, nil
}
// parseStores reads the card at each of the given mount points.
func parseStores(mountpoints []string) ([]Store, error) {
dcfStores := []Store{}
for _, mountpoint := range mountpoints {
dcfStore, err := parseStore(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)))
}