[RFC] Add preserve_mtime config (#1382)

Closes https://github.com/jmbannon/ytdl-sub/issues/386

Adds the ability to preserve mtime via Output Options. Usage:

```
output_options:
  preserve_mtime: True
```

Thanks @e1ven for the contribution!
This commit is contained in:
Colin Davis 2025-11-18 06:42:13 +00:00 committed by GitHub
parent 08180c0412
commit 63753c5649
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 87 additions and 0 deletions

View file

@ -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. 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`` ``thumbnail_name``
:expected type: Optional[EntryFormatter] :expected type: Optional[EntryFormatter]

View file

@ -107,6 +107,7 @@ class OutputOptions(OptionsDictValidator):
"keep_max_files", "keep_max_files",
"download_archive_standardized_date", "download_archive_standardized_date",
"keep_files_date_eval", "keep_files_date_eval",
"preserve_mtime",
} }
@classmethod @classmethod
@ -170,6 +171,10 @@ class OutputOptions(OptionsDictValidator):
default=f"{{{v.upload_date_standardized.variable_name}}}", default=f"{{{v.upload_date_standardized.variable_name}}}",
) )
self._preserve_mtime = self._validate_key_if_present(
key="preserve_mtime", validator=BoolValidator, default=False
)
if ( if (
self._keep_files_before or self._keep_files_after or self._keep_max_files self._keep_files_before or self._keep_files_after or self._keep_max_files
) and not self.maintain_download_archive: ) and not self.maintain_download_archive:
@ -309,6 +314,17 @@ class OutputOptions(OptionsDictValidator):
""" """
return self._keep_max_files 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]]: def added_variables(self, unresolved_variables: Set[str]) -> Dict[PluginOperation, Set[str]]:
return { return {
# PluginOperation.MODIFY_ENTRY_METADATA: { # PluginOperation.MODIFY_ENTRY_METADATA: {

View file

@ -73,6 +73,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
file_metadata=entry_metadata, file_metadata=entry_metadata,
output_file_name=output_file_name, output_file_name=output_file_name,
entry=entry, entry=entry,
preserve_mtime=self.output_options.preserve_mtime,
) )
# Always pretend to include the thumbnail in a dry-run # Always pretend to include the thumbnail in a dry-run
@ -87,6 +88,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
output_file_name=output_thumbnail_name, output_file_name=output_thumbnail_name,
entry=entry, entry=entry,
copy_file=True, copy_file=True,
preserve_mtime=self.output_options.preserve_mtime,
) )
elif not entry.is_thumbnail_downloaded(): elif not entry.is_thumbnail_downloaded():
logger.warning( logger.warning(
@ -106,6 +108,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
file_name=entry.get_download_info_json_name(), file_name=entry.get_download_info_json_name(),
output_file_name=output_info_json_name, output_file_name=output_info_json_name,
entry=entry, entry=entry,
preserve_mtime=self.output_options.preserve_mtime,
) )
def _delete_working_directory(self, is_error: bool = False) -> None: def _delete_working_directory(self, is_error: bool = False) -> None:

View file

@ -430,6 +430,25 @@ class FileHandler:
if os.path.isfile(file_path): if os.path.isfile(file_path):
os.remove(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( def move_file_to_output_directory(
self, self,
file_name: str, file_name: str,

View file

@ -1,6 +1,7 @@
import copy import copy
import json import json
import os.path import os.path
import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -642,6 +643,7 @@ class EnhancedDownloadArchive:
output_file_name: Optional[str] = None, output_file_name: Optional[str] = None,
entry: Optional[Entry] = None, entry: Optional[Entry] = None,
copy_file: bool = False, copy_file: bool = False,
preserve_mtime: bool = False,
): ):
""" """
Saves a file from the working directory to the output directory and record it in the 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 Optional. Entry that this file belongs to
copy_file copy_file
Optional. If True, copy the file. Move otherwise 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: if output_file_name is None:
output_file_name = file_name output_file_name = file_name
@ -674,6 +678,22 @@ class EnhancedDownloadArchive:
copy_file=copy_file, 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 # Determine if it's the entry file by seeing if the file_name to move matches the entry
# download file name # download file name
is_entry_file = entry and entry.get_download_file_name() == file_name is_entry_file = entry and entry.get_download_file_name() == file_name

View file

@ -120,6 +120,26 @@ class TestPreset:
== "today-2months" == "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( @pytest.mark.parametrize(
"parent_preset", ["preset_self_loop", "preset_loop_0", "preset_loop_1"] "parent_preset", ["preset_self_loop", "preset_loop_0", "preset_loop_1"]
) )