はじめに
ミラーレスカメラを使う際、JPG+RAWみたいな形式で撮影している方は多いと思いますが、帰ってきてSDカードを開くとそこに広がるのは大量のファイル達。
同じ名前で拡張子だけ違うファイルが2つ生成されるし、表面上はいつ撮ったものなのかわからんし…
そこで、これらを良い感じに整理できるようなツールを作れないかということで、コードを書いてみました。
この記事では、ツールの概要、コードの解説、リリースの自動化を紹介します。
概要
まずは、具体的に整理とはどのようなことをするのかを定義します。
例えば、このようなファイル群があるとします。
日付は撮影日とします。
/photos
├── IMG_0001.JPG //2024-08-30
├── IMG_0001.DNG //2024-08-30
├── IMG_0002.JPG //2024-08-30
├── IMG_0002.DNG //2024-08-30
├── IMG_0003.JPG //2024-09-01
├── IMG_0003.DNG //2024-09-01
├── DSC_1234.DNG //2024-08-31
└── vacation_2024.JPG //2024-08-30
これをこのように、拡張子ごとのディレクトリ、その中に日付のディレクトリがあり、そこに画像ファイルが置かれる形にします。
/photos
├── JPG
│ ├── 2024-08-30
│ │ ├── IMG_0001.JPG
│ │ ├── IMG_0002.JPG
│ │ └── vacation_2024.JPG
│ └── 2024-09-01
│ └── IMG_0003.JPG
└── DNG
├── 2024-08-30
│ ├── IMG_0001.DNG
│ └── IMG_0002.DNG
├── 2024-08-31
│ └── DSC_1234.DNG
└── 2024-09-01
└── IMG_0003.DNG
また、柔軟性を持たせる仕様にもしたいと思います。
今回は、CLIツールを作るので、オプションで以下の2つを指定できるようにします。
- 操作対象のディレクトリ
- 扱う画像ファイルの拡張子
前者はまあ一般的だと思います。デフォルトではカレントディレクトリとします。
後者は、JPGだけを対象としたい場合や、カメラメーカーによって独自のRAWファイルの形式を採用しているケースもあります。そうした状況にも対応できるようにするため、実装します。デフォルトではJPGとDNGを対象とします。
作る
大体の仕様は決まったので作っていきます。
今回はGolangで作ります。
撮影日を取得する
まずは、画像ファイルの撮影日が取得できなければ何も始まりません。
画像ファイルにはExifというメタデータがくっついています。撮影日や撮影機材、設定など、いろいろな情報が入っているものです。
そこで、ここから撮影日を取得する関数を作ります。
func extractDate(filename string) (string, error) {
file, err := os.Open(filename)
if err != nil {
return "", err
}
defer file.Close()
exifData, err := exif.Decode(file)
if err != nil {
return "", err
}
dateTime, err := exifData.Get(exif.DateTimeOriginal)
if err != nil {
return "", err
}
dateStr := strings.Trim(dateTime.String(), "\"")
parsedTime, err := time.Parse("2006:01:02 15:04:05", dateStr)
if err != nil {
return "", err
}
return parsedTime.Format("2006-01-02"), nil
}
goexifというパッケージを用いて、Exifから撮影日を取得します。
また、このデータはYYYY:MM:DD hh:mm:ss
という形式で入っており、今回はYYYY-MM-DD
という形式で使いたいので、よしなにフォーマットします。
timeパッケージのParse関数を使っていますが、2006:01:02 15:04:05
の部分は仕様です。
これで、画像ファイルの撮影日を取得できました。
ファイルを移動する
次に、ファイルを移動させる関数を作ります。
これは純粋に、mv
コマンドのようなものを置くだけです。
func moveFile(src, dst string) error {
return os.Rename(src, dst)
}
移動元のファイルパスと、移動先のファイルパスを引数とし、移動を行います。
もろもろの処理をする
ここまできたら、あとはファイルを読み込んで、上二つの関数を呼び出して、ファイルの操作を行います。
func processFiles(baseDir, pattern, dir string) {
//いろんな処理
}
設定している3つの引数ですが、これは
- 操作対象のディレクトリ
-
*.[拡張子]
な形 -
[拡張子]
な形
としています。
これはmain
関数から与えます。
まずは、*.[拡張子]
にマッチするようなファイルをピックアップして、files
に入れます。例えば、*.JPG
なら、JPG
ファイルだけがピックアップされます。
files, err := filepath.Glob(filepath.Join(baseDir, pattern))
if err != nil {
log.Fatal(err)
}
if len(files) == 0 {
log.Printf("No files found for pattern %s in %s", pattern, baseDir)
return
}
もしファイルが無ければ、メッセージを出して終わらせます。
次に、出力先の拡張子名なディレクトリを作成します。
outputDir := filepath.Join(baseDir, dir)
if err := os.Mkdir(outputDir, 0755); err != nil && !os.IsExist(err) {
log.Fatal(err)
}
ここから、ファイルごとに撮影日の取得や、ファイルの移動が始まります。
このままいくと、正常に動作している場合は何も表示されず、精神衛生上よろしくないので、プログレスバーを実装します。
まずは、長さがファイルの数分なプログレスバーを定義します。
bar := progressbar.NewOptions(
len(files),
progressbar.OptionSetDescription("Processing "+dir),
)
その後、ファイルの数分のfor文を書くのですが、その中で
bar.Add(1)
このようなものを書くと、バーが1つ進みます。
また、プログレスバーを進めるfor文が終わったら改行を挟みます。
なぜなら、2本目のプログレスバーが現れたとき、これがないと上書きされてしまうためです。
残しておきたかったので、for文の後に改行を挟むことで、上書きを防ぐようにしました。
println()
これで、プログレスバーを実装することができました。
で、そのfor文はこんな感じです。
前述した撮影日を取得する関数から撮影日を取得し、撮影日のディレクトリが無ければ作成、ファイルを移動して、プログレスバーを一つ進める。といった処理になっています。
for _, file := range files {
date, err := extractDate(file)
if err != nil {
log.Printf("Error processing %s: %v", file, err)
bar.Add(1)
continue
}
destDir := filepath.Join(outputDir, date)
if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) {
log.Fatal(err)
}
if err := moveFile(file, filepath.Join(destDir, filepath.Base(file))); err != nil {
log.Printf("Failed to move %s: %v", file, err)
}
bar.Add(1)
}
これで、この部分の関数は完成です。このようになりました。
func processFiles(baseDir, pattern, dir string) {
files, err := filepath.Glob(filepath.Join(baseDir, pattern))
if err != nil {
log.Fatal(err)
}
if len(files) == 0 {
log.Printf("No files found for pattern %s in %s", pattern, baseDir)
return
}
outputDir := filepath.Join(baseDir, dir)
if err := os.Mkdir(outputDir, 0755); err != nil && !os.IsExist(err) {
log.Fatal(err)
}
bar := progressbar.NewOptions(
len(files),
progressbar.OptionSetDescription("Processing "+dir),
)
for _, file := range files {
date, err := extractDate(file)
if err != nil {
log.Printf("Error processing %s: %v", file, err)
bar.Add(1)
continue
}
destDir := filepath.Join(outputDir, date)
if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) {
log.Fatal(err)
}
if err := moveFile(file, filepath.Join(destDir, filepath.Base(file))); err != nil {
log.Printf("Failed to move %s: %v", file, err)
}
bar.Add(1)
}
println()
}
メイン関数
最後にメイン関数を書きます。
ここでは、主にオプションの定義と取得、それを元にした処理関数の呼び出しを行います。
func main() {
extFlag := flag.String("ext", "JPG,DNG", "Comma-separated list of file extensions to process (e.g., JPG,DNG,PNG)")
dirFlag := flag.String("dir", ".", "Directory to process (default is current directory)")
flag.Parse()
extensions := strings.Split(*extFlag, ",")
for _, ext := range extensions {
processFiles(*dirFlag, "*."+strings.ToUpper(ext), strings.ToUpper(ext))
}
}
今回は、-ext=JPG,DNG
のような形で拡張子を取得、-dir=/path/to/photo
のような形でディレクトリを取得するようにしました。
拡張子をコンマ区切りにして、その拡張子分処理関数を実行することにしています。
これで、すべて書き終わりました。
動かす
さて、動かしてみましょう。
/Users/soli/Desktop/DCIM/100SIGMA
の中に、2024/08/29
と2024/08/30
に撮影したJPGファイルと、DNGファイルを置きました。
ホームディレクトリで以下のようなコマンドを実行します。
picture-arrange -dir=/Users/soli/Desktop/DCIM/100SIGMA -ext=JPG,DNG
しっかりとプログレスバーも表示されて…
見事整理することができました!
リリース
さて、うまく動くことがわかったので、最後にリリースを行います。
GolangはOS, アーキテクチャごとにクロスコンパイルが可能ですが、それを手作業で用意してアップロードするのは非常に大変です。
そこで、GoReleaserとGithub Actionsを用いて、自動的にクロスコンパイルとリリースを行います。
GoReleaserのセットアップ
まずは、Goreleaserのセットアップを行います。公式サイトの言うとおりにインストールします。
次に、プロジェクトのディレクトリで初期化します。
goreleaser init
すると、.goreleaser.yaml
というファイルが生成されます。これがGoReleaserのコンフィグファイルになりますが、今回はなにもいじらず使います。
次に、手元でうまくクロスコンパイルしてくれるか試しましょう。
goreleaser release --snapshot --clean
--snapshot
とすることで、手元で確認をすることができます。また、--clean
を付けると、成果物のディレクトリ/dist
をまっさらにしてからビルドしてくれます。
うまくいくと、このような感じで成果物が出てきます。
Github Actionsで自動リリース
手元でうまくいくことがわかったので、Github Actionsを用いて自動リリースしましょう。
GoReleaserのActionのリポジトリに、サンプルワークフローがあるので、これを使いましょう。
name: goreleaser
on:
pull_request:
push:
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
-
name: Set up Go
uses: actions/setup-go@v5
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
# either 'goreleaser' (default) or 'goreleaser-pro'
distribution: goreleaser
# 'latest', 'nightly', or a semver
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution
# GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
今回は、プルリク時ではなく、バージョンのタグが付けられたときにしたいので、以下のように変更します。
name: goreleaser
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
-
name: Set up Go
uses: actions/setup-go@v5
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
# either 'goreleaser' (default) or 'goreleaser-pro'
distribution: goreleaser
# 'latest', 'nightly', or a semver
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
これで、タグが付けられたときに、Goreleaserが動いてくれます。
リリースタグのページに行くと
うまく行っていることがわかります。
終わりに
今回は、撮影した画像を整理するためのツールをGolangで作成し、自動的に整理できる方法を紹介しました。写真の整理はなかなか手間がかかる作業ですが、このツールを使うことで、簡単に日付ごとにファイルを整理することができます。
また、ツールを開発する過程でのポイントや、リリースの自動化についても解説しました。GoReleaserとGitHub Actionsを活用することで、クロスコンパイルやリリースの手間を大幅に削減できる点も重要です。
写真整理が必要な方にとって、このツールが少しでも役立てば幸いです。ぜひ使ってみてください!
フィードバックやリクエストがあれば、コメントやIssue, PRなどお待ちしています!