Repair Archive download datetimes misread from a SingleFile saved date

Archives parsed before the offset fix keep the shifted download_datetime, because the parser only fills
an empty value.  A one-time background repair (gated by a wrolpi_kv version, after the first refresh)
re-reads each Archive's saved date and corrects a download_datetime that equals the offset-dropped value.
Any other download_datetime is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-10-23 13:28:46 -06:00
parent 3c07704b9b
commit afd8f157c6
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
4 changed files with 126 additions and 11 deletions

14
main.py
View file

@ -423,6 +423,20 @@ async def perpetual_start_video_missing_comments_download():
logger.debug('Waiting for downloads to be enabled before downloading comments...')
@perpetual_signal(sleep=3600)
async def perpetual_repair_archive_saved_dates():
"""Correct Archive download datetimes that were misread from their SingleFile saved date. Runs once."""
async with flags.refresh_complete.wait_for():
# Archives must be in the database.
pass
from modules.archive.lib import repair_singlefile_saved_dates
try:
await asyncio.to_thread(repair_singlefile_saved_dates)
except Exception as e:
logger.error('Failed to repair Archive download datetimes', exc_info=e)
@perpetual_signal(sleep=3600) # Check hourly
async def perpetual_check_for_updates():
"""

View file

@ -19,20 +19,20 @@ import pytz
from sqlalchemy import asc, func, BigInteger
from sqlalchemy.orm import Session
from modules.archive.models import Archive
from modules.archive.models import Archive, MATCH_DATE, parse_singlefile_saved_date
from wrolpi import dates
from wrolpi import fts
from wrolpi.cmd import READABILITY_BIN, run_command
from wrolpi.collections import Collection
from wrolpi.common import get_media_directory, get_relative_to_media_directory, logger, extract_domain, \
escape_file_name, aiohttp_post, format_html_string, split_lines_by_length, get_html_soup, get_title_from_html, \
get_wrolpi_config, html_screenshot, html_file_screenshot, ConfigFile, trim_file_name
get_wrolpi_config, html_screenshot, html_file_screenshot, ConfigFile, trim_file_name, chunks
from wrolpi.dates import now, Seconds
from wrolpi.db import get_db_session, get_db_curs, session_curs
from wrolpi.errors import UnknownArchive, InvalidOrderBy, InvalidDatetime
from wrolpi.events import Events
from wrolpi.files.lib import handle_file_group_search_results, cached_search_total, \
search_filter_cache_key, count_file_groups
search_filter_cache_key, count_file_groups, get_kv_value, set_kv_value
from wrolpi.files.models import FileGroup
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
from wrolpi.tags import tag_append_sub_select_where
@ -1849,3 +1849,52 @@ async def generate_archive_screenshot(archive_id: int) -> pathlib.Path:
session.flush()
return screenshot_path
# Bump to run `repair_singlefile_saved_dates` again.
SAVED_DATE_REPAIR_VERSION = '1'
SAVED_DATE_REPAIR_KEY = 'archive_saved_date_repair_version'
def repair_singlefile_saved_dates() -> int:
"""Correct each Archive whose download_datetime is its SingleFile "saved date" local time read as UTC.
The saved date was once parsed without its offset. Only a download_datetime equal to that misread value, from a
saved date with a non-zero offset, is changed; any other download_datetime is never touched. Runs once per
SAVED_DATE_REPAIR_VERSION. Returns the number of Archives corrected."""
if get_kv_value(SAVED_DATE_REPAIR_KEY) == SAVED_DATE_REPAIR_VERSION:
return 0
with get_db_session() as session:
rows = session.query(FileGroup.id, FileGroup.primary_path, FileGroup.download_datetime) \
.join(Archive, Archive.file_group_id == FileGroup.id) \
.filter(FileGroup.download_datetime.isnot(None)) \
.all()
corrections = dict()
for file_group_id, path, download_datetime in rows:
try:
with open(path, 'rb') as fh:
head = fh.read(1000).decode(errors='ignore')
except OSError:
continue
if not (match := MATCH_DATE.search(head)):
continue
saved_date = match.group(1).strip()
try:
corrected = parse_singlefile_saved_date(saved_date)
# The value the offset-dropping parser stored.
misread = dates.strpdate(' '.join(saved_date.split(' ')[:5])).replace(tzinfo=pytz.timezone('GMT'))
except Exception:
continue
if corrected != misread and download_datetime == misread:
corrections[file_group_id] = corrected
for chunk in chunks(list(corrections.items()), 500):
with get_db_session(commit=True) as session:
for file_group in session.query(FileGroup).filter(FileGroup.id.in_([i for i, _ in chunk])):
file_group.download_datetime = corrections[file_group.id]
set_kv_value(SAVED_DATE_REPAIR_KEY, SAVED_DATE_REPAIR_VERSION)
logger.info(f'Corrected the download datetime of {len(corrections)} Archives from their SingleFile saved date')
return len(corrections)

View file

@ -656,6 +656,48 @@ async def test_singlefile_saved_date_timezone(test_session, make_files_structure
assert archive.file_group.download_datetime == expected.replace(tzinfo=timezone.utc)
@pytest.mark.asyncio
async def test_repair_singlefile_saved_dates(test_session, make_files_structure, refresh_files):
"""An Archive whose download_datetime is its SingleFile saved date's local time read as UTC is corrected, once.
Any other download_datetime is never changed."""
mountain = 'Thu Nov 07 2024 18:36:23 GMT-0700 (Mountain Standard Time)'
make_files_structure({
f'archive/example.com/{name}.html': f'''<!DOCTYPE html> <html lang="en"><!--
Page saved with SingleFile
url: https://example.com/{name}
saved date: {saved_date}
--><head><meta charset="utf-8"></head></html>'''
for name, saved_date in (('misread', mountain), ('downloaded', mountain),
('utc', 'Mon May 16 2022 23:51:35 GMT+0000 (Coordinated Universal Time)'))
})
await refresh_files()
def file_groups() -> dict:
test_session.expire_all()
return {i.file_group.primary_path.stem: i.file_group for i in test_session.query(Archive)}
# What the offset-dropping parser stored, and a time that came from elsewhere (e.g. WROLPi's own download).
misread = datetime(2024, 11, 7, 18, 36, 23, tzinfo=timezone.utc)
downloaded = datetime(2024, 11, 8, 1, 36, 50, tzinfo=timezone.utc)
fgs = file_groups()
fgs['misread'].download_datetime = misread
fgs['downloaded'].download_datetime = downloaded
test_session.commit()
assert lib.repair_singlefile_saved_dates() == 1
fgs = file_groups()
assert fgs['misread'].download_datetime == datetime(2024, 11, 8, 1, 36, 23, tzinfo=timezone.utc)
assert fgs['downloaded'].download_datetime == downloaded
assert fgs['utc'].download_datetime == datetime(2022, 5, 16, 23, 51, 35, tzinfo=timezone.utc)
# The repair is done; it does not run again.
fgs['misread'].download_datetime = misread
test_session.commit()
assert lib.repair_singlefile_saved_dates() == 0
assert file_groups()['misread'].download_datetime == misread
@pytest.mark.asyncio
async def test_archive_meta(async_client, test_session, make_files_structure, refresh_files):
# The start of a typical singlefile html file.

View file

@ -17,7 +17,7 @@ import urllib.parse
import zipfile
from itertools import zip_longest
from pathlib import Path
from typing import Callable, List, Tuple, Union, Dict, Generator, Iterable, Set
from typing import Callable, List, Optional, Tuple, Union, Dict, Generator, Iterable, Set
import cachetools.func
from sqlalchemy import asc, or_, text as sa_text
@ -59,7 +59,7 @@ __all__ = ['list_directories_contents', 'delete', 'split_path_stem_and_suffix',
'get_tagged_file_groups_by_ids', 'delete_file_groups', 'get_special_directories',
'get_normalized_ignored_directories', 'sanitize_ignored_directories',
'cached_search_total', 'search_filter_cache_key', 'count_file_groups',
'STEM_ALGORITHM_VERSION', 'ensure_file_group_stems',
'STEM_ALGORITHM_VERSION', 'ensure_file_group_stems', 'get_kv_value', 'set_kv_value',
'backfill_null_file_group_stems', 'dedupe_file_groups_by_directory_stem', 'merge_file_group_relations',
'merge_into_surviving_duplicates']
@ -913,25 +913,35 @@ def _ensure_kv_table(curs):
curs.execute('CREATE TABLE IF NOT EXISTS wrolpi_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
def get_stem_algorithm_version() -> int:
"""The grouping-algorithm version last applied to file_group.stem, or 0 if never stored."""
def get_kv_value(key: str) -> Optional[str]:
"""The value stored in wrolpi_kv for `key`, or None."""
with get_db_curs() as curs:
_ensure_kv_table(curs)
curs.execute('SELECT value FROM wrolpi_kv WHERE key = ?', (STEM_ALGORITHM_VERSION_KEY,))
curs.execute('SELECT value FROM wrolpi_kv WHERE key = ?', (key,))
row = curs.fetchone()
return int(row['value']) if row else 0
return row['value'] if row else None
def set_stem_algorithm_version(version: int):
def set_kv_value(key: str, value: str):
with get_db_curs(commit=True) as curs:
_ensure_kv_table(curs)
curs.execute(
'INSERT INTO wrolpi_kv (key, value) VALUES (?, ?) '
'ON CONFLICT(key) DO UPDATE SET value = excluded.value',
(STEM_ALGORITHM_VERSION_KEY, str(version)),
(key, value),
)
def get_stem_algorithm_version() -> int:
"""The grouping-algorithm version last applied to file_group.stem, or 0 if never stored."""
value = get_kv_value(STEM_ALGORITHM_VERSION_KEY)
return int(value) if value else 0
def set_stem_algorithm_version(version: int):
set_kv_value(STEM_ALGORITHM_VERSION_KEY, str(version))
def ensure_file_group_stems():
"""Recompute file_group.stem when STEM_ALGORITHM_VERSION has changed.