The main concern is the hard requirement for python3.12 since I couldn't find a clean replacement for pathlib.Path.relative_to(root, walk_up=True)
in earlier pythons. I need it in order to generate relative URLs like ../../../.thumbs/some/dir/image.jpg
Also not sure if the multithreading is sane for parallel thumbnail generation as concurrency has never been my forte.
Would also be good to know if there is anything buggy, specifically ugly or inefficient that can be improved.
This is a stripped down version of the gist here — the only difference is in the amount of the js/css code which I cut down here in order to make it shorter. The gist version adds JS keyboard navigation and opens the assets in an overlay div rather than a new tab.
#!/usr/bin/env python3.12
# ^^^^^^^^^^^^^^^^^^^^^^^
# DEPENDENCIES:
# imagemagick:
# ubunbtu/debian: sudo apt install imagemagick
# macos: brew install imagemagick
# !!!MUST USE python3.12 of later!!!
# install python 3.12 on ubuntu:
# sudo add-apt-repository ppa:deadsnakes/ppa
# sudo apt install python3.12
#
# install python 3.12 on macos:
# brew install [email protected]
import os.path
import subprocess
from datetime import datetime
from pathlib import Path
from sys import argv
from urllib.parse import quote
from concurrent.futures import ThreadPoolExecutor
THUMBS_DIR = '~/.thumbs'
OUTPUT_FILE_NAME = "index.html"
IMAGE_SVG = ".svg"
IMAGE_HEIC = ".heic"
IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif", IMAGE_HEIC)
VIDEO_EXTENSIONS = (".mp4", ".mkv", ".webm", ".3gp", ".mov", ".mkv", ".ogv", ".mpg", ".mpeg")
AUDIO_EXTENSIONS = (".mp3", ".ogg", ".wav")
ASSET_EXTENSIONS = IMAGE_EXTENSIONS + VIDEO_EXTENSIONS + AUDIO_EXTENSIONS
class Asset:
def __init__(self, type, path, created_date, html, thumbnail_path=None):
self.type = type
self.path = path
self.created_date = created_date
self.html = html
self.thumbnail = thumbnail_path
def get_creation_date(file_stat):
try:
return file_stat.st_birthtime
except AttributeError:
# We are probably on Linux. No way to get the creation date, only the last modification date.
return file_stat.st_mtime
UNITS_MAPPING = [
(1024 ** 5, 'P'),
(1024 ** 4, 'T'),
(1024 ** 3, 'G'),
(1024 ** 2, 'M'),
(1024 ** 1, 'K'),
(1024 ** 0, (' byte', ' bytes')),
]
def pretty_size(bytes, units=UNITS_MAPPING):
"""Human-readable file sizes.
ripped from https://pypi.python.org/pypi/hurry.filesize/
"""
for factor, suffix in units:
if bytes >= factor:
break
amount = int(bytes / factor)
if isinstance(suffix, tuple):
singular, multiple = suffix
if amount == 1:
suffix = singular
else:
suffix = multiple
return str(amount) + suffix
def collect_media_assets(root: Path, thumbsdir: Path):
homedir = os.path.expanduser('~')
assets: list[Asset] = []
for asset_path in root.rglob("*"):
if asset_path.is_file() and asset_path.suffix.lower() in ASSET_EXTENSIONS:
if asset_path.name.startswith('._'): # skip macos junk
continue
size_pretty = pretty_size(asset_path.stat().st_size)
created_date = datetime.fromtimestamp(get_creation_date(asset_path.stat()))
created_date_formatted = created_date.strftime("%Y-%m-%d %H:%M:%S")
relative_path = str(asset_path.relative_to(root))
escaped_path = quote(relative_path)
if asset_path.suffix.lower() == IMAGE_SVG:
assets.append(Asset('svg',
relative_path,
created_date, f"""<a class="item" href="{escaped_path}" target="_blank" title="{relative_path} size: {size_pretty}; created: {created_date_formatted}">
<img src="{relative_path}" loading="lazy">
</a>"""))
elif asset_path.suffix.lower() in IMAGE_EXTENSIONS:
segment = str(root.absolute()).replace(homedir, '').lstrip('/')
thumbnail_path: Path = thumbsdir / segment / relative_path
if asset_path.suffix.lower() == IMAGE_HEIC:
thumbnail_path = thumbnail_path.with_suffix(".jpg")
thumbnail_path_relative = thumbnail_path.relative_to(root, walk_up=True) ## IMPORTANT: REQUIRED python 3.12 minimum!
thumbnail_path_relative_escaped = quote(str(thumbnail_path_relative))
assets.append(Asset('image',
relative_path,
created_date, f"""<a class="item" href="{escaped_path}" target="_blank" title="{relative_path} size: {size_pretty}; created: {created_date_formatted}">
<img src="{thumbnail_path_relative_escaped}" loading="lazy">
</a>""", thumbnail_path))
elif asset_path.suffix.lower() in VIDEO_EXTENSIONS:
assets.append(Asset('video', relative_path, created_date, f"""<a class="item" href="{escaped_path}" target="_blank" title="{relative_path} size: {size_pretty}; created: {created_date_formatted}">
<video preload="none" controls><source src="{escaped_path}"></video>{asset_path.name}
</a>"""))
elif asset_path.suffix.lower() in AUDIO_EXTENSIONS:
assets.append(Asset('audio', relative_path, created_date, f"""<a class="item" href="{escaped_path}" target="_blank" title="{relative_path} size: {size_pretty} KB; created: {created_date_formatted}">
<audio controls src="{escaped_path}"></audio>{asset_path.name}
</a>"""))
print(f'{asset_path}')
return assets
def generate_gallery_html(html_data, output_file_path: Path):
with output_file_path.open(mode="w", encoding="utf-8") as fout:
fout.write('''<html>
<head>
<style>
* {
font-family: system-ui, sans-serif;
}
body>div {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(321px, 1fr));
gap: 5px;
}
.item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
img, video {max-width: 321px;
height: auto;
display: table-cell;
}
a {
text-decoration: none;
font-size: 14px;
}
a:hover {
color: #0095e4;
}
a:visited {
color: #800080;
}
a:visited:hover {
color: #b900b9;
}
</style>
</head>
<body>
<div>'''
+ html_data
+ '''</div>
</body>
</html>''')
print(f"Gallery HTML file generated: {output_file_path.absolute()}")
def generate_thumbnails(assets: list[Asset], basedir: Path):
with ThreadPoolExecutor(max_workers=42) as executor:
asset: Asset
for asset in assets:
if asset.type == 'image':
executor.submit(make_thumbnail, asset, basedir)
def make_thumbnail(asset: Asset, basedir: Path):
thumbnail_dir: Path = asset.thumbnail.parent
thumbnail_dir.mkdir(parents=True, exist_ok=True)
if asset.thumbnail.exists():
print(f'⚡️\tSKIP existing thumbnail: {asset.thumbnail.absolute()}')
return
#### TODO: FIXME: validate path to convert: can be e.g. /usr/bin/convert, /usr/local/bin/convert...
args = ["/opt/homebrew/bin/convert",
str(basedir / asset.path),
"-strip",
"-quality", "60",
"-thumbnail", "300x"]
if Path(asset.path).suffix == '.heic':
args += ['-format', 'jpg']
args.append(str(asset.thumbnail.absolute()))
proc = subprocess.run(args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
result = proc.stdout.decode()
print('✅', asset.path, str(asset.thumbnail.absolute()), result)
err = proc.stderr.decode()
if err:
print("😱", err)
if __name__ == "__main__":
thumbnails_dir: Path = Path(os.path.expanduser(THUMBS_DIR))
thumbnails_dir.mkdir(exist_ok=True)
root_dir = len(argv) > 1 and argv[1] or '.' # Use 1st arg or current directory as root
base_dir: Path = Path(root_dir).absolute()
asset_list: list[Asset] = collect_media_assets(base_dir, thumbnails_dir)
if not asset_list:
print("No media files found.")
else:
asset_list.sort(key=lambda a: a.created_date, reverse=True)
html_content: str = '\n'.join(asset.html for asset in asset_list)
generate_thumbnails(asset_list, base_dir)
generate_gallery_html(html_content, base_dir / OUTPUT_FILE_NAME)
if asset_list and len(asset_list):
print(f'''
Total assets: {len(asset_list)}
Images: {len([a for a in asset_list if a.type == 'image'])}
Audios: {len([a for a in asset_list if a.type == 'audio'])}
Videos: {len([a for a in asset_list if a.type == 'video'])}
''')