diff --git a/mutagen/_iff.py b/mutagen/_iff.py index cdc6e3d8..d39e0285 100644 --- a/mutagen/_iff.py +++ b/mutagen/_iff.py @@ -20,6 +20,7 @@ delete_bytes, insert_bytes, loadfile, + set_restore_mtime, reraise, resize_bytes, ) @@ -364,11 +365,14 @@ def _pre_load_header(self, fileobj): @convert_error(IOError, error) @loadfile(writable=True) - def save(self, filething=None, v2_version=4, v23_sep='/', padding=None): + def save(self, filething=None, v2_version=4, v23_sep='/', + padding=None, preserve_mtime=False): """Save ID3v2 data to the IFF file""" - fileobj = filething.fileobj + if preserve_mtime: + set_restore_mtime(fileobj) + iff_file = self._load_file(fileobj) if 'ID3' not in iff_file: diff --git a/mutagen/_util.py b/mutagen/_util.py index b99c7c78..c486d2e3 100644 --- a/mutagen/_util.py +++ b/mutagen/_util.py @@ -16,6 +16,8 @@ import codecs import errno import decimal +import os +import time from io import BytesIO from typing import Tuple, List @@ -271,21 +273,35 @@ def _openfile(instance, filething, filename, fileobj, writable, create): else: raise MutagenError(e) - with fileobj as fileobj: - yield FileThing(fileobj, filename, filename) - - if inmemory_fileobj: - assert writable - data = fileobj.getvalue() - try: - with open(filename, "wb") as fileobj: - fileobj.write(data) - except IOError as e: - raise MutagenError(e) + try: + with fileobj as fileobj: + yield FileThing(fileobj, filename, filename) + + if inmemory_fileobj: + assert writable + data = fileobj.getvalue() + try: + with open(filename, "wb") as fileobj: + fileobj.write(data) + except IOError as e: + raise MutagenError(e) + finally: + if hasattr(fileobj, "__restore_mtime__"): + new_atime = time.time_ns() + original_mtime = fileobj.__restore_mtime__ + print("\nRetaining original mtime. file={}, atime={}, o_mtime={}" + .format(filename, new_atime, original_mtime)) + os.utime(filename, ns=(new_atime, original_mtime)) else: raise TypeError("Missing filename or fileobj argument") +def set_restore_mtime(fileobj): + if fileobj is not None: + original_mtime = os.stat(fileobj.name).st_mtime_ns + setattr(fileobj, "__restore_mtime__", original_mtime) + + class MutagenError(Exception): """Base class for all custom exceptions in mutagen diff --git a/mutagen/apev2.py b/mutagen/apev2.py index 4c57d94d..8e6864af 100644 --- a/mutagen/apev2.py +++ b/mutagen/apev2.py @@ -36,7 +36,8 @@ from mutagen import Metadata, FileType, StreamInfo from mutagen._util import DictMixin, cdata, delete_bytes, total_ordering, \ - MutagenError, loadfile, convert_error, seek_end, get_size, reraise + MutagenError, loadfile, convert_error, seek_end, get_size, reraise, \ + set_restore_mtime def is_valid_apev2_key(key): @@ -396,7 +397,7 @@ def __setitem__(self, key, value): @convert_error(IOError, error) @loadfile(writable=True, create=True) - def save(self, filething=None): + def save(self, filething=None, preserve_mtime=False): """Save changes to a file. If no filename is given, the one most recently loaded is used. @@ -407,6 +408,9 @@ def save(self, filething=None): fileobj = filething.fileobj + if preserve_mtime: + set_restore_mtime(fileobj) + data = _APEv2Data(fileobj) if data.is_at_start: diff --git a/mutagen/asf/__init__.py b/mutagen/asf/__init__.py index a756a691..efe9768c 100644 --- a/mutagen/asf/__init__.py +++ b/mutagen/asf/__init__.py @@ -11,7 +11,8 @@ __all__ = ["ASF", "Open"] from mutagen import FileType, Tags, StreamInfo -from mutagen._util import resize_bytes, DictMixin, loadfile, convert_error +from mutagen._util import resize_bytes, DictMixin, loadfile, convert_error, \ + set_restore_mtime from ._util import error, ASFError, ASFHeaderError from ._objects import HeaderObject, MetadataLibraryObject, MetadataObject, \ @@ -245,7 +246,7 @@ def load(self, filething): @convert_error(IOError, error) @loadfile(writable=True) - def save(self, filething=None, padding=None): + def save(self, filething=None, padding=None, preserve_mtime=False): """save(filething=None, padding=None) Save tag changes back to the loaded file. @@ -300,6 +301,9 @@ def save(self, filething=None, padding=None): header_ext.objects.append(MetadataLibraryObject()) fileobj = filething.fileobj + if preserve_mtime: + set_restore_mtime(fileobj) + # Render to file old_size = header.parse_size(fileobj)[0] data = header.render_full(self, fileobj, old_size, padding) diff --git a/mutagen/dsf.py b/mutagen/dsf.py index 121a01ba..bee2ba77 100644 --- a/mutagen/dsf.py +++ b/mutagen/dsf.py @@ -14,7 +14,7 @@ from mutagen import FileType, StreamInfo from mutagen._util import cdata, MutagenError, loadfile, \ - convert_error, reraise, endswith + convert_error, reraise, endswith, set_restore_mtime from mutagen.id3 import ID3 from mutagen.id3._util import ID3NoHeaderError, error as ID3Error @@ -198,10 +198,15 @@ def _pre_load_header(self, fileobj): @convert_error(IOError, error) @loadfile(writable=True) - def save(self, filething=None, v2_version=4, v23_sep='/', padding=None): + def save(self, filething=None, v2_version=4, v23_sep='/', padding=None, + preserve_mtime=False): """Save ID3v2 data to the DSF file""" fileobj = filething.fileobj + + if preserve_mtime: + set_restore_mtime(fileobj) + fileobj.seek(0) dsd_header = DSDChunk(fileobj) diff --git a/mutagen/easyid3.py b/mutagen/easyid3.py index 510b9222..7affabeb 100644 --- a/mutagen/easyid3.py +++ b/mutagen/easyid3.py @@ -174,8 +174,9 @@ def __init__(self, filename=None): @loadfile(writable=True, create=True) def save(self, filething=None, v1=1, v2_version=4, v23_sep='/', - padding=None): - """save(filething=None, v1=1, v2_version=4, v23_sep='/', padding=None) + padding=None, preserve_mtime=False): + """save(filething=None, v1=1, v2_version=4, v23_sep='/', padding=None, + preserve_mtime=False) Save changes to a file. See :meth:`mutagen.id3.ID3.save` for more info. @@ -191,12 +192,13 @@ def save(self, filething=None, v1=1, v2_version=4, v23_sep='/', self.__id3.update_to_v23() self.__id3.save( filething, v1=v1, v2_version=v2_version, v23_sep=v23_sep, - padding=padding) + padding=padding, preserve_mtime=preserve_mtime) finally: self.__id3._restore(backup) else: self.__id3.save(filething, v1=v1, v2_version=v2_version, - v23_sep=v23_sep, padding=padding) + v23_sep=v23_sep, padding=padding, + preserve_mtime=preserve_mtime) delete = property(lambda s: s.__id3.delete, lambda s, v: setattr(s.__id3, 'delete', v)) diff --git a/mutagen/flac.py b/mutagen/flac.py index bde374b4..dc1d03e8 100644 --- a/mutagen/flac.py +++ b/mutagen/flac.py @@ -27,7 +27,7 @@ import mutagen from mutagen._util import resize_bytes, MutagenError, get_size, loadfile, \ - convert_error, bchr, endswith + convert_error, bchr, endswith, set_restore_mtime from mutagen._tags import PaddingInfo from mutagen.id3._util import BitPaddedInt from functools import reduce @@ -836,13 +836,14 @@ def pictures(self): @convert_error(IOError, error) @loadfile(writable=True) - def save(self, filething=None, deleteid3=False, padding=None): + def save(self, filething=None, deleteid3=False, padding=None, preserve_mtime=False): """Save metadata blocks to a file. Args: filething (filething) deleteid3 (bool): delete id3 tags while at it padding (:obj:`mutagen.PaddingFunction`) + preserve_mtime (bool): Keep existing modified time on save If no filename is given, the one most recently loaded is used. """ @@ -856,6 +857,9 @@ def save(self, filething=None, deleteid3=False, padding=None): raise ValueError("Invalid seektable object type!") self.metadata_blocks.append(self.seektable) + if preserve_mtime: + set_restore_mtime(filething.fileobj) + self._save(filething, self.metadata_blocks, deleteid3, padding) def _save(self, filething, metadata_blocks, deleteid3, padding): diff --git a/mutagen/id3/_file.py b/mutagen/id3/_file.py index 95c460f2..2ec2af8a 100644 --- a/mutagen/id3/_file.py +++ b/mutagen/id3/_file.py @@ -11,7 +11,7 @@ import mutagen from mutagen._util import insert_bytes, delete_bytes, enum, \ - loadfile, convert_error, read_full + loadfile, convert_error, read_full, set_restore_mtime from mutagen._tags import PaddingInfo from ._util import error, ID3NoHeaderError, ID3UnsupportedVersionError, \ @@ -221,8 +221,9 @@ def _prepare_data(self, fileobj, start, available, v2_version, v23_sep, @convert_error(IOError, error) @loadfile(writable=True, create=True) def save(self, filething=None, v1=1, v2_version=4, v23_sep='/', - padding=None): - """save(filething=None, v1=1, v2_version=4, v23_sep='/', padding=None) + padding=None, preserve_mtime=False): + """save(filething=None, v1=1, v2_version=4, v23_sep='/', padding=None, + preserve_mtime=False) Save changes to a file. @@ -241,6 +242,8 @@ def save(self, filething=None, v1=1, v2_version=4, v23_sep='/', if v2_version == 3. Defaults to '/' but if it's None will be the ID3v2v2.4 null separator. padding (:obj:`mutagen.PaddingFunction`) + preserve_mtime: + Keep the original file modified time as it was before saving. Raises: mutagen.MutagenError @@ -253,6 +256,9 @@ def save(self, filething=None, v1=1, v2_version=4, v23_sep='/', f = filething.fileobj + if preserve_mtime: + set_restore_mtime(f) + try: header = ID3Header(filething.fileobj) except ID3NoHeaderError: diff --git a/mutagen/mp4/__init__.py b/mutagen/mp4/__init__.py index 9e1757ec..5a6ba60c 100644 --- a/mutagen/mp4/__init__.py +++ b/mutagen/mp4/__init__.py @@ -32,7 +32,7 @@ from mutagen._constants import GENRES from mutagen._util import cdata, insert_bytes, DictProxy, MutagenError, \ hashable, enum, get_size, resize_bytes, loadfile, convert_error, bchr, \ - reraise + reraise, set_restore_mtime from ._atom import Atoms, Atom, AtomError from ._util import parse_full_atom from ._as_entry import AudioSampleEntry, ASEntryError @@ -389,7 +389,7 @@ def _render(self, key, value): @convert_error(IOError, error) @loadfile(writable=True) - def save(self, filething=None, padding=None): + def save(self, filething=None, padding=None, preserve_mtime=False): values = [] items = sorted(self.items(), key=lambda kv: _item_sort_key(*kv)) @@ -418,7 +418,10 @@ def save(self, filething=None, padding=None): except AtomError as err: reraise(error, err, sys.exc_info()[2]) - self.__save(filething.fileobj, atoms, data, padding) + fileobj = filething.fileobj + if preserve_mtime: + set_restore_mtime(fileobj) + self.__save(fileobj, atoms, data, padding) def __save(self, fileobj, atoms, data, padding): try: diff --git a/mutagen/ogg.py b/mutagen/ogg.py index 263c939d..37296919 100644 --- a/mutagen/ogg.py +++ b/mutagen/ogg.py @@ -23,7 +23,7 @@ from mutagen import FileType from mutagen._util import cdata, resize_bytes, MutagenError, loadfile, \ - seek_end, bchr, reraise + seek_end, bchr, reraise, set_restore_mtime from mutagen._file import StreamInfo from mutagen._tags import Tags @@ -571,8 +571,8 @@ def add_tags(self): raise self._Error @loadfile(writable=True) - def save(self, filething=None, padding=None): - """save(filething=None, padding=None) + def save(self, filething=None, padding=None, preserve_mtime=False): + """save(filething=None, padding=None. preserve_mtime=False)) Save a tag to a file. @@ -581,11 +581,15 @@ def save(self, filething=None, padding=None): Args: filething (filething) padding (:obj:`mutagen.PaddingFunction`) + preserve_mtime (bool) Raises: mutagen.MutagenError """ try: + if preserve_mtime: + set_restore_mtime(filething.fileobj) + self.tags._inject(filething.fileobj, padding) except (IOError, error) as e: reraise(self._Error, e, sys.exc_info()[2]) diff --git a/mutagen/wave.py b/mutagen/wave.py index 6391a5d5..0605592a 100644 --- a/mutagen/wave.py +++ b/mutagen/wave.py @@ -22,6 +22,7 @@ endswith, loadfile, reraise, + set_restore_mtime ) __all__ = ["WAVE", "Open", "delete"] @@ -118,10 +119,15 @@ def _pre_load_header(self, fileobj): @convert_error(IOError, error) @loadfile(writable=True) - def save(self, filething, v1=1, v2_version=4, v23_sep='/', padding=None): + def save(self, filething, v1=1, v2_version=4, v23_sep='/', padding=None, + preserve_mtime=False): """Save ID3v2 data to the Wave/RIFF file""" fileobj = filething.fileobj + + if preserve_mtime: + set_restore_mtime(fileobj) + wave_file = _WaveFile(fileobj) if u'id3' not in wave_file: diff --git a/tests/__init__.py b/tests/__init__.py index 40919647..ec8ba14c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -35,6 +35,16 @@ def get_temp_copy(path): return filename +def get_temp_copy_keep_metadata(path): + """Returns a copy of the file with the same extension""" + + ext = os.path.splitext(path)[-1] + fd, filename = mkstemp(suffix=ext) + os.close(fd) + shutil.copy2(path, filename) + return filename + + def get_temp_empty(ext=""): """Returns an empty file with the extension""" diff --git a/tests/data/silence-44-s-aged-filetime.mp3 b/tests/data/silence-44-s-aged-filetime.mp3 new file mode 100644 index 00000000..84726329 Binary files /dev/null and b/tests/data/silence-44-s-aged-filetime.mp3 differ diff --git a/tests/test_id3.py b/tests/test_id3.py index 3b048baa..34442147 100644 --- a/tests/test_id3.py +++ b/tests/test_id3.py @@ -2,6 +2,8 @@ import os from io import BytesIO +import time + from mutagen import id3 from mutagen import MutagenError from mutagen.apev2 import APEv2 @@ -15,7 +17,8 @@ save_frame, ID3SaveConfig from mutagen.id3._id3v1 import find_id3v1 -from tests import TestCase, DATA_DIR, get_temp_copy, get_temp_empty +from tests import TestCase, DATA_DIR, get_temp_copy, \ + get_temp_copy_keep_metadata, get_temp_empty def test_id3_module_exports_all_frames(): @@ -893,6 +896,33 @@ def test_save(self): self.assertEqual(frame.encoding, 1) self.assertEqual(frame.text, strings) + def test_retain_mtime(self): + + def run_test(label, flag): + + file = get_temp_copy_keep_metadata( + os.path.join(DATA_DIR, 'silence-44-s-aged-filetime.mp3') + ) + audio = ID3(file) + + mtime_before = os.stat(file).st_mtime + if flag is False: + time.sleep(0.1) + audio.save(v2_version=3, preserve_mtime=flag) + mtime_after = os.stat(file).st_mtime + + if flag: + tolerance = 0.1 + self.assertTrue( + abs(mtime_after - mtime_before) < tolerance, + "mtime difference greater than tolerance" + ) + else: + self.assertNotEqual(mtime_before, mtime_after) + + run_test("file mtime will be preserved", flag=True) + run_test("file mtime will not be preserved", flag=False) + def test_save_off_spec_frames(self): # These are not defined in v2.3 and shouldn't be written. # Still make sure reading them again works and the encoding