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/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/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 f7c6e44f..cdf9c099 100644 --- a/src/ytdl_sub/utils/file_handler.py +++ b/src/ytdl_sub/utils/file_handler.py @@ -430,6 +430,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..50b12f42 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 @@ -642,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 @@ -660,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 @@ -674,6 +678,22 @@ class EnhancedDownloadArchive: copy_file=copy_file, ) + # Set mtime if preserve_mtime is enabled and we have an entry with upload_date + if preserve_mtime and entry and not self._file_handler.dry_run: + upload_date = entry.get(v.ytdl_sub_keep_files_date_eval, str) + if upload_date: + try: + # Convert YYYY-mm-dd 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 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"] )