From 5599c912d9bac741ea62b42d62964d27c38ddaa1 Mon Sep 17 00:00:00 2001 From: e1ven <401330+e1ven@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:04:21 -0400 Subject: [PATCH] move preserve_mtime to output_options --- docs/source/config_reference/plugins.rst | 9 +++++++++ src/ytdl_sub/__init__.py | 4 ++-- src/ytdl_sub/config/config_validator.py | 13 ------------ src/ytdl_sub/config/preset_options.py | 16 +++++++++++++++ .../subscriptions/base_subscription.py | 3 --- .../subscriptions/subscription_download.py | 3 +++ src/ytdl_sub/utils/file_handler.py | 2 +- .../enhanced_download_archive.py | 7 ++++--- tests/unit/config/test_preset.py | 20 +++++++++++++++++++ 9 files changed, 55 insertions(+), 22 deletions(-) diff --git a/docs/source/config_reference/plugins.rst b/docs/source/config_reference/plugins.rst index 74fbe988..6b8229e4 100644 --- a/docs/source/config_reference/plugins.rst +++ b/docs/source/config_reference/plugins.rst @@ -763,6 +763,15 @@ Defines where to output files and thumbnails after all post-processing has compl The output directory to store all media files downloaded. +``preserve_mtime`` + +:expected type: Optional[Boolean] +:description: + 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. Defaults to False. + + ``thumbnail_name`` :expected type: Optional[EntryFormatter] diff --git a/src/ytdl_sub/__init__.py b/src/ytdl_sub/__init__.py index a1ae89f9..b450125c 100644 --- a/src/ytdl_sub/__init__.py +++ b/src/ytdl_sub/__init__.py @@ -1,2 +1,2 @@ -__pypi_version__ = "2023.10.22.post3" -__local_version__ = "2023.10.22+bfba4f0" +__pypi_version__ = "2025.11.14" +__local_version__ = "2025.11.14+c4b7989" diff --git a/src/ytdl_sub/config/config_validator.py b/src/ytdl_sub/config/config_validator.py index 48517ba0..b5d234a9 100644 --- a/src/ytdl_sub/config/config_validator.py +++ b/src/ytdl_sub/config/config_validator.py @@ -112,7 +112,6 @@ class ConfigOptions(StrictDictValidator): "ffprobe_path", "file_name_max_bytes", "experimental", - "preserve_mtime", } def __init__(self, name: str, value: Any): @@ -147,9 +146,6 @@ 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: @@ -244,15 +240,6 @@ 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/config/preset_options.py b/src/ytdl_sub/config/preset_options.py index a16476ed..2f9e75df 100644 --- a/src/ytdl_sub/config/preset_options.py +++ b/src/ytdl_sub/config/preset_options.py @@ -107,6 +107,7 @@ class OutputOptions(OptionsDictValidator): "keep_max_files", "download_archive_standardized_date", "keep_files_date_eval", + "preserve_mtime", } @classmethod @@ -170,6 +171,10 @@ class OutputOptions(OptionsDictValidator): default=f"{{{v.upload_date_standardized.variable_name}}}", ) + self._preserve_mtime = self._validate_key_if_present( + key="preserve_mtime", validator=BoolValidator, default=False + ) + if ( self._keep_files_before or self._keep_files_after or self._keep_max_files ) and not self.maintain_download_archive: @@ -309,6 +314,17 @@ class OutputOptions(OptionsDictValidator): """ return self._keep_max_files + @property + def preserve_mtime(self) -> bool: + """ + :expected type: Optional[Boolean] + :description: + 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. Defaults to False. + """ + return self._preserve_mtime.value + def added_variables(self, unresolved_variables: Set[str]) -> Dict[PluginOperation, Set[str]]: return { # PluginOperation.MODIFY_ENTRY_METADATA: { diff --git a/src/ytdl_sub/subscriptions/base_subscription.py b/src/ytdl_sub/subscriptions/base_subscription.py index fa20118d..970f142d 100644 --- a/src/ytdl_sub/subscriptions/base_subscription.py +++ b/src/ytdl_sub/subscriptions/base_subscription.py @@ -22,7 +22,6 @@ 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: @@ -33,7 +32,6 @@ 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) @@ -82,7 +80,6 @@ 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/subscriptions/subscription_download.py b/src/ytdl_sub/subscriptions/subscription_download.py index 43b811a4..c9a8b6f7 100644 --- a/src/ytdl_sub/subscriptions/subscription_download.py +++ b/src/ytdl_sub/subscriptions/subscription_download.py @@ -73,6 +73,7 @@ class SubscriptionDownload(BaseSubscription, ABC): file_metadata=entry_metadata, output_file_name=output_file_name, entry=entry, + preserve_mtime=self.output_options.preserve_mtime, ) # Always pretend to include the thumbnail in a dry-run @@ -87,6 +88,7 @@ class SubscriptionDownload(BaseSubscription, ABC): output_file_name=output_thumbnail_name, entry=entry, copy_file=True, + preserve_mtime=self.output_options.preserve_mtime, ) elif not entry.is_thumbnail_downloaded(): logger.warning( @@ -106,6 +108,7 @@ class SubscriptionDownload(BaseSubscription, ABC): file_name=entry.get_download_info_json_name(), output_file_name=output_info_json_name, entry=entry, + preserve_mtime=self.output_options.preserve_mtime, ) def _delete_working_directory(self, is_error: bool = False) -> None: diff --git a/src/ytdl_sub/utils/file_handler.py b/src/ytdl_sub/utils/file_handler.py index 9089801e..cdf9c099 100644 --- a/src/ytdl_sub/utils/file_handler.py +++ b/src/ytdl_sub/utils/file_handler.py @@ -392,7 +392,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.copy2(src=src_file_path, dst=atomic_dst) + shutil.copyfile(src=src_file_path, dst=atomic_dst) shutil.move(src=atomic_dst, dst=dst_file_path) @classmethod diff --git a/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py b/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py index c2b821eb..8177e0e4 100644 --- a/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py +++ b/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py @@ -398,7 +398,6 @@ 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( @@ -406,7 +405,6 @@ 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 @@ -645,6 +643,7 @@ class EnhancedDownloadArchive: output_file_name: Optional[str] = None, entry: Optional[Entry] = None, copy_file: bool = False, + preserve_mtime: bool = False, ): """ Saves a file from the working directory to the output directory and record it in the @@ -663,6 +662,8 @@ class EnhancedDownloadArchive: Optional. Entry that this file belongs to copy_file Optional. If True, copy the file. Move otherwise + preserve_mtime + Optional. If True and entry has upload_date, set file mtime to upload date """ if output_file_name is None: output_file_name = file_name @@ -678,7 +679,7 @@ class EnhancedDownloadArchive: ) # 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: + if preserve_mtime and entry and not self._file_handler.dry_run: upload_date = entry.get(v.upload_date, str) if upload_date: try: diff --git a/tests/unit/config/test_preset.py b/tests/unit/config/test_preset.py index cb3c0b81..939eab92 100644 --- a/tests/unit/config/test_preset.py +++ b/tests/unit/config/test_preset.py @@ -120,6 +120,26 @@ class TestPreset: == "today-2months" ) + def test_preset_preserve_mtime_option(self, config_file, youtube_video, output_options): + # Test preserve_mtime defaults to False + preset_default = Preset( + config=config_file, + name="test_default", + value={"download": youtube_video, "output_options": output_options}, + ) + assert preset_default.output_options.preserve_mtime is False + + # Test preserve_mtime can be set to True + preset_enabled = Preset( + config=config_file, + name="test_enabled", + value={ + "download": youtube_video, + "output_options": dict(output_options, **{"preserve_mtime": True}), + }, + ) + assert preset_enabled.output_options.preserve_mtime is True + @pytest.mark.parametrize( "parent_preset", ["preset_self_loop", "preset_loop_0", "preset_loop_1"] )