Files
picard/test/test_file.py
2024-06-03 08:27:13 +02:00

724 lines
26 KiB
Python

# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2018-2022 Philipp Wolfer
# Copyright (C) 2019-2022 Laurent Monin
# Copyright (C) 2021 Bob Swift
# Copyright (C) 2021 Sophist-UK
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import os
import re
from types import GeneratorType
import unittest
from unittest.mock import MagicMock
from test.picardtestcase import PicardTestCase
from test.test_coverart_image import create_image
from picard import config
from picard.const.sys import (
IS_MACOS,
IS_WIN,
)
from picard.file import File
from picard.metadata import Metadata
from picard.util.tags import (
CALCULATED_TAGS,
FILE_INFO_TAGS,
)
class FileTest(PicardTestCase):
def setUp(self):
super().setUp()
self.tagger.acoustidmanager = MagicMock()
self.file = File('somepath/somefile.mp3')
self.set_config_values({
'save_acoustid_fingerprints': True,
})
def test_filename(self):
self.assertEqual('somepath/somefile.mp3', self.file.filename)
self.assertEqual('somefile.mp3', self.file.base_filename)
def test_tracknumber(self):
self.assertEqual(0, self.file.tracknumber)
self.file.metadata['tracknumber'] = '42'
self.assertEqual(42, self.file.tracknumber)
self.file.metadata['tracknumber'] = 'FOURTYTWO'
self.assertEqual(0, self.file.tracknumber)
def test_discnumber(self):
self.assertEqual(0, self.file.discnumber)
self.file.metadata['discnumber'] = '42'
self.assertEqual(42, self.file.discnumber)
self.file.metadata['discnumber'] = 'FOURTYTWO'
self.assertEqual(0, self.file.discnumber)
def test_set_acoustid_fingerprint(self):
fingerprint = 'foo'
length = 36
self.file.set_acoustid_fingerprint(fingerprint, length)
self.assertEqual(fingerprint, self.file.acoustid_fingerprint)
self.assertEqual(length, self.file.acoustid_length)
self.tagger.acoustidmanager.add.assert_called_with(self.file, None)
self.tagger.acoustidmanager.add.reset_mock()
self.file.set_acoustid_fingerprint(fingerprint, length)
self.tagger.acoustidmanager.add.assert_not_called()
self.tagger.acoustidmanager.remove.assert_not_called()
self.assertEqual(fingerprint, self.file.metadata['acoustid_fingerprint'])
def test_set_acoustid_fingerprint_no_length(self):
self.file.metadata.length = 42000
fingerprint = 'foo'
self.file.set_acoustid_fingerprint(fingerprint)
self.assertEqual(fingerprint, self.file.acoustid_fingerprint)
self.assertEqual(42, self.file.acoustid_length)
self.assertEqual(fingerprint, self.file.metadata['acoustid_fingerprint'])
def test_set_acoustid_fingerprint_unset(self):
self.file.acoustid_fingerprint = 'foo'
self.file.set_acoustid_fingerprint(None, 42)
self.tagger.acoustidmanager.add.assert_not_called()
self.tagger.acoustidmanager.remove.assert_called_with(self.file)
self.assertEqual(None, self.file.acoustid_fingerprint)
self.assertEqual(0, self.file.acoustid_length)
self.assertEqual('', self.file.metadata['acoustid_fingerprint'])
def format_specific_metadata(self):
values = ['foo', 'bar']
self.file.metadata['test'] = values
self.assertEqual(values, self.file.format_specific_metadata(self.file.metadata, 'test'))
def test_set_acoustid_fingerprint_no_save(self):
self.set_config_values({
'save_acoustid_fingerprints': False,
})
fingerprint = 'foo'
length = 36
self.file.set_acoustid_fingerprint(fingerprint, length)
self.assertEqual(fingerprint, self.file.acoustid_fingerprint)
self.assertEqual(length, self.file.acoustid_length)
self.assertEqual('', self.file.metadata['acoustid_fingerprint'])
class TestPreserveTimes(PicardTestCase):
def setUp(self):
super().setUp()
self.tmp_directory = self.mktmpdir()
filepath = os.path.join(self.tmp_directory, 'a.mp3')
self.file = File(filepath)
def _create_testfile(self):
# create a dummy file
with open(self.file.filename, 'w') as f:
f.write('xxx')
f.flush()
os.fsync(f.fileno())
def _modify_testfile(self):
# dummy file modification, append data to it
with open(self.file.filename, 'a') as f:
f.write('yyy')
f.flush()
os.fsync(f.fileno())
def _read_testfile(self):
with open(self.file.filename, 'r') as f:
return f.read()
def test_preserve_times(self):
self._create_testfile()
# test if times are preserved
(before_atime_ns, before_mtime_ns) = self.file._preserve_times(self.file.filename, self._modify_testfile)
# HERE an external access to the file is possible, modifying its access time
# read times again and compare with original
st = os.stat(self.file.filename)
(after_atime_ns, after_mtime_ns) = (st.st_atime_ns, st.st_mtime_ns)
# on macOS 10.14 and later os.utime only sets the times with second
# precision see https://tickets.metabrainz.org/browse/PICARD-1516.
# This also seems to depend on the Python build being used.
if IS_MACOS:
before_atime_ns //= 1000
before_mtime_ns //= 1000
after_atime_ns //= 1000
after_mtime_ns //= 1000
# modification times should be equal
self.assertEqual(before_mtime_ns, after_mtime_ns)
# access times may not be equal
# time difference should be positive and reasonably low (if no access in between, it should be 0)
delta = after_atime_ns - before_atime_ns
tolerance = 10**7 # 0.01 seconds
self.assertTrue(0 <= delta < tolerance, "0 <= %s < %s" % (delta, tolerance))
# ensure written data can be read back
# keep it at the end, we don't want to access file before time checks
self.assertEqual(self._read_testfile(), 'xxxyyy')
def test_preserve_times_nofile(self):
with self.assertRaises(self.file.PreserveTimesStatError):
self.file._preserve_times(self.file.filename,
self._modify_testfile)
with self.assertRaises(FileNotFoundError):
self._read_testfile()
def test_preserve_times_nofile_utime(self):
self._create_testfile()
def save():
os.remove(self.file.filename)
with self.assertRaises(self.file.PreserveTimesUtimeError):
self.file._preserve_times(self.file.filename, save)
class FakeMp3File(File):
EXTENSIONS = ['.mp3']
class FileNamingTest(PicardTestCase):
def setUp(self):
super().setUp()
self.file = File('/somepath/somefile.mp3')
self.set_config_values({
'ascii_filenames': False,
'clear_existing_tags': False,
'enabled_plugins': [],
'move_files_to': '/media/music',
'move_files': False,
'rename_files': False,
'windows_compatibility': True,
'win_compat_replacements': {},
'windows_long_paths': False,
'replace_spaces_with_underscores': False,
'replace_dir_separator': '_',
'file_renaming_scripts': {'test_id': {'script': '%album%/%title%'}},
'selected_file_naming_script_id': 'test_id',
})
self.metadata = Metadata({
'album': 'somealbum',
'title': 'sometitle',
})
def test_make_filename_no_move_and_rename(self):
filename = self.file.make_filename(self.file.filename, self.metadata)
self.assertEqual(os.path.realpath(self.file.filename), filename)
def test_make_filename_rename_only(self):
config.setting['rename_files'] = True
filename = self.file.make_filename(self.file.filename, self.metadata)
self.assertEqual(os.path.realpath('/somepath/sometitle.mp3'), filename)
def test_make_filename_move_only(self):
config.setting['move_files'] = True
filename = self.file.make_filename(self.file.filename, self.metadata)
self.assertEqual(
os.path.realpath('/media/music/somealbum/somefile.mp3'),
filename)
def test_make_filename_move_and_rename(self):
config.setting['rename_files'] = True
config.setting['move_files'] = True
filename = self.file.make_filename(self.file.filename, self.metadata)
self.assertEqual(
os.path.realpath('/media/music/somealbum/sometitle.mp3'),
filename)
def test_make_filename_move_relative_path(self):
config.setting['move_files'] = True
config.setting['move_files_to'] = 'subdir'
filename = self.file.make_filename(self.file.filename, self.metadata)
self.assertEqual(
os.path.realpath('/somepath/subdir/somealbum/somefile.mp3'),
filename)
def test_make_filename_empty_script(self):
config.setting['rename_files'] = True
config.setting['file_renaming_scripts'] = {'test_id': {'script': '$noop()'}}
filename = self.file.make_filename(self.file.filename, self.metadata)
self.assertEqual(os.path.realpath('/somepath/somefile.mp3'), filename)
def test_make_filename_empty_basename(self):
config.setting['move_files'] = True
config.setting['rename_files'] = True
config.setting['file_renaming_scripts'] = {'test_id': {'script': '/somedir/$noop()'}}
filename = self.file.make_filename(self.file.filename, self.metadata)
self.assertEqual(os.path.realpath('/media/music/somedir/somefile.mp3'), filename)
def test_make_filename_no_extension(self):
config.setting['rename_files'] = True
file_ = FakeMp3File('/somepath/_')
filename = file_.make_filename(file_.filename, self.metadata)
self.assertEqual(os.path.realpath('/somepath/sometitle.mp3'), filename)
def test_make_filename_lowercase_extension(self):
config.setting['rename_files'] = True
file_ = FakeMp3File('/somepath/somefile.MP3')
filename = file_.make_filename(file_.filename, self.metadata)
self.assertEqual(os.path.realpath('/somepath/sometitle.mp3'), filename)
def test_make_filename_scripted_extension(self):
config.setting['rename_files'] = True
config.setting['file_renaming_scripts'] = {'test_id': {'script': '$set(_extension,.foo)%title%'}}
filename = self.file.make_filename(self.file.filename, self.metadata)
self.assertEqual(os.path.realpath('/somepath/sometitle.foo'), filename)
def test_make_filename_replace_trailing_dots(self):
config.setting['rename_files'] = True
config.setting['move_files'] = True
config.setting['windows_compatibility'] = True
metadata = Metadata({
'album': 'somealbum.',
'title': 'sometitle',
})
filename = self.file.make_filename(self.file.filename, metadata)
self.assertEqual(
os.path.realpath('/media/music/somealbum_/sometitle.mp3'),
filename)
@unittest.skipUnless(not IS_WIN, "non-windows test")
def test_make_filename_keep_trailing_dots(self):
config.setting['rename_files'] = True
config.setting['move_files'] = True
config.setting['windows_compatibility'] = False
metadata = Metadata({
'album': 'somealbum.',
'title': 'sometitle',
})
filename = self.file.make_filename(self.file.filename, metadata)
self.assertEqual(
os.path.realpath('/media/music/somealbum./sometitle.mp3'),
filename)
def test_make_filename_replace_leading_dots(self):
config.setting['rename_files'] = True
config.setting['move_files'] = True
config.setting['windows_compatibility'] = True
metadata = Metadata({
'album': '.somealbum',
'title': '.sometitle',
})
filename = self.file.make_filename(self.file.filename, metadata)
self.assertEqual(
os.path.realpath('/media/music/_somealbum/_sometitle.mp3'),
filename)
class FileGuessTracknumberAndTitleTest(PicardTestCase):
def setUp(self):
super().setUp()
self.set_config_values({
'guess_tracknumber_and_title': True,
})
def test_no_guess(self):
f = File('/somepath/01 somefile.mp3')
metadata = Metadata({
'album': 'somealbum',
'title': 'sometitle',
'tracknumber': '2',
})
f._guess_tracknumber_and_title(metadata)
self.assertEqual(metadata['tracknumber'], '2')
self.assertEqual(metadata['title'], 'sometitle')
def test_guess_title(self):
f = File('/somepath/01 somefile.mp3')
metadata = Metadata({
'album': 'somealbum',
'tracknumber': '2',
})
f._guess_tracknumber_and_title(metadata)
self.assertEqual(metadata['tracknumber'], '2')
self.assertEqual(metadata['title'], 'somefile')
def test_guess_tracknumber(self):
f = File('/somepath/01 somefile.mp3')
metadata = Metadata({
'album': 'somealbum',
'title': 'sometitle',
})
f._guess_tracknumber_and_title(metadata)
self.assertEqual(metadata['tracknumber'], '1')
def test_guess_title_tracknumber(self):
f = File('/somepath/01 somefile.mp3')
metadata = Metadata({
'album': 'somealbum',
})
f._guess_tracknumber_and_title(metadata)
self.assertEqual(metadata['tracknumber'], '1')
self.assertEqual(metadata['title'], 'somefile')
class FileAdditionalFilesPatternsTest(PicardTestCase):
def test_empty_patterns(self):
self.assertEqual(File._compile_move_additional_files_pattern(' '), set())
def test_simple_patterns(self):
pattern = 'cover.jpg'
expected = {
(re.compile('(?s:cover\\.jpg)\\Z', re.IGNORECASE), False)
}
self.assertEqual(File._compile_move_additional_files_pattern(pattern), expected)
def test_whitespaces_patterns(self):
pattern = " a \n b "
expected = {
(re.compile('(?s:a)\\Z', re.IGNORECASE), False),
(re.compile('(?s:b)\\Z', re.IGNORECASE), False),
}
self.assertEqual(File._compile_move_additional_files_pattern(pattern), expected)
def test_duplicated_patterns(self):
pattern = 'cover.jpg cover.jpg COVER.JPG'
expected = {
(re.compile('(?s:cover\\.jpg)\\Z', re.IGNORECASE), False)
}
self.assertEqual(File._compile_move_additional_files_pattern(pattern), expected)
def test_simple_hidden_patterns(self):
pattern = 'cover.jpg .hidden'
expected = {
(re.compile('(?s:cover\\.jpg)\\Z', re.IGNORECASE), False),
(re.compile('(?s:\\.hidden)\\Z', re.IGNORECASE), True)
}
self.assertEqual(File._compile_move_additional_files_pattern(pattern), expected)
def test_wildcard_patterns(self):
pattern = 'c?ver.jpg .h?dden* *.jpg *.JPG'
expected = {
(re.compile('(?s:c.ver\\.jpg)\\Z', re.IGNORECASE), False),
(re.compile('(?s:\\.h.dden.*)\\Z', re.IGNORECASE), True),
(re.compile('(?s:.*\\.jpg)\\Z', re.IGNORECASE), False),
}
self.assertEqual(File._compile_move_additional_files_pattern(pattern), expected)
class FileUpdateTest(PicardTestCase):
def setUp(self):
super().setUp()
self.file = File('/somepath/somefile.mp3')
self.INVALIDSIMVAL = 666
self.file.similarity = self.INVALIDSIMVAL # to check if changed or not
self.file.supports_tag = lambda x: False if x.startswith('unsupported') else True
self.set_config_values({
'clear_existing_tags': False,
'compare_ignore_tags': [],
'enabled_plugins': [],
})
def test_same_image(self):
image = create_image(b'a')
self.file.metadata.images = [image]
self.file.orig_metadata.images = [image]
self.file.state = File.NORMAL
self.file.update(signal=False)
self.assertEqual(self.file.similarity, 1.0) # it should be modified
self.assertEqual(self.file.state, File.NORMAL)
def test_same_image_pending(self):
image = create_image(b'a')
self.file.metadata.images = [image]
self.file.orig_metadata.images = [image]
self.file.update(signal=False)
self.assertEqual(self.file.similarity, 1.0)
self.assertEqual(self.file.state, File.PENDING)
def test_same_image_changed_state(self):
image = create_image(b'a')
self.file.metadata.images = [image]
self.file.orig_metadata.images = [image]
self.file.state = File.CHANGED
self.file.update(signal=False)
self.assertEqual(self.file.similarity, 1.0)
self.assertEqual(self.file.state, File.NORMAL)
def test_changed_image(self):
old_image = create_image(b'a')
new_image = create_image(b'b')
self.file.metadata.images = [new_image]
self.file.orig_metadata.images = [old_image]
self.file.state = File.NORMAL
self.file.update(signal=False)
self.assertEqual(self.file.similarity, 1.0)
self.assertEqual(self.file.state, File.CHANGED)
def test_signal(self):
# just for coverage
self.file.update(signal=True)
self.assertEqual(self.file.metadata, Metadata())
self.assertEqual(self.file.orig_metadata, Metadata())
def test_tags_to_update(self):
self.file.orig_metadata = Metadata({
'album': 'somealbum',
'title': 'sometitle',
'ignoreme_old': 'a',
'~ignoreme_old': 'b',
'unsupported_old': 'c',
})
self.file.metadata = Metadata({
'artist': 'someartist',
'ignoreme_new': 'd',
'~ignoreme_new': 'e',
'unsupported_new': 'f',
})
ignore_tags = {'ignoreme_old', 'ignoreme_new'}
expected = {'album', 'title', 'artist'}
result = self.file._tags_to_update(ignore_tags)
self.assertIsInstance(result, GeneratorType)
self.assertEqual(set(result), expected)
def test_unchanged_metadata(self):
self.file.orig_metadata = Metadata({
'album': 'somealbum',
'title': 'sometitle',
})
self.file.metadata = Metadata({
'album': 'somealbum',
'title': 'sometitle',
})
self.file.state = File.NORMAL
self.file.update(signal=False)
self.assertEqual(self.file.similarity, 1.0)
self.assertEqual(self.file.state, File.NORMAL)
def test_changed_metadata(self):
self.file.orig_metadata = Metadata({
'album': 'somealbum',
'title': 'sometitle',
})
self.file.metadata = Metadata({
'album': 'somealbum2',
'title': 'sometitle2',
})
self.file.state = File.NORMAL
self.file.update(signal=False)
self.assertLess(self.file.similarity, 1.0)
self.assertEqual(self.file.state, File.CHANGED)
def test_changed_metadata_pending(self):
self.file.orig_metadata = Metadata({
'album': 'somealbum',
'title': 'sometitle',
})
self.file.metadata = Metadata({
'album': 'somealbum2',
'title': 'sometitle2',
})
self.file.update(signal=False)
self.assertLess(self.file.similarity, 1.0)
self.assertEqual(self.file.state, File.PENDING) # it shouldn't be modified
def test_clear_existing(self):
self.file.orig_metadata = Metadata({
'album': 'somealbum',
'title': 'sometitle',
})
self.file.metadata = Metadata()
self.file.state = File.NORMAL
config.setting["clear_existing_tags"] = True
self.file.update(signal=False)
self.assertEqual(self.file.similarity, 0.0)
self.assertEqual(self.file.state, File.CHANGED)
def test_no_new_metadata(self):
self.file.orig_metadata = Metadata({
'album': 'somealbum',
'title': 'sometitle',
})
self.file.metadata = Metadata()
self.file.state = File.NORMAL
self.file.update(signal=False)
self.assertEqual(self.file.similarity, 1.0)
self.assertEqual(self.file.state, File.NORMAL)
def test_tilde_tag(self):
self.file.orig_metadata = Metadata()
self.file.metadata = Metadata({
'~tag': 'value'
})
self.file.state = File.NORMAL
self.file.update(signal=False)
self.assertEqual(self.file.similarity, 1.0)
self.assertEqual(self.file.state, File.NORMAL)
def test_ignored_tag(self):
self.file.orig_metadata = Metadata()
self.file.metadata = Metadata({
'tag': 'value'
})
self.file.state = File.NORMAL
config.setting["compare_ignore_tags"] = ['tag']
self.file.update(signal=False)
self.assertEqual(self.file.similarity, 1.0)
self.assertEqual(self.file.state, File.NORMAL)
def test_unsupported_tag(self):
self.file.orig_metadata = Metadata()
self.file.metadata = Metadata({
'unsupported': 'value'
})
self.file.state = File.NORMAL
self.file.update(signal=False)
self.assertEqual(self.file.similarity, 1.0)
self.assertEqual(self.file.state, File.NORMAL)
def test_copy_file_info_tags(self):
info_tags = {}
for info in FILE_INFO_TAGS:
info_tags[info] = 'val' + info
orig_metadata = Metadata(info_tags)
orig_metadata['a'] = 'vala'
metadata = Metadata({
'~bitrate': 'xxx',
'b': 'valb',
})
self.file._copy_file_info_tags(metadata, orig_metadata)
for info in FILE_INFO_TAGS:
self.assertEqual('val' + info, metadata[info])
self.assertEqual('valb', metadata['b'])
self.assertNotIn('a', metadata)
class FileCopyMetadataTest(PicardTestCase):
def setUp(self):
super().setUp()
metadata = Metadata({
'album': 'somealbum',
'artist': 'someartist',
'title': 'sometitle',
})
del metadata['deletedtag']
metadata.images.append(create_image(b'a'))
self.file = File('/somepath/somefile.mp3')
self.file.metadata = metadata
self.file.orig_metadata = Metadata({
'album': 'origalbum',
'artist': 'origartist',
'title': 'origtitle',
})
self.INVALIDSIMVAL = 666
self.set_config_values({
'preserved_tags': [],
})
def test_copy_metadata_full(self):
new_metadata = Metadata({
'title': 'othertitle',
'~foo': 'bar',
})
del new_metadata['foo']
new_metadata.images.append(create_image(b'b'))
self.file.copy_metadata(new_metadata, preserve_deleted=False)
self.assertEqual(self.file.metadata, new_metadata)
self.assertEqual(self.file.metadata.images, new_metadata.images)
self.assertEqual(self.file.metadata.deleted_tags, new_metadata.deleted_tags)
def test_copy_metadata_must_preserve_deleted_tags_by_default(self):
new_metadata = Metadata({
'title': 'othertitle',
'~foo': 'bar',
})
del new_metadata['foo']
self.file.copy_metadata(new_metadata)
self.assertEqual(self.file.metadata, new_metadata)
self.assertEqual(self.file.metadata.deleted_tags, {'deletedtag', 'foo'})
def test_copy_metadata_do_not_preserve_deleted_tags(self):
new_metadata = Metadata({
'title': 'othertitle',
'~foo': 'bar',
})
del new_metadata['foo']
self.file.copy_metadata(new_metadata, preserve_deleted=False)
self.assertEqual(self.file.metadata, new_metadata)
self.assertEqual(self.file.metadata.deleted_tags, {'foo'})
def test_copy_metadata_must_keep_file_content_specific_tags(self):
for tag in CALCULATED_TAGS:
self.file.metadata[tag] = 'foo'
new_metadata = Metadata()
self.file.copy_metadata(new_metadata)
for tag in CALCULATED_TAGS:
self.assertEqual(
self.file.metadata[tag], 'foo',
f'Tag {tag}: {self.file.metadata[tag]!r} != "foo"')
def test_copy_metadata_must_remove_deleted_acoustid_id(self):
self.file.metadata['acoustid_id'] = 'foo'
new_metadata = Metadata()
new_metadata.delete('acoustid_id')
self.file.copy_metadata(new_metadata)
self.assertEqual(self.file.metadata['acoustid_id'], '')
self.assertIn('acoustid_id', self.file.metadata.deleted_tags)
def test_copy_metadata_with_preserved_tags(self):
self.set_config_values({
'preserved_tags': ['artist', 'title'],
})
new_metadata = Metadata({
'album': 'otheralbum',
'artist': 'otherartist',
'title': 'othertitle',
})
self.file.copy_metadata(new_metadata)
self.assertEqual(self.file.metadata['album'], 'otheralbum')
self.assertEqual(self.file.metadata['artist'], 'origartist')
self.assertEqual(self.file.metadata['title'], 'origtitle')
def test_copy_metadata_must_always_preserve_technical_variables(self):
self.file.orig_metadata['~filename'] = 'orig.flac'
new_metadata = Metadata({
'~filename': 'new.flac',
})
self.file.copy_metadata(new_metadata)
self.assertEqual(self.file.metadata['~filename'], 'orig.flac')