1# -*- coding: utf-8 -*- 2# 3# Picard, the next-generation MusicBrainz tagger 4# 5# Copyright (C) 2006-2008, 2011 Lukáš Lalinský 6# Copyright (C) 2008-2009 Nikolai Prokoschenko 7# Copyright (C) 2009-2010, 2014-2015, 2018-2021 Philipp Wolfer 8# Copyright (C) 2011-2013 Michael Wiencek 9# Copyright (C) 2011-2013 Wieland Hoffmann 10# Copyright (C) 2013 Calvin Walton 11# Copyright (C) 2013 Ionuț Ciocîrlan 12# Copyright (C) 2013-2014 Sophist-UK 13# Copyright (C) 2013-2015, 2018-2019 Laurent Monin 14# Copyright (C) 2015 Alex Berman 15# Copyright (C) 2015 Ohm Patel 16# Copyright (C) 2016 Suhas 17# Copyright (C) 2016-2017 Sambhav Kothari 18# 19# This program is free software; you can redistribute it and/or 20# modify it under the terms of the GNU General Public License 21# as published by the Free Software Foundation; either version 2 22# of the License, or (at your option) any later version. 23# 24# This program is distributed in the hope that it will be useful, 25# but WITHOUT ANY WARRANTY; without even the implied warranty of 26# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 27# GNU General Public License for more details. 28# 29# You should have received a copy of the GNU General Public License 30# along with this program; if not, write to the Free Software 31# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 32 33 34from functools import partial 35import os.path 36 37from PyQt5 import QtWidgets 38from PyQt5.QtCore import QStandardPaths 39from PyQt5.QtGui import QPalette 40 41from picard.config import ( 42 BoolOption, 43 TextOption, 44 get_config, 45) 46from picard.const import DEFAULT_FILE_NAMING_FORMAT 47from picard.const.sys import IS_WIN 48from picard.file import File 49from picard.script import ( 50 ScriptError, 51 ScriptParser, 52) 53from picard.util.settingsoverride import SettingsOverride 54 55from picard.ui.options import ( 56 OptionsCheckError, 57 OptionsPage, 58 register_options_page, 59) 60from picard.ui.options.scripting import ( 61 ScriptCheckError, 62 ScriptingDocumentationDialog, 63) 64from picard.ui.ui_options_renaming import Ui_RenamingOptionsPage 65from picard.ui.util import enabledSlot 66 67 68_default_music_dir = QStandardPaths.writableLocation(QStandardPaths.MusicLocation) 69 70 71class RenamingOptionsPage(OptionsPage): 72 73 NAME = "filerenaming" 74 TITLE = N_("File Naming") 75 PARENT = None 76 SORT_ORDER = 40 77 ACTIVE = True 78 HELP_URL = '/config/options_filerenaming.html' 79 80 options = [ 81 BoolOption("setting", "windows_compatibility", True), 82 BoolOption("setting", "ascii_filenames", False), 83 BoolOption("setting", "rename_files", False), 84 TextOption( 85 "setting", 86 "file_naming_format", 87 DEFAULT_FILE_NAMING_FORMAT, 88 ), 89 BoolOption("setting", "move_files", False), 90 TextOption("setting", "move_files_to", _default_music_dir), 91 BoolOption("setting", "move_additional_files", False), 92 TextOption("setting", "move_additional_files_pattern", "*.jpg *.png"), 93 BoolOption("setting", "delete_empty_dirs", True), 94 ] 95 96 def __init__(self, parent=None): 97 super().__init__(parent) 98 self.ui = Ui_RenamingOptionsPage() 99 self.ui.setupUi(self) 100 101 self.ui.ascii_filenames.clicked.connect(self.update_examples) 102 self.ui.windows_compatibility.clicked.connect(self.update_examples) 103 self.ui.rename_files.clicked.connect(self.update_examples) 104 self.ui.move_files.clicked.connect(self.update_examples) 105 self.ui.move_files_to.editingFinished.connect(self.update_examples) 106 107 self.ui.move_files.toggled.connect( 108 partial( 109 enabledSlot, 110 self.toggle_file_moving 111 ) 112 ) 113 self.ui.rename_files.toggled.connect( 114 partial( 115 enabledSlot, 116 self.toggle_file_renaming 117 ) 118 ) 119 self.ui.file_naming_format.textChanged.connect(self.check_formats) 120 self.ui.file_naming_format_default.clicked.connect(self.set_file_naming_format_default) 121 self.ui.move_files_to_browse.clicked.connect(self.move_files_to_browse) 122 123 script_edit = self.ui.file_naming_format 124 self.script_palette_normal = script_edit.palette() 125 self.script_palette_readonly = QPalette(self.script_palette_normal) 126 disabled_color = self.script_palette_normal.color(QPalette.Inactive, QPalette.Window) 127 self.script_palette_readonly.setColor(QPalette.Disabled, QPalette.Base, disabled_color) 128 self.ui.scripting_documentation_button.clicked.connect(self.show_scripting_documentation) 129 130 def show_scripting_documentation(self): 131 ScriptingDocumentationDialog.show_instance(parent=self) 132 133 def toggle_file_moving(self, state): 134 self.toggle_file_naming_format() 135 self.ui.delete_empty_dirs.setEnabled(state) 136 self.ui.move_files_to.setEnabled(state) 137 self.ui.move_files_to_browse.setEnabled(state) 138 self.ui.move_additional_files.setEnabled(state) 139 self.ui.move_additional_files_pattern.setEnabled(state) 140 141 def toggle_file_renaming(self, state): 142 self.toggle_file_naming_format() 143 144 def toggle_file_naming_format(self): 145 active = self.ui.move_files.isChecked() or self.ui.rename_files.isChecked() 146 self.ui.file_naming_format.setEnabled(active) 147 self.ui.file_naming_format_default.setEnabled(active) 148 palette = self.script_palette_normal if active else self.script_palette_readonly 149 self.ui.file_naming_format.setPalette(palette) 150 151 self.ui.ascii_filenames.setEnabled(active) 152 if not IS_WIN: 153 self.ui.windows_compatibility.setEnabled(active) 154 155 def check_formats(self): 156 self.test() 157 self.update_examples() 158 159 def _example_to_filename(self, file): 160 config = get_config() 161 settings = SettingsOverride(config.setting, { 162 'ascii_filenames': self.ui.ascii_filenames.isChecked(), 163 'file_naming_format': self.ui.file_naming_format.toPlainText(), 164 'move_files': self.ui.move_files.isChecked(), 165 'move_files_to': os.path.normpath(self.ui.move_files_to.text()), 166 'rename_files': self.ui.rename_files.isChecked(), 167 'windows_compatibility': self.ui.windows_compatibility.isChecked(), 168 }) 169 170 try: 171 if config.setting["enable_tagger_scripts"]: 172 for s_pos, s_name, s_enabled, s_text in config.setting["list_of_scripts"]: 173 if s_enabled and s_text: 174 parser = ScriptParser() 175 parser.eval(s_text, file.metadata) 176 filename = file.make_filename(file.filename, file.metadata, settings) 177 if not settings["move_files"]: 178 return os.path.basename(filename) 179 return filename 180 except ScriptError: 181 return "" 182 except TypeError: 183 return "" 184 185 def update_examples(self): 186 # TODO: Here should be more examples etc. 187 # TODO: Would be nice to show diffs too.... 188 example1 = self._example_to_filename(self.example_1()) 189 example2 = self._example_to_filename(self.example_2()) 190 self.ui.example_filename.setText(example1) 191 self.ui.example_filename_va.setText(example2) 192 193 def load(self): 194 config = get_config() 195 if IS_WIN: 196 self.ui.windows_compatibility.setChecked(True) 197 self.ui.windows_compatibility.setEnabled(False) 198 else: 199 self.ui.windows_compatibility.setChecked(config.setting["windows_compatibility"]) 200 self.ui.rename_files.setChecked(config.setting["rename_files"]) 201 self.ui.move_files.setChecked(config.setting["move_files"]) 202 self.ui.ascii_filenames.setChecked(config.setting["ascii_filenames"]) 203 self.ui.file_naming_format.setPlainText(config.setting["file_naming_format"]) 204 self.ui.move_files_to.setText(config.setting["move_files_to"]) 205 self.ui.move_files_to.setCursorPosition(0) 206 self.ui.move_additional_files.setChecked(config.setting["move_additional_files"]) 207 self.ui.move_additional_files_pattern.setText(config.setting["move_additional_files_pattern"]) 208 self.ui.delete_empty_dirs.setChecked(config.setting["delete_empty_dirs"]) 209 self.update_examples() 210 211 def check(self): 212 self.check_format() 213 if self.ui.move_files.isChecked() and not self.ui.move_files_to.text().strip(): 214 raise OptionsCheckError(_("Error"), _("The location to move files to must not be empty.")) 215 216 def check_format(self): 217 parser = ScriptParser() 218 try: 219 parser.eval(self.ui.file_naming_format.toPlainText()) 220 except Exception as e: 221 raise ScriptCheckError("", str(e)) 222 if self.ui.rename_files.isChecked(): 223 if not self.ui.file_naming_format.toPlainText().strip(): 224 raise ScriptCheckError("", _("The file naming format must not be empty.")) 225 226 def save(self): 227 config = get_config() 228 config.setting["windows_compatibility"] = self.ui.windows_compatibility.isChecked() 229 config.setting["ascii_filenames"] = self.ui.ascii_filenames.isChecked() 230 config.setting["rename_files"] = self.ui.rename_files.isChecked() 231 config.setting["file_naming_format"] = self.ui.file_naming_format.toPlainText() 232 self.tagger.window.enable_renaming_action.setChecked(config.setting["rename_files"]) 233 config.setting["move_files"] = self.ui.move_files.isChecked() 234 config.setting["move_files_to"] = os.path.normpath(self.ui.move_files_to.text()) 235 config.setting["move_additional_files"] = self.ui.move_additional_files.isChecked() 236 config.setting["move_additional_files_pattern"] = self.ui.move_additional_files_pattern.text() 237 config.setting["delete_empty_dirs"] = self.ui.delete_empty_dirs.isChecked() 238 self.tagger.window.enable_moving_action.setChecked(config.setting["move_files"]) 239 240 def display_error(self, error): 241 # Ignore scripting errors, those are handled inline 242 if not isinstance(error, ScriptCheckError): 243 super().display_error(error) 244 245 def set_file_naming_format_default(self): 246 self.ui.file_naming_format.setText(self.options[3].default) 247# self.ui.file_naming_format.setCursorPosition(0) 248 249 def example_1(self): 250 file = File("ticket_to_ride.mp3") 251 file.state = File.NORMAL 252 file.metadata['album'] = 'Help!' 253 file.metadata['title'] = 'Ticket to Ride' 254 file.metadata['~releasecomment'] = '2014 mono remaster' 255 file.metadata['artist'] = 'The Beatles' 256 file.metadata['artistsort'] = 'Beatles, The' 257 file.metadata['albumartist'] = 'The Beatles' 258 file.metadata['albumartistsort'] = 'Beatles, The' 259 file.metadata['tracknumber'] = '7' 260 file.metadata['totaltracks'] = '14' 261 file.metadata['discnumber'] = '1' 262 file.metadata['totaldiscs'] = '1' 263 file.metadata['originaldate'] = '1965-08-06' 264 file.metadata['originalyear'] = '1965' 265 file.metadata['date'] = '2014-09-08' 266 file.metadata['releasetype'] = ['album', 'soundtrack'] 267 file.metadata['~primaryreleasetype'] = ['album'] 268 file.metadata['~secondaryreleasetype'] = ['soundtrack'] 269 file.metadata['releasestatus'] = 'official' 270 file.metadata['releasecountry'] = 'US' 271 file.metadata['~extension'] = 'mp3' 272 file.metadata['musicbrainz_albumid'] = 'd7fbbb0a-1348-40ad-8eef-cd438d4cd203' 273 file.metadata['musicbrainz_albumartistid'] = 'b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d' 274 file.metadata['musicbrainz_artistid'] = 'b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d' 275 file.metadata['musicbrainz_recordingid'] = 'ed052ae1-c950-47f2-8d2b-46e1b58ab76c' 276 file.metadata['musicbrainz_releasetrackid'] = '392639f5-5629-477e-b04b-93bffa703405' 277 return file 278 279 def example_2(self): 280 config = get_config() 281 file = File("track05.mp3") 282 file.state = File.NORMAL 283 file.metadata['album'] = "Coup d'État, Volume 1: Ku De Ta / Prologue" 284 file.metadata['title'] = "I've Got to Learn the Mambo" 285 file.metadata['artist'] = "Snowboy feat. James Hunter" 286 file.metadata['artistsort'] = "Snowboy feat. Hunter, James" 287 file.metadata['albumartist'] = config.setting['va_name'] 288 file.metadata['albumartistsort'] = config.setting['va_name'] 289 file.metadata['tracknumber'] = '5' 290 file.metadata['totaltracks'] = '13' 291 file.metadata['discnumber'] = '2' 292 file.metadata['totaldiscs'] = '2' 293 file.metadata['discsubtitle'] = "Beat Up" 294 file.metadata['originaldate'] = '2005-07-04' 295 file.metadata['originalyear'] = '2005' 296 file.metadata['date'] = '2005-07-04' 297 file.metadata['releasetype'] = ['album', 'compilation'] 298 file.metadata['~primaryreleasetype'] = 'album' 299 file.metadata['~secondaryreleasetype'] = 'compilation' 300 file.metadata['releasestatus'] = 'official' 301 file.metadata['releasecountry'] = 'AU' 302 file.metadata['compilation'] = '1' 303 file.metadata['~multiartist'] = '1' 304 file.metadata['~extension'] = 'mp3' 305 file.metadata['musicbrainz_albumid'] = '4b50c71e-0a07-46ac-82e4-cb85dc0c9bdd' 306 file.metadata['musicbrainz_recordingid'] = 'b3c487cb-0e55-477d-8df3-01ec6590f099' 307 file.metadata['musicbrainz_releasetrackid'] = 'f8649a05-da39-39ba-957c-7abf8f9012be' 308 file.metadata['musicbrainz_albumartistid'] = '89ad4ac3-39f7-470e-963a-56509c546377' 309 file.metadata['musicbrainz_artistid'] = ['7b593455-d207-482c-8c6f-19ce22c94679', 310 '9e082466-2390-40d1-891e-4803531f43fd'] 311 return file 312 313 def move_files_to_browse(self): 314 path = QtWidgets.QFileDialog.getExistingDirectory(self, "", self.ui.move_files_to.text()) 315 if path: 316 path = os.path.normpath(path) 317 self.ui.move_files_to.setText(path) 318 319 def test(self): 320 self.ui.renaming_error.setStyleSheet("") 321 self.ui.renaming_error.setText("") 322 try: 323 self.check_format() 324 except ScriptCheckError as e: 325 self.ui.renaming_error.setStyleSheet(self.STYLESHEET_ERROR) 326 self.ui.renaming_error.setText(e.info) 327 return 328 329 330register_options_page(RenamingOptionsPage) 331