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>
249 lines
6.9 KiB
Go
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)))
|
|
}
|