Skip to main content
Camera Workflow automatically organizes your media files by date using metadata extraction, creating a clean, chronological archive.

Directory Structure

Default Organization

With --organize-by-date (enabled by default), files are organized hierarchically:
destination/
├── 2024/
│   ├── 03-March/
│   │   ├── 2024-03-15/
│   │   │   ├── images/
│   │   │   │   ├── 20240315_IMG_1234.avif
│   │   │   │   ├── 20240315_IMG_1235.avif
│   │   │   │   └── 20240315_DSC_5678.avif
│   │   │   └── videos/
│   │   │       ├── 20240315_VID_1001.mp4
│   │   │       └── 20240315_VID_1002.mp4
│   │   └── 2024-03-16/
│   │       ├── images/
│   │       └── videos/
│   └── 04-April/
│       └── 2024-04-01/
│           ├── images/
│           └── videos/
└── 2025/
    └── 01-January/
        └── 2025-01-01/
            ├── images/
            └── videos/
Structure breakdown:
  • YYYY/ - Year folder
  • MM-Month/ - Month folder with localized name
  • YYYY-MM-DD/ - Day folder
  • images/ or videos/ - Media type separation
  • YYYYMMDD_filename.ext - Date-prefixed files

Flat Organization

Disable date organization to keep files at the root:
media-converter --organize-by-date=false /source /dest
destination/
├── 20240315_IMG_1234.avif
├── 20240315_IMG_1235.avif
├── 20240315_VID_1001.mp4
├── 20240316_IMG_5678.avif
└── 20240316_VID_1002.mp4
Even with flat organization, files are still prefixed with their creation date (YYYYMMDD_).

Date Extraction

Extraction Priority

Dates are extracted using multiple methods in priority order:
1

macOS Metadata (mdls)

Most reliable for RAW files
mdls -name kMDItemContentCreationDate IMG_1234.CR2
# kMDItemContentCreationDate = 2024-03-15 14:23:45 +0000
Works for:
  • RAW formats (CR2, NEF, ARW, DNG)
  • HEIC/HEIF
  • MOV/MP4 videos
  • JPG with EXIF
2

EXIF Data (ImageMagick)

Standard for photos
magick identify -format "%[EXIF:DateTimeOriginal]" IMG_1234.JPG
# 2024:03:15 14:23:45
Reads EXIF:DateTimeOriginal tag from:
  • JPG/JPEG
  • TIFF
  • Some RAW formats
3

Video Metadata (FFprobe)

For video files
ffprobe -v quiet -show_entries format_tags=creation_time -of default=noprint_wrappers=1:nokey=1 VID_1234.MOV
# 2024-03-15T14:23:45.000000Z
Reads creation_time from:
  • MOV
  • MP4
  • MKV
4

Filesystem Modification Time

Fallback method
info, _ := os.Stat(filePath)
modTime := info.ModTime()
Only used if metadata extraction fails. May not reflect actual creation date.

Date Extraction Code

func GetFileDate(filePath string) (time.Time, error) {
    // Try macOS metadata first (most reliable)
    if runtime.GOOS == "darwin" {
        if date, err := getDateFromMDLS(filePath); err == nil {
            return date, nil
        }
    }

    // Try EXIF for images
    if HasExtension(filePath, photoFormats) {
        if date, err := getDateFromEXIF(filePath); err == nil {
            return date, nil
        }
    }

    // Try video metadata
    if HasExtension(filePath, videoFormats) {
        if date, err := getDateFromVideo(filePath); err == nil {
            return date, nil
        }
    }

    // Fallback to file modification time
    info, err := os.Stat(filePath)
    if err != nil {
        return time.Time{}, err
    }
    return info.ModTime(), nil
}
Dates are extracted from the original file before conversion to ensure accurate organization.

Filename Generation

Date Prefix

All files are prefixed with their creation date:
baseName := utils.CleanFilename(name, format, fileDate, 1)
// IMG_1234.CR2 + 2024-03-15 → 20240315_IMG_1234.avif

Duplicate Handling

If a file with the same name exists, a counter is appended:
20240315_IMG_1234.avif      # First file
20240315_IMG_1234_1.avif    # Duplicate name
20240315_IMG_1234_2.avif    # Another duplicate
Code:
func GetUniqueFilename(dir, baseName, ext string) (string, error) {
    counter := 1
    name := baseName
    
    for {
        path := filepath.Join(dir, name+ext)
        if _, err := os.Stat(path); os.IsNotExist(err) {
            return path, nil
        }
        
        name = fmt.Sprintf("%s_%d", baseName, counter)
        counter++
    }
}

Localization

Supported Languages

Month names support multiple languages:
media-converter --language en /source /dest
2024/
├── 01-January/
├── 02-February/
├── 03-March/
└── ...

Language Implementation

var monthNames = map[string][]string{
    "en": {"January", "February", "March", ...},
    "fr": {"Janvier", "Février", "Mars", ...},
    "es": {"Enero", "Febrero", "Marzo", ...},
    "de": {"Januar", "Februar", "März", ...},
}

func GetMonthName(month int, language string) string {
    names, ok := monthNames[language]
    if !ok {
        names = monthNames["en"]  // Fallback to English
    }
    return names[month-1]
}

Path Generation

CreateDestinationPath Function

func CreateDestinationPath(baseDir string, fileDate time.Time, mediaType string, organizeByDate bool, language string) string {
    if !organizeByDate {
        return baseDir  // Flat organization
    }

    year := fileDate.Format("2006")
    month := fileDate.Format("01")
    day := fileDate.Format("2006-01-02")
    monthName := GetMonthName(int(fileDate.Month()), language)

    // Build path: baseDir/YYYY/MM-MonthName/YYYY-MM-DD/mediaType/
    return filepath.Join(
        baseDir,
        year,
        fmt.Sprintf("%s-%s", month, monthName),
        day,
        mediaType,  // "images" or "videos"
    )
}
Usage:
// For an image taken on 2024-03-15
destPath := CreateDestinationPath(
    "/Volumes/Archive",
    time.Date(2024, 3, 15, 14, 23, 45, 0, time.UTC),
    "image",
    true,  // organize by date
    "en",
)
// Result: /Volumes/Archive/2024/03-March/2024-03-15/images/

Media Type Separation

Type Detection

Files are separated into images/ or videos/ folders:
func DetectMediaType(path string, photoFormats, videoFormats []string) string {
    if HasExtension(path, photoFormats) {
        return "image"
    }
    if HasExtension(path, videoFormats) {
        return "video"
    }
    return "unknown"
}
Photo formats:
  • CR2, NEF, ARW, DNG (RAW)
  • JPG, JPEG
  • PNG
  • HEIC, HEIF
  • TIFF, TIF
  • WebP, AVIF (already converted)
Video formats:
  • MOV, MP4
  • AVI, MKV
  • M4V, 3GP
  • MTS, M2TS
Format detection is case-insensitive (.JPG and .jpg are treated the same).

Use Cases

Chronological Photo Archive

media-converter \
  --organize-by-date \
  --language en \
  --photo-format avif \
  /Photos/Unorganized /Photos/Archive
Creates a clean, browsable archive:
Archive/
├── 2023/
│   └── 12-December/
│       └── 2023-12-25/
│           └── images/
│               ├── 20231225_Christmas_001.avif
│               ├── 20231225_Christmas_002.avif
│               └── 20231225_Family_001.avif
└── 2024/
    ├── 01-January/
    └── 02-February/

Mixed Media Organization

media-converter \
  --organize-by-date \
  --photo-format avif \
  --video-codec h265 \
  /DCIM /Archive
Photos and videos from the same day in separate folders:
2024/03-March/2024-03-15/
├── images/
│   ├── 20240315_IMG_1234.avif
│   └── 20240315_IMG_1235.avif
└── videos/
    ├── 20240315_VID_1001.mp4
    └── 20240315_VID_1002.mp4

Flat Archive (No Organization)

media-converter \
  --organize-by-date=false \
  --photo-format webp \
  /source /dest
All files in destination root with date prefixes:
dest/
├── 20240315_IMG_1234.webp
├── 20240315_IMG_1235.webp
├── 20240316_IMG_5678.webp
└── 20240317_VID_1001.mp4

Edge Cases

Missing Metadata

If date extraction fails entirely:
⚠️  Could not extract date from IMG_9999.JPG: using file modification time
File is organized using filesystem modification time as fallback.

Invalid Dates

Dates are validated before use:
if fileDate.IsZero() || fileDate.Year() < 1990 || fileDate.Year() > 2100 {
    // Use filesystem modification time instead
    fileDate = info.ModTime()
}

Timezone Handling

All dates are normalized to local time:
fileDate = fileDate.Local()
This ensures consistent organization regardless of timezone in EXIF data.

Configuration File

Store organization preferences:
# Organization settings
organize_by_date: true
language: en

# File naming
# (date prefix is always added, no configuration needed)

EXIF Preservation

During conversion, EXIF metadata (including dates) is preserved in output files.
cmd = exec.CommandContext(ctx, "magick", inputPath,
    "-define", "avif:preserve-exif=true",
    "-define", "webp:preserve-exif=true",
    ...)
This ensures:
  • Original dates remain in converted files
  • Re-running organization produces same structure
  • Metadata viewers show correct creation dates

Copy-Only Mode

Date organization works identically in copy-only mode:
media-converter --copy-only --organize-by-date /source /dest
dest/
├── 2024/
│   └── 03-March/
│       └── 2024-03-15/
│           ├── images/
│           │   ├── IMG_1234.CR2  # Original RAW preserved
│           │   └── IMG_1235.JPG
│           └── videos/
│               └── VID_1001.MOV
Files keep their original extensions but are organized by date.

Performance

Metadata Extraction Overhead

Date extraction adds minimal overhead:
  • mdls (macOS): ~10-20ms per file
  • EXIF reading: ~5-10ms per file
  • FFprobe: ~20-50ms per file
Extraction happens once per file during initial scan, not during conversion.

Directory Creation

Directories are created lazily:
func EnsureDir(path string) error {
    return os.MkdirAll(path, 0755)
}
Only creates directories as needed, avoiding unnecessary filesystem operations.

Build docs developers (and LLMs) love