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
+137 -39
View File
@@ -2,13 +2,14 @@
"""Normalize photo and image filenames from phones and other sources.""" """Normalize photo and image filenames from phones and other sources."""
import argparse import argparse
import json
import os
import re import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import termios
import tty
from datetime import datetime from datetime import datetime
from functools import lru_cache
from pathlib import Path from pathlib import Path
PHONE_PATTERN = re.compile( PHONE_PATTERN = re.compile(
@@ -26,6 +27,22 @@ TELEGRAM_PATTERN = re.compile(
NORMALIZED = re.compile(r'^\d{4}-\d{2}-\d{2} .+') 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. # Windows hidden-file attribute, mirrored from the stat module.
FILE_ATTRIBUTE_HIDDEN = 0x2 FILE_ATTRIBUTE_HIDDEN = 0x2
@@ -35,21 +52,102 @@ STATUS_NO_MATCH = 'skip (no rule matched)'
STATUS_CONFLICT = 'skip (conflict)' STATUS_CONFLICT = 'skip (conflict)'
def get_exif_date(filepath): @lru_cache(maxsize=1)
"""Try to get the image creation date via exiftool.""" 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: try:
result = subprocess.run( result = subprocess.run(
['exiftool', '-s3', '-d', '%Y-%m-%d', '-DateTimeOriginal', str(filepath)], ['exiftool', '-@', '-'],
input='\n'.join(args),
capture_output=True, capture_output=True,
text=True, encoding='utf-8',
timeout=10, timeout=60 + len(files),
) )
date_str = result.stdout.strip() records = json.loads(result.stdout or '[]')
if date_str and re.match(r'^\d{4}-\d{2}-\d{2}$', date_str): except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
return date_str return
except (FileNotFoundError, subprocess.TimeoutExpired):
pass for record in records:
return None 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): def get_file_date(filepath):
@@ -61,12 +159,12 @@ def get_file_date(filepath):
def plan_default_rename(filepath): def plan_default_rename(filepath):
"""Plan a rename using the default pattern-matching mode.""" """Plan a rename using the default pattern-matching mode."""
stem = filepath.stem
ext = filepath.suffix
if NORMALIZED.match(filepath.name): if NORMALIZED.match(filepath.name):
return None, STATUS_ALREADY return None, STATUS_ALREADY
stem = resolve_stem(filepath)
ext = filepath.suffix
tg_match = TELEGRAM_PATTERN.match(stem) tg_match = TELEGRAM_PATTERN.match(stem)
if tg_match: if tg_match:
number, day, month, year, hour, minute, second = tg_match.groups() number, day, month, year, hour, minute, second = tg_match.groups()
@@ -74,7 +172,7 @@ def plan_default_rename(filepath):
match = PHONE_PATTERN.match(stem) match = PHONE_PATTERN.match(stem)
if not match: if not match:
match = PHONE_PATTERN.match(filepath.name) match = PHONE_PATTERN.match(f'{stem}{ext}')
if match: if match:
year, month, day, rest = match.groups() year, month, day, rest = match.groups()
new_name = f'{year}-{month}-{day} {rest}' new_name = f'{year}-{month}-{day} {rest}'
@@ -93,7 +191,7 @@ def plan_parse_rename(filepath):
return None, STATUS_ALREADY return None, STATUS_ALREADY
date_str = None date_str = None
if shutil.which('exiftool') is not None: if has_exiftool():
date_str = get_exif_date(filepath) date_str = get_exif_date(filepath)
if not date_str: 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.""" """Resolve a date string for a file, or None if unavailable."""
if parse_mode: if parse_mode:
date_str = None date_str = None
if shutil.which('exiftool') is not None: if has_exiftool():
date_str = get_exif_date(filepath) date_str = get_exif_date(filepath)
if not date_str: if not date_str:
date_str = get_file_date(filepath) date_str = get_file_date(filepath)
return date_str return date_str
# Default pattern mode: extract date from filename # 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") # Check if already normalized (e.g. "2024-06-01 breakfast.jpg")
norm_match = re.match(r'^(\d{4})-(\d{2})-(\d{2}) ', filepath.name) 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 # Try the standard pattern
match = PHONE_PATTERN.match(stem) match = PHONE_PATTERN.match(stem)
if not match: if not match:
match = PHONE_PATTERN.match(filepath.name) match = PHONE_PATTERN.match(f'{stem}{filepath.suffix}')
if match: if match:
year, month, day, _ = match.groups() year, month, day, _ = match.groups()
return f'{year}-{month}-{day}' return f'{year}-{month}-{day}'
@@ -293,24 +391,18 @@ def apply_renames(plan):
return count return count
def confirm(prompt='Apply renames? [y/n] '): def confirm(prompt='Apply renames? [Y/n] '):
"""Ask the user for confirmation, reacting to a single keypress.""" """Ask the user for confirmation. An empty answer accepts the default (yes)."""
print(prompt, end='', flush=True) while True:
if not sys.stdin.isatty(): try:
return sys.stdin.read(1).strip().lower() == 'y' answer = input(prompt).strip().lower()
except EOFError:
fd = sys.stdin.fileno() print()
old_settings = termios.tcgetattr(fd) return True
try: if answer in ('', 'y', 'yes'):
# cbreak (not raw) keeps Ctrl-C as SIGINT and newlines well behaved. return True
tty.setcbreak(fd) if answer in ('n', 'no'):
while (key := sys.stdin.read(1).lower()) not in ('y', 'n', ''): return False
pass
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
print(key)
return key == 'y'
def main(): def main():
@@ -356,6 +448,12 @@ def main():
print('No files found.') print('No files found.')
sys.exit(0) 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: if args.number:
plan = build_number_plan(files, args.parse, args.force) plan = build_number_plan(files, args.parse, args.force)
else: else:
+1
View File
@@ -39,6 +39,7 @@ case $OS in
Fedora\ Linux) Fedora\ Linux)
dnf upgrade --refresh; dnf upgrade --refresh;
flatpak update --assumeyes; flatpak update --assumeyes;
dnf needs-restarting -r;
;; ;;
*) *)
echo "Cannot update system with OS: " $OS echo "Cannot update system with OS: " $OS
+137 -13
View File
@@ -1,11 +1,11 @@
// ==UserScript== // ==UserScript==
// @name Inline Reddit images and Comment Redirect // @name Inline Reddit images and Comment Redirect
// @namespace https://www.reddit.com // @namespace https://www.reddit.com
// @version 2025-09-24 // @version 2026-08-28.1
// @description Embed images posted as comments and redirect post links to comments // @description Embed images and videos posted as comments and redirect post links to comments
// @author luxick // @author luxick
// @updateURL 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/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://old.reddit.com/r/*
// @match https://www.reddit.com/r/* // @match https://www.reddit.com/r/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=reddit.com // @icon https://www.google.com/s2/favicons?sz=64&domain=reddit.com
@@ -15,18 +15,135 @@
(function() { (function() {
'use strict'; 'use strict';
// Handle image inlining // Collect the placeholder links Reddit renders for media comments,
let xpath = "//a[text()='<image>']"; // e.g. "<image>" or "<video>" as the link text.
var element; function collectLinks(placeholder) {
var result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); let xpath = "//a[text()='" + placeholder + "']";
let links = []; var element;
while(element = result.iterateNext()){ var result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
if (element.href) { let links = [];
links.push(element); while(element = result.iterateNext()){
if (element.href) {
links.push(element);
}
}
return 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];
} }
} }
for (var elem of links) { // 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); console.log("inlining image " + elem.href);
let image = new Image(); let image = new Image();
image.src = elem.href; image.src = elem.href;
@@ -45,6 +162,13 @@
elem.remove(); 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 // Only proceed with link rewriting if we're not already in a comments section
if (!window.location.pathname.includes('/comments/')) { if (!window.location.pathname.includes('/comments/')) {
// Handle post link redirection // Handle post link redirection