pic-archiver 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pic_archiver-0.1.0/PKG-INFO +143 -0
- pic_archiver-0.1.0/README.md +132 -0
- pic_archiver-0.1.0/config.py +26 -0
- pic_archiver-0.1.0/main.py +7 -0
- pic_archiver-0.1.0/parc_cli.py +259 -0
- pic_archiver-0.1.0/pic_archiver.egg-info/PKG-INFO +143 -0
- pic_archiver-0.1.0/pic_archiver.egg-info/SOURCES.txt +12 -0
- pic_archiver-0.1.0/pic_archiver.egg-info/dependency_links.txt +1 -0
- pic_archiver-0.1.0/pic_archiver.egg-info/entry_points.txt +2 -0
- pic_archiver-0.1.0/pic_archiver.egg-info/requires.txt +4 -0
- pic_archiver-0.1.0/pic_archiver.egg-info/top_level.txt +4 -0
- pic_archiver-0.1.0/pic_archiver.py +512 -0
- pic_archiver-0.1.0/pyproject.toml +22 -0
- pic_archiver-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pic-archiver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Rename, sort, and safely archive exported picture and video files.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: Pillow
|
|
8
|
+
Requires-Dist: piexif
|
|
9
|
+
Requires-Dist: rawpy
|
|
10
|
+
Requires-Dist: Send2Trash
|
|
11
|
+
|
|
12
|
+
# PicArchiver
|
|
13
|
+
|
|
14
|
+
Photos ライブラリなどから書き出した写真・動画をリネームし、`archive/` と `raw/` に安全に整理するためのツールです。
|
|
15
|
+
|
|
16
|
+
## 1. ディレクトリ構成
|
|
17
|
+
|
|
18
|
+
写真管理用の親ディレクトリを `PA_ROOT` と呼びます。`PA_ROOT` を指定しない場合は、`parc` を実行したカレントディレクトリが親になります。
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
PA_ROOT/
|
|
22
|
+
├── import/ # 処理対象ファイル
|
|
23
|
+
├── archive/ # アーカイブ出力先(年/年-月-日 の階層で保存)
|
|
24
|
+
├── raw/ # RAW ファイル出力先(拡張子ごとに分類)
|
|
25
|
+
├── .stage/ # 反映前の作業コピー
|
|
26
|
+
└── .backups/ # apply 時の退避先
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 2. 写真を書き出す
|
|
30
|
+
|
|
31
|
+
1. 内蔵ライブラリを起動する
|
|
32
|
+
2. すべての写真を選択する
|
|
33
|
+
3. `PA_ROOT/import/` に書き出す
|
|
34
|
+
|
|
35
|
+
## 3. ターミナルで実行する
|
|
36
|
+
|
|
37
|
+
初回だけ、PyPI からインストールします。
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install pic-archiver
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
PyPI に公開する前に GitHub から直接インストールする場合は、次のコマンドを使います。
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install "git+https://github.com/Ryota-Nitto/PicArchiver.git"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
最新版に更新したい場合は、再インストールします。
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install --upgrade --force-reinstall pic-archiver
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
開発中にこのディレクトリの変更を即反映したい場合だけ、リポジトリのディレクトリで代わりに `pip install -e .` を使います。
|
|
56
|
+
|
|
57
|
+
`parc` が見つからない場合は、`pip` の script 置き場に `PATH` を通します。Homebrew Python では `/opt/homebrew/bin`、ユーザーインストールでは `~/.local/bin` になることがあります。
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
以後は、どのディレクトリからでも実行できます。
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# import, archive, raw を含む親ディレクトリを指定する場合(推奨)
|
|
67
|
+
export PA_ROOT=/path/to/pictures-root
|
|
68
|
+
|
|
69
|
+
# 変更をステージして、差分を確認
|
|
70
|
+
parc stage
|
|
71
|
+
|
|
72
|
+
# 問題なければ適用
|
|
73
|
+
parc apply
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`parc` 単体では使えるコマンド一覧を表示します。`parc stage` は作業コピーを作って整理処理を実行し、差分を表示します。`parc apply` は差分を反映し、上書き・削除される既存ファイルを `.backups/YYYYMMDD_HHMMSS/` に退避します。退避ファイルがなかった場合、今回分の空バックアップディレクトリは削除されます。成功後、`import/` の中身はゴミ箱へ移ります。`.stage` は次回を速くするため削除せずに残します。
|
|
77
|
+
|
|
78
|
+
## 4. Option
|
|
79
|
+
|
|
80
|
+
### 取り込みをやり直す
|
|
81
|
+
|
|
82
|
+
`stage` したあとで、取り込みたくない画像や修正したい内容が見つかった場合は、`import/` を修正してから `parc stage` を再実行します。既存の `.stage` がある場合は、捨てずに同期して再利用します。
|
|
83
|
+
|
|
84
|
+
完全に作り直したい場合だけ `--replace` を使います。
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
parc stage --replace
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### ステージ後、即座に適用する
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
parc stage --apply
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`parc apply` と同じ確認プロンプトが出ます。
|
|
97
|
+
|
|
98
|
+
### Photos ライブラリを統合する
|
|
99
|
+
|
|
100
|
+
1. 内蔵ライブラリを複製する
|
|
101
|
+
2. 統合先の Photos ライブラリを起動する
|
|
102
|
+
3. `ファイル > 読み込む` で複製した内蔵ライブラリを選択する
|
|
103
|
+
4. すべての項目を読み込む
|
|
104
|
+
5. ライブラリにすべての写真が読み込まれたことを確認する
|
|
105
|
+
6. 内蔵ライブラリの写真・動画を全選択し、ゴミ箱へ移動する
|
|
106
|
+
7. 複製した内蔵ライブラリを削除する
|
|
107
|
+
|
|
108
|
+
### ディレクトリを個別に指定する
|
|
109
|
+
|
|
110
|
+
`PA_ROOT` 配下の標準構成を使わない場合だけ指定します。
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
export PA_IMPORT_DIR=/path/to/import
|
|
114
|
+
export PA_ARCHIVE_DIR=/path/to/archive
|
|
115
|
+
export PA_RAW_DIR=/path/to/raw
|
|
116
|
+
export PA_STAGE_ROOT=/path/to/stage
|
|
117
|
+
export PA_BACKUP_ROOT=/path/to/backups
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 直接実行する(非推奨)
|
|
121
|
+
|
|
122
|
+
`python main.py` を直接実行すると、指定した `PA_ROOT` または `PA_IMPORT_DIR` 内のファイルを直接リネーム・移動します。上書きされる対象はバックアップされません。
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python main.py
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 出力例
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
Processing files in: /path/to/project/import
|
|
132
|
+
Processing: IMG_1234.JPG
|
|
133
|
+
Moved: IMG_1234.JPG -> /path/to/project/archive/2026/2026-01-12/20260112_145828_001.jpg
|
|
134
|
+
Done
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## 主要ファイル
|
|
138
|
+
|
|
139
|
+
- `main.py`: エントリーポイント
|
|
140
|
+
- `parc`: どのディレクトリからでも実行するためのコマンド
|
|
141
|
+
- `pic_archiver.py`: コア処理(日時抽出・リネーム・振り分け)
|
|
142
|
+
- `config.py`: パス設定(デフォルトはこのリポジトリのディレクトリ、環境変数で上書き可)
|
|
143
|
+
- `*.sh`: 作業コピーで確認してから反映するための一連のコマンド
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# PicArchiver
|
|
2
|
+
|
|
3
|
+
Photos ライブラリなどから書き出した写真・動画をリネームし、`archive/` と `raw/` に安全に整理するためのツールです。
|
|
4
|
+
|
|
5
|
+
## 1. ディレクトリ構成
|
|
6
|
+
|
|
7
|
+
写真管理用の親ディレクトリを `PA_ROOT` と呼びます。`PA_ROOT` を指定しない場合は、`parc` を実行したカレントディレクトリが親になります。
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
PA_ROOT/
|
|
11
|
+
├── import/ # 処理対象ファイル
|
|
12
|
+
├── archive/ # アーカイブ出力先(年/年-月-日 の階層で保存)
|
|
13
|
+
├── raw/ # RAW ファイル出力先(拡張子ごとに分類)
|
|
14
|
+
├── .stage/ # 反映前の作業コピー
|
|
15
|
+
└── .backups/ # apply 時の退避先
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 2. 写真を書き出す
|
|
19
|
+
|
|
20
|
+
1. 内蔵ライブラリを起動する
|
|
21
|
+
2. すべての写真を選択する
|
|
22
|
+
3. `PA_ROOT/import/` に書き出す
|
|
23
|
+
|
|
24
|
+
## 3. ターミナルで実行する
|
|
25
|
+
|
|
26
|
+
初回だけ、PyPI からインストールします。
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install pic-archiver
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
PyPI に公開する前に GitHub から直接インストールする場合は、次のコマンドを使います。
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install "git+https://github.com/Ryota-Nitto/PicArchiver.git"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
最新版に更新したい場合は、再インストールします。
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install --upgrade --force-reinstall pic-archiver
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
開発中にこのディレクトリの変更を即反映したい場合だけ、リポジトリのディレクトリで代わりに `pip install -e .` を使います。
|
|
45
|
+
|
|
46
|
+
`parc` が見つからない場合は、`pip` の script 置き場に `PATH` を通します。Homebrew Python では `/opt/homebrew/bin`、ユーザーインストールでは `~/.local/bin` になることがあります。
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
以後は、どのディレクトリからでも実行できます。
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# import, archive, raw を含む親ディレクトリを指定する場合(推奨)
|
|
56
|
+
export PA_ROOT=/path/to/pictures-root
|
|
57
|
+
|
|
58
|
+
# 変更をステージして、差分を確認
|
|
59
|
+
parc stage
|
|
60
|
+
|
|
61
|
+
# 問題なければ適用
|
|
62
|
+
parc apply
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`parc` 単体では使えるコマンド一覧を表示します。`parc stage` は作業コピーを作って整理処理を実行し、差分を表示します。`parc apply` は差分を反映し、上書き・削除される既存ファイルを `.backups/YYYYMMDD_HHMMSS/` に退避します。退避ファイルがなかった場合、今回分の空バックアップディレクトリは削除されます。成功後、`import/` の中身はゴミ箱へ移ります。`.stage` は次回を速くするため削除せずに残します。
|
|
66
|
+
|
|
67
|
+
## 4. Option
|
|
68
|
+
|
|
69
|
+
### 取り込みをやり直す
|
|
70
|
+
|
|
71
|
+
`stage` したあとで、取り込みたくない画像や修正したい内容が見つかった場合は、`import/` を修正してから `parc stage` を再実行します。既存の `.stage` がある場合は、捨てずに同期して再利用します。
|
|
72
|
+
|
|
73
|
+
完全に作り直したい場合だけ `--replace` を使います。
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
parc stage --replace
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### ステージ後、即座に適用する
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
parc stage --apply
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`parc apply` と同じ確認プロンプトが出ます。
|
|
86
|
+
|
|
87
|
+
### Photos ライブラリを統合する
|
|
88
|
+
|
|
89
|
+
1. 内蔵ライブラリを複製する
|
|
90
|
+
2. 統合先の Photos ライブラリを起動する
|
|
91
|
+
3. `ファイル > 読み込む` で複製した内蔵ライブラリを選択する
|
|
92
|
+
4. すべての項目を読み込む
|
|
93
|
+
5. ライブラリにすべての写真が読み込まれたことを確認する
|
|
94
|
+
6. 内蔵ライブラリの写真・動画を全選択し、ゴミ箱へ移動する
|
|
95
|
+
7. 複製した内蔵ライブラリを削除する
|
|
96
|
+
|
|
97
|
+
### ディレクトリを個別に指定する
|
|
98
|
+
|
|
99
|
+
`PA_ROOT` 配下の標準構成を使わない場合だけ指定します。
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
export PA_IMPORT_DIR=/path/to/import
|
|
103
|
+
export PA_ARCHIVE_DIR=/path/to/archive
|
|
104
|
+
export PA_RAW_DIR=/path/to/raw
|
|
105
|
+
export PA_STAGE_ROOT=/path/to/stage
|
|
106
|
+
export PA_BACKUP_ROOT=/path/to/backups
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 直接実行する(非推奨)
|
|
110
|
+
|
|
111
|
+
`python main.py` を直接実行すると、指定した `PA_ROOT` または `PA_IMPORT_DIR` 内のファイルを直接リネーム・移動します。上書きされる対象はバックアップされません。
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
python main.py
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 出力例
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
Processing files in: /path/to/project/import
|
|
121
|
+
Processing: IMG_1234.JPG
|
|
122
|
+
Moved: IMG_1234.JPG -> /path/to/project/archive/2026/2026-01-12/20260112_145828_001.jpg
|
|
123
|
+
Done
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## 主要ファイル
|
|
127
|
+
|
|
128
|
+
- `main.py`: エントリーポイント
|
|
129
|
+
- `parc`: どのディレクトリからでも実行するためのコマンド
|
|
130
|
+
- `pic_archiver.py`: コア処理(日時抽出・リネーム・振り分け)
|
|
131
|
+
- `config.py`: パス設定(デフォルトはこのリポジトリのディレクトリ、環境変数で上書き可)
|
|
132
|
+
- `*.sh`: 作業コピーで確認してから反映するための一連のコマンド
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
def _root_path():
|
|
5
|
+
"""Return the parent directory containing import/archive/raw."""
|
|
6
|
+
value = os.environ.get("PA_ROOT")
|
|
7
|
+
if not value:
|
|
8
|
+
return Path.cwd().resolve()
|
|
9
|
+
return Path(value).expanduser().resolve()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ARCHIVE_ROOT = _root_path()
|
|
13
|
+
|
|
14
|
+
def _env_path(*names, default):
|
|
15
|
+
"""Return the first existing env var among names, otherwise default."""
|
|
16
|
+
for name in names:
|
|
17
|
+
value = os.environ.get(name)
|
|
18
|
+
if value:
|
|
19
|
+
return Path(value).expanduser().resolve()
|
|
20
|
+
return Path(default).resolve()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Defaults point at the current working directory unless explicitly overridden.
|
|
24
|
+
IMPORT_DIR = _env_path("PA_IMPORT_DIR", default=ARCHIVE_ROOT / "import")
|
|
25
|
+
ARCHIVE_DIR = _env_path("PA_ARCHIVE_DIR", default=ARCHIVE_ROOT / "archive")
|
|
26
|
+
RAW_DIR = _env_path("PA_RAW_DIR", default=ARCHIVE_ROOT / "raw")
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from send2trash import send2trash
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _absolute_path(value: str | Path) -> Path:
|
|
16
|
+
path = Path(value).expanduser()
|
|
17
|
+
if not path.is_absolute():
|
|
18
|
+
path = Path.cwd() / path
|
|
19
|
+
return path.resolve()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _paths() -> dict[str, Path]:
|
|
23
|
+
root_value = os.environ.get("PA_ROOT") or Path.cwd()
|
|
24
|
+
pa_root = _absolute_path(root_value)
|
|
25
|
+
return {
|
|
26
|
+
"pa_root": pa_root,
|
|
27
|
+
"import": _absolute_path(os.environ.get("PA_IMPORT_DIR") or pa_root / "import"),
|
|
28
|
+
"archive": _absolute_path(os.environ.get("PA_ARCHIVE_DIR") or pa_root / "archive"),
|
|
29
|
+
"raw": _absolute_path(os.environ.get("PA_RAW_DIR") or pa_root / "raw"),
|
|
30
|
+
"stage": _absolute_path(os.environ.get("PA_STAGE_ROOT") or pa_root / ".stage"),
|
|
31
|
+
"backup": _absolute_path(os.environ.get("PA_BACKUP_ROOT") or pa_root / ".backups"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _file_count(path: Path) -> int:
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return 0
|
|
38
|
+
return sum(1 for child in path.rglob("*") if child.is_file())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _print_progress_bar(done_count: int, total_count: int) -> None:
|
|
42
|
+
width = 20
|
|
43
|
+
percent = 100 if total_count <= 0 else min(done_count * 100 // total_count, 100)
|
|
44
|
+
display_done = min(done_count, total_count)
|
|
45
|
+
filled = percent * width // 100
|
|
46
|
+
bar = "#" * filled + "-" * (width - filled)
|
|
47
|
+
print(f"\r[{bar}] {percent:3d}% ({display_done}/{total_count} files)", end="", flush=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _run(command: list[str]) -> None:
|
|
51
|
+
subprocess.run(command, check=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _copy_with_hardlinks(step: int, name: str, source_dir: Path, target_dir: Path, reuse_stage: bool) -> None:
|
|
55
|
+
source_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
action = "Refreshing" if reuse_stage else "Copying"
|
|
58
|
+
print(f"[{step}/3] {action} {name}: {source_dir} -> {target_dir}")
|
|
59
|
+
|
|
60
|
+
command = [
|
|
61
|
+
"rsync",
|
|
62
|
+
"-a",
|
|
63
|
+
"--delete",
|
|
64
|
+
f"--link-dest={source_dir}",
|
|
65
|
+
f"{source_dir}/",
|
|
66
|
+
f"{target_dir}/",
|
|
67
|
+
]
|
|
68
|
+
if name != "archive":
|
|
69
|
+
_run(command)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
total_files = _file_count(source_dir)
|
|
73
|
+
if total_files == 0:
|
|
74
|
+
_run(command)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
print("Archive refresh progress:" if reuse_stage else "Archive copy progress:")
|
|
78
|
+
process = subprocess.Popen(command)
|
|
79
|
+
while process.poll() is None:
|
|
80
|
+
_print_progress_bar(_file_count(target_dir), total_files)
|
|
81
|
+
time.sleep(1)
|
|
82
|
+
|
|
83
|
+
_print_progress_bar(total_files, total_files)
|
|
84
|
+
print()
|
|
85
|
+
if process.returncode:
|
|
86
|
+
raise subprocess.CalledProcessError(process.returncode, command)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def prepare_command(args: argparse.Namespace) -> int:
|
|
90
|
+
paths = _paths()
|
|
91
|
+
stage_root = paths["stage"]
|
|
92
|
+
reuse_stage = False
|
|
93
|
+
|
|
94
|
+
if args.replace:
|
|
95
|
+
shutil.rmtree(stage_root, ignore_errors=True)
|
|
96
|
+
elif stage_root.exists():
|
|
97
|
+
reuse_stage = True
|
|
98
|
+
|
|
99
|
+
stage_root.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
_copy_with_hardlinks(1, "import", paths["import"], stage_root / "import", reuse_stage)
|
|
101
|
+
_copy_with_hardlinks(2, "archive", paths["archive"], stage_root / "archive", reuse_stage)
|
|
102
|
+
_copy_with_hardlinks(3, "raw", paths["raw"], stage_root / "raw", reuse_stage)
|
|
103
|
+
|
|
104
|
+
print(f"Prepared stage: {stage_root}")
|
|
105
|
+
print("Next: parc run")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_command(_: argparse.Namespace) -> int:
|
|
110
|
+
paths = _paths()
|
|
111
|
+
stage_root = paths["stage"]
|
|
112
|
+
if not (stage_root / "import").is_dir():
|
|
113
|
+
print(f"Stage import directory not found: {stage_root / 'import'}", file=sys.stderr)
|
|
114
|
+
print("Run parc prepare first.", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
os.environ["PA_IMPORT_DIR"] = str(stage_root / "import")
|
|
118
|
+
os.environ["PA_ARCHIVE_DIR"] = str(stage_root / "archive")
|
|
119
|
+
os.environ["PA_RAW_DIR"] = str(stage_root / "raw")
|
|
120
|
+
os.environ["PYTHONUNBUFFERED"] = "1"
|
|
121
|
+
|
|
122
|
+
from pic_archiver import main as archiver_main
|
|
123
|
+
|
|
124
|
+
archiver_main()
|
|
125
|
+
print("Staged archive update complete.")
|
|
126
|
+
print("Next: parc review")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def review_command(_: argparse.Namespace) -> int:
|
|
131
|
+
paths = _paths()
|
|
132
|
+
stage_root = paths["stage"]
|
|
133
|
+
if not (stage_root / "archive").is_dir() or not (stage_root / "raw").is_dir():
|
|
134
|
+
print(f"Stage archive/raw directories not found: {stage_root}", file=sys.stderr)
|
|
135
|
+
print("Run parc prepare and parc run first.", file=sys.stderr)
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
print("Archive changes:", flush=True)
|
|
139
|
+
_run(["rsync", "-aivn", "--8-bit-output", "--delete", f"{stage_root / 'archive'}/", f"{paths['archive']}/"])
|
|
140
|
+
print()
|
|
141
|
+
print("Raw changes:", flush=True)
|
|
142
|
+
_run(["rsync", "-aivn", "--8-bit-output", "--delete", f"{stage_root / 'raw'}/", f"{paths['raw']}/"])
|
|
143
|
+
print()
|
|
144
|
+
print("No files were changed. To apply approved changes, run:")
|
|
145
|
+
print(" parc apply")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def apply_command(_: argparse.Namespace) -> int:
|
|
150
|
+
paths = _paths()
|
|
151
|
+
stage_root = paths["stage"]
|
|
152
|
+
if not (stage_root / "archive").is_dir() or not (stage_root / "raw").is_dir():
|
|
153
|
+
print(f"Stage archive/raw directories not found: {stage_root}", file=sys.stderr)
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
backup_dir = paths["backup"] / datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
157
|
+
print("This will update:")
|
|
158
|
+
print(f" {paths['archive']}")
|
|
159
|
+
print(f" {paths['raw']}")
|
|
160
|
+
print(flush=True)
|
|
161
|
+
print("Changed or deleted destination files will be kept in:")
|
|
162
|
+
print(f" {backup_dir}")
|
|
163
|
+
print()
|
|
164
|
+
|
|
165
|
+
answer = input("Apply staged changes? [y/N] ")
|
|
166
|
+
if answer not in {"y", "Y", "yes", "YES"}:
|
|
167
|
+
print("Cancelled.")
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
(backup_dir / "archive").mkdir(parents=True, exist_ok=True)
|
|
171
|
+
(backup_dir / "raw").mkdir(parents=True, exist_ok=True)
|
|
172
|
+
_run(["rsync", "-a", "--delete", "--backup", f"--backup-dir={backup_dir / 'archive'}", f"{stage_root / 'archive'}/", f"{paths['archive']}/"])
|
|
173
|
+
_run(["rsync", "-a", "--delete", "--backup", f"--backup-dir={backup_dir / 'raw'}", f"{stage_root / 'raw'}/", f"{paths['raw']}/"])
|
|
174
|
+
|
|
175
|
+
print("Applied staged changes.")
|
|
176
|
+
if any(child.is_file() for child in backup_dir.rglob("*")):
|
|
177
|
+
print(f"Backup directory: {backup_dir}")
|
|
178
|
+
else:
|
|
179
|
+
shutil.rmtree(backup_dir)
|
|
180
|
+
print("No backup files were created.")
|
|
181
|
+
print(f"Kept stage for faster next run: {stage_root}")
|
|
182
|
+
|
|
183
|
+
if paths["import"].is_dir():
|
|
184
|
+
for entry in sorted(paths["import"].iterdir(), key=lambda path: path.name):
|
|
185
|
+
send2trash(str(entry))
|
|
186
|
+
print(f"Trashed import contents: {paths['import']}")
|
|
187
|
+
else:
|
|
188
|
+
print(f"Import directory not found, skipped trashing: {paths['import']}")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _run_with_progress(label: str, func, args: argparse.Namespace, keepalive: bool = True) -> int:
|
|
193
|
+
print(f"==> {label}")
|
|
194
|
+
if not keepalive:
|
|
195
|
+
return func(args)
|
|
196
|
+
start = time.monotonic()
|
|
197
|
+
last = start
|
|
198
|
+
result = func(args)
|
|
199
|
+
elapsed = int(time.monotonic() - start)
|
|
200
|
+
if elapsed >= 10 and time.monotonic() - last >= 10:
|
|
201
|
+
print(f"Still running ({elapsed}s): {label}")
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def stage_command(args: argparse.Namespace) -> int:
|
|
206
|
+
result = _run_with_progress(
|
|
207
|
+
"Preparing stage with replacement" if args.replace else "Preparing stage",
|
|
208
|
+
prepare_command,
|
|
209
|
+
args,
|
|
210
|
+
keepalive=False,
|
|
211
|
+
)
|
|
212
|
+
if result:
|
|
213
|
+
return result
|
|
214
|
+
result = _run_with_progress("Running picture archiving in stage", run_command, args)
|
|
215
|
+
if result:
|
|
216
|
+
return result
|
|
217
|
+
result = _run_with_progress("Reviewing staged changes", review_command, args)
|
|
218
|
+
if result:
|
|
219
|
+
return result
|
|
220
|
+
if args.apply:
|
|
221
|
+
print("==> Applying staged changes")
|
|
222
|
+
return apply_command(args)
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
227
|
+
parser = argparse.ArgumentParser(prog="parc")
|
|
228
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
229
|
+
|
|
230
|
+
stage = subparsers.add_parser("stage", help="Prepare a stage, run archiving, and show the review diff.")
|
|
231
|
+
stage.add_argument("--replace", action="store_true", help="Recreate the stage work tree.")
|
|
232
|
+
stage.add_argument("--apply", action="store_true", help="Apply staged changes after review.")
|
|
233
|
+
stage.set_defaults(func=stage_command)
|
|
234
|
+
|
|
235
|
+
prepare = subparsers.add_parser("prepare", help="Create the stage work tree.")
|
|
236
|
+
prepare.add_argument("--replace", action="store_true", help="Recreate the stage work tree.")
|
|
237
|
+
prepare.set_defaults(func=prepare_command)
|
|
238
|
+
|
|
239
|
+
subparsers.add_parser("run", help="Run archiving inside the existing stage.").set_defaults(func=run_command)
|
|
240
|
+
subparsers.add_parser("review", help="Show staged changes without applying them.").set_defaults(func=review_command)
|
|
241
|
+
subparsers.add_parser("apply", help="Apply staged changes to archive/raw.").set_defaults(func=apply_command)
|
|
242
|
+
return parser
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def main(argv: list[str] | None = None) -> int:
|
|
246
|
+
parser = build_parser()
|
|
247
|
+
args = parser.parse_args(argv)
|
|
248
|
+
if not hasattr(args, "func"):
|
|
249
|
+
parser.print_help()
|
|
250
|
+
return 0
|
|
251
|
+
try:
|
|
252
|
+
return args.func(args)
|
|
253
|
+
except subprocess.CalledProcessError as exc:
|
|
254
|
+
print(f"Command failed: {' '.join(str(part) for part in exc.cmd)}", file=sys.stderr)
|
|
255
|
+
return exc.returncode
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pic-archiver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Rename, sort, and safely archive exported picture and video files.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: Pillow
|
|
8
|
+
Requires-Dist: piexif
|
|
9
|
+
Requires-Dist: rawpy
|
|
10
|
+
Requires-Dist: Send2Trash
|
|
11
|
+
|
|
12
|
+
# PicArchiver
|
|
13
|
+
|
|
14
|
+
Photos ライブラリなどから書き出した写真・動画をリネームし、`archive/` と `raw/` に安全に整理するためのツールです。
|
|
15
|
+
|
|
16
|
+
## 1. ディレクトリ構成
|
|
17
|
+
|
|
18
|
+
写真管理用の親ディレクトリを `PA_ROOT` と呼びます。`PA_ROOT` を指定しない場合は、`parc` を実行したカレントディレクトリが親になります。
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
PA_ROOT/
|
|
22
|
+
├── import/ # 処理対象ファイル
|
|
23
|
+
├── archive/ # アーカイブ出力先(年/年-月-日 の階層で保存)
|
|
24
|
+
├── raw/ # RAW ファイル出力先(拡張子ごとに分類)
|
|
25
|
+
├── .stage/ # 反映前の作業コピー
|
|
26
|
+
└── .backups/ # apply 時の退避先
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 2. 写真を書き出す
|
|
30
|
+
|
|
31
|
+
1. 内蔵ライブラリを起動する
|
|
32
|
+
2. すべての写真を選択する
|
|
33
|
+
3. `PA_ROOT/import/` に書き出す
|
|
34
|
+
|
|
35
|
+
## 3. ターミナルで実行する
|
|
36
|
+
|
|
37
|
+
初回だけ、PyPI からインストールします。
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install pic-archiver
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
PyPI に公開する前に GitHub から直接インストールする場合は、次のコマンドを使います。
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install "git+https://github.com/Ryota-Nitto/PicArchiver.git"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
最新版に更新したい場合は、再インストールします。
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install --upgrade --force-reinstall pic-archiver
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
開発中にこのディレクトリの変更を即反映したい場合だけ、リポジトリのディレクトリで代わりに `pip install -e .` を使います。
|
|
56
|
+
|
|
57
|
+
`parc` が見つからない場合は、`pip` の script 置き場に `PATH` を通します。Homebrew Python では `/opt/homebrew/bin`、ユーザーインストールでは `~/.local/bin` になることがあります。
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
以後は、どのディレクトリからでも実行できます。
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# import, archive, raw を含む親ディレクトリを指定する場合(推奨)
|
|
67
|
+
export PA_ROOT=/path/to/pictures-root
|
|
68
|
+
|
|
69
|
+
# 変更をステージして、差分を確認
|
|
70
|
+
parc stage
|
|
71
|
+
|
|
72
|
+
# 問題なければ適用
|
|
73
|
+
parc apply
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`parc` 単体では使えるコマンド一覧を表示します。`parc stage` は作業コピーを作って整理処理を実行し、差分を表示します。`parc apply` は差分を反映し、上書き・削除される既存ファイルを `.backups/YYYYMMDD_HHMMSS/` に退避します。退避ファイルがなかった場合、今回分の空バックアップディレクトリは削除されます。成功後、`import/` の中身はゴミ箱へ移ります。`.stage` は次回を速くするため削除せずに残します。
|
|
77
|
+
|
|
78
|
+
## 4. Option
|
|
79
|
+
|
|
80
|
+
### 取り込みをやり直す
|
|
81
|
+
|
|
82
|
+
`stage` したあとで、取り込みたくない画像や修正したい内容が見つかった場合は、`import/` を修正してから `parc stage` を再実行します。既存の `.stage` がある場合は、捨てずに同期して再利用します。
|
|
83
|
+
|
|
84
|
+
完全に作り直したい場合だけ `--replace` を使います。
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
parc stage --replace
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### ステージ後、即座に適用する
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
parc stage --apply
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`parc apply` と同じ確認プロンプトが出ます。
|
|
97
|
+
|
|
98
|
+
### Photos ライブラリを統合する
|
|
99
|
+
|
|
100
|
+
1. 内蔵ライブラリを複製する
|
|
101
|
+
2. 統合先の Photos ライブラリを起動する
|
|
102
|
+
3. `ファイル > 読み込む` で複製した内蔵ライブラリを選択する
|
|
103
|
+
4. すべての項目を読み込む
|
|
104
|
+
5. ライブラリにすべての写真が読み込まれたことを確認する
|
|
105
|
+
6. 内蔵ライブラリの写真・動画を全選択し、ゴミ箱へ移動する
|
|
106
|
+
7. 複製した内蔵ライブラリを削除する
|
|
107
|
+
|
|
108
|
+
### ディレクトリを個別に指定する
|
|
109
|
+
|
|
110
|
+
`PA_ROOT` 配下の標準構成を使わない場合だけ指定します。
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
export PA_IMPORT_DIR=/path/to/import
|
|
114
|
+
export PA_ARCHIVE_DIR=/path/to/archive
|
|
115
|
+
export PA_RAW_DIR=/path/to/raw
|
|
116
|
+
export PA_STAGE_ROOT=/path/to/stage
|
|
117
|
+
export PA_BACKUP_ROOT=/path/to/backups
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 直接実行する(非推奨)
|
|
121
|
+
|
|
122
|
+
`python main.py` を直接実行すると、指定した `PA_ROOT` または `PA_IMPORT_DIR` 内のファイルを直接リネーム・移動します。上書きされる対象はバックアップされません。
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python main.py
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 出力例
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
Processing files in: /path/to/project/import
|
|
132
|
+
Processing: IMG_1234.JPG
|
|
133
|
+
Moved: IMG_1234.JPG -> /path/to/project/archive/2026/2026-01-12/20260112_145828_001.jpg
|
|
134
|
+
Done
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## 主要ファイル
|
|
138
|
+
|
|
139
|
+
- `main.py`: エントリーポイント
|
|
140
|
+
- `parc`: どのディレクトリからでも実行するためのコマンド
|
|
141
|
+
- `pic_archiver.py`: コア処理(日時抽出・リネーム・振り分け)
|
|
142
|
+
- `config.py`: パス設定(デフォルトはこのリポジトリのディレクトリ、環境変数で上書き可)
|
|
143
|
+
- `*.sh`: 作業コピーで確認してから反映するための一連のコマンド
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
config.py
|
|
3
|
+
main.py
|
|
4
|
+
parc_cli.py
|
|
5
|
+
pic_archiver.py
|
|
6
|
+
pyproject.toml
|
|
7
|
+
pic_archiver.egg-info/PKG-INFO
|
|
8
|
+
pic_archiver.egg-info/SOURCES.txt
|
|
9
|
+
pic_archiver.egg-info/dependency_links.txt
|
|
10
|
+
pic_archiver.egg-info/entry_points.txt
|
|
11
|
+
pic_archiver.egg-info/requires.txt
|
|
12
|
+
pic_archiver.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PicArchiver - 写真・動画ファイルの自動整理・リネーム・アーカイブツール
|
|
3
|
+
"""
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
import datetime
|
|
7
|
+
import subprocess
|
|
8
|
+
from PIL import Image
|
|
9
|
+
from PIL.ExifTags import TAGS
|
|
10
|
+
import piexif
|
|
11
|
+
from send2trash import send2trash
|
|
12
|
+
|
|
13
|
+
from config import IMPORT_DIR, ARCHIVE_DIR, RAW_DIR
|
|
14
|
+
|
|
15
|
+
RAW_SUBDIRS = {
|
|
16
|
+
".cr2": "canon cr2",
|
|
17
|
+
".nef": "nikon nef",
|
|
18
|
+
".arw": "sony arw",
|
|
19
|
+
}
|
|
20
|
+
VIDEO_EXTS = {".mov", ".mp4", ".avi"}
|
|
21
|
+
IMAGE_EXTS = {".jpeg", ".jpg", ".heic", ".png", ".webp"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalize_image_ext(ext):
|
|
25
|
+
"""出力用の拡張子を正規化する"""
|
|
26
|
+
ext = ext.lower()
|
|
27
|
+
if ext == ".jpg":
|
|
28
|
+
return ".jpeg"
|
|
29
|
+
return ext
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ===== DateTime Extraction Functions =====
|
|
33
|
+
|
|
34
|
+
def _normalize_subsec(subsec):
|
|
35
|
+
"""subsec を必ず3桁にそろえ、短い場合は右側に 0 を足す"""
|
|
36
|
+
if subsec is None:
|
|
37
|
+
return "000"
|
|
38
|
+
if isinstance(subsec, bytes):
|
|
39
|
+
subsec = subsec.decode("UTF-8", errors="ignore")
|
|
40
|
+
subsec = str(subsec).strip()
|
|
41
|
+
if not subsec:
|
|
42
|
+
return "000"
|
|
43
|
+
# 1桁/2桁は右側に 0 を足し、4桁以上は先頭3桁を残す
|
|
44
|
+
if len(subsec) >= 3:
|
|
45
|
+
return subsec[:3]
|
|
46
|
+
return subsec.ljust(3, "0")
|
|
47
|
+
|
|
48
|
+
def get_exif_of_image(file):
|
|
49
|
+
"""EXIFデータを取得"""
|
|
50
|
+
with Image.open(str(file)) as im:
|
|
51
|
+
try:
|
|
52
|
+
exif = im._getexif()
|
|
53
|
+
except AttributeError:
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
if not exif:
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
exif_table = {}
|
|
60
|
+
for tag_id, value in exif.items():
|
|
61
|
+
tag = TAGS.get(tag_id, tag_id)
|
|
62
|
+
exif_table[tag] = value
|
|
63
|
+
return exif_table
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_datetime_exif(file):
|
|
67
|
+
"""JPEG向け撮影日時の取得"""
|
|
68
|
+
exif_table = get_exif_of_image(file)
|
|
69
|
+
date = exif_table['DateTimeOriginal'].split(' ')[0].split(':')
|
|
70
|
+
time = exif_table['DateTimeOriginal'].split(' ')[1].split(':')
|
|
71
|
+
subsec = _normalize_subsec(exif_table.get('SubsecTimeOriginal', '000'))
|
|
72
|
+
return date, time, subsec
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_datetime_cr2exif(file):
|
|
76
|
+
"""CR2向け撮影日時の取得"""
|
|
77
|
+
exif_dict = piexif.load(str(file))
|
|
78
|
+
datetime_str = exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal]
|
|
79
|
+
subsec = exif_dict["Exif"].get(piexif.ExifIFD.SubSecTimeOriginal, b"000")
|
|
80
|
+
datetime_str = datetime_str.decode("UTF-8")
|
|
81
|
+
subsec = subsec.decode("UTF-8")
|
|
82
|
+
date = datetime_str.split(' ')[0].split(':')
|
|
83
|
+
time = datetime_str.split(' ')[1].split(':')
|
|
84
|
+
subsec = _normalize_subsec(subsec)
|
|
85
|
+
return date, time, subsec
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_datetime_raw_exif(file):
|
|
89
|
+
"""NEF/ARW向け撮影日時の取得"""
|
|
90
|
+
exif_table = get_exif_of_image(file)
|
|
91
|
+
date_time = exif_table.get('DateTimeOriginal')
|
|
92
|
+
if not date_time:
|
|
93
|
+
raise KeyError('DateTimeOriginal not found')
|
|
94
|
+
|
|
95
|
+
date = date_time.split(' ')[0].split(':')
|
|
96
|
+
time = date_time.split(' ')[1].split(':')
|
|
97
|
+
subsec = _normalize_subsec(exif_table.get('SubsecTimeOriginal', '000'))
|
|
98
|
+
return date, time, subsec
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_creation_datetime(file):
|
|
102
|
+
"""ファイルシステムの作成日時を取得"""
|
|
103
|
+
bt = os.stat(file).st_birthtime
|
|
104
|
+
d = datetime.datetime.fromtimestamp(bt)
|
|
105
|
+
return _datetime_to_parts(d)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _get_filesystem_datetimes(file):
|
|
109
|
+
"""ファイルシステムから取得できる作成日時・変更日時を返す"""
|
|
110
|
+
stat = Path(file).stat()
|
|
111
|
+
candidates = [datetime.datetime.fromtimestamp(stat.st_mtime)]
|
|
112
|
+
birthtime = getattr(stat, "st_birthtime", None)
|
|
113
|
+
if birthtime is not None:
|
|
114
|
+
candidates.append(datetime.datetime.fromtimestamp(birthtime))
|
|
115
|
+
return candidates
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_filesystem_oldest_datetime(file):
|
|
119
|
+
"""作成日時・変更日時のうち古い方を取得"""
|
|
120
|
+
return _datetime_to_parts(min(_get_filesystem_datetimes(file)))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _datetime_to_parts(d):
|
|
124
|
+
"""datetimeをファイル名用の日付・時刻・ミリ秒に変換"""
|
|
125
|
+
year = d.year
|
|
126
|
+
month = d.month
|
|
127
|
+
day = d.day
|
|
128
|
+
hour = d.hour
|
|
129
|
+
minute = d.minute
|
|
130
|
+
second = d.second
|
|
131
|
+
subsec = d.microsecond // 1000
|
|
132
|
+
|
|
133
|
+
year = str(year)
|
|
134
|
+
month = f"{month:02d}" if isinstance(month, int) else f"{int(month):02d}"
|
|
135
|
+
day = f"{day:02d}" if isinstance(day, int) else f"{int(day):02d}"
|
|
136
|
+
hour = f"{hour:02d}" if isinstance(hour, int) else f"{int(hour):02d}"
|
|
137
|
+
minute = f"{minute:02d}" if isinstance(minute, int) else f"{int(minute):02d}"
|
|
138
|
+
second = f"{second:02d}" if isinstance(second, int) else f"{int(second):02d}"
|
|
139
|
+
|
|
140
|
+
subsec = _normalize_subsec(subsec)
|
|
141
|
+
|
|
142
|
+
return [year, month, day], [hour, minute, second], subsec
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_exiftool_datetime(value):
|
|
146
|
+
"""exiftoolの日時文字列をdatetimeに変換"""
|
|
147
|
+
if not value:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
value = value.strip()
|
|
151
|
+
date_part, time_part = value.split(" ", 1)
|
|
152
|
+
year, month, day = [int(part) for part in date_part.split(":")]
|
|
153
|
+
|
|
154
|
+
parts = time_part.split()
|
|
155
|
+
time_value = parts[0]
|
|
156
|
+
timezone_value = parts[1] if len(parts) > 1 else None
|
|
157
|
+
|
|
158
|
+
offset = None
|
|
159
|
+
if len(time_value) >= 6 and time_value[-6] in ["+", "-"]:
|
|
160
|
+
offset = time_value[-6:]
|
|
161
|
+
time_value = time_value[:-6]
|
|
162
|
+
elif time_value.upper().endswith("Z"):
|
|
163
|
+
offset = "Z"
|
|
164
|
+
time_value = time_value[:-1]
|
|
165
|
+
elif timezone_value:
|
|
166
|
+
offset = timezone_value
|
|
167
|
+
|
|
168
|
+
hour_str, minute_str, second_str = time_value.split(":")
|
|
169
|
+
if "." in second_str:
|
|
170
|
+
second_whole, fraction = second_str.split(".", 1)
|
|
171
|
+
microsecond = int(fraction[:6].ljust(6, "0"))
|
|
172
|
+
else:
|
|
173
|
+
second_whole = second_str
|
|
174
|
+
microsecond = 0
|
|
175
|
+
|
|
176
|
+
tzinfo = None
|
|
177
|
+
if offset and offset.upper() != "Z":
|
|
178
|
+
sign = 1 if offset[0] == "+" else -1
|
|
179
|
+
offset_hour = int(offset[1:3])
|
|
180
|
+
offset_minute = int(offset[4:6])
|
|
181
|
+
tzinfo = datetime.timezone(
|
|
182
|
+
sign * datetime.timedelta(hours=offset_hour, minutes=offset_minute)
|
|
183
|
+
)
|
|
184
|
+
elif offset and offset.upper() == "Z":
|
|
185
|
+
tzinfo = datetime.timezone.utc
|
|
186
|
+
|
|
187
|
+
d = datetime.datetime(
|
|
188
|
+
year,
|
|
189
|
+
month,
|
|
190
|
+
day,
|
|
191
|
+
int(hour_str),
|
|
192
|
+
int(minute_str),
|
|
193
|
+
int(second_whole),
|
|
194
|
+
microsecond,
|
|
195
|
+
tzinfo=tzinfo,
|
|
196
|
+
)
|
|
197
|
+
if d.tzinfo:
|
|
198
|
+
d = d.astimezone()
|
|
199
|
+
return d.replace(tzinfo=None)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _parse_filename_datetime(file):
|
|
203
|
+
"""YYYYMMDD_HHMMSS_sss形式のファイル名からdatetimeを取得"""
|
|
204
|
+
stem = Path(file).stem
|
|
205
|
+
parts = stem.split("_")
|
|
206
|
+
if len(parts) < 3:
|
|
207
|
+
return None
|
|
208
|
+
date_value, time_value, subsec = parts[:3]
|
|
209
|
+
if not (len(date_value) == 8 and len(time_value) == 6):
|
|
210
|
+
return None
|
|
211
|
+
if not (date_value.isdigit() and time_value.isdigit() and subsec[:3].isdigit()):
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
return datetime.datetime(
|
|
215
|
+
int(date_value[0:4]),
|
|
216
|
+
int(date_value[4:6]),
|
|
217
|
+
int(date_value[6:8]),
|
|
218
|
+
int(time_value[0:2]),
|
|
219
|
+
int(time_value[2:4]),
|
|
220
|
+
int(time_value[4:6]),
|
|
221
|
+
int(subsec[:3].ljust(3, "0")) * 1000,
|
|
222
|
+
)
|
|
223
|
+
except ValueError:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _parts_to_datetime(date, time, subsec):
|
|
228
|
+
return datetime.datetime(
|
|
229
|
+
int(date[0]),
|
|
230
|
+
int(date[1]),
|
|
231
|
+
int(date[2]),
|
|
232
|
+
int(time[0]),
|
|
233
|
+
int(time[1]),
|
|
234
|
+
int(time[2]),
|
|
235
|
+
int(_normalize_subsec(subsec)) * 1000,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _preserve_filename_timestamp_if_close(file, datetime_info):
|
|
240
|
+
"""動画メタデータとファイル名が近い場合は、ファイル名のミリ秒を保持"""
|
|
241
|
+
filename_dt = _parse_filename_datetime(file)
|
|
242
|
+
if not filename_dt:
|
|
243
|
+
return datetime_info
|
|
244
|
+
|
|
245
|
+
date, time, subsec = datetime_info
|
|
246
|
+
metadata_dt = _parts_to_datetime(date, time, subsec)
|
|
247
|
+
if abs((filename_dt - metadata_dt).total_seconds()) <= 4:
|
|
248
|
+
return _datetime_to_parts(filename_dt)
|
|
249
|
+
|
|
250
|
+
return datetime_info
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _run_exiftool_datetimes(file, tags, quicktime_utc=False):
|
|
254
|
+
command = ["exiftool", "-s", "-s", "-s"]
|
|
255
|
+
if quicktime_utc:
|
|
256
|
+
command.extend(["-api", "QuickTimeUTC=1"])
|
|
257
|
+
command.extend(tags)
|
|
258
|
+
command.append(str(file))
|
|
259
|
+
try:
|
|
260
|
+
result = subprocess.run(command, capture_output=True, text=True, check=True)
|
|
261
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
262
|
+
return []
|
|
263
|
+
|
|
264
|
+
dates = []
|
|
265
|
+
for line in result.stdout.splitlines():
|
|
266
|
+
try:
|
|
267
|
+
d = _parse_exiftool_datetime(line)
|
|
268
|
+
except Exception:
|
|
269
|
+
continue
|
|
270
|
+
if d:
|
|
271
|
+
dates.append(d)
|
|
272
|
+
|
|
273
|
+
return dates
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _run_exiftool_datetime(file, tags, quicktime_utc=False):
|
|
277
|
+
dates = _run_exiftool_datetimes(file, tags, quicktime_utc=quicktime_utc)
|
|
278
|
+
return dates[0] if dates else None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _oldest_datetime_info(file, metadata_datetimes):
|
|
282
|
+
"""メタデータと作成/変更日時の候補から最古の日時を採用する"""
|
|
283
|
+
candidates = [d for d in metadata_datetimes if d]
|
|
284
|
+
candidates.extend(_get_filesystem_datetimes(file))
|
|
285
|
+
return _preserve_filename_timestamp_if_close(file, _datetime_to_parts(min(candidates)))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _select_quicktime_create_date(file):
|
|
289
|
+
"""QuickTimeのCreateDateをローカル/UTC解釈のうちファイル変更日時に近い方で読む"""
|
|
290
|
+
quicktime_local = _run_exiftool_datetime(file, ["-QuickTime:CreateDate"], quicktime_utc=False)
|
|
291
|
+
quicktime_utc = _run_exiftool_datetime(file, ["-QuickTime:CreateDate"], quicktime_utc=True)
|
|
292
|
+
if quicktime_local and quicktime_utc:
|
|
293
|
+
file_modify = datetime.datetime.fromtimestamp(Path(file).stat().st_mtime)
|
|
294
|
+
local_delta = abs((file_modify - quicktime_local).total_seconds())
|
|
295
|
+
utc_delta = abs((file_modify - quicktime_utc).total_seconds())
|
|
296
|
+
return quicktime_utc if utc_delta < local_delta else quicktime_local
|
|
297
|
+
return quicktime_local or quicktime_utc
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def get_datetime_video_metadata(file):
|
|
301
|
+
"""動画の内部メタデータと作成/変更日時から最古の日時を取得"""
|
|
302
|
+
metadata_datetimes = []
|
|
303
|
+
|
|
304
|
+
timezone_tags = ["-Keys:CreationDate", "-UserData:DateTimeOriginal"]
|
|
305
|
+
metadata_datetimes.extend(_run_exiftool_datetimes(file, timezone_tags, quicktime_utc=True))
|
|
306
|
+
|
|
307
|
+
if Path(file).suffix.lower() == ".mp4":
|
|
308
|
+
metadata_datetimes.extend(_run_exiftool_datetimes(
|
|
309
|
+
file,
|
|
310
|
+
["-Composite:SubSecDateTimeOriginal", "-ExifIFD:DateTimeOriginal", "-QuickTime:CreateDate"],
|
|
311
|
+
quicktime_utc=True,
|
|
312
|
+
))
|
|
313
|
+
|
|
314
|
+
quicktime_create_date = _select_quicktime_create_date(file)
|
|
315
|
+
if quicktime_create_date:
|
|
316
|
+
metadata_datetimes.append(quicktime_create_date)
|
|
317
|
+
|
|
318
|
+
return _oldest_datetime_info(file, metadata_datetimes)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_datetime_image_metadata(file):
|
|
322
|
+
"""HEIC/PNG/WebPのメタデータと作成/変更日時から最古の日時を取得"""
|
|
323
|
+
metadata_datetimes = _run_exiftool_datetimes(
|
|
324
|
+
file,
|
|
325
|
+
[
|
|
326
|
+
"-Composite:SubSecDateTimeOriginal",
|
|
327
|
+
"-EXIF:DateTimeOriginal",
|
|
328
|
+
"-QuickTime:CreateDate",
|
|
329
|
+
"-Keys:CreationDate",
|
|
330
|
+
],
|
|
331
|
+
quicktime_utc=True,
|
|
332
|
+
)
|
|
333
|
+
return _oldest_datetime_info(file, metadata_datetimes)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def get_datetime(file):
|
|
337
|
+
"""ファイルの撮影日時を取得(形式に応じた処理)"""
|
|
338
|
+
file = Path(file)
|
|
339
|
+
_, ext = os.path.splitext(str(file))
|
|
340
|
+
ext = ext.lower()
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
if ext in ['.jpeg', '.jpg']:
|
|
344
|
+
try:
|
|
345
|
+
return get_datetime_exif(file)
|
|
346
|
+
except Exception:
|
|
347
|
+
return get_creation_datetime(file)
|
|
348
|
+
elif ext == '.cr2':
|
|
349
|
+
try:
|
|
350
|
+
return get_datetime_cr2exif(file)
|
|
351
|
+
except Exception:
|
|
352
|
+
return get_creation_datetime(file)
|
|
353
|
+
elif ext in ['.nef', '.arw']:
|
|
354
|
+
try:
|
|
355
|
+
return get_datetime_raw_exif(file)
|
|
356
|
+
except Exception:
|
|
357
|
+
return get_creation_datetime(file)
|
|
358
|
+
elif ext in ['.mov', '.mp4', '.avi']:
|
|
359
|
+
try:
|
|
360
|
+
return get_datetime_video_metadata(file)
|
|
361
|
+
except Exception:
|
|
362
|
+
return get_filesystem_oldest_datetime(file)
|
|
363
|
+
elif ext in ['.heic', '.png', '.webp']:
|
|
364
|
+
try:
|
|
365
|
+
return get_datetime_image_metadata(file)
|
|
366
|
+
except Exception:
|
|
367
|
+
return get_filesystem_oldest_datetime(file)
|
|
368
|
+
else:
|
|
369
|
+
print(f"{file} is not a supported image or video file")
|
|
370
|
+
return None
|
|
371
|
+
except Exception as e:
|
|
372
|
+
print(f"Error getting datetime for {file}: {e}")
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ===== File Renaming Functions =====
|
|
377
|
+
|
|
378
|
+
def _build_filename(year, month, day, hour, minute, second, subsec, ext):
|
|
379
|
+
"""統一フォーマットでファイル名を生成"""
|
|
380
|
+
return f"{year}{month}{day}_{hour}{minute}{second}_{subsec}{ext}"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _find_existing_event_archive_dir(year, month, day):
|
|
384
|
+
"""同日付のイベント名付き archive フォルダがあれば返す"""
|
|
385
|
+
date_prefix = f"{year}-{month}-{day}"
|
|
386
|
+
year_dir = ARCHIVE_DIR / year
|
|
387
|
+
if not year_dir.exists():
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
candidates = []
|
|
391
|
+
for child in year_dir.iterdir():
|
|
392
|
+
if not child.is_dir():
|
|
393
|
+
continue
|
|
394
|
+
if not child.name.startswith(date_prefix):
|
|
395
|
+
continue
|
|
396
|
+
# "yyyy-mm-dd" ちょうどのフォルダより、イベント名付きフォルダを優先する
|
|
397
|
+
suffix = child.name[len(date_prefix):].strip()
|
|
398
|
+
candidates.append((0 if suffix else 1, child.name, child))
|
|
399
|
+
|
|
400
|
+
if not candidates:
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
candidates.sort()
|
|
404
|
+
return candidates[0][2]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _unique_target_path(target_path, source):
|
|
408
|
+
"""同名ファイルがある場合は同じパスへ上書きする"""
|
|
409
|
+
source = Path(source).resolve()
|
|
410
|
+
target_path = Path(target_path)
|
|
411
|
+
if not target_path.exists() or target_path.resolve() == source:
|
|
412
|
+
return target_path
|
|
413
|
+
return target_path
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def rename_and_sort(file):
|
|
417
|
+
"""ファイルのリネームと整理"""
|
|
418
|
+
file = Path(file)
|
|
419
|
+
_, ext = os.path.splitext(str(file))
|
|
420
|
+
ext = _normalize_image_ext(ext)
|
|
421
|
+
|
|
422
|
+
datetime_info = get_datetime(file)
|
|
423
|
+
if not datetime_info:
|
|
424
|
+
print(f"Skipping {file.name} - unable to extract datetime")
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
date, time, subsec = datetime_info
|
|
428
|
+
year = date[0]
|
|
429
|
+
month = date[1]
|
|
430
|
+
day = date[2]
|
|
431
|
+
hour = time[0]
|
|
432
|
+
minute = time[1]
|
|
433
|
+
second = time[2]
|
|
434
|
+
|
|
435
|
+
filename = _build_filename(year, month, day, hour, minute, second, subsec, ext)
|
|
436
|
+
|
|
437
|
+
# RAW系ファイルは拡張子ごとのサブディレクトリへ、それ以外はARCHIVE_DIRへ
|
|
438
|
+
if ext in RAW_SUBDIRS:
|
|
439
|
+
target_dir = RAW_DIR / RAW_SUBDIRS[ext]
|
|
440
|
+
else:
|
|
441
|
+
# 既存の "yyyy-mm-dd イベント名" フォルダがあれば優先して使う
|
|
442
|
+
target_dir = _find_existing_event_archive_dir(year, month, day)
|
|
443
|
+
if target_dir is None:
|
|
444
|
+
# なければ従来通り日付フォルダを作る
|
|
445
|
+
target_dir = ARCHIVE_DIR / year / f"{year}-{month}-{day}"
|
|
446
|
+
|
|
447
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
448
|
+
target_path = target_dir / filename
|
|
449
|
+
|
|
450
|
+
target_path = _unique_target_path(target_path, file)
|
|
451
|
+
|
|
452
|
+
file.rename(target_path)
|
|
453
|
+
print(f"Moved: {file.name} -> {target_path}")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def remove_same_file(directories):
|
|
457
|
+
"""同じベースネームの重複ファイルを削除"""
|
|
458
|
+
for directory in directories:
|
|
459
|
+
os.chdir(directory)
|
|
460
|
+
files = os.listdir()
|
|
461
|
+
bases = []
|
|
462
|
+
exts = []
|
|
463
|
+
for file in files:
|
|
464
|
+
base, ext = os.path.splitext(file)
|
|
465
|
+
ext = ext.lower()
|
|
466
|
+
for i in range(len(bases)):
|
|
467
|
+
if bases[i] == base and exts[i] == ext:
|
|
468
|
+
send2trash(Path(file))
|
|
469
|
+
bases.append(base)
|
|
470
|
+
exts.append(ext)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def remove_number(file):
|
|
474
|
+
"""ファイル名の末尾の番号をリセット"""
|
|
475
|
+
base, ext = os.path.splitext(file)
|
|
476
|
+
if base.endswith(')'):
|
|
477
|
+
base = base[:-4]
|
|
478
|
+
os.renames(file, f"{base}{ext}")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def lower_name(file):
|
|
482
|
+
"""拡張子を小文字に"""
|
|
483
|
+
base, ext = os.path.splitext(file)
|
|
484
|
+
ext = _normalize_image_ext(ext)
|
|
485
|
+
os.renames(file, f"{base}{ext}")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ===== Main Function =====
|
|
489
|
+
|
|
490
|
+
def main():
|
|
491
|
+
"""メイン処理 - IMPORT_DIRのファイルを処理"""
|
|
492
|
+
if not IMPORT_DIR.exists():
|
|
493
|
+
raise FileNotFoundError(f"Import directory not found: {IMPORT_DIR}")
|
|
494
|
+
|
|
495
|
+
print(f"Processing files in: {IMPORT_DIR}", flush=True)
|
|
496
|
+
files = sorted([f for f in IMPORT_DIR.iterdir() if f.is_file() and not f.name.startswith(".")])
|
|
497
|
+
|
|
498
|
+
if not files:
|
|
499
|
+
print("No files to process.", flush=True)
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
total = len(files)
|
|
503
|
+
print(f"Found {total} file(s) to process.", flush=True)
|
|
504
|
+
for index, file_path in enumerate(files, start=1):
|
|
505
|
+
print(f"[{index}/{total}] Processing: {file_path.name}", flush=True)
|
|
506
|
+
rename_and_sort(file_path)
|
|
507
|
+
|
|
508
|
+
print("Done", flush=True)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
if __name__ == '__main__':
|
|
512
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pic-archiver"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Rename, sort, and safely archive exported picture and video files."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"Pillow",
|
|
13
|
+
"piexif",
|
|
14
|
+
"rawpy",
|
|
15
|
+
"Send2Trash",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
parc = "parc_cli:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
py-modules = ["config", "main", "parc_cli", "pic_archiver"]
|