Compare commits

...
8 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
3 changed files with 275 additions and 52 deletions
+135 -37
View File
@@ -2,13 +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
import termios
import tty
from datetime import datetime
from functools import lru_cache
from pathlib import Path
PHONE_PATTERN = re.compile(
@@ -26,6 +27,22 @@ 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
@@ -35,21 +52,102 @@ 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):
@@ -61,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()
@@ -74,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}'
@@ -93,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:
@@ -120,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)
@@ -143,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}'
@@ -293,24 +391,18 @@ def apply_renames(plan):
return count
def confirm(prompt='Apply renames? [y/n] '):
"""Ask the user for confirmation, reacting to a single keypress."""
print(prompt, end='', flush=True)
if not sys.stdin.isatty():
return sys.stdin.read(1).strip().lower() == 'y'
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
def confirm(prompt='Apply renames? [Y/n] '):
"""Ask the user for confirmation. An empty answer accepts the default (yes)."""
while True:
try:
# cbreak (not raw) keeps Ctrl-C as SIGINT and newlines well behaved.
tty.setcbreak(fd)
while (key := sys.stdin.read(1).lower()) not in ('y', 'n', ''):
pass
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
print(key)
return key == 'y'
answer = input(prompt).strip().lower()
except EOFError:
print()
return True
if answer in ('', 'y', 'yes'):
return True
if answer in ('n', 'no'):
return False
def main():
@@ -356,6 +448,12 @@ def main():
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:
+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