From 728413affd46741281d51647794255394d0c5335 Mon Sep 17 00:00:00 2001 From: e1ven <401330+e1ven@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:09:52 -0400 Subject: [PATCH] Try to preserve time --- src/ytdl_sub/config/config_validator.py | 13 +++++++++++ .../subscriptions/base_subscription.py | 3 +++ src/ytdl_sub/utils/file_handler.py | 23 ++++++++++++++++++- .../enhanced_download_archive.py | 19 +++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/ytdl_sub/config/config_validator.py b/src/ytdl_sub/config/config_validator.py index b5d234a9..48517ba0 100644 --- a/src/ytdl_sub/config/config_validator.py +++ b/src/ytdl_sub/config/config_validator.py @@ -112,6 +112,7 @@ class ConfigOptions(StrictDictValidator): "ffprobe_path", "file_name_max_bytes", "experimental", + "preserve_mtime", } def __init__(self, name: str, value: Any): @@ -146,6 +147,9 @@ class ConfigOptions(StrictDictValidator): self._file_name_max_bytes = self._validate_key( key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES ) + self._preserve_mtime = self._validate_key( + key="preserve_mtime", validator=BoolValidator, default=False + ) @property def working_directory(self) -> str: @@ -240,6 +244,15 @@ class ConfigOptions(StrictDictValidator): """ return self._ffprobe_path.value + @property + def preserve_mtime(self) -> bool: + """ + Preserve the video's original upload time as the file modification time. + When True, sets the file's mtime to match the video's upload_date from + yt-dlp metadata. (default ``False``) + """ + return self._preserve_mtime.value + class ConfigValidator(StrictDictValidator): _optional_keys = {"configuration", "presets"} diff --git a/src/ytdl_sub/subscriptions/base_subscription.py b/src/ytdl_sub/subscriptions/base_subscription.py index 970f142d..fa20118d 100644 --- a/src/ytdl_sub/subscriptions/base_subscription.py +++ b/src/ytdl_sub/subscriptions/base_subscription.py @@ -22,6 +22,7 @@ def _initialize_download_archive( overrides: Overrides, working_directory: str, output_directory: str, + config_options: ConfigOptions, ) -> EnhancedDownloadArchive: migrated_file_name: Optional[str] = None if migrated_file_name_option := output_options.migrated_download_archive_name: @@ -32,6 +33,7 @@ def _initialize_download_archive( working_directory=working_directory, output_directory=output_directory, migrated_file_name=migrated_file_name, + preserve_mtime=config_options.preserve_mtime, ).reinitialize(dry_run=True) @@ -80,6 +82,7 @@ class BaseSubscription(ABC): overrides=self.overrides, working_directory=self.working_directory, output_directory=self.output_directory, + config_options=self._config_options, ) ) diff --git a/src/ytdl_sub/utils/file_handler.py b/src/ytdl_sub/utils/file_handler.py index f7c6e44f..eeef3e17 100644 --- a/src/ytdl_sub/utils/file_handler.py +++ b/src/ytdl_sub/utils/file_handler.py @@ -2,7 +2,9 @@ import hashlib import json import os import shutil +import time from collections import defaultdict +from datetime import datetime from pathlib import Path from typing import Any from typing import Dict @@ -392,7 +394,7 @@ class FileHandler: # Perform the copy by first writing to a temp file, then moving it. # This tries to prevent corrupted writes if the processed dies mid-write, atomic_dst = f"{dst_file_path}-ytdl-sub-incomplete" - shutil.copyfile(src=src_file_path, dst=atomic_dst) + shutil.copy2(src=src_file_path, dst=atomic_dst) shutil.move(src=atomic_dst, dst=dst_file_path) @classmethod @@ -430,6 +432,25 @@ class FileHandler: if os.path.isfile(file_path): os.remove(file_path) + @classmethod + def set_mtime(cls, file_path: Union[str, Path], mtime: float): + """ + Set the modification time of a file + + Parameters + ---------- + file_path + Path to the file to modify + mtime + Modification time as a Unix timestamp + """ + try: + # Set both access time and modification time + os.utime(file_path, (mtime, mtime)) + except OSError: + # If file operation fails, silently continue + pass + def move_file_to_output_directory( self, file_name: str, diff --git a/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py b/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py index 7a565412..c2b821eb 100644 --- a/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py +++ b/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py @@ -1,6 +1,7 @@ import copy import json import os.path +import time from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -397,6 +398,7 @@ class EnhancedDownloadArchive: output_directory: str, dry_run: bool = False, migrated_file_name: Optional[str] = None, + preserve_mtime: bool = False, ): self._file_name = file_name self._file_handler = FileHandler( @@ -404,6 +406,7 @@ class EnhancedDownloadArchive: ) self._download_mapping = DownloadMappings() # gets reinitialized self._migrated_file_name = migrated_file_name + self._preserve_mtime = preserve_mtime self.num_entries_added: int = 0 self.num_entries_modified: int = 0 @@ -674,6 +677,22 @@ class EnhancedDownloadArchive: copy_file=copy_file, ) + # Set mtime if preserve_mtime is enabled and we have an entry with upload_date + if self._preserve_mtime and entry and not self._file_handler.dry_run: + upload_date = entry.get(v.upload_date, str) + if upload_date: + try: + # Convert YYYYMMDD to timestamp + upload_datetime = datetime.strptime(upload_date, "%Y%m%d") + upload_timestamp = time.mktime(upload_datetime.timetuple()) + + # Set mtime on the output file + output_file_path = Path(self._file_handler.output_directory) / output_file_name + FileHandler.set_mtime(output_file_path, upload_timestamp) + except (ValueError, OSError): + # If date parsing or file operation fails, silently continue + pass + # Determine if it's the entry file by seeing if the file_name to move matches the entry # download file name is_entry_file = entry and entry.get_download_file_name() == file_name