diff --git a/picard/file.py b/picard/file.py index 99a01676e..42b036405 100644 --- a/picard/file.py +++ b/picard/file.py @@ -79,7 +79,7 @@ from picard.util import ( is_absolute_path, samefile, thread, - tracknum_from_filename, + tracknum_and_title_from_filename, ) from picard.util.filenaming import ( make_short_filename, @@ -198,6 +198,7 @@ class File(QtCore.QObject, Item): def _loading_finished(self, callback, result=None, error=None): if self.state != File.PENDING or self.tagger.stopping: return + config = get_config() if error is not None: self.state = self.ERROR self.error_append(str(error)) @@ -228,9 +229,11 @@ class File(QtCore.QObject, Item): else: self.clear_errors() self.state = self.NORMAL - self._copy_loaded_metadata(result) + postprocessors = [] + if config.setting["guess_tracknumber_and_title"]: + postprocessors.append(self._guess_tracknumber_and_title) + self._copy_loaded_metadata(result, postprocessors) # use cached fingerprint from file metadata - config = get_config() if not config.setting["ignore_existing_acoustid_fingerprints"]: fingerprints = self.metadata.getall('acoustid_fingerprint') if fingerprints: @@ -239,24 +242,22 @@ class File(QtCore.QObject, Item): self.update() callback(self) - def _copy_loaded_metadata(self, metadata): - filename, _ = os.path.splitext(self.base_filename) + def _copy_loaded_metadata(self, metadata, postprocessors=None): metadata['~length'] = format_time(metadata.length) - if 'tracknumber' not in metadata: - tracknumber = tracknum_from_filename(self.base_filename) - if tracknumber is not None: - tracknumber = str(tracknumber) - metadata['tracknumber'] = tracknumber - if 'title' not in metadata: - stripped_filename = filename.lstrip('0') - tnlen = len(tracknumber) - if stripped_filename[:tnlen] == tracknumber: - metadata['title'] = stripped_filename[tnlen:].lstrip() - if 'title' not in metadata: - metadata['title'] = filename + if postprocessors: + for processor in postprocessors: + processor(metadata) self.orig_metadata = metadata self.metadata.copy(metadata) + def _guess_tracknumber_and_title(self, metadata): + if 'tracknumber' not in metadata or 'title' not in metadata: + tracknumber, title = tracknum_and_title_from_filename(self.base_filename) + if 'tracknumber' not in metadata: + metadata['tracknumber'] = tracknumber + if 'title' not in metadata: + metadata['title'] = title + def copy_metadata(self, metadata, preserve_deleted=True): acoustid = self.metadata["acoustid_id"] saved_metadata = {} diff --git a/picard/ui/options/metadata.py b/picard/ui/options/metadata.py index e0383f325..2114d2bd9 100644 --- a/picard/ui/options/metadata.py +++ b/picard/ui/options/metadata.py @@ -3,7 +3,7 @@ # Picard, the next-generation MusicBrainz tagger # # Copyright (C) 2006-2008, 2011 Lukáš Lalinský -# Copyright (C) 2008-2009, 2018-2019 Philipp Wolfer +# Copyright (C) 2008-2009, 2018-2019, 2021 Philipp Wolfer # Copyright (C) 2011 Johannes Weißl # Copyright (C) 2011-2013 Michael Wiencek # Copyright (C) 2013, 2018 Laurent Monin @@ -75,6 +75,7 @@ class MetadataOptionsPage(OptionsPage): BoolOption("setting", "convert_punctuation", True), BoolOption("setting", "standardize_artists", False), BoolOption("setting", "standardize_instruments", True), + BoolOption("setting", "guess_tracknumber_and_title", True), ] def __init__(self, parent=None): @@ -103,6 +104,7 @@ class MetadataOptionsPage(OptionsPage): self.ui.nat_name.setText(config.setting["nat_name"]) self.ui.standardize_artists.setChecked(config.setting["standardize_artists"]) self.ui.standardize_instruments.setChecked(config.setting["standardize_instruments"]) + self.ui.guess_tracknumber_and_title.setChecked(config.setting["guess_tracknumber_and_title"]) def save(self): config = get_config() @@ -119,6 +121,7 @@ class MetadataOptionsPage(OptionsPage): self.tagger.nats.update() config.setting["standardize_artists"] = self.ui.standardize_artists.isChecked() config.setting["standardize_instruments"] = self.ui.standardize_instruments.isChecked() + config.setting["guess_tracknumber_and_title"] = self.ui.guess_tracknumber_and_title.isChecked() def set_va_name_default(self): self.ui.va_name.setText(self.options[0].default) diff --git a/picard/ui/ui_options_metadata.py b/picard/ui/ui_options_metadata.py index 1b98d547b..5d993f56c 100644 --- a/picard/ui/ui_options_metadata.py +++ b/picard/ui/ui_options_metadata.py @@ -3,8 +3,10 @@ # Automatically generated - don't edit. # Use `python setup.py build_ui` to update it. + from PyQt5 import QtCore, QtGui, QtWidgets + class Ui_MetadataOptionsPage(object): def setupUi(self, MetadataOptionsPage): MetadataOptionsPage.setObjectName("MetadataOptionsPage") @@ -43,6 +45,9 @@ class Ui_MetadataOptionsPage(object): self.track_ars = QtWidgets.QCheckBox(self.metadata_groupbox) self.track_ars.setObjectName("track_ars") self.verticalLayout_3.addWidget(self.track_ars) + self.guess_tracknumber_and_title = QtWidgets.QCheckBox(self.metadata_groupbox) + self.guess_tracknumber_and_title.setObjectName("guess_tracknumber_and_title") + self.verticalLayout_3.addWidget(self.guess_tracknumber_and_title) self.verticalLayout.addWidget(self.metadata_groupbox) self.custom_fields_groupbox = QtWidgets.QGroupBox(MetadataOptionsPage) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Maximum) @@ -100,9 +105,9 @@ class Ui_MetadataOptionsPage(object): self.convert_punctuation.setText(_("Convert Unicode punctuation characters to ASCII")) self.release_ars.setText(_("Use release relationships")) self.track_ars.setText(_("Use track relationships")) + self.guess_tracknumber_and_title.setText(_("Guess track number and title from filename if empty")) self.custom_fields_groupbox.setTitle(_("Custom Fields")) self.label_6.setText(_("Various artists:")) self.label_7.setText(_("Non-album tracks:")) self.nat_name_default.setText(_("Default")) self.va_name_default.setText(_("Default")) - diff --git a/picard/util/__init__.py b/picard/util/__init__.py index 67e178b1f..5051508f4 100644 --- a/picard/util/__init__.py +++ b/picard/util/__init__.py @@ -4,7 +4,7 @@ # # Copyright (C) 2004 Robert Kaye # Copyright (C) 2006-2009, 2011-2012, 2014 Lukáš Lalinský -# Copyright (C) 2008-2011, 2014, 2018-2020 Philipp Wolfer +# Copyright (C) 2008-2011, 2014, 2018-2021 Philipp Wolfer # Copyright (C) 2009 Carlin Mangar # Copyright (C) 2009 david # Copyright (C) 2010 fatih @@ -392,7 +392,7 @@ def tracknum_from_filename(base_filename): """Guess and extract track number from filename Returns `None` if none found, the number as integer else """ - filename, _ = os.path.splitext(base_filename) + filename, _ext = os.path.splitext(base_filename) for r in _tracknum_regexps: match = re.search(r, filename, re.I) if match: @@ -409,6 +409,26 @@ def tracknum_from_filename(base_filename): return None +def tracknum_and_title_from_filename(base_filename): + """Guess tracknumber and title from filename. + Uses `tracknum_from_filename` to guess the tracknumber. The filename is used + as the title. If the tracknumber is at the beginning of the title it gets stripped. + + Returns a tuple `(tracknumber, title)`. + """ + filename, _ext = os.path.splitext(base_filename) + title = filename + tracknumber = tracknum_from_filename(base_filename) + if tracknumber is not None: + tracknumber = str(tracknumber) + stripped_filename = filename.lstrip('0') + tnlen = len(tracknumber) + if stripped_filename[:tnlen] == tracknumber: + title = stripped_filename[tnlen:].lstrip() + + return (tracknumber, title) + + def is_hidden(filepath): """Test whether a file or directory is hidden. A file is considered hidden if it starts with a dot diff --git a/test/test_utils.py b/test/test_utils.py index 6824dfdc7..8d3a8ef46 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -4,7 +4,7 @@ # # Copyright (C) 2006-2007 Lukáš Lalinský # Copyright (C) 2010 fatih -# Copyright (C) 2010-2011, 2014, 2018-2020 Philipp Wolfer +# Copyright (C) 2010-2011, 2014, 2018-2021 Philipp Wolfer # Copyright (C) 2012, 2014, 2018 Wieland Hoffmann # Copyright (C) 2013 Ionuț Ciocîrlan # Copyright (C) 2013-2014, 2018-2020 Laurent Monin @@ -46,6 +46,8 @@ from picard.util import ( iter_unique, limited_join, sort_by_similarity, + tracknum_and_title_from_filename, + tracknum_from_filename, uniqify, ) @@ -429,3 +431,43 @@ class IterUniqueTest(PicardTestCase): result = iter_unique(items) self.assertTrue(isinstance(result, Iterator)) self.assertEqual([1, 2, 3, 4], list(result)) + + +class TracknumFromFilenameTest(PicardTestCase): + + def test_returns_expected_tracknumber(self): + tests = ( + (None, 'Foo.mp3'), + (1, 'Foo 0001.mp3'), + (99, '99 Foo.mp3'), + (42, '42. Foo.mp3'), + (None, '20000 Feet.mp3'), + (242, 'track no 242.mp3'), + (242, 'track-242.mp3'), + (242, 'track nr 242.mp3'), + (242, 'track_242.mp3'), + # (None, '30,000 Pounds of Bananas.mp3'), + # (None, 'Dalas 1 PM.mp3'), + # (None, "Don't Stop the 80's.mp3"), + # (None, '99 Luftballons.mp3'), + # (None, 'Symphony no. 5 in D minor.mp3'), + ) + for expected, filename in tests: + tracknumber = tracknum_from_filename(filename) + self.assertEqual(expected, tracknumber) + + +class TracknumAndTitleFromFilenameTest(PicardTestCase): + + def test_returns_expected_tracknumber(self): + tests = ( + ((None, 'Foo'), 'Foo.mp3'), + (('1', 'Track 0001'), 'Track 0001.mp3'), + (('99', 'Foo'), '99 Foo.mp3'), + (('42', 'Foo'), '0000042 Foo.mp3'), + ((None, '20000 Feet'), '20000 Feet.mp3'), + # ((None, '20,000 Feet'), '20,000 Feet.mp3'), + ) + for expected, filename in tests: + result = tracknum_and_title_from_filename(filename) + self.assertEqual(expected, result) diff --git a/ui/options_metadata.ui b/ui/options_metadata.ui index d78660740..eb89aad31 100644 --- a/ui/options_metadata.ui +++ b/ui/options_metadata.ui @@ -77,6 +77,13 @@ + + + + Guess track number and title from filename if empty + + +