0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

撮った写真を良い感じに整理するツール

Posted at

はじめに

ミラーレスカメラを使う際、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/292024/08/30に撮影したJPGファイルと、DNGファイルを置きました。

image.png

ホームディレクトリで以下のようなコマンドを実行します。

picture-arrange -dir=/Users/soli/Desktop/DCIM/100SIGMA -ext=JPG,DNG

しっかりとプログレスバーも表示されて…

image.png

見事整理することができました!

image.png

リリース

さて、うまく動くことがわかったので、最後にリリースを行います。
GolangはOS, アーキテクチャごとにクロスコンパイルが可能ですが、それを手作業で用意してアップロードするのは非常に大変です。

そこで、GoReleaserとGithub Actionsを用いて、自動的にクロスコンパイルとリリースを行います。

GoReleaserのセットアップ

まずは、Goreleaserのセットアップを行います。公式サイトの言うとおりにインストールします。

次に、プロジェクトのディレクトリで初期化します。

goreleaser init

すると、.goreleaser.yamlというファイルが生成されます。これがGoReleaserのコンフィグファイルになりますが、今回はなにもいじらず使います。

次に、手元でうまくクロスコンパイルしてくれるか試しましょう。

goreleaser release --snapshot --clean

--snapshotとすることで、手元で確認をすることができます。また、--cleanを付けると、成果物のディレクトリ/distをまっさらにしてからビルドしてくれます。

うまくいくと、このような感じで成果物が出てきます。

image.png

Github Actionsで自動リリース

手元でうまくいくことがわかったので、Github Actionsを用いて自動リリースしましょう。

GoReleaserのActionのリポジトリに、サンプルワークフローがあるので、これを使いましょう。

.github/workflows/release.yml
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 }}

今回は、プルリク時ではなく、バージョンのタグが付けられたときにしたいので、以下のように変更します。

.github/workflows/release.yml
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が動いてくれます。
リリースタグのページに行くと

image.png

うまく行っていることがわかります。

終わりに

今回は、撮影した画像を整理するためのツールをGolangで作成し、自動的に整理できる方法を紹介しました。写真の整理はなかなか手間がかかる作業ですが、このツールを使うことで、簡単に日付ごとにファイルを整理することができます。

また、ツールを開発する過程でのポイントや、リリースの自動化についても解説しました。GoReleaserとGitHub Actionsを活用することで、クロスコンパイルやリリースの手間を大幅に削減できる点も重要です。

写真整理が必要な方にとって、このツールが少しでも役立てば幸いです。ぜひ使ってみてください!

フィードバックやリクエストがあれば、コメントやIssue, PRなどお待ちしています!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?