2021/01/03

Windows画像ファイルの仕分けアプリを制作しました

c#windows-form

概要

親戚で『デジカメの画像をPCに取り込む時に、以前なら画像の撮影日時準に並び替えて閲覧できたのに、できなくなってしまって困っている』という感じのことを聞きました。

どうやらWindowsアップデートが原因?でこのような現象が起きてしまっていたようです。

そこで、簡単な画像ファイルの仕分けアプリが作れないか考えてみました。

要望としては以下のような感じです。

  • デジカメからピクチャフォルダに画像を取込む時に画像ファイルを撮影時間順に整理できると嬉しい
  • 撮影日でフォルダ分けされていると嬉しい
  • アプリを起動したりする手順がめんどくさいし、よくわからないので、自動で仕分けされていると嬉しい

このような要望から以下のような機能をもつアプリを作ろうと思い立ちました。

  • タスクトレイに常駐するWindowsアプリを作成する
    • この常駐アプリはスタートアップに登録しておけば、ユーザーやアプリ起動する必要がなく、ピクチャフォルダに画像を追加すると自動で仕分けされる
  • ピクチャフォルダを監視し、画像ファイルが作成されたタイミングで、Exif情報から撮影時間を取得し、取得できれば仕分けを行う。
    • 仕分け方法
      • 撮影日のフォルダーをyyyy-MM-dd形式で作成し、そこに画像ファイルを移動する
      • 撮影時間で画像の作成日時を上書きする
  • アプリの配布方法はインストーラーを使う(クリックするだけでインストール可能)

実装内容

  • Visual Studio 2017を使って開発しました。
  • Windows Formアプリケーションとして作成しています。

Program.cs

ポイント

  • mutexを使って多重起動しないように制御しています
  • Application.Run に引数を渡さないことで、画面を表示しないようにしています。
using System;
using System.Windows.Forms;
using System.Threading;

namespace TidyUpImages
{
    static class Program
    {
        /// <summary>
        /// アプリケーションのメイン エントリ ポイントです。
        /// </summary>
        [STAThread]
        static void Main()
        {
            string mutexName = "TidyUpImages";
            Mutex mutex = new Mutex(false, mutexName);
            bool hasHandle = false;
            try
            {
                try
                {
                    hasHandle = mutex.WaitOne(0, false);
                }
                catch (AbandonedMutexException)
                {
                    hasHandle = true;
                }
                if (!hasHandle)
                {
                    // 多重起動の場合
                    return;
                }
                
                Application.EnableVisualStyles();
                Application.SetCompatibleTextRenderingDefault(false);
                new MainForm();
                Application.Run();
            }
            finally
            {
                if (hasHandle)
                {
                    mutex.ReleaseMutex();
                }
                mutex.Close();
            }
        }
    }
}

MainForm.cs

ポイント

  • FileSystemWatcherを使って、ファイルの作成・変更を監視しています。
    • ファイルのコピー時などはコピーが完了するまで待機する必要があったりするので、Changedイベントも利用しています。
      • FileIsReadyを呼び出し、ファイルの操作ができるようになったら処理を開始するようにしています。
      • ちなみに、他のアプリから対象のファイルを操作するような処理が同時に走ると(多分)エラーになります。
      • また、ブラウザからのファイルダウンロードを監視対象のフォルダ内に行ったりすると予期せぬ動作をしました。(要改良
    • Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)を使って、ピクチャフォルダのパスを取得して監視対象にしています。
  • SetComponentsでタスクトレイに表示するメニューの制御を行っています。
    • Properties.Resources.appで利用するアプリのアイコン(ico)ファイルを別途Resourceに追加する必要があります。
using System;
using System.Windows.Forms;
using System.IO;

namespace TidyUpImages
{
    class MainForm : Form
    {
        private FileSystemWatcher watcher = null;

        public MainForm()
        {
            this.ShowInTaskbar = false;
            this.SetComponents();
            // start watching file
            this.startWatchingFiles();
        }

        private void startWatchingFiles()
        {
            if (watcher != null) return;
            watcher = new FileSystemWatcher();
            watcher.Path = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
            watcher.Filter = "";
            watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName;
            watcher.IncludeSubdirectories = false;
            watcher.SynchronizingObject = this;
            watcher.Changed += new FileSystemEventHandler(FileChanged);
            watcher.Created += new FileSystemEventHandler(FileChanged);
            watcher.EnableRaisingEvents = true;
        }

        private void FileChanged(System.Object source, FileSystemEventArgs e)
        {
            switch (e.ChangeType)
            {
                case WatcherChangeTypes.Created:
                case WatcherChangeTypes.Changed:
                    Console.WriteLine(
                        "ファイル 「" + e.FullPath + "」が作成・変更されました。");
                    if (IsDirectory(e.FullPath)) return;
                    if (!FileIsReady(e.FullPath)) return;
                    FileHandler handler = new FileHandler(e.FullPath);
                    try
                    {
                        handler.Handle();
                    }
                    catch (Exception)
                    {
                        Console.WriteLine("処理に失敗しました。");
                    }
                    
                    break;
                default:
                    break;
            }
        }

        private bool IsDirectory(string path)
        {
            try
            {
                var attr = File.GetAttributes(path);
                return attr.HasFlag(FileAttributes.Directory);
            }
            catch (IOException)
            {
                return false;
            }

        }

        private bool FileIsReady(string path)
        {
            try
            {
                using (var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read))
                {
                    return true;
                }
            }
            catch (IOException)
            {
                return false;
            }
        }

            private void Close_Click(object sender, EventArgs e)
        {
            Application.Exit();
        }

        private void SetComponents()
        {
            NotifyIcon icon = new NotifyIcon();
            icon.Icon = Properties.Resources.app;

            icon.Visible = true;
            icon.Text = "画像ファイル整頓";
            ContextMenuStrip menu = new ContextMenuStrip();
            ToolStripMenuItem menuItem = new ToolStripMenuItem();
            menuItem.Text = "&終了";
            menuItem.Click += new EventHandler(Close_Click);
            menu.Items.Add(menuItem);
            icon.ContextMenuStrip = menu;
        }
    }
}

FileHandler.cs

ポイント

  • isImageを使って、対象のファイルが画像ファイルか否かを判定しています。
  • readTimeFromExifに画像ファイルのExif情報から撮影日時を取得しています。
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

namespace TidyUpImages
{
    class FileHandler
    {
        private string filename = null;

        public FileHandler(string filename)
        {
            this.filename = filename;
        }

        internal void Handle()
        {
            if (!isImage()) {
                Console.WriteLine(
                     "ファイル 「" + filename + "」は画像ファイルではありませんでした。");
                return;
            }
            DateTime? time = readTimeFromExif();
            if (time == null) {
                Console.WriteLine(
                       "ファイル 「" + filename + "」は撮影時間を保持している画像ファイルではありませんでした。");
                return;
            }
            // 画像ファイルの作成日時を撮影時間で上書き
            File.SetCreationTime(filename, time.Value);

            // ディレクトリを作成
            string currentDirectoryName = Path.GetDirectoryName(filename);
            string targetDirectory = currentDirectoryName + "\\" + time.Value.ToString("yyyy-MM-dd");
            if (!Directory.Exists(targetDirectory))
            {
                Directory.CreateDirectory(targetDirectory);
            }

            // 画像ファイルを移動する
            string movePath = targetDirectory + "\\" + Path.GetFileName(filename);
            moveFile(movePath);
        }

        private void moveFile(string destination)
        {
            if (!File.Exists(filename)) return;

            if (File.Exists(destination))
            {
                File.Delete(destination);
            }
            File.Move(filename, destination);
        }

        private DateTime? readTimeFromExif()
        {
            using (Bitmap bitmap = new Bitmap(filename))
            {
                try
                {
                    foreach (PropertyItem item in bitmap.PropertyItems)
                    {
                        if (item.Id == 0x9003 && item.Type == 2)
                        {
                            string val = System.Text.Encoding.ASCII.GetString(item.Value);
                            val = val.Trim(new char[] { '\0' });
                            return DateTime.ParseExact(val, "yyyy:MM:dd HH:mm:ss", null);
                        }
                    }
                    return null;
                }
                catch (Exception e)
                {
                    return null;
                }
            }
        }

        private bool isImage()
        {
            IsImageExtension.ImageType type;
            return filename.IsImage(out type);
        }
    }
}

IsImageExtension.cs

ポイント

  • stringStream の拡張メソッドとして定義しています。
  • ファイルを読み込み、バイト配列がjpgやpngなどの画像ファイルのものの定義と一致しているかをチェックします。
  • HEIC ファイルはサポートしていません。
using System.Collections.Generic;
using System.IO;

namespace TidyUpImages
{
    public static class IsImageExtension
    {
        static List<string> jpg;
        static List<string> bmp;
        static List<string> gif;
        static List<string> png;

        public enum ImageType
        {
            JPG,
            BMP,
            GIF,
            PNG,
            NONE
        }

        const string JPG = "FF";
        const string BMP = "42";
        const string GIF = "47";
        const string PNG = "89";

        static IsImageExtension()
        {
            jpg = new List<string> { "FF", "D8" };
            bmp = new List<string> { "42", "4D" };
            gif = new List<string> { "47", "49", "46" };
            png = new List<string> { "89", "50", "4E", "47", "0D", "0A", "1A", "0A" };
        }

        public static bool IsImage(this string file, out ImageType type)
        {
            type = ImageType.NONE;
            if (string.IsNullOrWhiteSpace(file)) return false;
            if (!File.Exists(file)) return false;
            using (var stream = File.OpenRead(file))
                return stream.IsImage(out type);
        }

        public static bool IsImage(this Stream stream, out ImageType type)
        {
            type = ImageType.NONE;
            stream.Seek(0, SeekOrigin.Begin);
            string bit = stream.ReadByte().ToString("X2");
            switch (bit)
            {
                case JPG:
                    if (stream.IsImage(jpg))
                    {
                        type = ImageType.JPG;
                        return true;
                    }
                    break;
                case BMP:
                    if (stream.IsImage(bmp))
                    {
                        type = ImageType.BMP;
                        return true;
                    }
                    break;
                case GIF:
                    if (stream.IsImage(gif))
                    {
                        type = ImageType.GIF;
                        return true;
                    }
                    break;
                case PNG:
                    if (stream.IsImage(png))
                    {
                        type = ImageType.PNG;
                        return true;
                    }
                    break;
                default:
                    break;
            }
            return false;
        }

        public static bool IsImage(this Stream stream, List<string> comparer)
        {
            stream.Seek(0, SeekOrigin.Begin);
            foreach (string c in comparer)
            {
                string bit = stream.ReadByte().ToString("X2");
                if (0 != string.Compare(bit, c))
                    return false;
            }
            return true;
        }
    }
}

インストーラーの作成

詳細は割愛しますが、以下の拡張機能をインストールして作成しました。

Releaseビルドしてできた setup.exemsiファイルを配布することでインストールできるようになります。

その他

今回作ったアプリの需要ってそこそこありそうな気がするので、時間ができたらもうちょっと作り込んでWindowsストアにアップしてみようと思います。

その場合、もうちょっとニーズを聞きつつ機能を追加して対応できるようにしようと思います。

追加機能例)

  • 特定のフォルダを選択して仕分けを実行する
  • 設定画面を作り、仕分け対象のフォルダを変更できるようにする
  • HEIC形式のものをサポートする

その他必要機能)

  • アプリの署名情報などをちゃんとして、インストールできるようにする
  • クラッシュした場合にエラー情報を送信できるようにする
  • アプリの更新ができるようにする

感想

かなり久しぶりにC# + Visual Studioを触りました。
Windowsのキー配列やショートカットを大分忘れていてかなりイライラしましたが
Visual Studioはかなり扱いやすかったので、なんとかある程度のところまで組むことができました。