中級者向け No.101

写真の日付修復ツール — Google フォトから書き出した写真の撮影日時を戻す

Google データ エクスポート(Takeout)で書き出した写真・動画は、解凍した時点でファイルの日付がダウンロード日になります。同梱の JSON から本来の撮影日時を読み、ファイル日時と JPEG の EXIF に書き戻す tkinter 製ツールです。既定はドライランなので、結果を全部見てから実行できます。

🎯 難易度: ★★★ 難しい 📦 ライブラリ: tkinter / json / os(標準ライブラリ) ⏱️ 制作時間: 60〜120分

1. アプリ概要

Google フォトの写真を手元のパソコンに引き取ると、多くの人が同じ問題に当たります。フォルダを開くと数千枚の日付が全部同じ日になり、並び順がバラバラで撮った順に並べられない。写真管理ソフトに取り込んでも、2015年の旅行写真が今年の写真の上に来てしまいます。

このツールは、その並び順を撮影当時へ戻すものです。Google データ エクスポート(Takeout)には写真ごとに JSON が付くことが多く、本来の撮影日時がそこに残っています。ツールはその JSON を読み、ファイルの更新日時(Windows は下記ライブラリ導入時に作成日時も)と、JPEG なら EXIF(写真の中に埋め込まれた撮影情報。多くの写真管理ソフトは日付をここで見ます)の撮影日時に書き戻します。

入手のしかた更新日時EXIF 撮影日時JSON サイドカーこのツールで直せるか
Google フォトの画面から「ダウンロード」ダウンロード日時になりうる元のまま(無い写真も)付かない直せません(手がかりが無い)
Google データ エクスポート(Takeout)エクスポート・解凍日時になる元のまま(無い写真も)付くこれが対象です

Google のヘルプにも「写真や動画がダウンロードされた時刻に基づいて、オペレーティング システムがファイル自体に新しいタイムスタンプを割り当てることがあります」と書かれています。不具合ではなく、ダウンロードの副作用です。

ℹ️
このツールが直すもの・直さないもの
  • 直す:対象ファイル全部の更新日時(Windows で win32-setctime を入れていれば作成日時も)。
  • 直す:JPEG(.jpg / .jpeg)の EXIF 撮影日時(piexif を入れている場合)。
  • 直さない:HEIC・PNG・WebP・GIF の EXIF と、動画の内部タグ(理由は仕組みの章)。
  • 直さない:位置情報・説明文・アルバム構成(日時だけに絞っています)。
⚠️
実行前に必ず読んでください

このツールは写真ファイルそのものを書き換えます。実行前に Takeout の zip を消さずに残しておいてください。zip さえあれば、何かおかしくなっても解凍し直してやり直せます。本ツールは Google 非公式の学習用コードで、データの破損について運営者は責任を負いません。

写真の日付修復ツール 実行画面(Windows)。フォルダ選択欄の下に写真と対応JSON・現在の更新日時・修正後の日時・予定を並べた一覧が表示されている
実行画面(Windows 11・2026-09-30 撮影)。緑の行が「日付を直す」、灰色が「変更なし」、赤い行がスキップ

なぜ Google フォトから書き出した写真は日付がおかしくなるのか

Takeout の zip を解凍すると、写真と一緒に IMG_1234.jpg.json のようなファイルが大量に出てきます。邪魔に見えますが、これが撮影日時を取り戻す手がかりです。

IMG_2005.HEIC.supplemental-metadata.json(公開された実例・一部抜粋。出典: immich-app/immich Discussion #5083 のユーザー投稿。ファイル名は新形式に合わせて表記)

{
  "title": "IMG_2005.HEIC",
  "description": "A good description",
  "imageViews": "77",
  "photoTakenTime": {
    "timestamp": "1664140144",
    "formatted": "Sep 25, 2022, 9:09:04 PM UTC"
  },
  "geoData": { "latitude": 1.2345, "longitude": 5.4321, "altitude": 200.1234 },
  "favorited": true
}

ツールが使うのは次の3つです。

フィールド中身このツールでの使い道
photoTakenTime.timestamp撮影日時。UTC の秒数が文字列で入るこれが「正」。最優先で使う
creationTime.timestampGoogle フォトにアップロードされた日時photoTakenTime が欠けたときの代役
titleGoogle フォト上の元ファイル名「本当にこの写真の JSON か」の検算
⚠️
creationTime を撮影日時だと思わないこと

別物です。2019年7月17日に撮った写真を翌日アップロードすると、photoTakenTime は7月17日、creationTime は7月18日になります。昔のフィルム写真をスキャンして2020年に上げたなら creationTime は2020年です。ネット上には creationTime を使っているスクリプトもあるので、自分で書くときは取り違えに注意してください。

なお Google はこの JSON の仕様を公式に公開していないため、ここに書いた規則は実際のエクスポート結果とオープンソース実装から分かっている2026年9月時点の内容で、予告なく変わることがあります。

2. 機能一覧

  • 「フォルダを選ぶ」ボタン(_choose())・「まず試す(変更しない)」ボタン(_scan())・「日付を直す」ボタン(_run())・「ログをCSV保存」ボタン(_save_log())で操作
  • マウス操作 <Double-1> から _show_detail() を実行
  • ttk.Treeview による表形式の一覧表示
  • JSON ファイルの読み込み
  • CSV ファイルへの書き出し
  • filedialog.askdirectory() によるフォルダ選択ダイアログ
  • filedialog.asksaveasfilename() によるファイル保存ダイアログ
  • messagebox.askyesno()・messagebox.showerror()・messagebox.showinfo()・messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「写真の日付修復ツール」・サイズ 1000x680 で起動

3. 事前準備・環境

最初に Takeout 側の準備をします。分割 zip は全部を同じフォルダへ解凍してください(理由はつまずきの章)。Windows の「すべて展開」で zip ごとのサブフォルダに分かれても、その親フォルダを選べば中まで見にいきます。解凍後も zip は消さないでください。

ツール本体は標準ライブラリだけで動きます。追加インストールなしでも、全ファイルの更新日時を撮影日時に戻せます。次の2つは入れるとできることが増える任意のライブラリで、無ければその機能だけ自動で落ちます(起動時のログに現在のモードが出ます)。

📦 任意 — JPEG の EXIF 撮影日時も直したいとき
pip install piexif
📦 任意 — Windows の「作成日時」も直したいとき
pip install win32-setctime
ライブラリ入れないとどうなるかライセンス
piexifEXIF は書き換えず、ファイル日時だけ直します。EXIF を見て並べる写真管理ソフトを使うなら入れたほうが確実ですMIT
win32-setctime更新日時だけ直します。エクスプローラーの既定の並び順は更新日時なので、実害は小さいですMIT
  • Python 3.11 以上(3.12.10 で確認/Ubuntu で 3.14・3.15 の起動も確認)。まだ入れていない人はPythonのインストール手順から
  • OS: Windows 11(実機・ヘッドレステスト24件と、起動・基本操作(一覧表示→本適用)を確認)/Linux Mint 22.3(仮想マシン・Python 3.12.3。起動と一覧表示まで確認)
  • Takeout の zip をすべて同じフォルダへ解凍済み
  • OneDrive などの同期フォルダ以外に置くこと(理由はつまずきの章)

起動のしかた:4. 完全なソースコードの app101.py は好きな場所(デスクトップなど)に保存して構いません。写真フォルダと同じ場所に置く必要はありません(対象フォルダは起動後に画面から選びます)。Windows なら、保存したフォルダをエクスプローラーで開き、アドレスバーに cmd と打って Enter を押し、出てきた黒い画面に python app101.py と入力すると起動します。この章の pip install も同じ黒い画面に貼り付けます。

ℹ️
動作確認環境

Python 3.12.10 / Windows 11 Pro(実機・piexif 有効・win32-setctime 未導入)で、合成した Takeout フォルダに対する突合・ドライラン・書き込みのヘッドレステスト24件が全件成功。画面からの一覧表示→本適用も確認

Python 3.12.3 / Linux Mint 22.3(仮想マシン・Pillow 12.3.0・piexif 1.1.3)で起動と一覧表示を確認(書き込みは実行していません)。macOS は未検証

Linux Mint 22.3(仮想マシン)でも起動と一覧表示を確認しました。合成した Takeout サンプル(JPEG 7枚+JSON 5本)で、日付を直す5行・変更なし1行・スキップ(JSONなし)1行と、Windows と同じ一覧になります。

写真の日付修復ツール 実行画面(Linux Mint)。同じ一覧がLinuxのウィンドウ枠で表示されている
実行画面(Linux Mint 22.3・2026-09-30 撮影)。作成日時の書き戻しは Windows のみ

使い方 — まず試してから直す

このツールには「実際にファイルを書き換える」というチェックボックスがあり、既定はオフです。オフのままなら何度実行してもファイルは1バイトも変わりません。結果を先に全部見て、納得してからチェックを入れる2段構えです。チェックを外したまま「日付を直す」を押した場合も、ログに「(ドライラン)日付を直す」と出るだけで書き込みは起きません。

  1. 1
    フォルダを選ぶ

    Takeout を解凍したいちばん上のフォルダを指定します。中のサブフォルダは再帰的に見にいくので、Takeout フォルダごと選んで構いません。

  2. 2
    「まず試す(変更しない)」を押す

    写真と JSON の突合だけを行い、一覧を作ります。読むだけで書き込みはしません(進捗バーが動きます)。

  3. 3
    一覧と件数サマリを確認する

    画面下に「対象 1,240 件/直す 1,180 件/変更なし 12 件/JSONなし 44 件…」のように内訳が出ます。「JSONなし」が多いときは実行せず、つまずきの章を確認してください。

  4. 4
    必要ならログを CSV に保存する

    「ログをCSV保存」で一覧を CSV に落とせます。Excel で開ける形式(UTF-8 BOM 付き)なので、スキップされたファイルだけ抜き出して確認できます。

  5. 5
    チェックを入れて「日付を直す」

    初回は手順1「フォルダを選ぶ」に戻り、サブフォルダを1つだけ選び直して試すのがおすすめです。「Photos from 2015」のような小さいフォルダで実行し、エクスプローラーで更新日時が撮影日に変わったことを自分の目で確かめてから、Takeout フォルダ全体をやり直してください。(更新日時の列が見えないときは、エクスプローラーの「表示 → 詳細」に切り替えてください)

    「実際にファイルを書き換える」にチェックを入れて「日付を直す」を押すと、確認ダイアログのあとで書き込みが始まります。終わると自動で再スキャンします。件数サマリの「直す」が 0 件になっていれば成功です(「JSONなし」などのスキップ行は赤いまま残りますが、それが正常です)。

    エクスプローラーの詳細表示で、写真ファイルの更新日時の列に撮影当時の日付が並んでいる様子
    本適用後にエクスプローラー(詳細表示)で更新日時を見たところ(Windows 11・2026-09-30 撮影)

「予定」列には5種類の値が出ます(一覧の行もこの色で塗り分けられます)。何がスキップされたのかを隠さず残すのが方針です。

予定意味どうすればいいか
🟩 日付を直すJSON が見つかり、日時も妥当そのまま実行してください
⬜ 変更なし(既に一致)更新日時も EXIF も既に正しい何もしません。2回目以降の実行はほぼこれになります
🟥 スキップ(JSONなし)対応する JSON が見つからない未解凍の zip がないか確認(つまずきの章)
🟥 スキップ(日時が不正)timestamp が無い/0以下で扱えない0以下なら1970年以前の写真、無いなら JSON に日時が入っていません。どちらも手で直してください
🟥 スキップ(名前が一致しない)JSON の title がこの写真のファイル名と違う誤った日付を書き込む事故を防ぐ停止です。行をダブルクリックすると JSON 側の名前が見られます

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンでクリップボードにコピーできます。app101.py をダウンロード(右クリック→「名前を付けて保存」)することもできます。起動のしかたは3. 事前準備・環境をご覧ください。

✅ 追加インストール不要(標準ライブラリのみで起動します。piexif と win32-setctime は入っていれば自動で使います)
app101.py
"""写真の日付修復ツール(app101.py / PythonLand)

Google データ エクスポート(Takeout)で書き出した写真・動画は、解凍した時点で
ファイルの更新日時がダウンロード日時になってしまう。Takeout に同梱される
JSON サイドカー(photoTakenTime)を読み、本来の撮影日時へ戻す tkinter 製ツール。

安全側の設計:
  - 既定はドライラン。「実際にファイルを書き換える」を ON にしないと 1 バイトも書かない
  - JSON が見つからない写真は推測せず「スキップ」として一覧に残す
  - JPEG の EXIF 書き込みは piexif.insert のみ(再エンコードしない=画質を落とさない)
  - 1 ファイルの失敗で全体を止めない(ログに残して次へ進む)

任意ライブラリ(無くても動く):
  pip install piexif          … JPEG の EXIF 撮影日時も書き戻す
  pip install win32-setctime  … Windows の「作成日時」も書き戻す

MIT License / PythonLand (https://pythonland.tech/)
"""

import csv
import importlib
import json
import os
import re
import tkinter as tk
from datetime import datetime, timezone
from tkinter import filedialog, messagebox, ttk

# ---------------------------------------------------------------------------
# 任意ライブラリの読み込み(入っていなければ None にして機能だけ落とす)
# ---------------------------------------------------------------------------


def load_optional(module_name):
    """任意ライブラリを読み込む。入っていなければ None を返す(落とさない)。"""
    try:
        return importlib.import_module(module_name)
    except Exception:
        return None


piexif = load_optional("piexif")
setctime_mod = load_optional("win32_setctime") if os.name == "nt" else None

# ---------------------------------------------------------------------------
# 定数
# ---------------------------------------------------------------------------

MEDIA_EXTS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".mp4", ".mov")
EXIF_EXTS = (".jpg", ".jpeg")
SUPPLEMENTAL = "supplemental-metadata"

# Google フォトの「編集済みコピー」の接尾辞は表示言語でローカライズされる。
# 編集済みコピーは自前の JSON を持たず、元ファイルの JSON を共有する。
EDITED_SUFFIXES = (
    "-edited", "-編集済み", "-编辑", "-편집됨", "-editado", "-modifié",
    "-bearbeitet", "-modificato", "-bewerkt", "-muokattu", "-edytowane",
    "-redigeret", "-отредактировано", "-відредаговано",
)

SEQ_RE = re.compile(r"\((\d+)\)$")

ACT_FIX = "日付を直す"
ACT_SAME = "変更なし(既に一致)"
ACT_NO_JSON = "スキップ(JSONなし)"
ACT_BAD_TIME = "スキップ(日時が不正)"
ACT_MISMATCH = "スキップ(名前が一致しない)"

# ---------------------------------------------------------------------------
# コア(1)ファイル名の正規化
# ---------------------------------------------------------------------------


def split_sequence(name):
    """末尾の "(3)" を切り離して (本体, 3) を返す。無ければ (name, None)。"""
    m = SEQ_RE.search(name)
    if not m:
        return name, None
    return name[:m.start()], int(m.group(1))


def strip_edited(stem):
    """編集済みコピーの接尾辞を外して (元の名前, 外したか) を返す。"""
    for suffix in EDITED_SUFFIXES:
        if stem.endswith(suffix):
            return stem[:-len(suffix)], True
    return stem, False


def normalize_json_name(filename):
    """JSON サイドカーのファイル名から (照合キー, 連番) を作る。

    Google は ".supplemental-metadata" を長さの都合で途中まで切り詰めるので、
    「supplemental-metadata の前方一致になっているセグメント」を丸ごと落とす。
    切り詰めの文字数ルールには依存しない。
    """
    if not filename.lower().endswith(".json"):
        return None, None
    body = filename[:-len(".json")]
    body, num = split_sequence(body)
    dot = body.rfind(".")
    if dot != -1:
        segment = body[dot + 1:]
        # ".." で終わる(接尾辞がドットだけ残った)ケースも segment == "" で拾える
        if SUPPLEMENTAL.startswith(segment) and "." in body[:dot]:
            body = body[:dot]
    return body, num


def media_lookup_keys(filename):
    """メディアファイル名から、JSON 索引を引く順番どおりの候補キーを返す。"""
    stem, ext = os.path.splitext(filename)
    stem, num = split_sequence(stem)
    original, edited = strip_edited(stem)
    keys = [(stem + ext, num), (stem + ext, None)]
    if edited:
        keys += [(original + ext, num), (original + ext, None)]
    return keys

# ---------------------------------------------------------------------------
# コア(2)JSON の索引づくりと突合
# ---------------------------------------------------------------------------


def build_json_index(root):
    """フォルダ以下の JSON を先に全部読み、正規化キーの索引にする。"""
    index = {}
    for dirpath, _dirnames, filenames in os.walk(root):
        for name in filenames:
            if not name.lower().endswith(".json"):
                continue
            key, num = normalize_json_name(name)
            if not key:
                continue
            index.setdefault((key.casefold(), num), os.path.join(dirpath, name))
    return index


def find_json(index, filename):
    """索引からこのメディアに対応する JSON のパスを探す。見つからなければ None。"""
    for key, num in media_lookup_keys(filename):
        hit = index.get((key.casefold(), num))
        if hit:
            return hit
    # 元ファイル名まで切り詰められた場合の保険。最も長く一致したキーを採用する
    target = media_lookup_keys(filename)[0][0].casefold()
    _, num = split_sequence(os.path.splitext(filename)[0])
    best_key, best_path = "", None
    for (key, key_num), path in index.items():
        if key_num not in (num, None):
            continue
        if len(key) >= 8 and target.startswith(key) and len(key) > len(best_key):
            best_key, best_path = key, path
    return best_path


def read_sidecar(json_path):
    """JSON から (撮影日時のUTC秒, 使ったフィールド名, JSON内の元ファイル名) を読む。"""
    with open(json_path, "r", encoding="utf-8") as f:
        data = json.load(f)
    if not isinstance(data, dict):
        return None, None, None
    title = data.get("title") if isinstance(data.get("title"), str) else None
    for field in ("photoTakenTime", "creationTime"):
        node = data.get(field)
        if not isinstance(node, dict):
            continue
        raw = node.get("timestamp")
        if raw is None:
            continue
        try:
            return int(str(raw).strip()), field, title
        except ValueError:
            continue
    return None, None, title


def verify_title(filename, title):
    """JSON の title とメディアのファイル名を突き合わせて検算する。

    戻り値: "ok"(一致)/ "near"(編集済み・切り詰めの範囲で一致)/
            "none"(title が無い)/ "mismatch"(別の写真の JSON の疑い)
    """
    if not title:
        return "none"
    name = filename.casefold()
    title_cf = title.casefold()
    if name == title_cf:
        return "ok"
    stem, ext = os.path.splitext(filename)
    stem, _num = split_sequence(stem)
    original, _edited = strip_edited(stem)
    if (original + ext).casefold() == title_cf:
        return "ok"
    t_stem = os.path.splitext(title)[0].casefold()
    if t_stem and (original.casefold().startswith(t_stem)
                   or t_stem.startswith(original.casefold())):
        return "near"
    return "mismatch"

# ---------------------------------------------------------------------------
# コア(3)日時の変換
# ---------------------------------------------------------------------------


def to_local(timestamp):
    """UTC 秒を、このパソコンのタイムゾーンの日時に直す。扱えなければ None。"""
    if timestamp is None or timestamp <= 0:
        return None
    try:
        return datetime.fromtimestamp(timestamp, tz=timezone.utc).astimezone()
    except (OverflowError, OSError, ValueError):
        # Windows では 1970 年より前(負の値)で OSError [Errno 22] になる
        return None


def exif_datetime(local_dt):
    """EXIF の日時書式 "YYYY:MM:DD HH:MM:SS" に整える。"""
    return local_dt.strftime("%Y:%m:%d %H:%M:%S")


def exif_offset(local_dt):
    """EXIF の OffsetTimeOriginal 用に "+09:00" 形式の時差を作る。"""
    text = local_dt.strftime("%z")
    return text[:3] + ":" + text[3:] if len(text) == 5 else None

# ---------------------------------------------------------------------------
# コア(4)計画を立てる
# ---------------------------------------------------------------------------


class PlanItem:
    """1 ファイル分の「今どうなっていて、どう直すか」。"""

    def __init__(self, path, root):
        self.path = path
        self.name = os.path.basename(path)
        self.rel = os.path.relpath(path, root)
        self.json_path = None
        self.timestamp = None
        self.source = ""
        self.verify = ""
        self.local = None
        self.action = ACT_NO_JSON
        self.note = ""

    @property
    def json_name(self):
        return os.path.basename(self.json_path) if self.json_path else "—"

    @property
    def current_text(self):
        try:
            return datetime.fromtimestamp(os.path.getmtime(self.path)).strftime(
                "%Y-%m-%d %H:%M:%S")
        except (OSError, OverflowError, ValueError):
            return "読めません"

    @property
    def target_text(self):
        return self.local.strftime("%Y-%m-%d %H:%M:%S") if self.local else "—"

    @property
    def writes_exif(self):
        return (piexif is not None
                and os.path.splitext(self.name)[1].lower() in EXIF_EXTS)


def collect_media(root):
    """対象フォルダ以下の対応拡張子のファイルを集める。"""
    found = []
    for dirpath, _dirnames, filenames in os.walk(root):
        for name in filenames:
            if os.path.splitext(name)[1].lower() in MEDIA_EXTS:
                found.append(os.path.join(dirpath, name))
    found.sort()
    return found


def build_plan(root, progress=None):
    """フォルダをスキャンして PlanItem の一覧を返す(ファイルは一切書き換えない)。"""
    index = build_json_index(root)
    media = collect_media(root)
    items = []
    for i, path in enumerate(media):
        item = PlanItem(path, root)
        item.json_path = find_json(index, item.name)
        if item.json_path:
            try:
                item.timestamp, item.source, title = read_sidecar(item.json_path)
            except (OSError, ValueError, UnicodeDecodeError) as exc:
                item.action = ACT_NO_JSON
                item.note = "JSON を読めません: {}".format(exc)
                items.append(item)
                continue
            item.verify = verify_title(item.name, title)
            item.local = to_local(item.timestamp)
            if item.verify == "mismatch":
                item.action = ACT_MISMATCH
                item.note = "JSON の title が '{}' で別の写真の可能性".format(title)
            elif item.local is None:
                item.action = ACT_BAD_TIME
                item.note = "timestamp={} はこの環境で扱えません".format(item.timestamp)
            elif is_already_correct(item):
                item.action = ACT_SAME
            else:
                item.action = ACT_FIX
                if item.source == "creationTime":
                    item.note = "photoTakenTime が無いため creationTime を使用"
        if progress:
            progress(i + 1, len(media))
        items.append(item)
    return items


def is_already_correct(item):
    """更新日時も(対象なら)EXIF も既に正しいか。"""
    try:
        if int(os.path.getmtime(item.path)) != int(item.timestamp):
            return False
    except OSError:
        return False
    if item.writes_exif:
        return read_exif_original(item.path) == exif_datetime(item.local)
    return True

# ---------------------------------------------------------------------------
# コア(5)書き込み
# ---------------------------------------------------------------------------


def read_exif_original(path):
    """JPEG の EXIF 撮影日時を読む。無ければ None。"""
    if piexif is None:
        return None
    try:
        exif = piexif.load(path)
    except Exception:
        return None
    raw = exif.get("Exif", {}).get(piexif.ExifIFD.DateTimeOriginal)
    if isinstance(raw, bytes):
        return raw.decode("ascii", "ignore")
    return raw


def write_exif(path, local_dt):
    """piexif.insert で JPEG の日時タグだけ差し替える(再エンコードしない)。"""
    exif = piexif.load(path)
    stamp = exif_datetime(local_dt).encode("ascii")
    exif.setdefault("Exif", {})[piexif.ExifIFD.DateTimeOriginal] = stamp
    exif["Exif"][piexif.ExifIFD.DateTimeDigitized] = stamp
    exif.setdefault("0th", {})[piexif.ImageIFD.DateTime] = stamp
    offset = exif_offset(local_dt)
    if offset:
        code = offset.encode("ascii")
        exif["Exif"][piexif.ExifIFD.OffsetTimeOriginal] = code
        exif["Exif"][piexif.ExifIFD.OffsetTimeDigitized] = code
    exif.pop("thumbnail", None)
    exif.pop("1st", None)
    piexif.insert(piexif.dump(exif), path)


def apply_item(item, dry_run=True):
    """1 ファイルを直す。dry_run のときは何も書かずに予定だけ返す。"""
    done = []
    if item.action != ACT_FIX:
        return item.action
    if dry_run:
        return "(ドライラン)" + ACT_FIX
    if item.writes_exif:
        try:
            write_exif(item.path, item.local)
            done.append("EXIF")
        except Exception as exc:
            done.append("EXIF失敗({})".format(type(exc).__name__))
    os.utime(item.path, (item.timestamp, item.timestamp))
    done.append("更新日時")
    if setctime_mod is not None:
        try:
            setctime_mod.setctime(item.path, float(item.timestamp))
            done.append("作成日時")
        except Exception as exc:
            done.append("作成日時失敗({})".format(type(exc).__name__))
    return "書き換え: " + "・".join(done)


def apply_plan(items, dry_run=True, log=None, progress=None):
    """計画をまとめて実行する。1 件の失敗で全体を止めない。"""
    summary = {"fixed": 0, "skipped": 0, "failed": 0}
    targets = [it for it in items if it.action == ACT_FIX]
    for i, item in enumerate(targets):
        try:
            result = apply_item(item, dry_run)
            summary["fixed"] += 1
        except Exception as exc:
            result = "失敗: {}: {}".format(type(exc).__name__, exc)
            summary["failed"] += 1
        if log:
            log("{} … {}".format(item.rel, result))
        if progress:
            progress(i + 1, len(targets))
    summary["skipped"] = len(items) - len(targets)
    return summary


def save_log_csv(items, path):
    """一覧をそのまま CSV に保存する(Excel で開ける UTF-8 BOM 付き)。"""
    with open(path, "w", encoding="utf-8-sig", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["ファイル", "対応するJSON", "現在の更新日時",
                         "修正後の日時", "予定", "メモ"])
        for item in items:
            writer.writerow([item.rel, item.json_name, item.current_text,
                             item.target_text, item.action, item.note])

# ---------------------------------------------------------------------------
# GUI
# ---------------------------------------------------------------------------


class App101:
    """写真の日付修復ツール"""

    COLUMNS = ("ファイル", "対応するJSON", "現在の更新日時", "修正後の日時", "予定")
    WIDTHS = (250, 250, 150, 150, 170)

    def __init__(self, root):
        self.root = root
        self.root.title("写真の日付修復ツール")
        self.root.geometry("1000x680")
        self.folder = tk.StringVar(value="")
        self.do_write = tk.BooleanVar(value=False)
        self.status = tk.StringVar(value="フォルダを選んで「まず試す」を押してください。")
        self.items = []
        self._busy = False
        self._build_ui()
        self._show_environment()

    def _build_ui(self):
        top = tk.Frame(self.root, padx=10, pady=8)
        top.pack(fill="x")
        tk.Label(top, text="Takeout を解凍したフォルダ").pack(anchor="w")
        row = tk.Frame(top)
        row.pack(fill="x", pady=4)
        ttk.Entry(row, textvariable=self.folder).pack(side="left", fill="x",
                                                      expand=True)
        ttk.Button(row, text="フォルダを選ぶ", command=self._choose).pack(
            side="left", padx=6)
        ttk.Button(row, text="まず試す(変更しない)", command=self._scan).pack(
            side="left")

        opt = tk.Frame(self.root, padx=10)
        opt.pack(fill="x")
        ttk.Checkbutton(opt, text="実際にファイルを書き換える(チェックを外している間は何も変更しません)",
                        variable=self.do_write).pack(side="left")
        ttk.Button(opt, text="日付を直す", command=self._run).pack(side="right")
        ttk.Button(opt, text="ログをCSV保存", command=self._save_log).pack(
            side="right", padx=6)

        self.tree = ttk.Treeview(self.root, columns=self.COLUMNS, show="headings",
                                 height=14)
        for col, width in zip(self.COLUMNS, self.WIDTHS):
            self.tree.heading(col, text=col)
            self.tree.column(col, width=width, anchor="w")
        self.tree.tag_configure("fix", background="#e8f5e9")
        self.tree.tag_configure("skip", background="#ffebee")
        self.tree.tag_configure("same", background="#f5f5f5")
        bar = ttk.Scrollbar(self.root, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=bar.set)
        self.tree.pack(side="top", fill="both", expand=True, padx=10, pady=8)
        self.tree.bind("<Double-1>", self._show_detail)

        self.progress = ttk.Progressbar(self.root, mode="determinate")
        self.progress.pack(fill="x", padx=10)
        tk.Label(self.root, textvariable=self.status, anchor="w").pack(
            fill="x", padx=10, pady=4)
        self.log = tk.Text(self.root, height=7, wrap="none")
        self.log.pack(fill="both", padx=10, pady=(0, 10))

    def _show_environment(self):
        self._log("写真の日付修復ツール — 先に Takeout の zip を残したままにしてください。")
        self._log("JPEG の EXIF 書き込み: {}".format(
            "有効(piexif)" if piexif else "無効(piexif が未導入のためファイル日時のみ直します)"))
        self._log("Windows の作成日時: {}".format(
            "有効(win32-setctime)" if setctime_mod else "無効(更新日時のみ直します)"))

    def _log(self, text):
        self.log.insert("end", text + "\n")
        self.log.see("end")

    def _choose(self):
        path = filedialog.askdirectory(title="Takeout を解凍したフォルダを選んでください")
        if path:
            self.folder.set(path)

    def _folder_ok(self):
        path = self.folder.get().strip()
        if not path or not os.path.isdir(path):
            messagebox.showwarning("フォルダが未選択です",
                                   "Takeout を解凍したフォルダを選んでください。")
            return None
        return path

    def _set_progress(self, done, total):
        self.progress["maximum"] = max(total, 1)
        self.progress["value"] = done
        self.root.update_idletasks()

    def _scan(self):
        path = self._folder_ok()
        if not path or self._busy:
            return
        self._busy = True
        self.status.set("スキャン中…")
        try:
            self.items = build_plan(path, progress=self._set_progress)
        except OSError as exc:
            messagebox.showerror("スキャンできません", str(exc))
            self._busy = False
            return
        self._fill_tree()
        self._busy = False

    def _fill_tree(self):
        self.tree.delete(*self.tree.get_children())
        counts = {ACT_FIX: 0, ACT_SAME: 0, ACT_NO_JSON: 0,
                  ACT_BAD_TIME: 0, ACT_MISMATCH: 0}
        for item in self.items:
            counts[item.action] = counts.get(item.action, 0) + 1
            tag = ("fix" if item.action == ACT_FIX
                   else "same" if item.action == ACT_SAME else "skip")
            self.tree.insert("", "end", tags=(tag,),
                             values=(item.rel, item.json_name, item.current_text,
                                     item.target_text, item.action))
        self.status.set(
            "対象 {} 件/直す {} 件/変更なし {} 件/JSONなし {} 件/日時が不正 {} 件/名前が一致しない {} 件".format(
                len(self.items), counts[ACT_FIX], counts[ACT_SAME],
                counts[ACT_NO_JSON], counts[ACT_BAD_TIME], counts[ACT_MISMATCH]))
        self._log(self.status.get())

    def _show_detail(self, event):
        selected = self.tree.focus()
        if not selected:
            return
        row = self.tree.index(selected)
        if 0 <= row < len(self.items):
            item = self.items[row]
            messagebox.showinfo(item.name, item.note or "特記事項はありません。")

    def _run(self):
        if not self.items:
            messagebox.showinfo("先にスキャンしてください",
                                "「まず試す(変更しない)」で内容を確認してから実行してください。")
            return
        if self._busy:
            return
        dry_run = not self.do_write.get()
        targets = sum(1 for it in self.items if it.action == ACT_FIX)
        if not dry_run and not messagebox.askyesno(
                "確認", "{} 件のファイルを書き換えます。Takeout の zip は残っていますか?".format(targets)):
            return
        self._busy = True
        self.status.set("実行中…")
        summary = apply_plan(self.items, dry_run=dry_run, log=self._log,
                             progress=self._set_progress)
        self.status.set("{}: 処理 {} 件/スキップ {} 件/失敗 {} 件".format(
            "ドライラン" if dry_run else "書き換え完了",
            summary["fixed"], summary["skipped"], summary["failed"]))
        self._log(self.status.get())
        if not dry_run:
            self.items = build_plan(self.folder.get(), progress=self._set_progress)
            self._fill_tree()
        self._busy = False

    def _save_log(self):
        if not self.items:
            messagebox.showinfo("保存するものがありません", "先にスキャンしてください。")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv", initialfile="photo_date_log.csv",
            filetypes=[("CSVファイル", "*.csv")])
        if not path:
            return
        try:
            save_log_csv(self.items, path)
        except OSError as exc:
            messagebox.showerror("保存できません", str(exc))
            return
        self._log("ログを保存しました: " + path)


if __name__ == "__main__":
    root = tk.Tk()
    app = App101(root)
    root.mainloop()

5. コード解説

写真の日付修復ツールのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App101 クラスにアプリの全機能をまとめています(メソッド12個・全595行)。__init__ ではタイトル「写真の日付修復ツール」とウィンドウサイズ 1000x680 を設定します。あわせて状態を保持する変数(self._busy)を初期化し、そのあと _build_ui() → _show_environment() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×3・tk.Label×2・ttk.Entry・ttk.Button×4・ttk.Checkbutton・ttk.Treeview・ttk.Scrollbar・ttk.Progressbar・tk.Text で構成しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「フォルダを選ぶ」ボタン(_choose())・「まず試す(変更しない)」ボタン(_scan())・「日付を直す」ボタン(_run())・「ログをCSV保存」ボタン(_save_log())を command= で接続しています。また bind("<Double-1>", ...) から _show_detail() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:COLUMNS = ('ファイル', '対応するJSON', '現在の更新日時', '修正後の日時', '予定')、WIDTHS = (250, 250, 150, 150, 170)。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で Exception・OSError・OverflowError・UnicodeDecodeError・ValueError を捕捉しています。あわせて messagebox.askyesno()・messagebox.showerror()・messagebox.showinfo()・messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

仕組み — JSON の索引づくりと日時の線引き

ここからは実装の話です。写真を直したいだけならTakeout ならではのつまずきへ飛んで構いません。

写真と JSON の名前は、きれいに対応していない

「IMG_1234.jpg なら IMG_1234.jpg.json を読めばいい」と書くと、かなりの数で JSON が見つかりません。Takeout のファイル名に癖があるためです。

写真・動画対応する JSON癖
IMG_1234.jpgIMG_1234.jpg.json基本形
IMG_1234.jpgIMG_1234.jpg.supplemental-metadata.json同じ Takeout の中で新旧が混ざる
IMG_1234(1).jpgIMG_1234.jpg(1).json連番 (1) の位置がズレる(写真は拡張子の前、JSON は末尾)
IMG_1234(1).jpgIMG_1234.jpg.supplemental-metadata(1).json新形式では連番が最後尾
PXL_20240817_202602411.mp4PXL_20240817_202602411.mp4.supplemental-metada.json接尾辞が途中で切れる(-metada)
PXL_20230815_142536789.jpgPXL_20230815_142536789.jpg.supplemental-m.json切れる長さは元の名前の長さ次第で変わる
IMG_3333(1).jpgIMG_3333.jpg.suppl(1).json切り詰めと連番の合わせ技
filename_that_is_45_chars_long.jpgfilename_that_is_45_chars_long.jpg..json接尾辞がドットだけ残る
IMG_20180830_123540573-edited(1).jpgIMG_20180830_123540573.jpg(1).json編集済みコピーは自前の JSON を持たず元のものを共有
photo-編集済み.jpgphoto.jpg.json編集済みの接尾辞は表示言語でローカライズされる

切り詰めの閾値は「46文字」「50文字」と説が分かれ、一次情報では確認できませんでした。確認できていない数字をコードに書くと、Google が仕様を変えた瞬間に動かなくなります。そこで文字数のルールを使わない方式にしました。

💡
索引方式 — JSON を先に全部読んで、正規化した名前で引く
  1. フォルダ以下の .json を集める
  2. 末尾の .json を外し、(3) を連番として切り離す
  3. 残った名前の最後のセグメントが supplemental-metadata の前方一致なら丸ごと捨てる(-metada も -m も空文字も、この1本で落ちる)
  4. 残りを (キー, 連番) の辞書に登録する
  5. 写真側は連番を切り離し、編集済みの接尾辞を外してからこの辞書を引く

上の表の例を含む12ペアのダミーファイルで突合テストを書き、すべてが正しい JSON に当たることを確認しました。文字数を数えていないので、Google が切り詰めの長さを変えても影響を受けません。

間違った JSON を当ててしまう事故を止める

索引が引けても、その写真の JSON だとは限りません。前方一致で似た名前の別の写真に当たる余地が残り、間違った日付は元に戻せません。

そこでツールは、見つけた JSON の title(Google フォト上の元ファイル名)と写真のファイル名を突き合わせて検算し、一致しなければ書き込まず「スキップ(名前が一致しない)」として一覧に赤く残します。編集済みコピーのように接尾辞を外せば一致する場合は正常です。手元で確認した範囲では、他のツールはこの検算をしていません。

EXIF を直す形式と、ファイル日時だけ直す形式

「全形式で EXIF も直す」ほうが親切に見えますが、あえてやっていません。理由は画質です。

形式EXIF 撮影日時ファイル日時そうしている理由
JPEG(.jpg / .jpeg)書き換える直すpiexif.insert() は EXIF 領域を差し替えるだけで再圧縮しない
HEIC書き換えない直す書き込むと再エンコードで画質が落ちる
PNG / WebP / GIF書き換えない直すPNG の eXIf チャンクは対応ビューアが限られる
MP4 / MOV—直す動画に EXIF という仕組みが無い(別系統の QuickTime タグ)

JPEG が無劣化であることは手元で測りました。640×480・約232KB のテスト JPEG に日時を書き込むと、サイズは 232,778 バイト → 232,932 バイト(+154 バイト)。増えたぶんは EXIF そのもので、デコードした画素データの SHA-256 は書き込み前後で一致しました。Pillow の save() でも EXIF を付けられますが、JPEG を再圧縮するため実行のたびに画質が落ちます。だから piexif.insert() しか使いません。

書き込むタグは DateTimeOriginal(撮影日時・本命)・DateTimeDigitized・DateTime の3つと、+09:00 のような時差を入れる OffsetTimeOriginal・OffsetTimeDigitized です。EXIF の日時タグ自体にタイムゾーン情報が無いため、時差も併記しています。

タイムゾーンで気をつけること

JSON の photoTakenTime.timestamp は UTC の秒数です。更新日時(os.utime)は秒数をそのまま渡せますが、EXIF に書くときだけは表示用の時刻に直す必要があります。ツールは datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() で実行中のパソコンのタイムゾーンへ変換する実装です。限界もあり、パソコンが日本時間なら海外で撮った写真も日本時間になります。JSON に撮影地のタイムゾーンが入っていないため、どのツールでも同じ制約です。

⚠️
1970年より前の写真は、あえてスキップします

スキャンした古い写真では photoTakenTime.timestamp が -725791800(1947年)のような負の値になることがあります。Windows 11 / Python 3.12.10 で試すと、datetime.fromtimestamp(-725791800) は OSError: [Errno 22] Invalid argument で失敗しました。os.utime() は負の値でも通りますが、書いた日時を読み取れず、結果を確認できない書き込みになります。ツールは 0 以下の timestamp を「不正」として書き込まず、一覧に残します。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app101.py と保存します。使うのは csv・datetime・importlib・json・os・re・tkinter だけなので、追加インストールは不要です。

  2. 2
    クラスの骨格を作る

    App101 クラスを定義し、__init__ と、末尾の root = tk.Tk() ~ mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("写真の日付修復ツール")・geometry("1000x680") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×3・tk.Label×2・ttk.Entry・ttk.Button×4・ttk.Checkbutton・ttk.Treeview・ttk.Scrollbar・ttk.Progressbar・tk.Text を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_choose()・_scan()・_run()・_save_log())につなぎます。bind("<Double-1>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _run()(24行)・_save_log()(15行)・_scan()(14行)など を実装します。

  7. 7
    動作確認する

    python app101.py で起動し、各ボタンが反応すること・マウス操作(クリック・ホイールなど)が効くことを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 元フォルダを触らず、コピー先に出力する

apply_item() がその場のファイルを書き換える前に shutil.copy2() で別フォルダへ写し、写した側だけを直すようにします。元フォルダを1バイトも触らずに済む代わりに、ディスクを2倍使います。どちらを既定にするかは空き容量で決めてください。

💡 位置情報も JSON から書き戻す

JSON の geoData には緯度・経度が入っています。piexif の GPS IFD に GPSLatitude / GPSLongitude を書けば、地図対応ビューアで撮影場所が出ます。度分秒の分数表現に直す変換関数を1つ足すのが最初の課題です。

💡 撮影日でフォルダ分けする

日付を直したあと、2023/08/ のような年月フォルダへ振り分けます。PlanItem は local(変換後の日時)を持っているので、strftime("%Y/%m") で行き先を作って shutil.move() するだけです。移動は取り消せないので、こちらもドライランを先に用意してください。

💡 一覧をクリックして並べ替えられるようにする

いまの ttk.Treeview はファイル名順で固定です。self.tree.heading(col, command=...) に並べ替え関数をつなぐと、「予定」列でスキップだけをまとめて眺められます。件数が多いときの確認が楽になります。

8. よくある問題と解決法

❌ ウィンドウの大きさが画面に合わない

原因:geometry("1000x680") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

Takeout ならではのつまずき

❌ 「スキップ(JSONなし)」が大量に出る

原因:分割 zip の解凍もれです。Takeout は指定サイズを超えると複数の zip に分かれ、写真とその JSON が別々の zip に入ることがあります。アーカイブは約7日で期限切れになるため、数日かけて落としていると最後の zip が取得できていないこともあります。

解決法:すべての zip を同じ1つのフォルダに解凍し直してから、もう一度「まず試す」を押してください。それでも残るぶんは Takeout 自体が JSON を出していない写真です(ユーザー報告が複数あります)。日時の手がかりが無いので、ツールは推測せずスキップします。

❌ 「-編集済み」が付いた写真だけ直らない

原因:Google フォトで明るさなどを調整すると photo-編集済み.jpg が別に作られます。編集済みコピーは自分の JSON を持たず元ファイルのものを共有し、接尾辞は表示言語で変わるため、海外製ツールは -edited しか知らないことがあります。

解決法:このツールは EDITED_SUFFIXES に14言語ぶんを持っていて、日本語の -編集済み も最初から対象です。直らない接尾辞があれば、このタプルに1行足してください。

❌ iPhone の HEIC は日付が直るのに、写真アプリでは古い日付のまま

原因:ツールは HEIC のファイル日時は直しますが、内部の EXIF は書き換えません。写真アプリが EXIF を見て並べている場合、表示は変わりません。

解決法:HEIC の EXIF まで直すなら ExifTool を別途使ってください。pillow-heif でも書けますが、再エンコードで画質が落ちるため採用していません。

❌ OneDrive / Google ドライブのフォルダで実行すると失敗する

原因:オンデマンド機能で実体のないプレースホルダになっているファイルがあり、処理中に同期が走ると書き込んだ日時が上書きされることもあります。

解決法:Takeout は同期フォルダの外(C:\Users\あなた\Pictures\takeout_work など)に解凍してから実行してください。どうしても同期フォルダで作業するなら、実行中だけ同期を止めます。

❌ 同じ写真が何度も一覧に出てくる

原因:Takeout には「Photos from 2023」のような年フォルダとアルバム名のフォルダが両方入り、アルバムに入れた写真は両方に重複して出力されます。ツールは両方とも対象にするため、一覧に同じファイル名が複数行並びます。

解決法:どちらも正しく日付が直るので、そのままで問題ありません。重複を整理したいなら、日付を直したあとに重複ファイル検出ツールでハッシュ比較して片方を消すのが安全です。

❌ PermissionError で止まる

原因:zip の中を直接開いている、読み取り専用属性が付いている、写真ビューアがファイルを掴んでいる、のいずれかです。

解決法:ツールは1ファイルの失敗で全体を止めず、ログに残して次へ進みます。ログに出たファイルだけ、開いているアプリを閉じてから再実行してください。

9. 練習問題

アプリの理解を深めるための練習問題です(課題3は定型の発展課題)。どの課題もダミーのファイルで試してください。空のフォルダに適当な jpg と JSON を作れば、本物の写真を使わずに確認できます。

  1. 課題1:スキップされた理由を一覧の中で見えるようにする

    「スキップ(名前が一致しない)」の詳しい理由は PlanItem.note に入っていますが、行をダブルクリックしないと読めません。一覧に「メモ」列を足して、その場で読めるようにしてください。

    期待結果:スキップされた行の右端に「JSON の title が '〜' で別の写真の可能性」が出る。

    合格条件

    • 「日付を直す」の行はメモ列が空か、フォールバックの注記だけ
    • 列を足しても既存5列の幅が崩れない
    • CSV 保存の中身は変わらない(もともとメモ列を書き出している)
    • ヒント①(どこを触るか): App101.COLUMNS・WIDTHS・_fill_tree() の values=(...) の3か所です。
    • ヒント②(使うもの): _build_ui() は zip(self.COLUMNS, self.WIDTHS) で見出しを作るので、両方のタプルに同じ数だけ足せば列が増えます。
    • つまずきやすい点: values= の要素数が COLUMNS と合っていないと、ずれた列に値が入ります。
  2. 課題2:creationTime を使うかどうかを選べるようにする

    read_sidecar() は photoTakenTime が無いとき creationTime にフォールバックしますが、これは「アップロードした日」で撮影日ではありません。使うかどうかをチェックボックスで選べるようにしてください。

    期待結果:チェックを外すと photoTakenTime が無い写真は「スキップ(日時が不正)」になり、対象から外れる。

    合格条件

    • チェックを入れた状態はいまと同じ結果になる
    • photoTakenTime のある写真は、チェックの有無によらず同じ日時になる
    • フォールバックを使った行にはメモが残る
    • ヒント①(どこを触るか): read_sidecar() に引数を1つ足し、build_plan() 経由で GUI の tk.BooleanVar の値を渡します。
    • ヒント②(使うもの): read_sidecar() の for field in ("photoTakenTime", "creationTime"): のタプルを引数で切り替えます。
    • つまずきやすい点: build_plan() は _scan() と _run() の2か所から呼ばれます。片方だけ直すと、書き換え後の再スキャンで結果が変わります。
  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

他の方法との比較

同じ問題を解く道具は既にいくつかあります。向き不向きが分かれるので、自分の状況で選んでください。

方法できることつまずきやすいところ
このツール
無料・MIT・Python
日本語 GUI・ドライラン既定。-編集済み と切り詰め名に対応し、JPEG は EXIF も直す 動画の内部タグ・位置情報・説明文は移さない。数万枚では専用ツールより遅い
GooglePhotosTakeoutHelper
無料・Apache-2.0・実行ファイル
Takeout をまとめて整理し、更新日時を復元する EXIF は書かない(README が exiftool を別途使うよう案内)。英語 CLI。コピーではなく移動する
ExifTool のワンライナー
無料・CLI
JSON から EXIF へ広範に転記でき、動画のタグにも対応する 長い英語コマンド。.json と .supplemental-metadata.json で2回流す必要があり、切り詰め名と (1) のズレはこの書き方では拾えない。-overwrite_original は取り消せない
有料のデスクトップアプリ インストールして押すだけ。サポートあり 無料で試せる枚数に上限があり、超えると有料(金額は各社サイトで確認)。UI は英語が多い

正直に書くと、数万枚を一気に処理したいだけなら ExifTool のほうが速く、対応形式も広いです。

このツールが向くのは、日本語の画面で「何がどう変わるのか」を先に見てから実行したい人と、-編集済み や切り詰め名で取りこぼした人。まず GUI で全体像を把握し、残った特殊なファイルだけ ExifTool で仕上げる使い分けが現実的です。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。

参考リンク

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。