iphoto-sizer 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.
- iphoto_sizer-0.1.0/PKG-INFO +120 -0
- iphoto_sizer-0.1.0/README.md +109 -0
- iphoto_sizer-0.1.0/pyproject.toml +106 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/__init__.py +45 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/__main__.py +4 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/cli.py +222 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/core.py +181 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/models.py +103 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/web/__init__.py +48 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/web/routes.py +125 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/web/static/app.js +634 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/web/static/style.css +903 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/web/templates/index.html +193 -0
- iphoto_sizer-0.1.0/src/iphoto_sizer/writers.py +40 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: iphoto-sizer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export and sort Apple Photos library metadata by file size
|
|
5
|
+
Requires-Dist: osxphotos~=0.75.6
|
|
6
|
+
Requires-Dist: pydantic~=2.12.5
|
|
7
|
+
Requires-Dist: flask>=3.0 ; extra == 'web'
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Provides-Extra: web
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# iphoto-sizer
|
|
13
|
+
|
|
14
|
+
Exports metadata from your macOS Apple Photos library to CSV or JSON, sorted by file size (largest first). Useful for finding what's eating your iCloud storage.
|
|
15
|
+
|
|
16
|
+
Each record includes: filename, extension, media type (photo/video), size in bytes, human-readable size, creation date, UUID, and iCloud sync status (local vs cloud-only).
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
- macOS with the Photos app and a library present
|
|
21
|
+
- Python 3.11+
|
|
22
|
+
- Full Disk Access granted to your terminal (System Settings > Privacy & Security > Full Disk Access)
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Clone and install with uv
|
|
28
|
+
git clone https://github.com/spencerpresley/iPhotoSizer.git && cd iPhotoSizer
|
|
29
|
+
uv sync
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or install with pip:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
After installing, an `iphoto-sizer` CLI command is available:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Export everything to photos_report.csv in the current directory
|
|
44
|
+
iphoto-sizer
|
|
45
|
+
|
|
46
|
+
# Only items larger than 100 MB
|
|
47
|
+
iphoto-sizer --min-size-mb 100
|
|
48
|
+
|
|
49
|
+
# Write to a specific path
|
|
50
|
+
iphoto-sizer -o ~/Desktop/large_files.csv
|
|
51
|
+
|
|
52
|
+
# Export as JSON instead of CSV
|
|
53
|
+
iphoto-sizer -f json -o ~/Desktop/photos.json
|
|
54
|
+
|
|
55
|
+
# Combine options
|
|
56
|
+
iphoto-sizer --min-size-mb 500 -f json -o ~/Desktop/big_ones.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
You can also run it as a module:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python -m iphoto_sizer
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## CLI Options
|
|
66
|
+
|
|
67
|
+
| Flag | Description | Default |
|
|
68
|
+
|------|-------------|---------|
|
|
69
|
+
| `--min-size-mb` | Only include items at or above this size (MB) | `0` (all items) |
|
|
70
|
+
| `-o`, `--output` | Output file path | `photos_report.csv` |
|
|
71
|
+
| `-f`, `--format` | Output format: `csv` or `json` | `csv` |
|
|
72
|
+
| `--web` | Launch the web UI in a browser | off |
|
|
73
|
+
|
|
74
|
+
## Web UI
|
|
75
|
+
|
|
76
|
+
An optional browser-based interface for browsing and filtering your library.
|
|
77
|
+
|
|
78
|
+
### Install
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install iphoto-sizer[web]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Or with uv:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv sync --extra web
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Usage
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
iphoto-sizer --web
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Opens a local web server in your browser. From there you can run a new scan, open an existing report, export results, and open individual photos in Photos.app.
|
|
97
|
+
|
|
98
|
+
The web UI provides:
|
|
99
|
+
- Summary stats (total items, total size, video/photo breakdown)
|
|
100
|
+
- Sortable, filterable browsing of all library items
|
|
101
|
+
- Export to CSV, JSON, or both from the browser
|
|
102
|
+
- Open individual photos directly in Photos.app (experimental)
|
|
103
|
+
|
|
104
|
+
## Output
|
|
105
|
+
|
|
106
|
+
**CSV columns / JSON fields:**
|
|
107
|
+
|
|
108
|
+
`filename`, `extension`, `media_type`, `size_bytes`, `size`, `creation_date`, `uuid`, `icloud_status`
|
|
109
|
+
|
|
110
|
+
- `size` is human-readable (e.g. `"150.23 MB"`, `"1.50 GB"`)
|
|
111
|
+
- `icloud_status` is `"local"` if the original file is on disk, `"cloud-only"` if it only exists in iCloud
|
|
112
|
+
- Records are sorted by `size_bytes` descending
|
|
113
|
+
|
|
114
|
+
A summary of total items, total size, and the 10 largest files is printed to stderr after export.
|
|
115
|
+
|
|
116
|
+
## Notes
|
|
117
|
+
|
|
118
|
+
- Initial library load takes 15-20 seconds on large libraries.
|
|
119
|
+
- Photos that fail to parse are skipped with a warning; they don't stop the export.
|
|
120
|
+
- The tool checks for at least 50 MB of free disk space before writing.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# iphoto-sizer
|
|
2
|
+
|
|
3
|
+
Exports metadata from your macOS Apple Photos library to CSV or JSON, sorted by file size (largest first). Useful for finding what's eating your iCloud storage.
|
|
4
|
+
|
|
5
|
+
Each record includes: filename, extension, media type (photo/video), size in bytes, human-readable size, creation date, UUID, and iCloud sync status (local vs cloud-only).
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- macOS with the Photos app and a library present
|
|
10
|
+
- Python 3.11+
|
|
11
|
+
- Full Disk Access granted to your terminal (System Settings > Privacy & Security > Full Disk Access)
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Clone and install with uv
|
|
17
|
+
git clone https://github.com/spencerpresley/iPhotoSizer.git && cd iPhotoSizer
|
|
18
|
+
uv sync
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install with pip:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
After installing, an `iphoto-sizer` CLI command is available:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Export everything to photos_report.csv in the current directory
|
|
33
|
+
iphoto-sizer
|
|
34
|
+
|
|
35
|
+
# Only items larger than 100 MB
|
|
36
|
+
iphoto-sizer --min-size-mb 100
|
|
37
|
+
|
|
38
|
+
# Write to a specific path
|
|
39
|
+
iphoto-sizer -o ~/Desktop/large_files.csv
|
|
40
|
+
|
|
41
|
+
# Export as JSON instead of CSV
|
|
42
|
+
iphoto-sizer -f json -o ~/Desktop/photos.json
|
|
43
|
+
|
|
44
|
+
# Combine options
|
|
45
|
+
iphoto-sizer --min-size-mb 500 -f json -o ~/Desktop/big_ones.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
You can also run it as a module:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python -m iphoto_sizer
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## CLI Options
|
|
55
|
+
|
|
56
|
+
| Flag | Description | Default |
|
|
57
|
+
|------|-------------|---------|
|
|
58
|
+
| `--min-size-mb` | Only include items at or above this size (MB) | `0` (all items) |
|
|
59
|
+
| `-o`, `--output` | Output file path | `photos_report.csv` |
|
|
60
|
+
| `-f`, `--format` | Output format: `csv` or `json` | `csv` |
|
|
61
|
+
| `--web` | Launch the web UI in a browser | off |
|
|
62
|
+
|
|
63
|
+
## Web UI
|
|
64
|
+
|
|
65
|
+
An optional browser-based interface for browsing and filtering your library.
|
|
66
|
+
|
|
67
|
+
### Install
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install iphoto-sizer[web]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or with uv:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
uv sync --extra web
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Usage
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
iphoto-sizer --web
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Opens a local web server in your browser. From there you can run a new scan, open an existing report, export results, and open individual photos in Photos.app.
|
|
86
|
+
|
|
87
|
+
The web UI provides:
|
|
88
|
+
- Summary stats (total items, total size, video/photo breakdown)
|
|
89
|
+
- Sortable, filterable browsing of all library items
|
|
90
|
+
- Export to CSV, JSON, or both from the browser
|
|
91
|
+
- Open individual photos directly in Photos.app (experimental)
|
|
92
|
+
|
|
93
|
+
## Output
|
|
94
|
+
|
|
95
|
+
**CSV columns / JSON fields:**
|
|
96
|
+
|
|
97
|
+
`filename`, `extension`, `media_type`, `size_bytes`, `size`, `creation_date`, `uuid`, `icloud_status`
|
|
98
|
+
|
|
99
|
+
- `size` is human-readable (e.g. `"150.23 MB"`, `"1.50 GB"`)
|
|
100
|
+
- `icloud_status` is `"local"` if the original file is on disk, `"cloud-only"` if it only exists in iCloud
|
|
101
|
+
- Records are sorted by `size_bytes` descending
|
|
102
|
+
|
|
103
|
+
A summary of total items, total size, and the 10 largest files is printed to stderr after export.
|
|
104
|
+
|
|
105
|
+
## Notes
|
|
106
|
+
|
|
107
|
+
- Initial library load takes 15-20 seconds on large libraries.
|
|
108
|
+
- Photos that fail to parse are skipped with a warning; they don't stop the export.
|
|
109
|
+
- The tool checks for at least 50 MB of free disk space before writing.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "iphoto-sizer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Export and sort Apple Photos library metadata by file size"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"osxphotos~=0.75.6",
|
|
9
|
+
"pydantic~=2.12.5",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
web = ["flask>=3.0"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
iphoto-sizer = "iphoto_sizer.cli:main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.10.9,<0.11.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pyright>=1.1.407",
|
|
25
|
+
"pytest>=9.0.2",
|
|
26
|
+
"pytest-cov>=7.0.0",
|
|
27
|
+
"ruff>=0.14.9",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 100
|
|
32
|
+
target-version = "py311"
|
|
33
|
+
|
|
34
|
+
[tool.ruff.format]
|
|
35
|
+
docstring-code-format = true
|
|
36
|
+
quote-style = "double"
|
|
37
|
+
indent-style = "space"
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = ["ALL"]
|
|
41
|
+
ignore = [
|
|
42
|
+
# Conflicts with formatter
|
|
43
|
+
"COM812",
|
|
44
|
+
"ISC001",
|
|
45
|
+
|
|
46
|
+
# Docstrings - too strict
|
|
47
|
+
"D100", # Missing docstring in public module
|
|
48
|
+
"D104", # Missing docstring in public package
|
|
49
|
+
"D107", # Missing docstring in __init__
|
|
50
|
+
|
|
51
|
+
# Annoying / not useful
|
|
52
|
+
"C90", # McCabe complexity
|
|
53
|
+
"CPY", # Copyright headers
|
|
54
|
+
"FIX002", # Line contains TODO
|
|
55
|
+
"PLR09", # Too many arguments/statements/etc
|
|
56
|
+
"TD002", # Missing author in TODO
|
|
57
|
+
"TD003", # Missing issue link for TODO
|
|
58
|
+
"FBT", # Boolean trap - internal APIs are fine
|
|
59
|
+
"PLW0603", # Global statement - config module pattern
|
|
60
|
+
|
|
61
|
+
# Doesn't play well with Pydantic
|
|
62
|
+
"RUF012", # Mutable class attrs
|
|
63
|
+
"TC001", # Move import into TYPE_CHECKING
|
|
64
|
+
"TC002", # Move import into TYPE_CHECKING
|
|
65
|
+
"TC003", # Move import into TYPE_CHECKING
|
|
66
|
+
|
|
67
|
+
# May want to revisit
|
|
68
|
+
"BLE", # Blind exceptions
|
|
69
|
+
]
|
|
70
|
+
unfixable = [
|
|
71
|
+
"B028", # warnings.warn() stacklevel - think about it
|
|
72
|
+
"PLW1510", # subprocess.run() check param - be explicit
|
|
73
|
+
]
|
|
74
|
+
fixable = ["ALL"]
|
|
75
|
+
|
|
76
|
+
# Pydantic-friendly settings
|
|
77
|
+
flake8-annotations.allow-star-arg-any = true
|
|
78
|
+
flake8-annotations.mypy-init-return = true
|
|
79
|
+
flake8-builtins.ignorelist = ["id", "type", "input"]
|
|
80
|
+
flake8-type-checking.runtime-evaluated-base-classes = ["pydantic.BaseModel"]
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint.pydocstyle]
|
|
83
|
+
convention = "google"
|
|
84
|
+
ignore-var-parameters = true
|
|
85
|
+
|
|
86
|
+
[tool.ruff.lint.per-file-ignores]
|
|
87
|
+
"tests/**" = ["ANN", "D", "S", "PLR2004", "ARG", "PT", "T201", "FBT", "SLF001", "PLC0415", "ERA001"]
|
|
88
|
+
"**/cli.py" = ["T201"] # CLI tool — print to stderr is intentional
|
|
89
|
+
"**/core.py" = ["T201"] # load_photos_db prints status to stderr
|
|
90
|
+
"**/web/*.py" = ["T201"]
|
|
91
|
+
|
|
92
|
+
[tool.pyright]
|
|
93
|
+
include = ["."]
|
|
94
|
+
exclude = ["**/__pycache__", ".venv", "**/.venv", "output"]
|
|
95
|
+
pythonVersion = "3.11"
|
|
96
|
+
typeCheckingMode = "strict"
|
|
97
|
+
|
|
98
|
+
venvPath = "."
|
|
99
|
+
venv = ".venv"
|
|
100
|
+
|
|
101
|
+
reportUnusedCallResult = "none"
|
|
102
|
+
reportMissingTypeStubs = "none"
|
|
103
|
+
reportPrivateUsage = "none"
|
|
104
|
+
reportUnknownMemberType = "none"
|
|
105
|
+
reportUnnecessaryTypeIgnoreComment = "warning"
|
|
106
|
+
reportMissingTypeArgument = "none"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""iphoto-sizer — export Apple Photos library metadata sorted by file size."""
|
|
2
|
+
|
|
3
|
+
from iphoto_sizer.core import apply_filters, load_photos_db, photo_to_record, scan_library
|
|
4
|
+
from iphoto_sizer.models import (
|
|
5
|
+
BYTES_PER_GB,
|
|
6
|
+
BYTES_PER_MB,
|
|
7
|
+
CSV_COLUMNS,
|
|
8
|
+
DEFAULT_FORMAT,
|
|
9
|
+
DEFAULT_OUTPUT_FILE,
|
|
10
|
+
DEFAULT_OUTPUT_STEM,
|
|
11
|
+
ICLOUD_STATUS_CLOUD_ONLY,
|
|
12
|
+
ICLOUD_STATUS_LOCAL,
|
|
13
|
+
MEDIA_TYPE_PHOTO,
|
|
14
|
+
MEDIA_TYPE_VIDEO,
|
|
15
|
+
SUPPORTED_FORMATS,
|
|
16
|
+
OutputFormat,
|
|
17
|
+
PhotoRecord,
|
|
18
|
+
RecordWriter,
|
|
19
|
+
format_bytes,
|
|
20
|
+
)
|
|
21
|
+
from iphoto_sizer.writers import write_csv, write_json
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"BYTES_PER_GB",
|
|
25
|
+
"BYTES_PER_MB",
|
|
26
|
+
"CSV_COLUMNS",
|
|
27
|
+
"DEFAULT_FORMAT",
|
|
28
|
+
"DEFAULT_OUTPUT_FILE",
|
|
29
|
+
"DEFAULT_OUTPUT_STEM",
|
|
30
|
+
"ICLOUD_STATUS_CLOUD_ONLY",
|
|
31
|
+
"ICLOUD_STATUS_LOCAL",
|
|
32
|
+
"MEDIA_TYPE_PHOTO",
|
|
33
|
+
"MEDIA_TYPE_VIDEO",
|
|
34
|
+
"SUPPORTED_FORMATS",
|
|
35
|
+
"OutputFormat",
|
|
36
|
+
"PhotoRecord",
|
|
37
|
+
"RecordWriter",
|
|
38
|
+
"apply_filters",
|
|
39
|
+
"format_bytes",
|
|
40
|
+
"load_photos_db",
|
|
41
|
+
"photo_to_record",
|
|
42
|
+
"scan_library",
|
|
43
|
+
"write_csv",
|
|
44
|
+
"write_json",
|
|
45
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""CLI entry point for iphoto-sizer.
|
|
2
|
+
|
|
3
|
+
.. code-block:: bash
|
|
4
|
+
|
|
5
|
+
# Export all items to photos_report.csv in the current directory
|
|
6
|
+
iphoto-sizer
|
|
7
|
+
|
|
8
|
+
# Or run as a module
|
|
9
|
+
python -m iphoto_sizer
|
|
10
|
+
|
|
11
|
+
# Export only items larger than 100 MB
|
|
12
|
+
iphoto-sizer --min-size-mb 100
|
|
13
|
+
|
|
14
|
+
# Export to a custom path
|
|
15
|
+
iphoto-sizer -o ~/Desktop/large_files.csv
|
|
16
|
+
|
|
17
|
+
# Export as JSON
|
|
18
|
+
iphoto-sizer -f json -o ~/Desktop/photos.json
|
|
19
|
+
|
|
20
|
+
# Combine options
|
|
21
|
+
iphoto-sizer --min-size-mb 500 -o ~/Desktop/big_ones.csv
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from iphoto_sizer.core import load_photos_db, scan_library
|
|
30
|
+
from iphoto_sizer.models import (
|
|
31
|
+
BYTES_PER_MB,
|
|
32
|
+
DEFAULT_FORMAT,
|
|
33
|
+
DEFAULT_OUTPUT_FILE,
|
|
34
|
+
SUPPORTED_FORMATS,
|
|
35
|
+
PhotoRecord,
|
|
36
|
+
format_bytes,
|
|
37
|
+
)
|
|
38
|
+
from iphoto_sizer.writers import FORMAT_WRITERS
|
|
39
|
+
|
|
40
|
+
_EXIT_CODE_ERROR = 1
|
|
41
|
+
_EXIT_CODE_INTERRUPTED = 130
|
|
42
|
+
_MIN_FREE_DISK_SPACE_MB = 50
|
|
43
|
+
|
|
44
|
+
# Summary table column widths
|
|
45
|
+
_COL_WIDTH_RANK = 3
|
|
46
|
+
_COL_WIDTH_FILENAME = 40
|
|
47
|
+
_COL_WIDTH_SIZE = 10
|
|
48
|
+
_COL_WIDTH_MEDIA_TYPE = 5
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_arg_parser() -> argparse.ArgumentParser:
|
|
52
|
+
"""Build the CLI argument parser.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
argparse.ArgumentParser: Configured parser with ``--min-size-mb``
|
|
56
|
+
and ``--output`` arguments.
|
|
57
|
+
"""
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
description="Export Apple Photos library metadata to CSV, sorted by file size."
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--min-size-mb",
|
|
63
|
+
type=float,
|
|
64
|
+
default=0.0,
|
|
65
|
+
help="Only include items larger than this size in MB (default: include all)",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--output",
|
|
69
|
+
"-o",
|
|
70
|
+
type=str,
|
|
71
|
+
default=DEFAULT_OUTPUT_FILE,
|
|
72
|
+
help=f"Output file path (default: {DEFAULT_OUTPUT_FILE})",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--format",
|
|
76
|
+
"-f",
|
|
77
|
+
type=str,
|
|
78
|
+
choices=SUPPORTED_FORMATS,
|
|
79
|
+
default=DEFAULT_FORMAT,
|
|
80
|
+
help=f"Output format (default: {DEFAULT_FORMAT})",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--web",
|
|
84
|
+
action="store_true",
|
|
85
|
+
default=False,
|
|
86
|
+
help="Launch the web UI instead of exporting to a file",
|
|
87
|
+
)
|
|
88
|
+
return parser
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_output_path(output_path: str) -> Path:
|
|
92
|
+
"""Validate and prepare the output file path.
|
|
93
|
+
|
|
94
|
+
Creates parent directories if they don't exist. Warns if an existing
|
|
95
|
+
file will be overwritten. Checks that the destination has enough disk
|
|
96
|
+
space for a reasonable CSV output.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
output_path (str): Destination file path for the CSV.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Path: The validated output path.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
SystemExit: If the parent directory cannot be created or there
|
|
106
|
+
is insufficient disk space.
|
|
107
|
+
"""
|
|
108
|
+
path = Path(output_path)
|
|
109
|
+
|
|
110
|
+
# Ensure parent directory exists
|
|
111
|
+
try:
|
|
112
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
except OSError as e:
|
|
114
|
+
print(f"Cannot create output directory {path.parent}: {e}", file=sys.stderr)
|
|
115
|
+
sys.exit(_EXIT_CODE_ERROR)
|
|
116
|
+
|
|
117
|
+
if path.exists():
|
|
118
|
+
print(f"Note: overwriting existing file {path}", file=sys.stderr)
|
|
119
|
+
|
|
120
|
+
# Rough disk space check — a large library (100k items) produces
|
|
121
|
+
# roughly 15-20 MB of CSV, so 50 MB is a comfortable minimum
|
|
122
|
+
min_free_bytes = _MIN_FREE_DISK_SPACE_MB * BYTES_PER_MB
|
|
123
|
+
try:
|
|
124
|
+
free_bytes = shutil.disk_usage(path.parent).free
|
|
125
|
+
except OSError as e:
|
|
126
|
+
print(
|
|
127
|
+
f"Warning: could not check disk space for {path.parent}: {e}",
|
|
128
|
+
file=sys.stderr,
|
|
129
|
+
)
|
|
130
|
+
return path
|
|
131
|
+
if free_bytes < min_free_bytes:
|
|
132
|
+
print(
|
|
133
|
+
f"Insufficient disk space: {format_bytes(free_bytes)} free, "
|
|
134
|
+
f"need at least {format_bytes(min_free_bytes)}",
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
)
|
|
137
|
+
sys.exit(_EXIT_CODE_ERROR)
|
|
138
|
+
|
|
139
|
+
return path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def print_summary(records: list[PhotoRecord], top_n: int = 10) -> None:
|
|
143
|
+
"""Print a summary report to stderr.
|
|
144
|
+
|
|
145
|
+
Displays total item count, total size, and the largest items.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
records (list[PhotoRecord]): Records to summarize, assumed pre-sorted
|
|
149
|
+
by size descending.
|
|
150
|
+
top_n (int): Number of largest items to display. Defaults to 10.
|
|
151
|
+
"""
|
|
152
|
+
if not records:
|
|
153
|
+
print("No items found.", file=sys.stderr)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
total_bytes = sum(r.size_bytes for r in records)
|
|
157
|
+
|
|
158
|
+
print(file=sys.stderr)
|
|
159
|
+
print("=== Photos Library Report ===", file=sys.stderr)
|
|
160
|
+
print(f"Total items: {len(records):,}", file=sys.stderr)
|
|
161
|
+
print(f"Total size: {format_bytes(total_bytes)}", file=sys.stderr)
|
|
162
|
+
display_count = min(top_n, len(records))
|
|
163
|
+
|
|
164
|
+
print(file=sys.stderr)
|
|
165
|
+
print(f"Top {display_count} Largest Items:", file=sys.stderr)
|
|
166
|
+
|
|
167
|
+
for i, record in enumerate(records[:display_count], start=1):
|
|
168
|
+
print(
|
|
169
|
+
f" {i:>{_COL_WIDTH_RANK}}. {record.filename:<{_COL_WIDTH_FILENAME}}"
|
|
170
|
+
f" {record.size:>{_COL_WIDTH_SIZE}}"
|
|
171
|
+
f" {record.media_type:<{_COL_WIDTH_MEDIA_TYPE}} {record.icloud_status}",
|
|
172
|
+
file=sys.stderr,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _start_web() -> None:
|
|
177
|
+
"""Import and start the web UI. Raises ImportError if Flask is not installed."""
|
|
178
|
+
from iphoto_sizer.web import serve_web # noqa: PLC0415
|
|
179
|
+
|
|
180
|
+
serve_web()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main() -> None:
|
|
184
|
+
"""Run the full export pipeline: load, extract, filter, sort, write, summarize."""
|
|
185
|
+
try:
|
|
186
|
+
_run()
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
189
|
+
sys.exit(_EXIT_CODE_INTERRUPTED)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _run() -> None:
|
|
193
|
+
"""Inner pipeline logic, separated so ``main()`` owns interrupt handling."""
|
|
194
|
+
args = build_arg_parser().parse_args()
|
|
195
|
+
|
|
196
|
+
if args.min_size_mb < 0:
|
|
197
|
+
print("Error: --min-size-mb cannot be negative", file=sys.stderr)
|
|
198
|
+
sys.exit(_EXIT_CODE_ERROR)
|
|
199
|
+
|
|
200
|
+
if args.web:
|
|
201
|
+
try:
|
|
202
|
+
_start_web()
|
|
203
|
+
except ImportError:
|
|
204
|
+
print(
|
|
205
|
+
"The --web flag requires the [web] extra.\n"
|
|
206
|
+
"Install it with: pip install iphoto-sizer[web]",
|
|
207
|
+
file=sys.stderr,
|
|
208
|
+
)
|
|
209
|
+
sys.exit(_EXIT_CODE_ERROR)
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
output_path = validate_output_path(args.output)
|
|
213
|
+
db = load_photos_db()
|
|
214
|
+
|
|
215
|
+
records, skipped = scan_library(db, min_size_mb=args.min_size_mb)
|
|
216
|
+
if skipped:
|
|
217
|
+
print(f"Skipped {skipped} item(s) due to errors.", file=sys.stderr)
|
|
218
|
+
|
|
219
|
+
writer = FORMAT_WRITERS[args.format]
|
|
220
|
+
writer(records, output_path)
|
|
221
|
+
print(f"{args.format.upper()} written to {output_path}", file=sys.stderr)
|
|
222
|
+
print_summary(records)
|