def _test_plugin_install(self, name): unload_plugin('dummyplugin') with self.assertRaises(ImportError): from picard.plugins.dummyplugin import DummyPlugin plugin_path = _testplugins[name] pm = PluginManager(plugins_directory=self.tmp_directory) msg = "install_plugin: %s %r" % (name, plugin_path) pm.install_plugin(plugin_path) self.assertEqual(len(pm.plugins), 1, msg) self.assertEqual(pm.plugins[0].name, 'Dummy plugin', msg) # if module is properly loaded, this should work from picard.plugins.dummyplugin import DummyPlugin DummyPlugin()
def _test_plugin_install(self, name): unload_plugin('dummyplugin') with self.assertRaises(ImportError): from picard.plugins.dummyplugin import DummyPlugin plugin_path = _testplugins[name] pm = PluginManager(plugins_directory=self.tmp_directory) msg = "install_plugin: %s %r" % (name, plugin_path) pm.install_plugin(plugin_path) self.assertEqual(len(pm.plugins), 1, msg) self.assertEqual(pm.plugins[0].name, 'Dummy plugin', msg) # if module is properly loaded, this should work from picard.plugins.dummyplugin import DummyPlugin # noqa: F811 DummyPlugin()
def _test_plugin_load_from_directory(self, name): unload_plugin('dummyplugin') with self.assertRaises(ImportError): from picard.plugins.dummyplugin import DummyPlugin pm = PluginManager(plugins_directory=self.tmp_directory) src_dir = os.path.dirname(_testplugins[name]) msg = "plugins_load_from_directory: %s %r" % (name, src_dir) pm.load_plugins_from_directory(src_dir) self.assertEqual(len(pm.plugins), 1, msg) self.assertEqual(pm.plugins[0].name, 'Dummy plugin', msg) # if module is properly loaded, this should work from picard.plugins.dummyplugin import DummyPlugin # noqa: F811 DummyPlugin()
def _test_plugin_load_from_directory(self, name): unload_plugin('dummyplugin') with self.assertRaises(ImportError): from picard.plugins.dummyplugin import DummyPlugin pm = PluginManager(plugins_directory=self.tmp_directory) src_dir = os.path.dirname(_testplugins[name]) msg = "plugins_load_from_directory: %s %r" % (name, src_dir) pm.load_plugins_from_directory(src_dir) self.assertEqual(len(pm.plugins), 1, msg) self.assertEqual(pm.plugins[0].name, 'Dummy plugin', msg) # if module is properly loaded, this should work from picard.plugins.dummyplugin import DummyPlugin DummyPlugin()
def _test_plugin_install_data(self, name): unload_plugin('dummyplugin') with self.assertRaises(ImportError): from picard.plugins.dummyplugin import DummyPlugin # simulate installation from UI using data from picard plugins api web service with open(_testplugins[name], 'rb') as f: data = f.read() pm = PluginManager(plugins_directory=self.tmp_directory) msg = "install_plugin_data: %s data: %d bytes" % (name, len(data)) pm.install_plugin(None, plugin_name='dummyplugin', plugin_data=data) self.assertEqual(len(pm.plugins), 1, msg) self.assertEqual(pm.plugins[0].name, 'Dummy plugin', msg) # if module is properly loaded, this should work from picard.plugins.dummyplugin import DummyPlugin # noqa: F811 DummyPlugin()
def _test_plugin_install_data(self, name): unload_plugin('dummyplugin') with self.assertRaises(ImportError): from picard.plugins.dummyplugin import DummyPlugin # simulate installation from UI using data from picard plugins api web service with open(_testplugins[name], 'rb') as f: data = f.read() pm = PluginManager(plugins_directory=self.tmp_directory) msg = "install_plugin_data: %s data: %d bytes" % (name, len(data)) pm.install_plugin(None, plugin_name='dummyplugin', plugin_data=data) self.assertEqual(len(pm.plugins), 1, msg) self.assertEqual(pm.plugins[0].name, 'Dummy plugin', msg) # if module is properly loaded, this should work from picard.plugins.dummyplugin import DummyPlugin DummyPlugin()
def __init__(self, picard_args, unparsed_args, localedir, autoupdate): # Use the new fusion style from PyQt5 for a modern and consistent look # across all OSes. if not IS_MACOS: self.setStyle('Fusion') # Set the WM_CLASS to 'MusicBrainz-Picard' so desktop environments # can use it to look up the app super().__init__(['MusicBrainz-Picard'] + unparsed_args) self.__class__.__instance = self config._setup(self, picard_args.config_file) super().setStyleSheet( 'QGroupBox::title { /* PICARD-1206, Qt bug workaround */ }') self._cmdline_files = picard_args.FILE self.autoupdate_enabled = autoupdate self._no_restore = picard_args.no_restore self._no_plugins = picard_args.no_plugins self.set_log_level(config.setting['log_verbosity']) if picard_args.debug or "PICARD_DEBUG" in os.environ: self.set_log_level(logging.DEBUG) # FIXME: Figure out what's wrong with QThreadPool.globalInstance(). # It's a valid reference, but its start() method doesn't work. self.thread_pool = QtCore.QThreadPool(self) # Provide a separate thread pool for operations that should not be # delayed by longer background processing tasks, e.g. because the user # expects instant feedback instead of waiting for a long list of # operations to finish. self.priority_thread_pool = QtCore.QThreadPool(self) # Use a separate thread pool for file saving, with a thread count of 1, # to avoid race conditions in File._save_and_rename. self.save_thread_pool = QtCore.QThreadPool(self) self.save_thread_pool.setMaxThreadCount(1) if not IS_WIN: # Set up signal handling # It's not possible to call all available functions from signal # handlers, therefore we need to set up a QSocketNotifier to listen # on a socket. Sending data through a socket can be done in a # signal handler, so we use the socket to notify the application of # the signal. # This code is adopted from # https://qt-project.org/doc/qt-4.8/unix-signals.html # To not make the socket module a requirement for the Windows # installer, import it here and not globally import socket self.signalfd = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) self.signalnotifier = QtCore.QSocketNotifier( self.signalfd[1].fileno(), QtCore.QSocketNotifier.Read, self) self.signalnotifier.activated.connect(self.sighandler) signal.signal(signal.SIGHUP, self.signal) signal.signal(signal.SIGINT, self.signal) signal.signal(signal.SIGTERM, self.signal) if IS_MACOS: # On macOS it is not common that the global menu shows icons self.setAttribute(QtCore.Qt.AA_DontShowIconsInMenus) # Setup logging log.debug("Starting Picard from %r", os.path.abspath(__file__)) log.debug("Platform: %s %s %s", platform.platform(), platform.python_implementation(), platform.python_version()) log.debug("Versions: %s", versions.as_string()) log.debug("Configuration file path: %r", config.config.fileName()) log.debug("User directory: %r", os.path.abspath(USER_DIR)) # for compatibility with pre-1.3 plugins QtCore.QObject.tagger = self QtCore.QObject.config = config QtCore.QObject.log = log check_io_encoding() # Must be before config upgrade because upgrade dialogs need to be # translated setup_gettext(localedir, config.setting["ui_language"], log.debug) upgrade_config(config.config) self.webservice = WebService() self.mb_api = MBAPIHelper(self.webservice) self.acoustid_api = AcoustIdAPIHelper(self.webservice) load_user_collections() # Initialize fingerprinting self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() if not self._no_plugins: if IS_FROZEN: self.pluginmanager.load_plugins_from_directory( os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: mydir = os.path.dirname(os.path.abspath(__file__)) self.pluginmanager.load_plugins_from_directory( os.path.join(mydir, "plugins")) if not os.path.exists(USER_PLUGIN_DIR): os.makedirs(USER_PLUGIN_DIR) self.pluginmanager.load_plugins_from_directory(USER_PLUGIN_DIR) self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unclustered_files = UnclusteredFiles() self.nats = None self.window = MainWindow() self.exit_cleanup = [] self.stopping = False # Load release version information if self.autoupdate_enabled: self.updatecheckmanager = UpdateCheckManager(parent=self.window)
class Tagger(QtWidgets.QApplication): tagger_stats_changed = QtCore.pyqtSignal() listen_port_changed = QtCore.pyqtSignal(int) cluster_added = QtCore.pyqtSignal(Cluster) cluster_removed = QtCore.pyqtSignal(Cluster) album_added = QtCore.pyqtSignal(Album) album_removed = QtCore.pyqtSignal(Album) __instance = None _debug = False _no_restore = False def __init__(self, picard_args, unparsed_args, localedir, autoupdate): # Use the new fusion style from PyQt5 for a modern and consistent look # across all OSes. if not IS_MACOS: self.setStyle('Fusion') # Set the WM_CLASS to 'MusicBrainz-Picard' so desktop environments # can use it to look up the app super().__init__(['MusicBrainz-Picard'] + unparsed_args) self.__class__.__instance = self config._setup(self, picard_args.config_file) super().setStyleSheet( 'QGroupBox::title { /* PICARD-1206, Qt bug workaround */ }') self._cmdline_files = picard_args.FILE self.autoupdate_enabled = autoupdate self._no_restore = picard_args.no_restore self._no_plugins = picard_args.no_plugins self.set_log_level(config.setting['log_verbosity']) if picard_args.debug or "PICARD_DEBUG" in os.environ: self.set_log_level(logging.DEBUG) # FIXME: Figure out what's wrong with QThreadPool.globalInstance(). # It's a valid reference, but its start() method doesn't work. self.thread_pool = QtCore.QThreadPool(self) # Provide a separate thread pool for operations that should not be # delayed by longer background processing tasks, e.g. because the user # expects instant feedback instead of waiting for a long list of # operations to finish. self.priority_thread_pool = QtCore.QThreadPool(self) # Use a separate thread pool for file saving, with a thread count of 1, # to avoid race conditions in File._save_and_rename. self.save_thread_pool = QtCore.QThreadPool(self) self.save_thread_pool.setMaxThreadCount(1) if not IS_WIN: # Set up signal handling # It's not possible to call all available functions from signal # handlers, therefore we need to set up a QSocketNotifier to listen # on a socket. Sending data through a socket can be done in a # signal handler, so we use the socket to notify the application of # the signal. # This code is adopted from # https://qt-project.org/doc/qt-4.8/unix-signals.html # To not make the socket module a requirement for the Windows # installer, import it here and not globally import socket self.signalfd = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) self.signalnotifier = QtCore.QSocketNotifier( self.signalfd[1].fileno(), QtCore.QSocketNotifier.Read, self) self.signalnotifier.activated.connect(self.sighandler) signal.signal(signal.SIGHUP, self.signal) signal.signal(signal.SIGINT, self.signal) signal.signal(signal.SIGTERM, self.signal) if IS_MACOS: # On macOS it is not common that the global menu shows icons self.setAttribute(QtCore.Qt.AA_DontShowIconsInMenus) # Setup logging log.debug("Starting Picard from %r", os.path.abspath(__file__)) log.debug("Platform: %s %s %s", platform.platform(), platform.python_implementation(), platform.python_version()) log.debug("Versions: %s", versions.as_string()) log.debug("Configuration file path: %r", config.config.fileName()) log.debug("User directory: %r", os.path.abspath(USER_DIR)) # for compatibility with pre-1.3 plugins QtCore.QObject.tagger = self QtCore.QObject.config = config QtCore.QObject.log = log check_io_encoding() # Must be before config upgrade because upgrade dialogs need to be # translated setup_gettext(localedir, config.setting["ui_language"], log.debug) upgrade_config(config.config) self.webservice = WebService() self.mb_api = MBAPIHelper(self.webservice) self.acoustid_api = AcoustIdAPIHelper(self.webservice) load_user_collections() # Initialize fingerprinting self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() if not self._no_plugins: if IS_FROZEN: self.pluginmanager.load_plugins_from_directory( os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: mydir = os.path.dirname(os.path.abspath(__file__)) self.pluginmanager.load_plugins_from_directory( os.path.join(mydir, "plugins")) if not os.path.exists(USER_PLUGIN_DIR): os.makedirs(USER_PLUGIN_DIR) self.pluginmanager.load_plugins_from_directory(USER_PLUGIN_DIR) self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unclustered_files = UnclusteredFiles() self.nats = None self.window = MainWindow() self.exit_cleanup = [] self.stopping = False # Load release version information if self.autoupdate_enabled: self.updatecheckmanager = UpdateCheckManager(parent=self.window) def register_cleanup(self, func): self.exit_cleanup.append(func) def run_cleanup(self): for f in self.exit_cleanup: f() def set_log_level(self, level): self._debug = level == logging.DEBUG log.set_level(level) def mb_login(self, callback, parent=None): scopes = "profile tag rating collection submit_isrc submit_barcode" authorization_url = self.webservice.oauth_manager.get_authorization_url( scopes) webbrowser2.open(authorization_url) if not parent: parent = self.window authorization_code, ok = QtWidgets.QInputDialog.getText( parent, _("MusicBrainz Account"), _("Authorization code:")) if ok: self.webservice.oauth_manager.exchange_authorization_code( authorization_code, scopes, partial(self.on_mb_authorization_finished, callback)) else: callback(False) def on_mb_authorization_finished(self, callback, successful=False): if successful: self.webservice.oauth_manager.fetch_username( partial(self.on_mb_login_finished, callback)) else: callback(False) @classmethod def on_mb_login_finished(self, callback, successful): if successful: load_user_collections() callback(successful) def mb_logout(self): self.webservice.oauth_manager.revoke_tokens() load_user_collections() def move_files_to_album(self, files, albumid=None, album=None): """Move `files` to tracks on album `albumid`.""" if album is None: album = self.load_album(albumid) if album.loaded: album.match_files(files) else: for file in list(files): file.move(album.unmatched_files) def move_file_to_album(self, file, albumid): """Move `file` to a track on album `albumid`.""" self.move_files_to_album([file], albumid) def move_file_to_track(self, file, albumid, recordingid): """Move `file` to recording `recordingid` on album `albumid`.""" album = self.load_album(albumid) file.move(album.unmatched_files) album.run_when_loaded( partial(album.match_files, [file], recordingid=recordingid)) def create_nats(self): if self.nats is None: self.nats = NatAlbum() self.albums["NATS"] = self.nats self.album_added.emit(self.nats) self.nats.item.setExpanded(True) return self.nats def move_file_to_nat(self, file, recordingid, node=None): self.create_nats() file.move(self.nats.unmatched_files) nat = self.load_nat(recordingid, node=node) nat.run_when_loaded(partial(file.move, nat)) if nat.loaded: self.nats.update() def exit(self): if self.stopping: return self.stopping = True log.debug("Picard stopping") self._acoustid.done() self.thread_pool.waitForDone() self.save_thread_pool.waitForDone() self.priority_thread_pool.waitForDone() self.browser_integration.stop() self.webservice.stop() self.run_cleanup() QtCore.QCoreApplication.processEvents() def _run_init(self): if self._cmdline_files: files = [] for file in self._cmdline_files: if os.path.isdir(file): self.add_directory(decode_filename(file)) else: files.append(decode_filename(file)) if files: self.add_files(files) del self._cmdline_files def run(self): if config.setting["browser_integration"]: self.browser_integration.start() self.window.show() QtCore.QTimer.singleShot(0, self._run_init) res = self.exec_() self.exit() return res def event(self, event): if isinstance(event, thread.ProxyToMainEvent): event.run() elif event.type() == QtCore.QEvent.FileOpen: self.add_files([event.file()]) # We should just return True here, except that seems to # cause the event's sender to get a -9874 error, so # apparently there's some magic inside QFileOpenEvent... return 1 return super().event(event) def _file_loaded(self, file, target=None): if file is None or file.has_error(): return if target is not None: self.move_files([file], target) return if not config.setting["ignore_file_mbids"]: recordingid = file.metadata['musicbrainz_recordingid'] is_valid_recordingid = mbid_validate(recordingid) albumid = file.metadata['musicbrainz_albumid'] is_valid_albumid = mbid_validate(albumid) if is_valid_albumid and is_valid_recordingid: log.debug( "%r has release (%s) and recording (%s) MBIDs, moving to track...", file, albumid, recordingid) self.move_file_to_track(file, albumid, recordingid) return if is_valid_albumid: log.debug("%r has only release MBID (%s), moving to album...", file, albumid) self.move_file_to_album(file, albumid) return if is_valid_recordingid: log.debug( "%r has only recording MBID (%s), moving to non-album track...", file, recordingid) self.move_file_to_nat(file, recordingid) return # fallback on analyze if nothing else worked if config.setting['analyze_new_files'] and file.can_analyze(): log.debug("Trying to analyze %r ...", file) self.analyze([file]) def move_files(self, files, target): if target is None: log.debug("Aborting move since target is invalid") return if isinstance(target, (Track, Cluster)): for file in files: file.move(target) elif isinstance(target, File): for file in files: file.move(target.parent) elif isinstance(target, Album): self.move_files_to_album(files, album=target) elif isinstance(target, ClusterList): self.cluster(files) def add_files(self, filenames, target=None): """Add files to the tagger.""" ignoreregex = None pattern = config.setting['ignore_regex'] if pattern: ignoreregex = re.compile(pattern) ignore_hidden = config.setting["ignore_hidden_files"] new_files = [] for filename in filenames: filename = os.path.normpath(os.path.realpath(filename)) if ignore_hidden and is_hidden(filename): log.debug("File ignored (hidden): %r" % (filename)) continue # Ignore .smbdelete* files which Applie iOS SMB creates by renaming a file when it cannot delete it if os.path.basename(filename).startswith(".smbdelete"): log.debug("File ignored (.smbdelete): %r", filename) continue if ignoreregex is not None and ignoreregex.search(filename): log.info("File ignored (matching %r): %r" % (pattern, filename)) continue if filename not in self.files: file = open_file(filename) if file: self.files[filename] = file new_files.append(file) if new_files: log.debug("Adding files %r", new_files) new_files.sort(key=lambda x: x.filename) if target is None or target is self.unclustered_files: self.unclustered_files.add_files(new_files) target = None for file in new_files: file.load(partial(self._file_loaded, target=target)) def add_directory(self, path): if config.setting['recursively_add_files']: self._add_directory_recursive(path) else: self._add_directory_non_recursive(path) def _add_directory_recursive(self, path): ignore_hidden = config.setting["ignore_hidden_files"] walk = os.walk(path) def get_files(): try: root, dirs, files = next(walk) if ignore_hidden: dirs[:] = [ d for d in dirs if not is_hidden(os.path.join(root, d)) ] except StopIteration: return None else: number_of_files = len(files) if number_of_files: mparms = { 'count': number_of_files, 'directory': root, } log.debug("Adding %(count)d files from '%(directory)r'" % mparms) self.window.set_statusbar_message(ngettext( "Adding %(count)d file from '%(directory)s' ...", "Adding %(count)d files from '%(directory)s' ...", number_of_files), mparms, translate=None, echo=None) return (os.path.join(root, f) for f in files) def process(result=None, error=None): if result: if error is None: self.add_files(result) thread.run_task(get_files, process) process(True, False) def _add_directory_non_recursive(self, path): files = [] for f in os.listdir(path): listing = os.path.join(path, f) if os.path.isfile(listing): files.append(listing) number_of_files = len(files) if number_of_files: mparms = { 'count': number_of_files, 'directory': path, } log.debug("Adding %(count)d files from '%(directory)r'" % mparms) self.window.set_statusbar_message(ngettext( "Adding %(count)d file from '%(directory)s' ...", "Adding %(count)d files from '%(directory)s' ...", number_of_files), mparms, translate=None, echo=None) # Function call only if files exist self.add_files(files) def get_file_lookup(self): """Return a FileLookup object.""" return FileLookup(self, config.setting["server_host"], config.setting["server_port"], self.browser_integration.port) def copy_files(self, objects): mimeData = QtCore.QMimeData() mimeData.setUrls([ QtCore.QUrl.fromLocalFile(f.filename) for f in (self.get_files_from_objects(objects)) ]) self.clipboard().setMimeData(mimeData) def paste_files(self, target): mimeData = self.clipboard().mimeData() if mimeData.hasUrls(): BaseTreeView.drop_urls(mimeData.urls(), target) def search(self, text, search_type, adv=False, mbid_matched_callback=None, force_browser=False): """Search on the MusicBrainz website.""" search_types = { 'track': { 'entity': 'recording', 'dialog': TrackSearchDialog }, 'album': { 'entity': 'release', 'dialog': AlbumSearchDialog }, 'artist': { 'entity': 'artist', 'dialog': ArtistSearchDialog }, } if search_type not in search_types: return search = search_types[search_type] lookup = self.get_file_lookup() if config.setting["builtin_search"] and not force_browser: if not lookup.mbid_lookup( text, search['entity'], mbid_matched_callback=mbid_matched_callback): dialog = search['dialog'](self.window) dialog.search(text) dialog.exec_() else: lookup.search_entity(search['entity'], text, adv, mbid_matched_callback=mbid_matched_callback) def collection_lookup(self): """Lookup the users collections on the MusicBrainz website.""" lookup = self.get_file_lookup() lookup.collection_lookup(config.persist["oauth_username"]) def browser_lookup(self, item): """Lookup the object's metadata on the MusicBrainz website.""" lookup = self.get_file_lookup() metadata = item.metadata # Only lookup via MB IDs if matched to a DataObject; otherwise ignore and use metadata details if isinstance(item, DataObject): itemid = item.id if isinstance(item, Track): lookup.recording_lookup(itemid) elif isinstance(item, Album): lookup.album_lookup(itemid) else: lookup.tag_lookup( metadata["albumartist"] if item.is_album_like() else metadata["artist"], metadata["album"], metadata["title"], metadata["tracknumber"], '' if item.is_album_like() else str(metadata.length), item.filename if isinstance(item, File) else '') def get_files_from_objects(self, objects, save=False): """Return list of files from list of albums, clusters, tracks or files.""" return uniqify(chain(*[obj.iterfiles(save) for obj in objects])) def save(self, objects): """Save the specified objects.""" files = self.get_files_from_objects(objects, save=True) for file in files: file.save() def load_album(self, album_id, discid=None): album_id = self.mbid_redirects.get(album_id, album_id) album = self.albums.get(album_id) if album: log.debug("Album %s already loaded.", album_id) album.add_discid(discid) return album album = Album(album_id, discid=discid) self.albums[album_id] = album self.album_added.emit(album) album.load() return album def load_nat(self, nat_id, node=None): self.create_nats() nat = self.get_nat_by_id(nat_id) if nat: log.debug("NAT %s already loaded.", nat_id) return nat nat = NonAlbumTrack(nat_id) self.nats.tracks.append(nat) self.nats.update(True) if node: nat._parse_recording(node) else: nat.load() return nat def get_nat_by_id(self, nat_id): if self.nats is not None: for nat in self.nats.tracks: if nat.id == nat_id: return nat def get_release_group_by_id(self, rg_id): return self.release_groups.setdefault(rg_id, ReleaseGroup(rg_id)) def remove_files(self, files, from_parent=True): """Remove files from the tagger.""" for file in files: if file.filename in self.files: file.clear_lookup_task() self._acoustid.stop_analyze(file) del self.files[file.filename] file.remove(from_parent) def remove_album(self, album): """Remove the specified album.""" log.debug("Removing %r", album) album.stop_loading() self.remove_files(self.get_files_from_objects([album])) del self.albums[album.id] if album.release_group: album.release_group.remove_album(album.id) if album == self.nats: self.nats = None self.album_removed.emit(album) run_album_post_removal_processors(album) def remove_nat(self, track): """Remove the specified non-album track.""" log.debug("Removing %r", track) self.remove_files(self.get_files_from_objects([track])) self.nats.tracks.remove(track) if not self.nats.tracks: self.remove_album(self.nats) else: self.nats.update(True) def remove_cluster(self, cluster): """Remove the specified cluster.""" if not cluster.special: log.debug("Removing %r", cluster) files = list(cluster.files) cluster.files = [] cluster.clear_lookup_task() self.remove_files(files, from_parent=False) self.clusters.remove(cluster) self.cluster_removed.emit(cluster) def remove(self, objects): """Remove the specified objects.""" files = [] for obj in objects: if isinstance(obj, File): files.append(obj) elif isinstance(obj, NonAlbumTrack): self.remove_nat(obj) elif isinstance(obj, Track): files.extend(obj.linked_files) elif isinstance(obj, Album) and not isinstance(obj, NatAlbum): self.window.set_statusbar_message( N_("Removing album %(id)s: %(artist)s - %(album)s"), { 'id': obj.id, 'artist': obj.metadata['albumartist'], 'album': obj.metadata['album'] }) self.remove_album(obj) elif isinstance(obj, UnclusteredFiles): files.extend(list(obj.files)) elif isinstance(obj, Cluster): self.remove_cluster(obj) if files: self.remove_files(files) def _lookup_disc(self, disc, result=None, error=None): self.restore_cursor() if error is not None: QtWidgets.QMessageBox.critical( self.window, _("CD Lookup Error"), _("Error while reading CD:\n\n%s") % error) else: disc.lookup() def lookup_cd(self, action): """Reads CD from the selected drive and tries to lookup the DiscID on MusicBrainz.""" if isinstance(action, QtWidgets.QAction): device = action.data() elif config.setting["cd_lookup_device"] != '': device = config.setting["cd_lookup_device"].split(",", 1)[0] else: # rely on python-discid auto detection device = None disc = Disc() self.set_wait_cursor() thread.run_task(partial(disc.read, encode_filename(device)), partial(self._lookup_disc, disc), traceback=self._debug) @property def use_acoustid(self): return config.setting["fingerprinting_system"] == "acoustid" def analyze(self, objs): """Analyze the file(s).""" if not self.use_acoustid: return files = self.get_files_from_objects(objs) for file in files: file.set_pending() self._acoustid.analyze( file, partial(file._lookup_finished, File.LOOKUP_ACOUSTID)) # ======================================================================= # Metadata-based lookups # ======================================================================= def autotag(self, objects): for obj in objects: if obj.can_autotag(): obj.lookup_metadata() # ======================================================================= # Clusters # ======================================================================= def cluster(self, objs): """Group files with similar metadata to 'clusters'.""" log.debug("Clustering %r", objs) if len(objs) <= 1 or self.unclustered_files in objs: files = list(self.unclustered_files.files) else: files = self.get_files_from_objects(objs) for name, artist, files in Cluster.cluster(files, 1.0): QtCore.QCoreApplication.processEvents() cluster = self.load_cluster(name, artist) for file in sorted(files, key=attrgetter('discnumber', 'tracknumber', 'base_filename')): file.move(cluster) def load_cluster(self, name, artist): for cluster in self.clusters: cm = cluster.metadata if name == cm["album"] and artist == cm["albumartist"]: return cluster cluster = Cluster(name, artist) self.clusters.append(cluster) self.cluster_added.emit(cluster) return cluster # ======================================================================= # Utils # ======================================================================= def set_wait_cursor(self): """Sets the waiting cursor.""" super().setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor)) def restore_cursor(self): """Restores the cursor set by ``set_wait_cursor``.""" super().restoreOverrideCursor() def refresh(self, objs): for obj in objs: if obj.can_refresh(): obj.load(priority=True, refresh=True) def bring_tagger_front(self): self.window.setWindowState(self.window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) self.window.raise_() self.window.activateWindow() @classmethod def instance(cls): return cls.__instance def signal(self, signum, frame): log.debug("signal %i received", signum) # Send a notification about a received signal from the signal handler # to Qt. self.signalfd[0].sendall(b"a") def sighandler(self): self.signalnotifier.setEnabled(False) self.exit() self.quit() self.signalnotifier.setEnabled(True)
def test_plugin_install_no_path_no_plugin_name(self): pm = PluginManager(plugins_directory=self.tmp_directory) with self.assertRaises(AssertionError): pm.install_plugin(None)
def __init__(self, picard_args, unparsed_args, localedir, autoupdate): # Use the new fusion style from PyQt5 for a modern and consistent look # across all OSes. if not IS_MACOS: self.setStyle('Fusion') # Set the WM_CLASS to 'MusicBrainz-Picard' so desktop environments # can use it to look up the app super().__init__(['MusicBrainz-Picard'] + unparsed_args) self.__class__.__instance = self config._setup(self, picard_args.config_file) # Allow High DPI Support self.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) self.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) super().setStyleSheet( 'QGroupBox::title { /* PICARD-1206, Qt bug workaround */ }' ) self._cmdline_files = picard_args.FILE self.autoupdate_enabled = autoupdate self._no_restore = picard_args.no_restore self._no_plugins = picard_args.no_plugins self.set_log_level(config.setting['log_verbosity']) if picard_args.debug or "PICARD_DEBUG" in os.environ: self.set_log_level(logging.DEBUG) # FIXME: Figure out what's wrong with QThreadPool.globalInstance(). # It's a valid reference, but its start() method doesn't work. self.thread_pool = QtCore.QThreadPool(self) # Provide a separate thread pool for operations that should not be # delayed by longer background processing tasks, e.g. because the user # expects instant feedback instead of waiting for a long list of # operations to finish. self.priority_thread_pool = QtCore.QThreadPool(self) # Use a separate thread pool for file saving, with a thread count of 1, # to avoid race conditions in File._save_and_rename. self.save_thread_pool = QtCore.QThreadPool(self) self.save_thread_pool.setMaxThreadCount(1) if not IS_WIN: # Set up signal handling # It's not possible to call all available functions from signal # handlers, therefore we need to set up a QSocketNotifier to listen # on a socket. Sending data through a socket can be done in a # signal handler, so we use the socket to notify the application of # the signal. # This code is adopted from # https://qt-project.org/doc/qt-4.8/unix-signals.html # To not make the socket module a requirement for the Windows # installer, import it here and not globally import socket self.signalfd = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) self.signalnotifier = QtCore.QSocketNotifier(self.signalfd[1].fileno(), QtCore.QSocketNotifier.Read, self) self.signalnotifier.activated.connect(self.sighandler) signal.signal(signal.SIGHUP, self.signal) signal.signal(signal.SIGINT, self.signal) signal.signal(signal.SIGTERM, self.signal) if IS_MACOS: # On macOS it is not common that the global menu shows icons self.setAttribute(QtCore.Qt.AA_DontShowIconsInMenus) # Setup logging log.debug("Starting Picard from %r", os.path.abspath(__file__)) log.debug("Platform: %s %s %s", platform.platform(), platform.python_implementation(), platform.python_version()) log.debug("Versions: %s", versions.as_string()) log.debug("Configuration file path: %r", config.config.fileName()) log.debug("User directory: %r", os.path.abspath(USER_DIR)) # for compatibility with pre-1.3 plugins QtCore.QObject.tagger = self QtCore.QObject.config = config QtCore.QObject.log = log check_io_encoding() # Must be before config upgrade because upgrade dialogs need to be # translated setup_gettext(localedir, config.setting["ui_language"], log.debug) upgrade_config(config.config) self.webservice = WebService() self.mb_api = MBAPIHelper(self.webservice) self.acoustid_api = AcoustIdAPIHelper(self.webservice) load_user_collections() # Initialize fingerprinting self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() if not self._no_plugins: if IS_FROZEN: self.pluginmanager.load_plugins_from_directory(os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: mydir = os.path.dirname(os.path.abspath(__file__)) self.pluginmanager.load_plugins_from_directory(os.path.join(mydir, "plugins")) if not os.path.exists(USER_PLUGIN_DIR): os.makedirs(USER_PLUGIN_DIR) self.pluginmanager.load_plugins_from_directory(USER_PLUGIN_DIR) self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unclustered_files = UnclusteredFiles() self.nats = None self.window = MainWindow() self.exit_cleanup = [] self.stopping = False # Load release version information if self.autoupdate_enabled: self.updatecheckmanager = UpdateCheckManager(parent=self.window)
class Tagger(QtWidgets.QApplication): tagger_stats_changed = QtCore.pyqtSignal() listen_port_changed = QtCore.pyqtSignal(int) cluster_added = QtCore.pyqtSignal(Cluster) cluster_removed = QtCore.pyqtSignal(Cluster) album_added = QtCore.pyqtSignal(Album) album_removed = QtCore.pyqtSignal(Album) __instance = None _debug = False _no_restore = False def __init__(self, picard_args, unparsed_args, localedir, autoupdate): # Use the new fusion style from PyQt5 for a modern and consistent look # across all OSes. if not IS_MACOS: self.setStyle('Fusion') # Set the WM_CLASS to 'MusicBrainz-Picard' so desktop environments # can use it to look up the app super().__init__(['MusicBrainz-Picard'] + unparsed_args) self.__class__.__instance = self config._setup(self, picard_args.config_file) # Allow High DPI Support self.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) self.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) super().setStyleSheet( 'QGroupBox::title { /* PICARD-1206, Qt bug workaround */ }' ) self._cmdline_files = picard_args.FILE self.autoupdate_enabled = autoupdate self._no_restore = picard_args.no_restore self._no_plugins = picard_args.no_plugins self.set_log_level(config.setting['log_verbosity']) if picard_args.debug or "PICARD_DEBUG" in os.environ: self.set_log_level(logging.DEBUG) # FIXME: Figure out what's wrong with QThreadPool.globalInstance(). # It's a valid reference, but its start() method doesn't work. self.thread_pool = QtCore.QThreadPool(self) # Provide a separate thread pool for operations that should not be # delayed by longer background processing tasks, e.g. because the user # expects instant feedback instead of waiting for a long list of # operations to finish. self.priority_thread_pool = QtCore.QThreadPool(self) # Use a separate thread pool for file saving, with a thread count of 1, # to avoid race conditions in File._save_and_rename. self.save_thread_pool = QtCore.QThreadPool(self) self.save_thread_pool.setMaxThreadCount(1) if not IS_WIN: # Set up signal handling # It's not possible to call all available functions from signal # handlers, therefore we need to set up a QSocketNotifier to listen # on a socket. Sending data through a socket can be done in a # signal handler, so we use the socket to notify the application of # the signal. # This code is adopted from # https://qt-project.org/doc/qt-4.8/unix-signals.html # To not make the socket module a requirement for the Windows # installer, import it here and not globally import socket self.signalfd = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) self.signalnotifier = QtCore.QSocketNotifier(self.signalfd[1].fileno(), QtCore.QSocketNotifier.Read, self) self.signalnotifier.activated.connect(self.sighandler) signal.signal(signal.SIGHUP, self.signal) signal.signal(signal.SIGINT, self.signal) signal.signal(signal.SIGTERM, self.signal) if IS_MACOS: # On macOS it is not common that the global menu shows icons self.setAttribute(QtCore.Qt.AA_DontShowIconsInMenus) # Setup logging log.debug("Starting Picard from %r", os.path.abspath(__file__)) log.debug("Platform: %s %s %s", platform.platform(), platform.python_implementation(), platform.python_version()) log.debug("Versions: %s", versions.as_string()) log.debug("Configuration file path: %r", config.config.fileName()) log.debug("User directory: %r", os.path.abspath(USER_DIR)) # for compatibility with pre-1.3 plugins QtCore.QObject.tagger = self QtCore.QObject.config = config QtCore.QObject.log = log check_io_encoding() # Must be before config upgrade because upgrade dialogs need to be # translated setup_gettext(localedir, config.setting["ui_language"], log.debug) upgrade_config(config.config) self.webservice = WebService() self.mb_api = MBAPIHelper(self.webservice) self.acoustid_api = AcoustIdAPIHelper(self.webservice) load_user_collections() # Initialize fingerprinting self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() if not self._no_plugins: if IS_FROZEN: self.pluginmanager.load_plugins_from_directory(os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: mydir = os.path.dirname(os.path.abspath(__file__)) self.pluginmanager.load_plugins_from_directory(os.path.join(mydir, "plugins")) if not os.path.exists(USER_PLUGIN_DIR): os.makedirs(USER_PLUGIN_DIR) self.pluginmanager.load_plugins_from_directory(USER_PLUGIN_DIR) self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unclustered_files = UnclusteredFiles() self.nats = None self.window = MainWindow() self.exit_cleanup = [] self.stopping = False # Load release version information if self.autoupdate_enabled: self.updatecheckmanager = UpdateCheckManager(parent=self.window) def register_cleanup(self, func): self.exit_cleanup.append(func) def run_cleanup(self): for f in self.exit_cleanup: f() def set_log_level(self, level): self._debug = level == logging.DEBUG log.set_level(level) def mb_login(self, callback, parent=None): scopes = "profile tag rating collection submit_isrc submit_barcode" authorization_url = self.webservice.oauth_manager.get_authorization_url(scopes) webbrowser2.open(authorization_url) if not parent: parent = self.window authorization_code, ok = QtWidgets.QInputDialog.getText(parent, _("MusicBrainz Account"), _("Authorization code:")) if ok: self.webservice.oauth_manager.exchange_authorization_code( authorization_code, scopes, partial(self.on_mb_authorization_finished, callback)) else: callback(False) def on_mb_authorization_finished(self, callback, successful=False): if successful: self.webservice.oauth_manager.fetch_username( partial(self.on_mb_login_finished, callback)) else: callback(False) @classmethod def on_mb_login_finished(self, callback, successful): if successful: load_user_collections() callback(successful) def mb_logout(self): self.webservice.oauth_manager.revoke_tokens() load_user_collections() def move_files_to_album(self, files, albumid=None, album=None): """Move `files` to tracks on album `albumid`.""" if album is None: album = self.load_album(albumid) if album.loaded: album.match_files(files) else: for file in list(files): file.move(album.unmatched_files) def move_file_to_album(self, file, albumid): """Move `file` to a track on album `albumid`.""" self.move_files_to_album([file], albumid) def move_file_to_track(self, file, albumid, recordingid): """Move `file` to recording `recordingid` on album `albumid`.""" album = self.load_album(albumid) file.move(album.unmatched_files) album.run_when_loaded(partial(album.match_files, [file], recordingid=recordingid)) def create_nats(self): if self.nats is None: self.nats = NatAlbum() self.albums["NATS"] = self.nats self.album_added.emit(self.nats) self.nats.item.setExpanded(True) return self.nats def move_file_to_nat(self, file, recordingid, node=None): self.create_nats() file.move(self.nats.unmatched_files) nat = self.load_nat(recordingid, node=node) nat.run_when_loaded(partial(file.move, nat)) if nat.loaded: self.nats.update() def exit(self): if self.stopping: return self.stopping = True log.debug("Picard stopping") self._acoustid.done() self.thread_pool.waitForDone() self.save_thread_pool.waitForDone() self.priority_thread_pool.waitForDone() self.browser_integration.stop() self.webservice.stop() self.run_cleanup() QtCore.QCoreApplication.processEvents() def _run_init(self): if self._cmdline_files: files = [] for file in self._cmdline_files: if os.path.isdir(file): self.add_directory(decode_filename(file)) else: files.append(decode_filename(file)) if files: self.add_files(files) del self._cmdline_files def run(self): if config.setting["browser_integration"]: self.browser_integration.start() self.window.show() QtCore.QTimer.singleShot(0, self._run_init) res = self.exec_() self.exit() return res def event(self, event): if isinstance(event, thread.ProxyToMainEvent): event.run() elif event.type() == QtCore.QEvent.FileOpen: self.add_files([event.file()]) # We should just return True here, except that seems to # cause the event's sender to get a -9874 error, so # apparently there's some magic inside QFileOpenEvent... return 1 return super().event(event) def _file_loaded(self, file, target=None): if file is None or file.has_error(): return if target is not None: self.move_files([file], target) return if not config.setting["ignore_file_mbids"]: recordingid = file.metadata['musicbrainz_recordingid'] is_valid_recordingid = mbid_validate(recordingid) albumid = file.metadata['musicbrainz_albumid'] is_valid_albumid = mbid_validate(albumid) if is_valid_albumid and is_valid_recordingid: log.debug("%r has release (%s) and recording (%s) MBIDs, moving to track...", file, albumid, recordingid) self.move_file_to_track(file, albumid, recordingid) return if is_valid_albumid: log.debug("%r has only release MBID (%s), moving to album...", file, albumid) self.move_file_to_album(file, albumid) return if is_valid_recordingid: log.debug("%r has only recording MBID (%s), moving to non-album track...", file, recordingid) self.move_file_to_nat(file, recordingid) return # fallback on analyze if nothing else worked if config.setting['analyze_new_files'] and file.can_analyze(): log.debug("Trying to analyze %r ...", file) self.analyze([file]) def move_files(self, files, target): if target is None: log.debug("Aborting move since target is invalid") return if isinstance(target, (Track, Cluster)): for file in files: file.move(target) elif isinstance(target, File): for file in files: file.move(target.parent) elif isinstance(target, Album): self.move_files_to_album(files, album=target) elif isinstance(target, ClusterList): self.cluster(files) def add_files(self, filenames, target=None): """Add files to the tagger.""" ignoreregex = None pattern = config.setting['ignore_regex'] if pattern: ignoreregex = re.compile(pattern) ignore_hidden = config.setting["ignore_hidden_files"] new_files = [] for filename in filenames: filename = os.path.normpath(os.path.realpath(filename)) if ignore_hidden and is_hidden(filename): log.debug("File ignored (hidden): %r" % (filename)) continue # Ignore .smbdelete* files which Applie iOS SMB creates by renaming a file when it cannot delete it if os.path.basename(filename).startswith(".smbdelete"): log.debug("File ignored (.smbdelete): %r", filename) continue if ignoreregex is not None and ignoreregex.search(filename): log.info("File ignored (matching %r): %r" % (pattern, filename)) continue if filename not in self.files: file = open_file(filename) if file: self.files[filename] = file new_files.append(file) if new_files: log.debug("Adding files %r", new_files) new_files.sort(key=lambda x: x.filename) if target is None or target is self.unclustered_files: self.unclustered_files.add_files(new_files) target = None for file in new_files: file.load(partial(self._file_loaded, target=target)) def add_directory(self, path): if config.setting['recursively_add_files']: self._add_directory_recursive(path) else: self._add_directory_non_recursive(path) def _add_directory_recursive(self, path): ignore_hidden = config.setting["ignore_hidden_files"] walk = os.walk(path) def get_files(): try: root, dirs, files = next(walk) if ignore_hidden: dirs[:] = [d for d in dirs if not is_hidden(os.path.join(root, d))] except StopIteration: return None else: number_of_files = len(files) if number_of_files: mparms = { 'count': number_of_files, 'directory': root, } log.debug("Adding %(count)d files from '%(directory)r'" % mparms) self.window.set_statusbar_message( ngettext( "Adding %(count)d file from '%(directory)s' ...", "Adding %(count)d files from '%(directory)s' ...", number_of_files), mparms, translate=None, echo=None ) return (os.path.join(root, f) for f in files) def process(result=None, error=None): if result: if error is None: self.add_files(result) thread.run_task(get_files, process) process(True, False) def _add_directory_non_recursive(self, path): files = [] for f in os.listdir(path): listing = os.path.join(path, f) if os.path.isfile(listing): files.append(listing) number_of_files = len(files) if number_of_files: mparms = { 'count': number_of_files, 'directory': path, } log.debug("Adding %(count)d files from '%(directory)r'" % mparms) self.window.set_statusbar_message( ngettext( "Adding %(count)d file from '%(directory)s' ...", "Adding %(count)d files from '%(directory)s' ...", number_of_files), mparms, translate=None, echo=None ) # Function call only if files exist self.add_files(files) def get_file_lookup(self): """Return a FileLookup object.""" return FileLookup(self, config.setting["server_host"], config.setting["server_port"], self.browser_integration.port) def copy_files(self, objects): mimeData = QtCore.QMimeData() mimeData.setUrls([QtCore.QUrl.fromLocalFile(f.filename) for f in (self.get_files_from_objects(objects))]) self.clipboard().setMimeData(mimeData) def paste_files(self, target): mimeData = self.clipboard().mimeData() if mimeData.hasUrls(): BaseTreeView.drop_urls(mimeData.urls(), target) def search(self, text, search_type, adv=False, mbid_matched_callback=None, force_browser=False): """Search on the MusicBrainz website.""" search_types = { 'track': { 'entity': 'recording', 'dialog': TrackSearchDialog }, 'album': { 'entity': 'release', 'dialog': AlbumSearchDialog }, 'artist': { 'entity': 'artist', 'dialog': ArtistSearchDialog }, } if search_type not in search_types: return search = search_types[search_type] lookup = self.get_file_lookup() if config.setting["builtin_search"] and not force_browser: if not lookup.mbid_lookup(text, search['entity'], mbid_matched_callback=mbid_matched_callback): dialog = search['dialog'](self.window) dialog.search(text) dialog.exec_() else: lookup.search_entity(search['entity'], text, adv, mbid_matched_callback=mbid_matched_callback) def collection_lookup(self): """Lookup the users collections on the MusicBrainz website.""" lookup = self.get_file_lookup() lookup.collection_lookup(config.persist["oauth_username"]) def browser_lookup(self, item): """Lookup the object's metadata on the MusicBrainz website.""" lookup = self.get_file_lookup() metadata = item.metadata # Only lookup via MB IDs if matched to a DataObject; otherwise ignore and use metadata details if isinstance(item, DataObject): itemid = item.id if isinstance(item, Track): lookup.recording_lookup(itemid) elif isinstance(item, Album): lookup.album_lookup(itemid) else: lookup.tag_lookup( metadata["albumartist"] if item.is_album_like() else metadata["artist"], metadata["album"], metadata["title"], metadata["tracknumber"], '' if item.is_album_like() else str(metadata.length), item.filename if isinstance(item, File) else '') def get_files_from_objects(self, objects, save=False): """Return list of files from list of albums, clusters, tracks or files.""" return uniqify(chain(*[obj.iterfiles(save) for obj in objects])) def save(self, objects): """Save the specified objects.""" files = self.get_files_from_objects(objects, save=True) for file in files: file.save() def load_album(self, album_id, discid=None): album_id = self.mbid_redirects.get(album_id, album_id) album = self.albums.get(album_id) if album: log.debug("Album %s already loaded.", album_id) album.add_discid(discid) return album album = Album(album_id, discid=discid) self.albums[album_id] = album self.album_added.emit(album) album.load() return album def load_nat(self, nat_id, node=None): self.create_nats() nat = self.get_nat_by_id(nat_id) if nat: log.debug("NAT %s already loaded.", nat_id) return nat nat = NonAlbumTrack(nat_id) self.nats.tracks.append(nat) self.nats.update(True) if node: nat._parse_recording(node) else: nat.load() return nat def get_nat_by_id(self, nat_id): if self.nats is not None: for nat in self.nats.tracks: if nat.id == nat_id: return nat def get_release_group_by_id(self, rg_id): return self.release_groups.setdefault(rg_id, ReleaseGroup(rg_id)) def remove_files(self, files, from_parent=True): """Remove files from the tagger.""" for file in files: if file.filename in self.files: file.clear_lookup_task() self._acoustid.stop_analyze(file) del self.files[file.filename] file.remove(from_parent) def remove_album(self, album): """Remove the specified album.""" log.debug("Removing %r", album) album.stop_loading() self.remove_files(self.get_files_from_objects([album])) del self.albums[album.id] if album.release_group: album.release_group.remove_album(album.id) if album == self.nats: self.nats = None self.album_removed.emit(album) run_album_post_removal_processors(album) def remove_nat(self, track): """Remove the specified non-album track.""" log.debug("Removing %r", track) self.remove_files(self.get_files_from_objects([track])) self.nats.tracks.remove(track) if not self.nats.tracks: self.remove_album(self.nats) else: self.nats.update(True) def remove_cluster(self, cluster): """Remove the specified cluster.""" if not cluster.special: log.debug("Removing %r", cluster) files = list(cluster.files) cluster.files = [] cluster.clear_lookup_task() self.remove_files(files, from_parent=False) self.clusters.remove(cluster) self.cluster_removed.emit(cluster) def remove(self, objects): """Remove the specified objects.""" files = [] for obj in objects: if isinstance(obj, File): files.append(obj) elif isinstance(obj, NonAlbumTrack): self.remove_nat(obj) elif isinstance(obj, Track): files.extend(obj.linked_files) elif isinstance(obj, Album): self.window.set_statusbar_message( N_("Removing album %(id)s: %(artist)s - %(album)s"), { 'id': obj.id, 'artist': obj.metadata['albumartist'], 'album': obj.metadata['album'] } ) self.remove_album(obj) elif isinstance(obj, UnclusteredFiles): files.extend(list(obj.files)) elif isinstance(obj, Cluster): self.remove_cluster(obj) if files: self.remove_files(files) def _lookup_disc(self, disc, result=None, error=None): self.restore_cursor() if error is not None: QtWidgets.QMessageBox.critical(self.window, _("CD Lookup Error"), _("Error while reading CD:\n\n%s") % error) else: disc.lookup() def lookup_cd(self, action): """Reads CD from the selected drive and tries to lookup the DiscID on MusicBrainz.""" if isinstance(action, QtWidgets.QAction): device = action.data() elif config.setting["cd_lookup_device"] != '': device = config.setting["cd_lookup_device"].split(",", 1)[0] else: # rely on python-discid auto detection device = None disc = Disc() self.set_wait_cursor() thread.run_task( partial(disc.read, encode_filename(device)), partial(self._lookup_disc, disc), traceback=self._debug) @property def use_acoustid(self): return config.setting["fingerprinting_system"] == "acoustid" def analyze(self, objs): """Analyze the file(s).""" if not self.use_acoustid: return files = self.get_files_from_objects(objs) for file in files: file.set_pending() self._acoustid.analyze(file, partial(file._lookup_finished, File.LOOKUP_ACOUSTID)) # ======================================================================= # Metadata-based lookups # ======================================================================= def autotag(self, objects): for obj in objects: if obj.can_autotag(): obj.lookup_metadata() # ======================================================================= # Clusters # ======================================================================= def cluster(self, objs): """Group files with similar metadata to 'clusters'.""" log.debug("Clustering %r", objs) if len(objs) <= 1 or self.unclustered_files in objs: files = list(self.unclustered_files.files) else: files = self.get_files_from_objects(objs) for name, artist, files in Cluster.cluster(files, 1.0): QtCore.QCoreApplication.processEvents() cluster = self.load_cluster(name, artist) for file in sorted(files, key=attrgetter('discnumber', 'tracknumber', 'base_filename')): file.move(cluster) def load_cluster(self, name, artist): for cluster in self.clusters: cm = cluster.metadata if name == cm["album"] and artist == cm["albumartist"]: return cluster cluster = Cluster(name, artist) self.clusters.append(cluster) self.cluster_added.emit(cluster) return cluster # ======================================================================= # Utils # ======================================================================= def set_wait_cursor(self): """Sets the waiting cursor.""" super().setOverrideCursor( QtGui.QCursor(QtCore.Qt.WaitCursor)) def restore_cursor(self): """Restores the cursor set by ``set_wait_cursor``.""" super().restoreOverrideCursor() def refresh(self, objs): for obj in objs: if obj.can_refresh(): obj.load(priority=True, refresh=True) def bring_tagger_front(self): self.window.setWindowState(self.window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) self.window.raise_() self.window.activateWindow() @classmethod def instance(cls): return cls.__instance def signal(self, signum, frame): log.debug("signal %i received", signum) # Send a notification about a received signal from the signal handler # to Qt. self.signalfd[0].sendall(b"a") def sighandler(self): self.signalnotifier.setEnabled(False) self.exit() self.quit() self.signalnotifier.setEnabled(True)
def __init__(self, picard_args, unparsed_args, localedir, autoupdate): super().__init__(sys.argv) self.__class__.__instance = self setup_config(self, picard_args.config_file) config = get_config() theme.setup(self) self._cmdline_files = picard_args.FILE self.autoupdate_enabled = autoupdate self._no_restore = picard_args.no_restore self._no_plugins = picard_args.no_plugins self.set_log_level(config.setting['log_verbosity']) if picard_args.debug or "PICARD_DEBUG" in os.environ: self.set_log_level(logging.DEBUG) # Default thread pool self.thread_pool = ThreadPoolExecutor() # Provide a separate thread pool for operations that should not be # delayed by longer background processing tasks, e.g. because the user # expects instant feedback instead of waiting for a long list of # operations to finish. self.priority_thread_pool = ThreadPoolExecutor(max_workers=1) # Use a separate thread pool for file saving, with a thread count of 1, # to avoid race conditions in File._save_and_rename. self.save_thread_pool = ThreadPoolExecutor(max_workers=1) if not IS_WIN: # Set up signal handling # It's not possible to call all available functions from signal # handlers, therefore we need to set up a QSocketNotifier to listen # on a socket. Sending data through a socket can be done in a # signal handler, so we use the socket to notify the application of # the signal. # This code is adopted from # https://qt-project.org/doc/qt-4.8/unix-signals.html # To not make the socket module a requirement for the Windows # installer, import it here and not globally import socket self.signalfd = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) self.signalnotifier = QtCore.QSocketNotifier( self.signalfd[1].fileno(), QtCore.QSocketNotifier.Type.Read, self) self.signalnotifier.activated.connect(self.sighandler) signal.signal(signal.SIGHUP, self.signal) signal.signal(signal.SIGINT, self.signal) signal.signal(signal.SIGTERM, self.signal) # Setup logging log.debug("Starting Picard from %r", os.path.abspath(__file__)) log.debug("Platform: %s %s %s", platform.platform(), platform.python_implementation(), platform.python_version()) log.debug("Versions: %s", versions.as_string()) log.debug("Configuration file path: %r", config.fileName()) log.debug("User directory: %r", os.path.abspath(USER_DIR)) # for compatibility with pre-1.3 plugins QtCore.QObject.tagger = self QtCore.QObject.config = config QtCore.QObject.log = log check_io_encoding() # Must be before config upgrade because upgrade dialogs need to be # translated setup_gettext(localedir, config.setting["ui_language"], log.debug) upgrade_config(config) self.webservice = WebService() self.mb_api = MBAPIHelper(self.webservice) load_user_collections() # Initialize fingerprinting acoustid_api = AcoustIdAPIHelper(self.webservice) self._acoustid = acoustid.AcoustIDClient(acoustid_api) self._acoustid.init() self.acoustidmanager = AcoustIDManager(acoustid_api) # Setup AcousticBrainz extraction self.ab_extractor = ABExtractor() self.enable_menu_icons(config.setting['show_menu_icons']) # Load plugins self.pluginmanager = PluginManager() if not self._no_plugins: for plugin_dir in plugin_dirs(): self.pluginmanager.load_plugins_from_directory(plugin_dir) self.browser_integration = BrowserIntegration() self.browser_integration.listen_port_changed.connect( self.listen_port_changed) self._pending_files_count = 0 self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unclustered_files = UnclusteredFiles() self.nats = None self.window = MainWindow(disable_player=picard_args.no_player) self.exit_cleanup = [] self.stopping = False # Load release version information if self.autoupdate_enabled: self.updatecheckmanager = UpdateCheckManager(parent=self.window)
class Tagger(QtWidgets.QApplication): tagger_stats_changed = QtCore.pyqtSignal() listen_port_changed = QtCore.pyqtSignal(int) cluster_added = QtCore.pyqtSignal(Cluster) cluster_removed = QtCore.pyqtSignal(Cluster) album_added = QtCore.pyqtSignal(Album) album_removed = QtCore.pyqtSignal(Album) __instance = None _debug = False _no_restore = False def __init__(self, picard_args, unparsed_args, localedir, autoupdate): super().__init__(sys.argv) self.__class__.__instance = self setup_config(self, picard_args.config_file) config = get_config() theme.setup(self) self._cmdline_files = picard_args.FILE self.autoupdate_enabled = autoupdate self._no_restore = picard_args.no_restore self._no_plugins = picard_args.no_plugins self.set_log_level(config.setting['log_verbosity']) if picard_args.debug or "PICARD_DEBUG" in os.environ: self.set_log_level(logging.DEBUG) # Default thread pool self.thread_pool = ThreadPoolExecutor() # Provide a separate thread pool for operations that should not be # delayed by longer background processing tasks, e.g. because the user # expects instant feedback instead of waiting for a long list of # operations to finish. self.priority_thread_pool = ThreadPoolExecutor(max_workers=1) # Use a separate thread pool for file saving, with a thread count of 1, # to avoid race conditions in File._save_and_rename. self.save_thread_pool = ThreadPoolExecutor(max_workers=1) if not IS_WIN: # Set up signal handling # It's not possible to call all available functions from signal # handlers, therefore we need to set up a QSocketNotifier to listen # on a socket. Sending data through a socket can be done in a # signal handler, so we use the socket to notify the application of # the signal. # This code is adopted from # https://qt-project.org/doc/qt-4.8/unix-signals.html # To not make the socket module a requirement for the Windows # installer, import it here and not globally import socket self.signalfd = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) self.signalnotifier = QtCore.QSocketNotifier( self.signalfd[1].fileno(), QtCore.QSocketNotifier.Type.Read, self) self.signalnotifier.activated.connect(self.sighandler) signal.signal(signal.SIGHUP, self.signal) signal.signal(signal.SIGINT, self.signal) signal.signal(signal.SIGTERM, self.signal) # Setup logging log.debug("Starting Picard from %r", os.path.abspath(__file__)) log.debug("Platform: %s %s %s", platform.platform(), platform.python_implementation(), platform.python_version()) log.debug("Versions: %s", versions.as_string()) log.debug("Configuration file path: %r", config.fileName()) log.debug("User directory: %r", os.path.abspath(USER_DIR)) # for compatibility with pre-1.3 plugins QtCore.QObject.tagger = self QtCore.QObject.config = config QtCore.QObject.log = log check_io_encoding() # Must be before config upgrade because upgrade dialogs need to be # translated setup_gettext(localedir, config.setting["ui_language"], log.debug) upgrade_config(config) self.webservice = WebService() self.mb_api = MBAPIHelper(self.webservice) load_user_collections() # Initialize fingerprinting acoustid_api = AcoustIdAPIHelper(self.webservice) self._acoustid = acoustid.AcoustIDClient(acoustid_api) self._acoustid.init() self.acoustidmanager = AcoustIDManager(acoustid_api) # Setup AcousticBrainz extraction self.ab_extractor = ABExtractor() self.enable_menu_icons(config.setting['show_menu_icons']) # Load plugins self.pluginmanager = PluginManager() if not self._no_plugins: for plugin_dir in plugin_dirs(): self.pluginmanager.load_plugins_from_directory(plugin_dir) self.browser_integration = BrowserIntegration() self.browser_integration.listen_port_changed.connect( self.listen_port_changed) self._pending_files_count = 0 self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unclustered_files = UnclusteredFiles() self.nats = None self.window = MainWindow(disable_player=picard_args.no_player) self.exit_cleanup = [] self.stopping = False # Load release version information if self.autoupdate_enabled: self.updatecheckmanager = UpdateCheckManager(parent=self.window) def enable_menu_icons(self, enabled): self.setAttribute( QtCore.Qt.ApplicationAttribute.AA_DontShowIconsInMenus, not enabled) def register_cleanup(self, func): self.exit_cleanup.append(func) def run_cleanup(self): for f in self.exit_cleanup: f() def set_log_level(self, level): self._debug = level == logging.DEBUG log.set_level(level) def _mb_login_dialog(self, parent): if not parent: parent = self.window dialog = QtWidgets.QInputDialog(parent) dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal) dialog.setWindowTitle(_("MusicBrainz Account")) dialog.setLabelText(_("Authorization code:")) status = dialog.exec_() if status == QtWidgets.QDialog.DialogCode.Accepted: return dialog.textValue() else: return None def mb_login(self, callback, parent=None): scopes = "profile tag rating collection submit_isrc submit_barcode" authorization_url = self.webservice.oauth_manager.get_authorization_url( scopes) webbrowser2.open(authorization_url) authorization_code = self._mb_login_dialog(parent) if authorization_code is not None: self.webservice.oauth_manager.exchange_authorization_code( authorization_code, scopes, partial(self.on_mb_authorization_finished, callback)) else: callback(False, None) def on_mb_authorization_finished(self, callback, successful=False, error_msg=None): if successful: self.webservice.oauth_manager.fetch_username( partial(self.on_mb_login_finished, callback)) else: callback(False, error_msg) @classmethod def on_mb_login_finished(self, callback, successful, error_msg): if successful: load_user_collections() callback(successful, error_msg) def mb_logout(self): self.webservice.oauth_manager.revoke_tokens() load_user_collections() def move_files_to_album(self, files, albumid=None, album=None): """Move `files` to tracks on album `albumid`.""" if album is None: album = self.load_album(albumid) album.match_files(files) def move_file_to_album(self, file, albumid): """Move `file` to a track on album `albumid`.""" self.move_files_to_album([file], albumid) def move_file_to_track(self, file, albumid, recordingid): """Move `file` to recording `recordingid` on album `albumid`.""" album = self.load_album(albumid) file.match_recordingid = recordingid album.match_files([file]) def create_nats(self): if self.nats is None: self.nats = NatAlbum() self.albums["NATS"] = self.nats self.album_added.emit(self.nats) self.nats.item.setExpanded(True) return self.nats def move_file_to_nat(self, file, recordingid, node=None): self.create_nats() file.move(self.nats.unmatched_files) nat = self.load_nat(recordingid, node=node) nat.run_when_loaded(partial(file.move, nat)) if nat.loaded: self.nats.update() def exit(self): if self.stopping: return self.stopping = True log.debug("Picard stopping") self._acoustid.done() self.thread_pool.shutdown() self.save_thread_pool.shutdown() self.priority_thread_pool.shutdown() self.browser_integration.stop() self.webservice.stop() self.run_cleanup() QtCore.QCoreApplication.processEvents() def _run_init(self): if self._cmdline_files: files = [decode_filename(f) for f in self._cmdline_files] self.add_paths(files) del self._cmdline_files def run(self): self.update_browser_integration() self.window.show() QtCore.QTimer.singleShot(0, self._run_init) res = self.exec_() self.exit() return res def update_browser_integration(self): config = get_config() if config.setting["browser_integration"]: self.browser_integration.start() else: self.browser_integration.stop() def event(self, event): if isinstance(event, thread.ProxyToMainEvent): event.run() elif event.type() == QtCore.QEvent.Type.FileOpen: file = event.file() self.add_paths([file]) if IS_HAIKU: self.bring_tagger_front() # We should just return True here, except that seems to # cause the event's sender to get a -9874 error, so # apparently there's some magic inside QFileOpenEvent... return 1 return super().event(event) def _file_loaded(self, file, target=None, remove_file=False, unmatched_files=None): config = get_config() self._pending_files_count -= 1 if self._pending_files_count == 0: self.window.set_sorting(True) if remove_file: file.remove() return if file is None: return if file.has_error(): self.unclustered_files.add_file(file) return file_moved = False if not config.setting["ignore_file_mbids"]: recordingid = file.metadata.getall('musicbrainz_recordingid') recordingid = recordingid[0] if recordingid else '' is_valid_recordingid = mbid_validate(recordingid) albumid = file.metadata.getall('musicbrainz_albumid') albumid = albumid[0] if albumid else '' is_valid_albumid = mbid_validate(albumid) if is_valid_albumid and is_valid_recordingid: log.debug( "%r has release (%s) and recording (%s) MBIDs, moving to track...", file, albumid, recordingid) self.move_file_to_track(file, albumid, recordingid) file_moved = True elif is_valid_albumid: log.debug("%r has only release MBID (%s), moving to album...", file, albumid) self.move_file_to_album(file, albumid) file_moved = True elif is_valid_recordingid: log.debug( "%r has only recording MBID (%s), moving to non-album track...", file, recordingid) self.move_file_to_nat(file, recordingid) file_moved = True if not file_moved: target = self.move_file(file, target) if target and target != self.unclustered_files: file_moved = True if not file_moved and unmatched_files is not None: unmatched_files.append(file) # fallback on analyze if nothing else worked if not file_moved and config.setting[ 'analyze_new_files'] and file.can_analyze(): log.debug("Trying to analyze %r ...", file) self.analyze([file]) # Auto cluster newly added files if they are not explicitly moved elsewhere if self._pending_files_count == 0 and unmatched_files and config.setting[ "cluster_new_files"]: self.cluster(unmatched_files) def move_file(self, file, target): """Moves a file to target, if possible Returns the actual target the files has been moved to or None """ if isinstance(target, Album): self.move_files_to_album([file], album=target) else: if isinstance(target, File) and target.parent: target = target.parent if not file.move(target): # Ensure a file always has a parent so it shows up in UI if not file.parent: target = self.unclustered_files file.move(target) # Unsupported target, do not move the file else: target = None return target def move_files(self, files, target, move_to_multi_tracks=True): if target is None: log.debug("Aborting move since target is invalid") return self.window.set_sorting(False) if isinstance(target, Cluster): for file in process_events_iter(files): file.move(target) elif isinstance(target, Track): album = target.album for file in process_events_iter(files): file.move(target) if move_to_multi_tracks: # Assign next file to following track target = album.get_next_track( target) or album.unmatched_files elif isinstance(target, File): for file in process_events_iter(files): file.move(target.parent) elif isinstance(target, Album): self.move_files_to_album(files, album=target) elif isinstance(target, ClusterList): self.cluster(files) self.window.set_sorting(True) def add_files(self, filenames, target=None): """Add files to the tagger.""" ignoreregex = None config = get_config() pattern = config.setting['ignore_regex'] if pattern: try: ignoreregex = re.compile(pattern) except re.error as e: log.error( "Failed evaluating regular expression for ignore_regex: %s", e) ignore_hidden = config.setting["ignore_hidden_files"] new_files = [] for filename in filenames: filename = normpath(filename) if ignore_hidden and is_hidden(filename): log.debug("File ignored (hidden): %r" % (filename)) continue # Ignore .smbdelete* files which Applie iOS SMB creates by renaming a file when it cannot delete it if os.path.basename(filename).startswith(".smbdelete"): log.debug("File ignored (.smbdelete): %r", filename) continue if ignoreregex is not None and ignoreregex.search(filename): log.info("File ignored (matching %r): %r" % (pattern, filename)) continue if filename not in self.files: file = open_file(filename) if file: self.files[filename] = file new_files.append(file) QtCore.QCoreApplication.processEvents() if new_files: log.debug("Adding files %r", new_files) new_files.sort(key=lambda x: x.filename) self.window.set_sorting(False) self._pending_files_count += len(new_files) unmatched_files = [] for i, file in enumerate(new_files): file.load( partial(self._file_loaded, target=target, unmatched_files=unmatched_files)) # Calling processEvents helps processing the _file_loaded # callbacks in between, which keeps the UI more responsive. # Avoid calling it to often to not slow down the loading to much # Using an uneven number to have the unclustered file counter # not look stuck in certain digits. if i % 17 == 0: QtCore.QCoreApplication.processEvents() @staticmethod def _scan_paths_recursive(paths, recursive, ignore_hidden): local_paths = list(paths) while local_paths: current_path = local_paths.pop(0) try: if os.path.isdir(current_path): for entry in os.scandir(current_path): if ignore_hidden and is_hidden(entry.path): continue if recursive and entry.is_dir(): local_paths.append(entry.path) else: yield entry.path else: yield current_path except OSError as err: log.warning(err) def add_paths(self, paths, target=None): config = get_config() files = self._scan_paths_recursive( paths, config.setting['recursively_add_files'], config.setting["ignore_hidden_files"]) self.add_files(files, target=target) def get_file_lookup(self): """Return a FileLookup object.""" config = get_config() return FileLookup(self, config.setting["server_host"], config.setting["server_port"], self.browser_integration.port) def search(self, text, search_type, adv=False, mbid_matched_callback=None, force_browser=False): """Search on the MusicBrainz website.""" search_types = { 'track': { 'entity': 'recording', 'dialog': TrackSearchDialog }, 'album': { 'entity': 'release', 'dialog': AlbumSearchDialog }, 'artist': { 'entity': 'artist', 'dialog': ArtistSearchDialog }, } if search_type not in search_types: return search = search_types[search_type] lookup = self.get_file_lookup() config = get_config() if config.setting["builtin_search"] and not force_browser: if not lookup.mbid_lookup( text, search['entity'], mbid_matched_callback=mbid_matched_callback): dialog = search['dialog'](self.window) dialog.search(text) dialog.exec_() else: lookup.search_entity(search['entity'], text, adv, mbid_matched_callback=mbid_matched_callback) def collection_lookup(self): """Lookup the users collections on the MusicBrainz website.""" lookup = self.get_file_lookup() config = get_config() lookup.collection_lookup(config.persist["oauth_username"]) def browser_lookup(self, item): """Lookup the object's metadata on the MusicBrainz website.""" lookup = self.get_file_lookup() metadata = item.metadata # Only lookup via MB IDs if matched to a DataObject; otherwise ignore and use metadata details if isinstance(item, DataObject): itemid = item.id if isinstance(item, Track): lookup.recording_lookup(itemid) elif isinstance(item, Album): lookup.album_lookup(itemid) else: lookup.tag_lookup( metadata["albumartist"] if item.is_album_like() else metadata["artist"], metadata["album"], metadata["title"], metadata["tracknumber"], '' if item.is_album_like() else str(metadata.length), item.filename if isinstance(item, File) else '') def get_files_from_objects(self, objects, save=False): """Return list of unique files from list of albums, clusters, tracks or files. Note: Consider using picard.util.iter_files_from_objects instead, which returns an iterator. """ return list(iter_files_from_objects(objects, save=save)) def save(self, objects): """Save the specified objects.""" for file in iter_files_from_objects(objects, save=True): file.save() def load_mbid(self, type, mbid): self.bring_tagger_front() if type == 'album': self.load_album(mbid) elif type == 'nat': self.load_nat(mbid) else: log.warning('Unknown type to load: %s', type) def load_album(self, album_id, discid=None): album_id = self.mbid_redirects.get(album_id, album_id) album = self.albums.get(album_id) if album: log.debug("Album %s already loaded.", album_id) album.add_discid(discid) return album album = Album(album_id, discid=discid) self.albums[album_id] = album self.album_added.emit(album) album.load() return album def load_nat(self, nat_id, node=None): self.create_nats() nat = self.get_nat_by_id(nat_id) if nat: log.debug("NAT %s already loaded.", nat_id) return nat nat = NonAlbumTrack(nat_id) self.nats.tracks.append(nat) self.nats.update(True) if node: nat._parse_recording(node) else: nat.load() return nat def get_nat_by_id(self, nat_id): if self.nats is not None: for nat in self.nats.tracks: if nat.id == nat_id: return nat def get_release_group_by_id(self, rg_id): return self.release_groups.setdefault(rg_id, ReleaseGroup(rg_id)) def remove_files(self, files, from_parent=True): """Remove files from the tagger.""" for file in files: if file.filename in self.files: file.clear_lookup_task() self._acoustid.stop_analyze(file) del self.files[file.filename] file.remove(from_parent) self.tagger_stats_changed.emit() def remove_album(self, album): """Remove the specified album.""" log.debug("Removing %r", album) if album.id not in self.albums: return album.stop_loading() self.remove_files(list(album.iterfiles())) del self.albums[album.id] if album.release_group: album.release_group.remove_album(album.id) if album == self.nats: self.nats = None self.album_removed.emit(album) run_album_post_removal_processors(album) self.tagger_stats_changed.emit() def remove_nat(self, track): """Remove the specified non-album track.""" log.debug("Removing %r", track) self.remove_files(list(track.iterfiles())) if not self.nats: return self.nats.tracks.remove(track) if not self.nats.tracks: self.remove_album(self.nats) else: self.nats.update(True) def remove_cluster(self, cluster): """Remove the specified cluster.""" if not cluster.special: log.debug("Removing %r", cluster) files = list(cluster.files) cluster.files = [] cluster.clear_lookup_task() self.remove_files(files, from_parent=False) self.clusters.remove(cluster) self.cluster_removed.emit(cluster) def remove(self, objects): """Remove the specified objects.""" files = [] with self.window.ignore_selection_changes: for obj in objects: if isinstance(obj, File): files.append(obj) elif isinstance(obj, NonAlbumTrack): self.remove_nat(obj) elif isinstance(obj, Track): files.extend(obj.files) elif isinstance(obj, Album): self.window.set_statusbar_message( N_("Removing album %(id)s: %(artist)s - %(album)s"), { 'id': obj.id, 'artist': obj.metadata['albumartist'], 'album': obj.metadata['album'] }) self.remove_album(obj) elif isinstance(obj, UnclusteredFiles): files.extend(list(obj.files)) elif isinstance(obj, Cluster): self.remove_cluster(obj) if files: self.remove_files(files) def _lookup_disc(self, disc, result=None, error=None): self.restore_cursor() if error is not None: QtWidgets.QMessageBox.critical( self.window, _("CD Lookup Error"), _("Error while reading CD:\n\n%s") % error) else: disc.lookup() def lookup_cd(self, action): """Reads CD from the selected drive and tries to lookup the DiscID on MusicBrainz.""" config = get_config() if isinstance(action, QtWidgets.QAction): device = action.data() elif config.setting["cd_lookup_device"] != '': device = config.setting["cd_lookup_device"].split(",", 1)[0] else: # rely on python-discid auto detection device = None disc = Disc() self.set_wait_cursor() thread.run_task(partial(disc.read, encode_filename(device)), partial(self._lookup_disc, disc), traceback=self._debug) @property def use_acoustid(self): config = get_config() return config.setting["fingerprinting_system"] == "acoustid" def analyze(self, objs): """Analyze the file(s).""" if not self.use_acoustid: return for file in iter_files_from_objects(objs): if file.can_analyze(): file.set_pending() self._acoustid.analyze( file, partial(file._lookup_finished, File.LOOKUP_ACOUSTID)) def generate_fingerprints(self, objs): """Generate the fingerprints without matching the files.""" if not self.use_acoustid: return def finished(file, result): file.clear_pending() for file in iter_files_from_objects(objs): file.set_pending() self._acoustid.fingerprint(file, partial(finished, file)) def extract_and_submit_acousticbrainz_features(self, objs): """Extract AcousticBrainz features and submit them.""" if not self.ab_extractor.available(): return for file in iter_files_from_objects(objs): # Skip unmatched files if not file.can_extract(): log.warning( "AcousticBrainz requires a MusicBrainz Recording ID, but file does not have it: %s" % file.filename) # And process matched ones else: file.set_pending() # Check if file was either already processed or sent to the AcousticBrainz server if file.acousticbrainz_features_file: results = (file.acousticbrainz_features_file, 0, "Writing results") ab_extractor_callback(self, file, results, False) elif file.acousticbrainz_is_duplicate: results = (None, 0, "Duplicate") ab_extractor_callback(self, file, results, False) else: file.acousticbrainz_error = False # Launch the acousticbrainz on a separate process log.debug("Extracting AcousticBrainz features from %s" % file.filename) ab_feature_extraction( self, file.metadata["musicbrainz_recordingid"], file.filename, partial(ab_extractor_callback, self, file)) # ======================================================================= # Metadata-based lookups # ======================================================================= def autotag(self, objects): for obj in objects: if obj.can_autotag(): obj.lookup_metadata() # ======================================================================= # Clusters # ======================================================================= def cluster(self, objs, callback=None): """Group files with similar metadata to 'clusters'.""" log.debug("Clustering %r", objs) files = iter_files_from_objects(objs) try: file = next(files) except StopIteration: files = self.unclustered_files.files else: files = itertools.chain([file], files) thread.run_task(partial(self._do_clustering, list(files)), partial(self._clustering_finished, callback)) def _do_clustering(self, files): # The clustering algorithm should completely run in the thread, # hence do not return the iterator. return list(Cluster.cluster(files)) def _clustering_finished(self, callback, result=None, error=None): if error: log.error('Error while clustering: %r', error) return with self.window.ignore_selection_changes: self.window.set_sorting(False) for file_cluster in process_events_iter(result): files = set(file_cluster.files) if len(files) > 1: cluster = self.load_cluster(file_cluster.title, file_cluster.artist) else: cluster = self.unclustered_files cluster.add_files(files) self.window.set_sorting(True) if callback: callback() def load_cluster(self, name, artist): for cluster in self.clusters: cm = cluster.metadata if name == cm["album"] and artist == cm["albumartist"]: return cluster cluster = Cluster(name, artist) self.clusters.append(cluster) self.cluster_added.emit(cluster) return cluster # ======================================================================= # Utils # ======================================================================= def set_wait_cursor(self): """Sets the waiting cursor.""" super().setOverrideCursor( QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor)) def restore_cursor(self): """Restores the cursor set by ``set_wait_cursor``.""" super().restoreOverrideCursor() def refresh(self, objs): for obj in objs: if obj.can_refresh(): obj.load(priority=True, refresh=True) def bring_tagger_front(self): self.window.setWindowState(self.window.windowState() & ~QtCore.Qt.WindowState.WindowMinimized | QtCore.Qt.WindowState.WindowActive) self.window.raise_() self.window.activateWindow() @classmethod def instance(cls): return cls.__instance def signal(self, signum, frame): log.debug("signal %i received", signum) # Send a notification about a received signal from the signal handler # to Qt. self.signalfd[0].sendall(b"a") def sighandler(self): self.signalnotifier.setEnabled(False) self.exit() self.quit() self.signalnotifier.setEnabled(True)
def test_plugin_install_no_path_no_plugin_name(self): pm = PluginManager(plugins_directory=self.tmp_directory) with self.assertRaises(AssertionError): pm.install_plugin(None)