Compare commits

...
10 Commits
Author SHA1 Message Date
luxick c092140d2f Update nn
Improve exif parsing performance
2026-09-26 17:38:49 +02:00
luxick 51b63eb6b6 Fix Pixel phone naming 2026-09-25 08:44:15 +02:00
luxick f1d29d5109 Update nn
drop termios dep
2026-08-28 14:43:24 +02:00
luxick d2d8c7dbf6 Update URL again 2026-08-28 14:12:57 +02:00
luxick 46eabcecd6 Update reddit-image-inline.user.js 2026-08-28 14:11:40 +02:00
luxick ad2c20ea39 Also inline video comments 2026-08-28 14:00:47 +02:00
luxick ddd97c12aa Update URL 2026-08-28 13:57:38 +02:00
luxick 7b7b7512f5 Add restart check to fedora system-update 2026-08-26 08:11:19 +02:00
luxick a8b1d41398 Update nn
Exclude hidden files per default. Use `-a` to include them
2026-08-24 10:54:58 +02:00
luxick 2dc08013b7 Update nn
Direct input into Y/N questions
2026-08-24 10:52:48 +02:00
3 changed files with 305 additions and 41 deletions
+164 -25
View File
@@ -2,11 +2,14 @@
"""Normalize photo and image filenames from phones and other sources."""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime
from functools import lru_cache
from pathlib import Path
PHONE_PATTERN = re.compile(
@@ -24,27 +27,127 @@ TELEGRAM_PATTERN = re.compile(
NORMALIZED = re.compile(r'^\d{4}-\d{2}-\d{2} .+')
# Filename prefixes whose embedded timestamp is UTC rather than local time.
# The Pixel camera names captures in UTC but records the local wall clock time
# in EXIF, so for these the metadata is authoritative and the filename is not.
# Other sources (Samsung's IMG_/VID_, Telegram exports) already name in local
# time and are left alone.
UTC_FILENAME_PREFIXES = ('PXL',)
# e.g. "PXL_20260917_163001280.MP" -> prefix, timestamp, subseconds, suffix
UTC_STEM_PATTERN = re.compile(
r'^(' + '|'.join(UTC_FILENAME_PREFIXES) + r')[_-]'
r'(\d{8}[_-]\d{6})'
r'(\d*)'
r'(.*)$',
re.IGNORECASE,
)
# Windows hidden-file attribute, mirrored from the stat module.
FILE_ATTRIBUTE_HIDDEN = 0x2
STATUS_RENAME = 'rename'
STATUS_ALREADY = 'skip (already normalized)'
STATUS_NO_MATCH = 'skip (no rule matched)'
STATUS_CONFLICT = 'skip (conflict)'
def get_exif_date(filepath):
"""Try to get the image creation date via exiftool."""
@lru_cache(maxsize=1)
def has_exiftool():
"""Check once whether exiftool is available on PATH."""
return shutil.which('exiftool') is not None
# Capture timestamps keyed by exif_key(path), filled by prefetch_exif().
_exif_cache = {}
def exif_key(filepath):
"""Normalize a path so exiftool's SourceFile and our Path agree."""
return os.path.normcase(os.path.abspath(filepath))
def prefetch_exif(files):
"""Read capture timestamps for all files with a single exiftool call.
Starting exiftool dominates the cost of reading a few tags, so reading
every file in one process is far faster than one process per file.
Paths are passed on stdin to stay clear of command line length limits.
"""
files = [f for f in files if exif_key(f) not in _exif_cache]
if not files or not has_exiftool():
return
# Files without a usable timestamp stay None so they aren't re-queried.
_exif_cache.update((exif_key(f), None) for f in files)
args = [
'-json', '-fast', '-charset', 'filename=utf8',
'-d', '%Y%m%d_%H%M%S',
'-DateTimeOriginal', '-SubSecTimeOriginal',
*(str(f) for f in files),
]
try:
result = subprocess.run(
['exiftool', '-s3', '-d', '%Y-%m-%d', '-DateTimeOriginal', str(filepath)],
['exiftool', '-@', '-'],
input='\n'.join(args),
capture_output=True,
text=True,
timeout=10,
encoding='utf-8',
timeout=60 + len(files),
)
date_str = result.stdout.strip()
if date_str and re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
return date_str
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
records = json.loads(result.stdout or '[]')
except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
return
for record in records:
stamp = str(record.get('DateTimeOriginal', ''))
if not re.match(r'^\d{8}_\d{6}$', stamp):
continue
subsec = str(record.get('SubSecTimeOriginal', ''))
_exif_cache[exif_key(record['SourceFile'])] = (
stamp, subsec if subsec.isdigit() else '',
)
def get_exif_date(filepath):
"""Get the image creation date (YYYY-MM-DD) from EXIF, or None."""
stamp = get_exif_timestamp(filepath)
if stamp is None:
return None
day = stamp[0]
return f'{day[:4]}-{day[4:6]}-{day[6:8]}'
def get_exif_timestamp(filepath):
"""Get the local capture time from EXIF as (YYYYMMDD_HHMMSS, subsec).
DateTimeOriginal is the wall clock time at the moment of capture, so it
needs no timezone correction. Returns None when unavailable.
"""
prefetch_exif([filepath])
return _exif_cache.get(exif_key(filepath))
def resolve_stem(filepath):
"""Return the file's stem, with UTC-named timestamps corrected from EXIF.
Pixel filenames encode UTC, so the name alone is off by the UTC offset in
effect at capture. Falls back to the original stem when the file is not a
UTC-named capture, or when its metadata has no usable timestamp.
"""
stem = filepath.stem
match = UTC_STEM_PATTERN.match(stem)
if not match or not has_exiftool():
return stem
prefix, _, subsec, rest = match.groups()
stamp = get_exif_timestamp(filepath)
if stamp is None:
return stem
exif_stamp, exif_subsec = stamp
return f'{prefix}_{exif_stamp}{exif_subsec or subsec}{rest}'
def get_file_date(filepath):
@@ -56,12 +159,12 @@ def get_file_date(filepath):
def plan_default_rename(filepath):
"""Plan a rename using the default pattern-matching mode."""
stem = filepath.stem
ext = filepath.suffix
if NORMALIZED.match(filepath.name):
return None, STATUS_ALREADY
stem = resolve_stem(filepath)
ext = filepath.suffix
tg_match = TELEGRAM_PATTERN.match(stem)
if tg_match:
number, day, month, year, hour, minute, second = tg_match.groups()
@@ -69,7 +172,7 @@ def plan_default_rename(filepath):
match = PHONE_PATTERN.match(stem)
if not match:
match = PHONE_PATTERN.match(filepath.name)
match = PHONE_PATTERN.match(f'{stem}{ext}')
if match:
year, month, day, rest = match.groups()
new_name = f'{year}-{month}-{day} {rest}'
@@ -88,7 +191,7 @@ def plan_parse_rename(filepath):
return None, STATUS_ALREADY
date_str = None
if shutil.which('exiftool') is not None:
if has_exiftool():
date_str = get_exif_date(filepath)
if not date_str:
@@ -115,14 +218,14 @@ def get_date_for_file(filepath, parse_mode):
"""Resolve a date string for a file, or None if unavailable."""
if parse_mode:
date_str = None
if shutil.which('exiftool') is not None:
if has_exiftool():
date_str = get_exif_date(filepath)
if not date_str:
date_str = get_file_date(filepath)
return date_str
# Default pattern mode: extract date from filename
stem = filepath.stem
stem = resolve_stem(filepath)
# Check if already normalized (e.g. "2024-06-01 breakfast.jpg")
norm_match = re.match(r'^(\d{4})-(\d{2})-(\d{2}) ', filepath.name)
@@ -138,7 +241,7 @@ def get_date_for_file(filepath, parse_mode):
# Try the standard pattern
match = PHONE_PATTERN.match(stem)
if not match:
match = PHONE_PATTERN.match(filepath.name)
match = PHONE_PATTERN.match(f'{stem}{filepath.suffix}')
if match:
year, month, day, _ = match.groups()
return f'{year}-{month}-{day}'
@@ -146,7 +249,19 @@ def get_date_for_file(filepath, parse_mode):
return None
def collect_files(paths):
def is_hidden(filepath):
"""Check whether a file is hidden (dotfile, or hidden attribute on Windows)."""
if filepath.name.startswith('.'):
return True
try:
attrs = filepath.stat().st_file_attributes
except (AttributeError, OSError):
return False
return bool(attrs & FILE_ATTRIBUTE_HIDDEN)
def collect_files(paths, include_hidden=False):
"""Resolve the list of files to process."""
if not paths:
paths = ['.']
@@ -155,8 +270,12 @@ def collect_files(paths):
for path in paths:
candidate = Path(path)
if candidate.is_dir():
files.extend(sorted(file for file in candidate.iterdir() if file.is_file()))
files.extend(sorted(
file for file in candidate.iterdir()
if file.is_file() and (include_hidden or not is_hidden(file))
))
elif candidate.is_file():
# Explicitly named files are always processed.
files.append(candidate)
return files
@@ -272,13 +391,17 @@ def apply_renames(plan):
return count
def confirm(prompt='Apply renames? [y/n] '):
"""Ask the user for confirmation."""
def confirm(prompt='Apply renames? [Y/n] '):
"""Ask the user for confirmation. An empty answer accepts the default (yes)."""
while True:
try:
answer = input(prompt).strip().lower()
if answer == 'y':
except EOFError:
print()
return True
if answer == 'n':
if answer in ('', 'y', 'yes'):
return True
if answer in ('n', 'no'):
return False
@@ -306,6 +429,12 @@ def main():
action='store_true',
help='Force numbering even for already-normalized files (used with -n).',
)
parser.add_argument(
'-a',
'--all',
action='store_true',
help='Include hidden files when scanning directories.',
)
parser.add_argument(
'files',
nargs='*',
@@ -314,11 +443,17 @@ def main():
args = parser.parse_args()
files = collect_files(args.files)
files = collect_files(args.files, args.all)
if not files:
print('No files found.')
sys.exit(0)
prefetch_exif([
f for f in files
if (args.force or not NORMALIZED.match(f.name))
and (args.parse or UTC_STEM_PATTERN.match(f.stem))
])
if args.number:
plan = build_number_plan(files, args.parse, args.force)
else:
@@ -337,4 +472,8 @@ def main():
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print('\nAborted.')
sys.exit(1)
+1
View File
@@ -39,6 +39,7 @@ case $OS in
Fedora\ Linux)
dnf upgrade --refresh;
flatpak update --assumeyes;
dnf needs-restarting -r;
;;
*)
echo "Cannot update system with OS: " $OS
+131 -7
View File
@@ -1,11 +1,11 @@
// ==UserScript==
// @name Inline Reddit images and Comment Redirect
// @namespace https://www.reddit.com
// @version 2025-09-24
// @description Embed images posted as comments and redirect post links to comments
// @version 2026-08-28.1
// @description Embed images and videos posted as comments and redirect post links to comments
// @author luxick
// @updateURL https://git.luxick.de/luxick/scripts/raw/branch/master/reddit-image-inline.user.js
// @downloadURL https://git.luxick.de/luxick/scripts/raw/branch/master/reddit-image-inline.user.js
// @updateURL https://git.luxick.de/luxick/scripts/raw/branch/master/js/reddit-image-inline.user.js
// @downloadURL https://git.luxick.de/luxick/scripts/raw/branch/master/js/reddit-image-inline.user.js
// @match https://old.reddit.com/r/*
// @match https://www.reddit.com/r/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=reddit.com
@@ -15,8 +15,10 @@
(function() {
'use strict';
// Handle image inlining
let xpath = "//a[text()='<image>']";
// Collect the placeholder links Reddit renders for media comments,
// e.g. "<image>" or "<video>" as the link text.
function collectLinks(placeholder) {
let xpath = "//a[text()='" + placeholder + "']";
var element;
var result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
let links = [];
@@ -25,8 +27,123 @@
links.push(element);
}
}
return links;
}
for (var elem of links) {
// Comment media is linked through Reddit's viewer:
// "https://reddit.com/media?url=<media url>"
function unwrapMediaUrl(href) {
let match = href.match(/[?&]url=([^&]+)/);
if (!match) {
return href;
}
try {
return decodeURIComponent(match[1]);
} catch (e) {
return match[1];
}
}
// Video comments point at an HLS playlist, which only Safari plays without
// a library. The same upload is also served as standalone MP4 renditions,
// so use those instead.
//
// Newer uploads name them CMAF_<height>.mp4, older ones DASH_<height>.mp4,
// and no upload has every height. Missing names return 403, so just walk
// the list until one loads. 720 first as a sane default for a 300px embed;
// 1080 last, only worth fetching when nothing smaller exists.
const HEIGHTS = [720, 480, 360, 270, 240, 220, 1080];
const FAMILIES = ['CMAF', 'DASH'];
const AUDIO_NAMES = [
'CMAF_AUDIO_128', 'CMAF_AUDIO_64',
'DASH_AUDIO_128', 'DASH_AUDIO_64', 'DASH_audio',
];
function mediaUrls(id, names) {
return names.map(name => 'https://v.redd.it/' + id + '/' + name + '.mp4');
}
function videoNames() {
let names = [];
for (let family of FAMILIES) {
for (let height of HEIGHTS) {
names.push(family + '_' + height);
}
}
return names;
}
function vredditId(url) {
let match = url.match(/^https?:\/\/v\.redd\.it\/([A-Za-z0-9]+)/i);
return match ? match[1] : null;
}
// Move to the next candidate URL whenever the current one fails to load.
function chainSources(media, sources, onExhausted) {
let index = 0;
media.src = sources[0];
media.addEventListener('error', function() {
index += 1;
if (index < sources.length) {
media.src = sources[index];
media.load();
} else if (onExhausted) {
onExhausted();
}
});
}
// The MP4 renditions carry the video track only; the audio is a separate
// file that has to be kept in step with the video element by hand.
function buildAudio(video, id) {
let audio = document.createElement('audio');
audio.preload = 'metadata';
chainSources(audio, mediaUrls(id, AUDIO_NAMES));
video.addEventListener('play', function() {
audio.currentTime = video.currentTime;
audio.play().catch(() => {});
});
video.addEventListener('pause', () => audio.pause());
video.addEventListener('seeking', () => { audio.currentTime = video.currentTime; });
video.addEventListener('ratechange', () => { audio.playbackRate = video.playbackRate; });
video.addEventListener('volumechange', function() {
audio.volume = video.volume;
audio.muted = video.muted;
});
return audio;
}
function buildVideo(href) {
let container = document.createElement('span');
let mediaUrl = unwrapMediaUrl(href);
let id = vredditId(mediaUrl);
let video = document.createElement('video');
video.controls = true;
video.preload = 'metadata';
video.style = "max-width: 300px";
let sources = id ? mediaUrls(id, videoNames()) : [mediaUrl];
chainSources(video, sources, function() {
console.log("no playable source for " + href);
let anchor = document.createElement('a');
anchor.href = href;
anchor.target = '_blank';
anchor.textContent = '<video>';
container.replaceChildren(anchor);
});
container.appendChild(video);
if (id) {
container.appendChild(buildAudio(video, id));
}
return container;
}
// Handle image inlining
for (var elem of collectLinks('<image>')) {
console.log("inlining image " + elem.href);
let image = new Image();
image.src = elem.href;
@@ -45,6 +162,13 @@
elem.remove();
}
// Handle video inlining
for (var videoElem of collectLinks('<video>')) {
console.log("inlining video " + videoElem.href);
videoElem.parentElement.appendChild(buildVideo(videoElem.href));
videoElem.remove();
}
// Only proceed with link rewriting if we're not already in a comments section
if (!window.location.pathname.includes('/comments/')) {
// Handle post link redirection