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)
class Tagger(QtGui.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 def __init__(self, args, localedir, autoupdate, debug=False): QtGui.QApplication.__init__(self, args) self.__class__.__instance = self self._args = args self._autoupdate = autoupdate # 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) # 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) # Setup logging if debug or "PICARD_DEBUG" in os.environ: log.log_levels = log.log_levels|log.LOG_DEBUG log.debug("Starting Picard %s from %r", picard.__version__, os.path.abspath(__file__)) # TODO remove this before the final release if sys.platform == "win32": olduserdir = "~\\Local Settings\\Application Data\\MusicBrainz Picard" else: olduserdir = "~/.picard" olduserdir = os.path.expanduser(olduserdir) if os.path.isdir(olduserdir): log.info("Moving %s to %s", olduserdir, USER_DIR) try: shutil.move(olduserdir, USER_DIR) except: pass # for compatibility with pre-1.3 plugins QtCore.QObject.tagger = self QtCore.QObject.config = config QtCore.QObject.log = log check_io_encoding() self._upgrade_config() setup_gettext(localedir, config.setting["ui_language"], log.debug) self.xmlws = XmlWebService() load_user_collections() # Initialize fingerprinting self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() if hasattr(sys, "frozen"): self.pluginmanager.load_plugindir(os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: self.pluginmanager.load_plugindir(os.path.join(os.path.dirname(__file__), "plugins")) if not os.path.exists(USER_PLUGIN_DIR): os.makedirs(USER_PLUGIN_DIR) self.pluginmanager.load_plugindir(USER_PLUGIN_DIR) self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unmatched_files = UnmatchedFiles() self.nats = None self.window = MainWindow() def _upgrade_config(self): cfg = config._config # In version 1.0, the file naming formats for single and various # artist releases were merged. def upgrade_to_v1_0(): def remove_va_file_naming_format(merge=True): if merge: config.setting["file_naming_format"] = ( "$if($eq(%compilation%,1),\n$noop(Various Artist " "albums)\n%s,\n$noop(Single Artist Albums)\n%s)" % ( config.setting["va_file_naming_format"].toString(), config.setting["file_naming_format"] )) config.setting.remove("va_file_naming_format") config.setting.remove("use_va_format") if ("va_file_naming_format" in config.setting and "use_va_format" in config.setting): if config.setting["use_va_format"].toBool(): remove_va_file_naming_format() self.window.show_va_removal_notice() elif (config.setting["va_file_naming_format"].toString() != r"$if2(%albumartist%,%artist%)/%album%/$if($gt(%totaldis" "cs%,1),%discnumber%-,)$num(%tracknumber%,2) %artist% - " "%title%"): if self.window.confirm_va_removal(): remove_va_file_naming_format(merge=False) else: remove_va_file_naming_format() else: # default format, disabled remove_va_file_naming_format(merge=False) cfg.register_upgrade_hook("1.0.0final0", upgrade_to_v1_0) cfg.run_upgrade_hooks() 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, trackid): """Move `file` to track `trackid` on album `albumid`.""" album = self.load_album(albumid) file.move(album.unmatched_files) album.run_when_loaded(partial(album.match_file, file, trackid)) def create_nats(self): if self.nats is None: self.nats = NatAlbum() self.albums["NATS"] = self.nats self.album_added.emit(self.nats) return self.nats def move_file_to_nat(self, file, trackid, node=None): self.create_nats() file.move(self.nats.unmatched_files) nat = self.load_nat(trackid, node=node) nat.run_when_loaded(partial(file.move, nat)) if nat.loaded: self.nats.update() def exit(self): self.stopping = True self._acoustid.done() self.thread_pool.waitForDone() self.browser_integration.stop() self.xmlws.stop() def _run_init(self): if self._args: files = [] for file in self._args: 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._args def run(self): 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: f = str(event.file()) self.add_files([f]) # 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 QtGui.QApplication.event(self, event) def _file_loaded(self, file, target=None): if file is not None and not file.has_error(): trackid = file.metadata['musicbrainz_trackid'] if target is not None: self.move_files([file], target) elif not config.setting["ignore_file_mbids"]: albumid = file.metadata['musicbrainz_albumid'] if mbid_validate(albumid): if mbid_validate(trackid): self.move_file_to_track(file, albumid, trackid) else: self.move_file_to_album(file, albumid) elif mbid_validate(trackid): self.move_file_to_nat(file, trackid) elif config.setting['analyze_new_files'] and file.can_analyze(): self.analyze([file]) elif config.setting['analyze_new_files'] and file.can_analyze(): self.analyze([file]) def move_files(self, files, target): 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.""" log.debug("Adding files %r", filenames) new_files = [] for filename in filenames: filename = os.path.normpath(os.path.realpath(filename)) if filename not in self.files: file = open_file(filename) if file: self.files[filename] = file new_files.append(file) if new_files: if target is None or target is self.unmatched_files: self.unmatched_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): walk = os.walk(unicode(path)) def get_files(): try: root, dirs, files = walk.next() except StopIteration: return None else: self.window.set_statusbar_message(N_("Loading directory %s"), root) 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 get_file_lookup(self): """Return a FileLookup object.""" return FileLookup(self, config.setting["server_host"], config.setting["server_port"], self.browser_integration.port) def search(self, text, type, adv=False): """Search on the MusicBrainz website.""" lookup = self.get_file_lookup() getattr(lookup, type + "Search")(text, adv) def browser_lookup(self, item): """Lookup the object's metadata on the MusicBrainz website.""" lookup = self.get_file_lookup() metadata = item.metadata albumid = metadata["musicbrainz_albumid"] trackid = metadata["musicbrainz_trackid"] # Only lookup via MB IDs if matched to a DataObject; otherwise ignore and use metadata details if isinstance(item, Track) and trackid: lookup.trackLookup(trackid) elif isinstance(item, Album) and albumid: lookup.albumLookup(albumid) else: lookup.tagLookup( 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, id, discid=None): id = self.mbid_redirects.get(id, id) album = self.albums.get(id) if album: return album album = Album(id, discid=discid) self.albums[id] = album self.album_added.emit(album) album.load() return album def load_nat(self, id, node=None): self.create_nats() nat = self.get_nat_by_id(id) if nat: return nat nat = NonAlbumTrack(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, id): if self.nats is not None: for nat in self.nats.tracks: if nat.id == id: return nat def get_release_group_by_id(self, id): return self.release_groups.setdefault(id, ReleaseGroup(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) 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, Track): files.extend(obj.linked_files) elif isinstance(obj, Album): self.remove_album(obj) 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: QtGui.QMessageBox.critical(self.window, _(u"CD Lookup Error"), _(u"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, QtGui.QAction): device = unicode(action.text()) 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)) @property def use_acoustid(self): return config.setting["fingerprinting_system"] == "acoustid" def analyze(self, objs): """Analyze the file(s).""" files = self.get_files_from_objects(objs) for file in files: file.set_pending() if self.use_acoustid: self._acoustid.analyze(file, partial(file._lookup_finished, '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.unmatched_files in objs: files = list(self.unmatched_files.files) else: files = self.get_files_from_objects(objs) fcmp = lambda a, b: ( cmp(a.discnumber, b.discnumber) or cmp(a.tracknumber, b.tracknumber) or cmp(a.base_filename, b.base_filename)) for name, artist, files in Cluster.cluster(files, 1.0): QtCore.QCoreApplication.processEvents() cluster = self.load_cluster(name, artist) for file in sorted(files, fcmp): 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.""" QtGui.QApplication.setOverrideCursor( QtGui.QCursor(QtCore.Qt.WaitCursor)) def restore_cursor(self): """Restores the cursor set by ``set_wait_cursor``.""" QtGui.QApplication.restoreOverrideCursor() def refresh(self, objs): for obj in objs: obj.load() @classmethod def instance(cls): return cls.__instance
class Tagger(QtGui.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 def __init__(self, args, localedir, autoupdate, debug=False): QtGui.QApplication.__init__(self, args) self.__class__.__instance = self self._args = args self._autoupdate = autoupdate self._debug = False # 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) # 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 sys.platform == "win32": # 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) # Setup logging self.debug(debug or "PICARD_DEBUG" in os.environ) log.debug("Starting Picard %s from %r", picard.__version__, os.path.abspath(__file__)) log.debug("Platform: %s %s %s", platform.platform(), platform.python_implementation(), platform.python_version()) if config.storage_type == config.REGISTRY_PATH: log.debug("Configuration registry path: %s", config.storage) else: log.debug("Configuration file path: %s", config.storage) # TODO remove this before the final release if sys.platform == "win32": olduserdir = "~\\Local Settings\\Application Data\\MusicBrainz Picard" else: olduserdir = "~/.picard" olduserdir = os.path.expanduser(olduserdir) if os.path.isdir(olduserdir): log.info("Moving %s to %s", olduserdir, USER_DIR) try: shutil.move(olduserdir, USER_DIR) except: pass log.debug("User directory: %s", 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() self.xmlws = XmlWebService() load_user_collections() # Initialize fingerprinting self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() if hasattr(sys, "frozen"): self.pluginmanager.load_plugindir(os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: mydir = os.path.dirname(os.path.abspath(__file__)) self.pluginmanager.load_plugindir(os.path.join(mydir, "plugins")) self.pluginmanager.load_plugindir(os.path.join(mydir, os.pardir, "contrib", "plugins")) if not os.path.exists(USER_PLUGIN_DIR): os.makedirs(USER_PLUGIN_DIR) self.pluginmanager.load_plugindir(USER_PLUGIN_DIR) self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unmatched_files = UnmatchedFiles() self.nats = None self.window = MainWindow() self.exit_cleanup = [] def register_cleanup(self, func): self.exit_cleanup.append(func) def run_cleanup(self): for f in self.exit_cleanup: f() def debug(self, debug): if self._debug == debug: return if debug: log.log_levels = log.log_levels | log.LOG_DEBUG log.debug("Debug mode on") else: log.debug("Debug mode off") log.log_levels = log.log_levels & ~log.LOG_DEBUG self._debug = debug 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_file, file, recordingid)) def create_nats(self): if self.nats is None: self.nats = NatAlbum() self.albums["NATS"] = self.nats self.album_added.emit(self.nats) 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): log.debug("exit") self.stopping = True self._acoustid.done() self.thread_pool.waitForDone() self.browser_integration.stop() self.xmlws.stop() for f in self.exit_cleanup: f() def _run_init(self): if self._args: files = [] for file in self._args: 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._args 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: f = str(event.file()) self.add_files([f]) # 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 QtGui.QApplication.event(self, event) def _file_loaded(self, file, target=None): if file is not None and not file.has_error(): recordingid = file.metadata.getall('musicbrainz_recordingid')[0] \ if 'musicbrainz_recordingid' in file.metadata else '' if target is not None: self.move_files([file], target) elif not config.setting["ignore_file_mbids"]: albumid = file.metadata.getall('musicbrainz_albumid')[0] \ if 'musicbrainz_albumid' in file.metadata else '' if mbid_validate(albumid): if mbid_validate(recordingid): self.move_file_to_track(file, albumid, recordingid) else: self.move_file_to_album(file, albumid) elif mbid_validate(recordingid): self.move_file_to_nat(file, recordingid) elif config.setting['analyze_new_files'] and file.can_analyze(): self.analyze([file]) elif config.setting['analyze_new_files'] and file.can_analyze(): self.analyze([file]) def move_files(self, files, target): 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 = not config.persist["show_hidden_files"] new_files = [] for filename in filenames: filename = os.path.normpath(os.path.realpath(filename)) if ignore_hidden and is_hidden_path(filename): log.debug("File ignored (hidden): %s" % (filename)) continue if ignoreregex is not None and ignoreregex.search(filename): log.info("File ignored (matching %s): %s" % (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.unmatched_files: self.unmatched_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): walk = os.walk(unicode(path)) def get_files(): try: root, dirs, files = walk.next() 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)s'" % mparms) self.window.set_statusbar_message( ungettext( "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 get_file_lookup(self): """Return a FileLookup object.""" return FileLookup(self, config.setting["server_host"], config.setting["server_port"], self.browser_integration.port) def search(self, text, type, adv=False): """Search on the MusicBrainz website.""" lookup = self.get_file_lookup() getattr(lookup, type + "Search")(text, adv) def collection_lookup(self): """Lookup the users collections on the MusicBrainz website.""" lookup = self.get_file_lookup() lookup.collectionLookup(config.setting["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.recordingLookup(itemid) elif isinstance(item, Album): lookup.albumLookup(itemid) else: lookup.tagLookup( 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, id, discid=None): id = self.mbid_redirects.get(id, id) album = self.albums.get(id) if album: log.debug("Album %s already loaded.", id) return album album = Album(id, discid=discid) self.albums[id] = album self.album_added.emit(album) album.load() return album def load_nat(self, id, node=None): self.create_nats() nat = self.get_nat_by_id(id) if nat: log.debug("NAT %s already loaded.", id) return nat nat = NonAlbumTrack(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, id): if self.nats is not None: for nat in self.nats.tracks: if nat.id == id: return nat def get_release_group_by_id(self, id): return self.release_groups.setdefault(id, ReleaseGroup(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) 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, 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, 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: QtGui.QMessageBox.critical(self.window, _(u"CD Lookup Error"), _(u"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, QtGui.QAction): device = unicode(action.text()) 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)) @property def use_acoustid(self): return config.setting["fingerprinting_system"] == "acoustid" def analyze(self, objs): """Analyze the file(s).""" files = self.get_files_from_objects(objs) for file in files: file.set_pending() if self.use_acoustid: self._acoustid.analyze(file, partial(file._lookup_finished, '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.unmatched_files in objs: files = list(self.unmatched_files.files) else: files = self.get_files_from_objects(objs) fcmp = lambda a, b: ( cmp(a.discnumber, b.discnumber) or cmp(a.tracknumber, b.tracknumber) or cmp(a.base_filename, b.base_filename)) for name, artist, files in Cluster.cluster(files, 1.0): QtCore.QCoreApplication.processEvents() cluster = self.load_cluster(name, artist) for file in sorted(files, fcmp): 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.""" QtGui.QApplication.setOverrideCursor( QtGui.QCursor(QtCore.Qt.WaitCursor)) def restore_cursor(self): """Restores the cursor set by ``set_wait_cursor``.""" QtGui.QApplication.restoreOverrideCursor() def refresh(self, objs): for obj in objs: if obj.can_refresh(): obj.load(priority=True, refresh=True) @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("a") def sighandler(self): self.signalnotifier.setEnabled(False) self.exit() self.quit() self.signalnotifier.setEnabled(True)
class Tagger(QtGui.QApplication): file_state_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 def __init__(self, args, localedir, autoupdate, debug=False): QtGui.QApplication.__init__(self, args) self.__class__.__instance = self self._args = args self._autoupdate = autoupdate self.config = Config() if sys.platform == "win32": userdir = os.environ.get("APPDATA", "~\\Application Data") else: userdir = os.environ.get("XDG_CONFIG_HOME", "~/.config") self.userdir = os.path.join(os.path.expanduser(userdir), "MusicBrainz", "Picard") # Initialize threading and allocate threads self.thread_pool = thread.ThreadPool(self) self.load_queue = queue.Queue() self.save_queue = queue.Queue() self.analyze_queue = queue.Queue() self.other_queue = queue.Queue() threads = self.thread_pool.threads threads.append(thread.Thread(self.thread_pool, self.load_queue)) threads.append(thread.Thread(self.thread_pool, self.load_queue)) threads.append(thread.Thread(self.thread_pool, self.save_queue)) threads.append(thread.Thread(self.thread_pool, self.other_queue)) threads.append(thread.Thread(self.thread_pool, self.other_queue)) threads.append(thread.Thread(self.thread_pool, self.analyze_queue)) self.thread_pool.start() self.stopping = False # Setup logging if debug or "PICARD_DEBUG" in os.environ: self.log = log.DebugLog() else: self.log = log.Log() self.log.debug("Starting Picard %s from %r", picard.__version__, os.path.abspath(__file__)) # TODO remove this before the final release if sys.platform == "win32": olduserdir = "~\\Local Settings\\Application Data\\MusicBrainz Picard" else: olduserdir = "~/.picard" olduserdir = os.path.expanduser(olduserdir) if os.path.isdir(olduserdir): self.log.info("Moving %s to %s", olduserdir, self.userdir) try: shutil.move(olduserdir, self.userdir) except: pass QtCore.QObject.tagger = self QtCore.QObject.config = self.config QtCore.QObject.log = self.log check_io_encoding() self.setup_gettext(localedir) self.xmlws = XmlWebService() # Initialize fingerprinting self._ofa = musicdns.OFA() self._ofa.init() self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() self.user_plugin_dir = os.path.join(self.userdir, "plugins") if not os.path.exists(self.user_plugin_dir): os.makedirs(self.user_plugin_dir) self.pluginmanager.load_plugindir(self.user_plugin_dir) if hasattr(sys, "frozen"): self.pluginmanager.load_plugindir( os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: self.pluginmanager.load_plugindir( os.path.join(os.path.dirname(__file__), "plugins")) self.puidmanager = PUIDManager() self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.mbid_redirects = {} self.unmatched_files = UnmatchedFiles() self.nats = None self.window = MainWindow() def remove_va_file_naming_format(merge=True): if merge: self.config.setting["file_naming_format"] = \ "$if($eq(%compilation%,1),\n$noop(Various Artist albums)\n"+\ "%s,\n$noop(Single Artist Albums)\n%s)" %\ (self.config.setting["va_file_naming_format"].toString(), self.config.setting["file_naming_format"]) self.config.setting.remove("va_file_naming_format") self.config.setting.remove("use_va_format") if "va_file_naming_format" in self.config.setting\ and "use_va_format" in self.config.setting: if self.config.setting["use_va_format"].toBool(): remove_va_file_naming_format() self.window.show_va_removal_notice() elif self.config.setting["va_file_naming_format"].toString() !=\ r"$if2(%albumartist%,%artist%)/%album%/$if($gt(%totaldiscs%,1),%discnumber%-,)$num(%tracknumber%,2) %artist% - %title%": if self.window.confirm_va_removal(): remove_va_file_naming_format(merge=False) else: remove_va_file_naming_format() else: # default format, disabled remove_va_file_naming_format(merge=False) def setup_gettext(self, localedir): """Setup locales, load translations, install gettext functions.""" if self.config.setting["ui_language"]: os.environ['LANGUAGE'] = '' os.environ['LANG'] = self.config.setting["ui_language"] if sys.platform == "win32": try: locale.setlocale(locale.LC_ALL, os.environ["LANG"]) except KeyError: os.environ["LANG"] = locale.getdefaultlocale()[0] try: locale.setlocale(locale.LC_ALL, "") except: pass except: pass else: try: locale.setlocale(locale.LC_ALL, "") except: pass try: self.log.debug("Loading gettext translation, localedir=%r", localedir) self.translation = gettext.translation("picard", localedir) self.translation.install(True) ungettext = self.translation.ungettext except IOError: __builtin__.__dict__['_'] = lambda a: a def ungettext(a, b, c): if c == 1: return a else: return b __builtin__.__dict__['ungettext'] = ungettext 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, trackid): """Move `file` to track `trackid` on album `albumid`.""" album = self.load_album(albumid) file.move(album.unmatched_files) album.run_when_loaded(partial(album.match_file, file, trackid)) def create_nats(self): if self.nats is None: self.nats = NatAlbum() self.albums["NATS"] = self.nats self.album_added.emit(self.nats) return self.nats def move_file_to_nat(self, file, trackid, node=None): self.create_nats() file.move(self.nats.unmatched_files) nat = self.load_nat(trackid, node=node) nat.run_when_loaded(partial(file.move, nat)) if nat.loaded: self.nats.update() def exit(self): self.stopping = True self._ofa.done() self._acoustid.done() self.thread_pool.stop() self.browser_integration.stop() self.xmlws.stop() def _run_init(self): if self._args: files = [] for file in self._args: 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._args def run(self): 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 event.type() == QtCore.QEvent.FileOpen: f = str(event.file()) self.add_files([f]) # 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 QtGui.QApplication.event(self, event) def _file_loaded(self, result=None, error=None): file = result if file is not None and error is None and not file.has_error(): puid = file.metadata['musicip_puid'] trackid = file.metadata['musicbrainz_trackid'] self.puidmanager.add(puid, trackid) if not self.config.setting["ignore_file_mbids"]: albumid = file.metadata['musicbrainz_albumid'] if mbid_validate(albumid): if mbid_validate(trackid): self.move_file_to_track(file, albumid, trackid) else: self.move_file_to_album(file, albumid) elif mbid_validate(trackid): self.move_file_to_nat(file, trackid) elif self.config.setting['analyze_new_files']: self.analyze([file]) elif self.config.setting['analyze_new_files']: self.analyze([file]) def add_files(self, filenames): """Add files to the tagger.""" self.log.debug("Adding files %r", filenames) new_files = [] for filename in filenames: filename = os.path.normpath(os.path.realpath(filename)) if filename not in self.files: file = open_file(filename) if file: self.files[filename] = file new_files.append(file) if new_files: self.unmatched_files.add_files(new_files) for file in new_files: file.load(self._file_loaded) def process_directory_listing(self, root, queue, result=None, error=None): try: # Read directory listing if result is not None and error is None: files = [] directories = deque() try: for path in result: path = os.path.join(root, path) if os.path.isdir(path): directories.appendleft(path) else: try: files.append(decode_filename(path)) except UnicodeDecodeError: self.log.warning( "Failed to decode filename: %r", path) continue finally: if files: self.add_files(files) queue.extendleft(directories) finally: # Scan next directory in the queue try: path = queue.popleft() except IndexError: pass else: self.other_queue.put( (partial(os.listdir, path), partial(self.process_directory_listing, path, queue), QtCore.Qt.LowEventPriority)) def add_directory(self, path): path = encode_filename(path) self.other_queue.put((partial(os.listdir, path), partial(self.process_directory_listing, path, deque()), QtCore.Qt.LowEventPriority)) def get_file_by_id(self, id): """Get file by a file ID.""" for file in self.files.itervalues(): if file.id == id: return file return None def get_file_by_filename(self, filename): """Get file by a filename.""" return self.files.get(filename, None) def get_file_lookup(self): """Return a FileLookup object.""" return FileLookup(self, self.config.setting["server_host"], self.config.setting["server_port"], self.browser_integration.port) def search(self, text, type, adv=False): """Search on the MusicBrainz website.""" lookup = self.get_file_lookup() getattr(lookup, type + "Search")(text, adv) def browser_lookup(self, item): """Lookup the object's metadata on the MusicBrainz website.""" lookup = self.get_file_lookup() metadata = item.metadata albumid = metadata["musicbrainz_albumid"] trackid = metadata["musicbrainz_trackid"] # Only lookup via MB IDs if matched to a DataObject; otherwise ignore and use metadata details if isinstance(item, Track) and trackid: lookup.trackLookup(trackid) elif isinstance(item, Album) and albumid: lookup.albumLookup(albumid) else: lookup.tagLookup( 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 '', metadata["musicip_puid"]) def get_files_from_objects(self, objects, save=False): """Return list of files from list of albums, clusters, tracks or files.""" files = set() for obj in objects: files.update(obj.iterfiles(save)) return list(files) def _file_saved(self, result=None, error=None): if error is None: file, old_filename, new_filename = result del self.files[old_filename] self.files[new_filename] = file def save(self, objects): """Save the specified objects.""" files = self.get_files_from_objects(objects, save=True) for file in files: file.save(self._file_saved, self.tagger.config.setting) def load_album(self, id, discid=None): id = self.mbid_redirects.get(id, id) album = self.albums.get(id) if album: return album album = Album(id, discid=discid) self.albums[id] = album self.album_added.emit(album) album.load() return album def load_nat(self, id, node=None): self.create_nats() nat = self.get_nat_by_id(id) if nat: return nat nat = NonAlbumTrack(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, id): if self.nats is not None: for nat in self.nats.tracks: if nat.id == id: return nat def remove_files(self, files, from_parent=True): """Remove files from the tagger.""" for file in files: if self.files.has_key(file.filename): file.clear_lookup_task() self._ofa.stop_analyze(file) self._acoustid.stop_analyze(file) del self.files[file.filename] file.remove(from_parent) def remove_album(self, album): """Remove the specified album.""" self.log.debug("Removing %r", album) album.stop_loading() self.remove_files(self.get_files_from_objects([album])) del self.albums[album.id] if album == self.nats: self.nats = None self.album_removed.emit(album) def remove_cluster(self, cluster): """Remove the specified cluster.""" if not cluster.special: self.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, Track): files.extend(obj.linked_files) elif isinstance(obj, Album): self.remove_album(obj) 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: QtGui.QMessageBox.critical( self.window, _(u"CD Lookup Error"), _(u"Error while reading CD:\n\n%s") % error) else: disc.lookup() def lookup_cd(self, action=None): """Reads CD from the selected drive and tries to lookup the DiscID on MusicBrainz.""" if action is None: device = self.config.setting["cd_lookup_device"].split(",", 1)[0] else: device = unicode(action.text()) disc = Disc() self.set_wait_cursor() self.other_queue.put((partial(disc.read, encode_filename(device)), partial(self._lookup_disc, disc), QtCore.Qt.LowEventPriority)) def _lookup_puid(self, file, result=None, error=None): puid = result if file.state == File.PENDING: if puid: self.puidmanager.add(puid, None) file.metadata['musicip_puid'] = puid file.lookup_puid(puid) else: self.window.set_statusbar_message( N_("Could not find PUID for file %s"), file.filename) file.clear_pending() @property def use_acoustid(self): return self.config.setting["fingerprinting_system"] == "acoustid" def analyze(self, objs): """Analyze the file(s).""" files = self.get_files_from_objects(objs) for file in files: file.set_pending() if self.use_acoustid: self._acoustid.analyze( file, partial(file._lookup_finished, 'acoustid')) else: self._ofa.analyze(file, partial(self._lookup_puid, file)) # ======================================================================= # Metadata-based lookups # ======================================================================= def autotag(self, objects): for obj in objects: if isinstance(obj, (File, Cluster)) and not obj.lookup_task: obj.lookup_metadata() # ======================================================================= # Clusters # ======================================================================= def cluster(self, objs): """Group files with similar metadata to 'clusters'.""" self.log.debug("Clustering %r", objs) if len(objs) <= 1 or self.unmatched_files in objs: files = list(self.unmatched_files.files) else: files = self.get_files_from_objects(objs) fcmp = lambda a, b: (cmp(a.discnumber, b.discnumber) or cmp( a.tracknumber, b.tracknumber) or cmp(a.base_filename, b. base_filename)) for name, artist, files in Cluster.cluster(files, 1.0): QtCore.QCoreApplication.processEvents() cluster = self.load_cluster(name, artist) for file in sorted(files, fcmp): file.move(cluster) def load_cluster(self, name, artist): for cluster in self.clusters: cm = cluster.metadata if name == cm["album"] and artist == cm["artist"]: 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.""" QtGui.QApplication.setOverrideCursor( QtGui.QCursor(QtCore.Qt.WaitCursor)) def restore_cursor(self): """Restores the cursor set by ``set_wait_cursor``.""" QtGui.QApplication.restoreOverrideCursor() def refresh(self, objs): for obj in objs: obj.load() @classmethod def instance(cls): return cls.__instance def num_files(self): return len(self.files) def num_pending_files(self): return len([ file for file in self.files.values() if file.state == File.PENDING ])
class Tagger(QtGui.QApplication): file_state_changed = QtCore.pyqtSignal(int) 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 def __init__(self, args, localedir, autoupdate, debug=False): QtGui.QApplication.__init__(self, args) self.__class__.__instance = self self._args = args self._autoupdate = autoupdate self.config = Config() if sys.platform == "win32": userdir = os.environ.get("APPDATA", "~\\Application Data") else: userdir = os.environ.get("XDG_CONFIG_HOME", "~/.config") self.userdir = os.path.join(os.path.expanduser(userdir), "MusicBrainz", "Picard") # Initialize threading and allocate threads self.thread_pool = thread.ThreadPool(self) self.load_queue = queue.Queue() self.save_queue = queue.Queue() self.analyze_queue = queue.Queue() self.other_queue = queue.Queue() threads = self.thread_pool.threads for i in range(4): threads.append(thread.Thread(self.thread_pool, self.load_queue)) threads.append(thread.Thread(self.thread_pool, self.save_queue)) threads.append(thread.Thread(self.thread_pool, self.other_queue)) threads.append(thread.Thread(self.thread_pool, self.other_queue)) threads.append(thread.Thread(self.thread_pool, self.analyze_queue)) self.thread_pool.start() self.stopping = False # Setup logging if debug or "PICARD_DEBUG" in os.environ: self.log = log.DebugLog() else: self.log = log.Log() self.log.debug("Starting Picard %s from %r", picard.__version__, os.path.abspath(__file__)) # TODO remove this before the final release if sys.platform == "win32": olduserdir = "~\\Local Settings\\Application Data\\MusicBrainz Picard" else: olduserdir = "~/.picard" olduserdir = os.path.expanduser(olduserdir) if os.path.isdir(olduserdir): self.log.info("Moving %s to %s", olduserdir, self.userdir) try: shutil.move(olduserdir, self.userdir) except: pass QtCore.QObject.tagger = self QtCore.QObject.config = self.config QtCore.QObject.log = self.log check_io_encoding() self.setup_gettext(localedir) self.xmlws = XmlWebService() # Initialize fingerprinting self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() self.user_plugin_dir = os.path.join(self.userdir, "plugins") if not os.path.exists(self.user_plugin_dir): os.makedirs(self.user_plugin_dir) self.pluginmanager.load_plugindir(self.user_plugin_dir) if hasattr(sys, "frozen"): self.pluginmanager.load_plugindir(os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: self.pluginmanager.load_plugindir(os.path.join(os.path.dirname(__file__), "plugins")) self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unmatched_files = UnmatchedFiles() self.nats = None self.window = MainWindow() def remove_va_file_naming_format(merge=True): if merge: self.config.setting["file_naming_format"] = \ "$if($eq(%compilation%,1),\n$noop(Various Artist albums)\n"+\ "%s,\n$noop(Single Artist Albums)\n%s)" %\ (self.config.setting["va_file_naming_format"].toString(), self.config.setting["file_naming_format"]) self.config.setting.remove("va_file_naming_format") self.config.setting.remove("use_va_format") if "va_file_naming_format" in self.config.setting\ and "use_va_format" in self.config.setting: if self.config.setting["use_va_format"].toBool(): remove_va_file_naming_format() self.window.show_va_removal_notice() elif self.config.setting["va_file_naming_format"].toString() !=\ r"$if2(%albumartist%,%artist%)/%album%/$if($gt(%totaldiscs%,1),%discnumber%-,)$num(%tracknumber%,2) %artist% - %title%": if self.window.confirm_va_removal(): remove_va_file_naming_format(merge=False) else: remove_va_file_naming_format() else: # default format, disabled remove_va_file_naming_format(merge=False) def setup_gettext(self, localedir): """Setup locales, load translations, install gettext functions.""" ui_language = self.config.setting["ui_language"] if ui_language: os.environ['LANGUAGE'] = '' os.environ['LANG'] = ui_language if sys.platform == "win32": try: locale.setlocale(locale.LC_ALL, os.environ["LANG"]) except KeyError: os.environ["LANG"] = locale.getdefaultlocale()[0] try: locale.setlocale(locale.LC_ALL, "") except: pass except: pass else: if sys.platform == "darwin" and not ui_language: try: import Foundation defaults = Foundation.NSUserDefaults.standardUserDefaults() os.environ["LANG"] = defaults.objectForKey_("AppleLanguages")[0] except: pass try: locale.setlocale(locale.LC_ALL, "") except: pass try: self.log.debug("Loading gettext translation, localedir=%r", localedir) self.translation = gettext.translation("picard", localedir) self.translation.install(True) ungettext = self.translation.ungettext except IOError: __builtin__.__dict__['_'] = lambda a: a def ungettext(a, b, c): if c == 1: return a else: return b __builtin__.__dict__['ungettext'] = ungettext 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, trackid): """Move `file` to track `trackid` on album `albumid`.""" album = self.load_album(albumid) file.move(album.unmatched_files) album.run_when_loaded(partial(album.match_file, file, trackid)) def create_nats(self): if self.nats is None: self.nats = NatAlbum() self.albums["NATS"] = self.nats self.album_added.emit(self.nats) return self.nats def move_file_to_nat(self, file, trackid, node=None): self.create_nats() file.move(self.nats.unmatched_files) nat = self.load_nat(trackid, node=node) nat.run_when_loaded(partial(file.move, nat)) if nat.loaded: self.nats.update() def exit(self): self.stopping = True self._acoustid.done() self.thread_pool.stop() self.browser_integration.stop() self.xmlws.stop() def _run_init(self): if self._args: files = [] for file in self._args: 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._args def run(self): 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 event.type() == QtCore.QEvent.FileOpen: f = str(event.file()) self.add_files([f]) # 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 QtGui.QApplication.event(self, event) def _file_loaded(self, target, result=None, error=None): file = result if file is not None and error is None and not file.has_error(): trackid = file.metadata['musicbrainz_trackid'] if target is not None: self.move_files([file], target) elif not self.config.setting["ignore_file_mbids"]: albumid = file.metadata['musicbrainz_albumid'] if mbid_validate(albumid): if mbid_validate(trackid): self.move_file_to_track(file, albumid, trackid) else: self.move_file_to_album(file, albumid) elif mbid_validate(trackid): self.move_file_to_nat(file, trackid) elif self.config.setting['analyze_new_files'] and file.can_analyze(): self.analyze([file]) elif self.config.setting['analyze_new_files'] and file.can_analyze(): self.analyze([file]) def move_files(self, files, target): 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.""" self.log.debug("Adding files %r", filenames) new_files = [] for filename in filenames: filename = os.path.normpath(os.path.realpath(filename)) if filename not in self.files: file = open_file(filename) if file: self.files[filename] = file new_files.append(file) if new_files: if target is None or target is self.unmatched_files: self.unmatched_files.add_files(new_files) target = None for file in new_files: file.load(partial(self._file_loaded, target)) def add_directory(self, path): walk = os.walk(unicode(path)) def get_files(): try: root, dirs, files = walk.next() except StopIteration: return None else: self.window.set_statusbar_message(N_("Loading directory %s"), root) 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) self.other_queue.put((get_files, process, QtCore.Qt.LowEventPriority)) process(True, False) def get_file_lookup(self): """Return a FileLookup object.""" return FileLookup(self, self.config.setting["server_host"], self.config.setting["server_port"], self.browser_integration.port) def search(self, text, type, adv=False): """Search on the MusicBrainz website.""" lookup = self.get_file_lookup() getattr(lookup, type + "Search")(text, adv) def browser_lookup(self, item): """Lookup the object's metadata on the MusicBrainz website.""" lookup = self.get_file_lookup() metadata = item.metadata albumid = metadata["musicbrainz_albumid"] trackid = metadata["musicbrainz_trackid"] # Only lookup via MB IDs if matched to a DataObject; otherwise ignore and use metadata details if isinstance(item, Track) and trackid: lookup.trackLookup(trackid) elif isinstance(item, Album) and albumid: lookup.albumLookup(albumid) else: lookup.tagLookup( 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.""" files = set() for obj in objects: files.update(obj.iterfiles(save)) return list(files) def _file_saved(self, result=None, error=None): if error is None: file, old_filename, new_filename = result del self.files[old_filename] self.files[new_filename] = file def save(self, objects): """Save the specified objects.""" files = self.get_files_from_objects(objects, save=True) for file in files: file.save(self._file_saved, self.tagger.config.setting) def load_album(self, id, discid=None): id = self.mbid_redirects.get(id, id) album = self.albums.get(id) if album: return album album = Album(id, discid=discid) self.albums[id] = album self.album_added.emit(album) album.load() return album def load_nat(self, id, node=None): self.create_nats() nat = self.get_nat_by_id(id) if nat: return nat nat = NonAlbumTrack(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, id): if self.nats is not None: for nat in self.nats.tracks: if nat.id == id: return nat def get_release_group_by_id(self, id): return self.release_groups.setdefault(id, ReleaseGroup(id)) def remove_files(self, files, from_parent=True): """Remove files from the tagger.""" for file in files: if self.files.has_key(file.filename): 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.""" self.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) def remove_cluster(self, cluster): """Remove the specified cluster.""" if not cluster.special: self.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, Track): files.extend(obj.linked_files) elif isinstance(obj, Album): self.remove_album(obj) 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: QtGui.QMessageBox.critical(self.window, _(u"CD Lookup Error"), _(u"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, QtGui.QAction): device = unicode(action.text()) else: device = self.config.setting["cd_lookup_device"].split(",", 1)[0] disc = Disc() self.set_wait_cursor() self.other_queue.put(( partial(disc.read, encode_filename(device)), partial(self._lookup_disc, disc), QtCore.Qt.LowEventPriority)) @property def use_acoustid(self): return self.config.setting["fingerprinting_system"] == "acoustid" def analyze(self, objs): """Analyze the file(s).""" files = self.get_files_from_objects(objs) for file in files: file.set_pending() if self.use_acoustid: self._acoustid.analyze(file, partial(file._lookup_finished, 'acoustid')) # ======================================================================= # Metadata-based lookups # ======================================================================= def autotag(self, objects): for obj in objects: if isinstance(obj, (File, Cluster)) and not obj.lookup_task: obj.lookup_metadata() # ======================================================================= # Clusters # ======================================================================= def cluster(self, objs): """Group files with similar metadata to 'clusters'.""" self.log.debug("Clustering %r", objs) if len(objs) <= 1 or self.unmatched_files in objs: files = list(self.unmatched_files.files) else: files = self.get_files_from_objects(objs) fcmp = lambda a, b: ( cmp(a.discnumber, b.discnumber) or cmp(a.tracknumber, b.tracknumber) or cmp(a.base_filename, b.base_filename)) for name, artist, files in Cluster.cluster(files, 1.0): QtCore.QCoreApplication.processEvents() cluster = self.load_cluster(name, artist) for file in sorted(files, fcmp): file.move(cluster) def load_cluster(self, name, artist): for cluster in self.clusters: cm = cluster.metadata if name == cm["album"] and artist == cm["artist"]: 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.""" QtGui.QApplication.setOverrideCursor( QtGui.QCursor(QtCore.Qt.WaitCursor)) def restore_cursor(self): """Restores the cursor set by ``set_wait_cursor``.""" QtGui.QApplication.restoreOverrideCursor() def refresh(self, objs): for obj in objs: obj.load() @classmethod def instance(cls): return cls.__instance def num_files(self): return len(self.files)
class Tagger(QtGui.QApplication): file_state_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 def __init__(self, args, localedir, autoupdate, debug=False): QtGui.QApplication.__init__(self, args) self.__class__.__instance = self self._args = args self._autoupdate = autoupdate self.config = Config() if sys.platform == "win32": userdir = os.environ.get("APPDATA", "~\\Application Data") else: userdir = os.environ.get("XDG_CONFIG_HOME", "~/.config") self.userdir = os.path.join(os.path.expanduser(userdir), "MusicBrainz", "Picard") # Initialize threading and allocate threads self.thread_pool = thread.ThreadPool(self) self.load_queue = queue.Queue() self.save_queue = queue.Queue() self.analyze_queue = queue.Queue() self.other_queue = queue.Queue() threads = self.thread_pool.threads threads.append(thread.Thread(self.thread_pool, self.load_queue)) threads.append(thread.Thread(self.thread_pool, self.load_queue)) threads.append(thread.Thread(self.thread_pool, self.save_queue)) threads.append(thread.Thread(self.thread_pool, self.other_queue)) threads.append(thread.Thread(self.thread_pool, self.other_queue)) threads.append(thread.Thread(self.thread_pool, self.analyze_queue)) self.thread_pool.start() self.stopping = False # Setup logging if debug or "PICARD_DEBUG" in os.environ: self.log = log.DebugLog() else: self.log = log.Log() self.log.debug("Starting Picard %s from %r", picard.__version__, os.path.abspath(__file__)) # TODO remove this before the final release if sys.platform == "win32": olduserdir = "~\\Local Settings\\Application Data\\MusicBrainz Picard" else: olduserdir = "~/.picard" olduserdir = os.path.expanduser(olduserdir) if os.path.isdir(olduserdir): self.log.info("Moving %s to %s", olduserdir, self.userdir) try: shutil.move(olduserdir, self.userdir) except: pass QtCore.QObject.tagger = self QtCore.QObject.config = self.config QtCore.QObject.log = self.log self.setup_gettext(localedir) self.xmlws = XmlWebService() # Initialize fingerprinting self._ofa = musicdns.OFA() self._ofa.init() self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() self.user_plugin_dir = os.path.join(self.userdir, "plugins") if not os.path.exists(self.user_plugin_dir): os.makedirs(self.user_plugin_dir) self.pluginmanager.load_plugindir(self.user_plugin_dir) if hasattr(sys, "frozen"): self.pluginmanager.load_plugindir(os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: self.pluginmanager.load_plugindir(os.path.join(os.path.dirname(__file__), "plugins")) self.puidmanager = PUIDManager() self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.mbid_redirects = {} self.unmatched_files = UnmatchedFiles() self.nats = None self.window = MainWindow() def setup_gettext(self, localedir): """Setup locales, load translations, install gettext functions.""" if self.config.setting["ui_language"]: os.environ["LANGUAGE"] = "" os.environ["LANG"] = self.config.setting["ui_language"] if sys.platform == "win32": try: locale.setlocale(locale.LC_ALL, os.environ["LANG"]) except KeyError: os.environ["LANG"] = locale.getdefaultlocale()[0] try: locale.setlocale(locale.LC_ALL, "") except: pass except: pass else: try: locale.setlocale(locale.LC_ALL, "") except: pass try: self.log.debug("Loading gettext translation, localedir=%r", localedir) self.translation = gettext.translation("picard", localedir) self.translation.install(True) ungettext = self.translation.ungettext except IOError: __builtin__.__dict__["_"] = lambda a: a def ungettext(a, b, c): if c == 1: return a else: return b __builtin__.__dict__["ungettext"] = ungettext 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, trackid): """Move `file` to track `trackid` on album `albumid`.""" album = self.load_album(albumid) file.move(album.unmatched_files) album.run_when_loaded(partial(album.match_file, file, trackid)) def create_nats(self): if self.nats is None: self.nats = NatAlbum() self.albums["NATS"] = self.nats self.album_added.emit(self.nats) return self.nats def move_file_to_nat(self, file, trackid, node=None): self.create_nats() file.move(self.nats.unmatched_files) nat = self.load_nat(trackid, node=node) nat.run_when_loaded(partial(file.move, nat)) if nat.loaded: self.nats.update() def exit(self): self.stopping = True self._ofa.done() self._acoustid.done() self.thread_pool.stop() self.browser_integration.stop() self.xmlws.stop() def _run_init(self): if self._args: files = [] for file in self._args: 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._args def run(self): 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 event.type() == QtCore.QEvent.FileOpen: f = str(event.file()) self.add_files([f]) # 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 QtGui.QApplication.event(self, event) def _file_loaded(self, result=None, error=None): file = result if file is not None and error is None and not file.has_error(): puid = file.metadata["musicip_puid"] trackid = file.metadata["musicbrainz_trackid"] self.puidmanager.add(puid, trackid) if not self.config.setting["ignore_file_mbids"]: albumid = file.metadata["musicbrainz_albumid"] if mbid_validate(albumid): if mbid_validate(trackid): self.move_file_to_track(file, albumid, trackid) else: self.move_file_to_album(file, albumid) elif mbid_validate(trackid): self.move_file_to_nat(file, trackid) elif self.config.setting["analyze_new_files"]: self.analyze([file]) elif self.config.setting["analyze_new_files"]: self.analyze([file]) def add_files(self, filenames): """Add files to the tagger.""" self.log.debug("Adding files %r", filenames) new_files = [] for filename in filenames: filename = os.path.normpath(os.path.realpath(filename)) if filename not in self.files: file = open_file(filename) if file: self.files[filename] = file new_files.append(file) if new_files: self.unmatched_files.add_files(new_files) for file in new_files: file.load(self._file_loaded) def process_directory_listing(self, root, queue, result=None, error=None): try: # Read directory listing if result is not None and error is None: files = [] directories = deque() try: for path in result: path = os.path.join(root, path) if os.path.isdir(path): directories.appendleft(path) else: try: files.append(decode_filename(path)) except UnicodeDecodeError: self.log.warning("Failed to decode filename: %r", path) continue finally: if files: self.add_files(files) queue.extendleft(directories) finally: # Scan next directory in the queue try: path = queue.popleft() except IndexError: pass else: self.other_queue.put( ( partial(os.listdir, path), partial(self.process_directory_listing, path, queue), QtCore.Qt.LowEventPriority, ) ) def add_directory(self, path): path = encode_filename(path) self.other_queue.put( ( partial(os.listdir, path), partial(self.process_directory_listing, path, deque()), QtCore.Qt.LowEventPriority, ) ) def get_file_by_id(self, id): """Get file by a file ID.""" for file in self.files.itervalues(): if file.id == id: return file return None def get_file_by_filename(self, filename): """Get file by a filename.""" return self.files.get(filename, None) def get_file_lookup(self): """Return a FileLookup object.""" return FileLookup( self, self.config.setting["server_host"], self.config.setting["server_port"], self.browser_integration.port ) def search(self, text, type, adv=False): """Search on the MusicBrainz website.""" lookup = self.get_file_lookup() getattr(lookup, type + "Search")(text, adv) def lookup(self, metadata): """Lookup the metadata on the MusicBrainz website.""" lookup = self.get_file_lookup() albumid = metadata["musicbrainz_albumid"] trackid = metadata["musicbrainz_trackid"] if trackid: lookup.trackLookup(trackid) elif albumid: lookup.albumLookup(albumid) else: lookup.tagLookup( metadata["artist"], metadata["album"], metadata["title"], metadata["tracknumber"], str(metadata.length), metadata["~filename"], metadata["musicip_puid"], ) def get_files_from_objects(self, objects, save=False): """Return list of files from list of albums, clusters, tracks or files.""" files = set() for obj in objects: files.update(obj.iterfiles(save)) return list(files) def _file_saved(self, result=None, error=None): if error is None: file, old_filename, new_filename = result del self.files[old_filename] self.files[new_filename] = file def save(self, objects): """Save the specified objects.""" files = self.get_files_from_objects(objects, save=True) for file in files: file.save(self._file_saved, self.tagger.config.setting) def load_album(self, id, discid=None): id = self.mbid_redirects.get(id, id) album = self.albums.get(id) if album: return album album = Album(id, discid=discid) self.albums[id] = album self.album_added.emit(album) album.load() return album def reload_album(self, album): if album == self.nats: album.update() else: album.load() def load_nat(self, id, node=None): self.create_nats() nat = self.get_nat_by_id(id) if nat: return nat nat = NonAlbumTrack(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, id): if self.nats is not None: for nat in self.nats.tracks: if nat.id == id: return nat def remove_files(self, files, from_parent=True): """Remove files from the tagger.""" for file in files: if self.files.has_key(file.filename): file.clear_lookup_task() self._ofa.stop_analyze(file) self._acoustid.stop_analyze(file) del self.files[file.filename] file.remove(from_parent) def remove_album(self, album): """Remove the specified album.""" self.log.debug("Removing %r", album) album.stop_loading() self.remove_files(self.get_files_from_objects([album])) del self.albums[album.id] if album == self.nats: self.nats = None self.album_removed.emit(album) def remove_cluster(self, cluster): """Remove the specified cluster.""" if not cluster.special: self.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, Track): files.extend(obj.linked_files) elif isinstance(obj, Album): self.remove_album(obj) 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: QtGui.QMessageBox.critical(self.window, _(u"CD Lookup Error"), _(u"Error while reading CD:\n\n%s") % error) else: disc.lookup() def lookup_cd(self, action=None): """Reads CD from the selected drive and tries to lookup the DiscID on MusicBrainz.""" if action is None: device = self.config.setting["cd_lookup_device"].split(",", 1)[0] else: device = unicode(action.text()) disc = Disc() self.set_wait_cursor() self.other_queue.put( (partial(disc.read, encode_filename(device)), partial(self._lookup_disc, disc), QtCore.Qt.LowEventPriority) ) def _lookup_puid(self, file, result=None, error=None): puid = result if file.state == File.PENDING: if puid: self.puidmanager.add(puid, None) file.metadata["musicip_puid"] = puid file.lookup_puid(puid) else: self.window.set_statusbar_message(N_("Could not find PUID for file %s"), file.filename) file.clear_pending() @property def use_acoustid(self): return self.config.setting["fingerprinting_system"] == "acoustid" def analyze(self, objs): """Analyze the file(s).""" files = self.get_files_from_objects(objs) for file in files: file.set_pending() if self.use_acoustid: self._acoustid.analyze(file, partial(file._lookup_finished, "acoustid")) else: self._ofa.analyze(file, partial(self._lookup_puid, file)) # ======================================================================= # Metadata-based lookups # ======================================================================= def autotag(self, objects): for obj in objects: if isinstance(obj, (File, Cluster)) and not obj.lookup_task: obj.lookup_metadata() # ======================================================================= # Clusters # ======================================================================= def cluster(self, objs): """Group files with similar metadata to 'clusters'.""" self.log.debug("Clustering %r", objs) if len(objs) <= 1 or self.unmatched_files in objs: files = list(self.unmatched_files.files) else: files = self.get_files_from_objects(objs) fcmp = lambda a, b: ( cmp(a.discnumber, b.discnumber) or cmp(a.tracknumber, b.tracknumber) or cmp(a.base_filename, b.base_filename) ) for name, artist, files in Cluster.cluster(files, 1.0): QtCore.QCoreApplication.processEvents() cluster = self.load_cluster(name, artist) for file in sorted(files, fcmp): file.move(cluster) def load_cluster(self, name, artist): for cluster in self.clusters: cm = cluster.metadata if name == cm["album"] and artist == cm["artist"]: 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.""" QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor)) def restore_cursor(self): """Restores the cursor set by ``set_wait_cursor``.""" QtGui.QApplication.restoreOverrideCursor() def refresh(self, objs): for obj in objs: if isinstance(obj, Album): self.reload_album(obj) elif isinstance(obj, NonAlbumTrack): obj.load() @classmethod def instance(cls): return cls.__instance def num_files(self): return len(self.files) def num_pending_files(self): return len([file for file in self.files.values() if file.state == File.PENDING])
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.setDesktopFileName('org.musicbrainz.Picard.desktop') 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_plugindir(os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: mydir = os.path.dirname(os.path.abspath(__file__)) self.pluginmanager.load_plugindir(os.path.join(mydir, "plugins")) if not os.path.exists(USER_PLUGIN_DIR): os.makedirs(USER_PLUGIN_DIR) self.pluginmanager.load_plugindir(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) 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)
class Tagger(QtGui.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 def __init__(self, picard_args, unparsed_args, localedir, autoupdate): # Set the WM_CLASS to 'MusicBrainz-Picard' so desktop environments # can use it to look up the app QtGui.QApplication.__init__(self, ['MusicBrainz-Picard'] + unparsed_args) self.__class__.__instance = self config._setup(self) self._cmdline_files = picard_args.FILE self._autoupdate = autoupdate self._debug = False # 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) # 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 sys.platform == "win32": # 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) # Setup logging self.debug(picard_args.debug or "PICARD_DEBUG" in os.environ) 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()) # TODO remove this before the final release if sys.platform == "win32": olduserdir = "~\\Local Settings\\Application Data\\MusicBrainz Picard" else: olduserdir = "~/.picard" olduserdir = os.path.expanduser(olduserdir) if os.path.isdir(olduserdir): log.info("Moving %r to %r", olduserdir, USER_DIR) try: shutil.move(olduserdir, USER_DIR) except: pass 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() self.xmlws = XmlWebService() load_user_collections() # Initialize fingerprinting self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() if hasattr(sys, "frozen"): self.pluginmanager.load_plugindir( os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: mydir = os.path.dirname(os.path.abspath(__file__)) self.pluginmanager.load_plugindir(os.path.join(mydir, "plugins")) self.pluginmanager.load_plugindir( os.path.join(mydir, os.pardir, "contrib", "plugins")) if not os.path.exists(USER_PLUGIN_DIR): os.makedirs(USER_PLUGIN_DIR) self.pluginmanager.load_plugindir(USER_PLUGIN_DIR) self.pluginmanager.query_available_plugins() self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unmatched_files = UnmatchedFiles() self.nats = None self.window = MainWindow() self.exit_cleanup = [] self.stopping = False def register_cleanup(self, func): self.exit_cleanup.append(func) def run_cleanup(self): for f in self.exit_cleanup: f() def debug(self, debug): if self._debug == debug: return if debug: log.log_levels = log.log_levels | log.LOG_DEBUG log.debug("Debug mode on") else: log.debug("Debug mode off") log.log_levels = log.log_levels & ~log.LOG_DEBUG self._debug = debug 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_file, file, recordingid)) def create_nats(self): if self.nats is None: self.nats = NatAlbum() self.albums["NATS"] = self.nats self.album_added.emit(self.nats) 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): log.debug("Picard stopping") self.stopping = True self._acoustid.done() self.thread_pool.waitForDone() self.save_thread_pool.waitForDone() self.browser_integration.stop() self.xmlws.stop() for f in self.exit_cleanup: f() 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: f = str(event.file()) self.add_files([f]) # 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 QtGui.QApplication.event(self, event) def _file_loaded(self, file, target=None): if file is not None and not file.has_error(): recordingid = file.metadata.getall('musicbrainz_recordingid')[0] \ if 'musicbrainz_recordingid' in file.metadata else '' if target is not None: self.move_files([file], target) elif not config.setting["ignore_file_mbids"]: albumid = file.metadata.getall('musicbrainz_albumid')[0] \ if 'musicbrainz_albumid' in file.metadata else '' if mbid_validate(albumid): if mbid_validate(recordingid): self.move_file_to_track(file, albumid, recordingid) else: self.move_file_to_album(file, albumid) elif mbid_validate(recordingid): self.move_file_to_nat(file, recordingid) elif config.setting['analyze_new_files'] and file.can_analyze( ): self.analyze([file]) elif config.setting['analyze_new_files'] and file.can_analyze(): self.analyze([file]) def move_files(self, files, target): 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 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.unmatched_files: self.unmatched_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(unicode(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(ungettext( "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(ungettext( "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, type, adv=False): """Search on the MusicBrainz website.""" lookup = self.get_file_lookup() if config.setting["builtin_search"]: if type == "track" and not lookup.mbidLookup(text, 'recording'): dialog = TrackSearchDialog(self.window) dialog.search(text) dialog.exec_() elif type == "album" and not lookup.mbidLookup(text, 'release'): dialog = AlbumSearchDialog(self.window) dialog.search(text) dialog.exec_() elif type == "artist" and not lookup.mbidLookup(text, 'artist'): dialog = ArtistSearchDialog(self.window) dialog.search(text) dialog.exec_() else: getattr(lookup, type + "Search")(text, adv) def collection_lookup(self): """Lookup the users collections on the MusicBrainz website.""" lookup = self.get_file_lookup() lookup.collectionLookup(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.recordingLookup(itemid) elif isinstance(item, Album): lookup.albumLookup(itemid) else: lookup.tagLookup( 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, id, discid=None): id = self.mbid_redirects.get(id, id) album = self.albums.get(id) if album: log.debug("Album %s already loaded.", id) return album album = Album(id, discid=discid) self.albums[id] = album self.album_added.emit(album) album.load() return album def load_nat(self, id, node=None): self.create_nats() nat = self.get_nat_by_id(id) if nat: log.debug("NAT %s already loaded.", id) return nat nat = NonAlbumTrack(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, id): if self.nats is not None: for nat in self.nats.tracks: if nat.id == id: return nat def get_release_group_by_id(self, id): return self.release_groups.setdefault(id, ReleaseGroup(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) 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, 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, UnmatchedFiles): 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: QtGui.QMessageBox.critical( self.window, _(u"CD Lookup Error"), _(u"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, QtGui.QAction): device = unicode(action.text()) 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)) @property def use_acoustid(self): return config.setting["fingerprinting_system"] == "acoustid" def analyze(self, objs): """Analyze the file(s).""" files = self.get_files_from_objects(objs) for file in files: file.set_pending() if self.use_acoustid: self._acoustid.analyze( file, partial(file._lookup_finished, '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.unmatched_files in objs: files = list(self.unmatched_files.files) else: files = self.get_files_from_objects(objs) fcmp = lambda a, b: (cmp(a.discnumber, b.discnumber) or cmp( a.tracknumber, b.tracknumber) or cmp(a.base_filename, b. base_filename)) for name, artist, files in Cluster.cluster(files, 1.0): QtCore.QCoreApplication.processEvents() cluster = self.load_cluster(name, artist) for file in sorted(files, fcmp): 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.""" QtGui.QApplication.setOverrideCursor( QtGui.QCursor(QtCore.Qt.WaitCursor)) def restore_cursor(self): """Restores the cursor set by ``set_wait_cursor``.""" QtGui.QApplication.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("a") def sighandler(self): self.signalnotifier.setEnabled(False) self.exit() self.quit() self.signalnotifier.setEnabled(True)
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)
class Tagger(QtGui.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 def __init__(self, args, localedir, autoupdate, debug=False): QtGui.QApplication.__init__(self, args) self.__class__.__instance = self self._args = args self._autoupdate = autoupdate # 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) # 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) # Setup logging if debug or "PICARD_DEBUG" in os.environ: log.log_levels = log.log_levels | log.LOG_DEBUG log.debug("Starting Picard %s from %r", picard.__version__, os.path.abspath(__file__)) # TODO remove this before the final release if sys.platform == "win32": olduserdir = "~\\Local Settings\\Application Data\\MusicBrainz Picard" else: olduserdir = "~/.picard" olduserdir = os.path.expanduser(olduserdir) if os.path.isdir(olduserdir): log.info("Moving %s to %s", olduserdir, USER_DIR) try: shutil.move(olduserdir, USER_DIR) except: pass # for compatibility with pre-1.3 plugins QtCore.QObject.tagger = self QtCore.QObject.config = config QtCore.QObject.log = log check_io_encoding() self._upgrade_config() setup_gettext(localedir, config.setting["ui_language"], log.debug) self.xmlws = XmlWebService() load_user_collections() # Initialize fingerprinting self._acoustid = acoustid.AcoustIDClient() self._acoustid.init() # Load plugins self.pluginmanager = PluginManager() if hasattr(sys, "frozen"): self.pluginmanager.load_plugindir(os.path.join(os.path.dirname(sys.argv[0]), "plugins")) else: self.pluginmanager.load_plugindir(os.path.join(os.path.dirname(__file__), "plugins")) if not os.path.exists(USER_PLUGIN_DIR): os.makedirs(USER_PLUGIN_DIR) self.pluginmanager.load_plugindir(USER_PLUGIN_DIR) self.acoustidmanager = AcoustIDManager() self.browser_integration = BrowserIntegration() self.files = {} self.clusters = ClusterList() self.albums = {} self.release_groups = {} self.mbid_redirects = {} self.unmatched_files = UnmatchedFiles() self.nats = None self.window = MainWindow() def _upgrade_config(self): cfg = config._config # In version 1.0, the file naming formats for single and various # artist releases were merged. def upgrade_to_v1_0(): def remove_va_file_naming_format(merge=True): if merge: config.setting["file_naming_format"] = ( "$if($eq(%compilation%,1),\n$noop(Various Artist " "albums)\n%s,\n$noop(Single Artist Albums)\n%s)" % ( config.setting["va_file_naming_format"].toString(), config.setting["file_naming_format"] )) config.setting.remove("va_file_naming_format") config.setting.remove("use_va_format") if ("va_file_naming_format" in config.setting and "use_va_format" in config.setting): if config.setting["use_va_format"].toBool(): remove_va_file_naming_format() self.window.show_va_removal_notice() elif (config.setting["va_file_naming_format"].toString() != r"$if2(%albumartist%,%artist%)/%album%/$if($gt(%totaldis" "cs%,1),%discnumber%-,)$num(%tracknumber%,2) %artist% - " "%title%"): if self.window.confirm_va_removal(): remove_va_file_naming_format(merge=False) else: remove_va_file_naming_format() else: # default format, disabled remove_va_file_naming_format(merge=False) cfg.register_upgrade_hook("1.0.0final0", upgrade_to_v1_0) cfg.run_upgrade_hooks() 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_file, file, recordingid)) def create_nats(self): if self.nats is None: self.nats = NatAlbum() self.albums["NATS"] = self.nats self.album_added.emit(self.nats) 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): self.stopping = True self._acoustid.done() self.thread_pool.waitForDone() self.browser_integration.stop() self.xmlws.stop() def _run_init(self): if self._args: files = [] for file in self._args: 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._args 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: f = str(event.file()) self.add_files([f]) # 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 QtGui.QApplication.event(self, event) def _file_loaded(self, file, target=None): if file is not None and not file.has_error(): recordingid = file.metadata['musicbrainz_recordingid'] if target is not None: self.move_files([file], target) elif not config.setting["ignore_file_mbids"]: albumid = file.metadata['musicbrainz_albumid'] if mbid_validate(albumid): if mbid_validate(recordingid): self.move_file_to_track(file, albumid, recordingid) else: self.move_file_to_album(file, albumid) elif mbid_validate(recordingid): self.move_file_to_nat(file, recordingid) elif config.setting['analyze_new_files'] and file.can_analyze(): self.analyze([file]) elif config.setting['analyze_new_files'] and file.can_analyze(): self.analyze([file]) def move_files(self, files, target): 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.""" log.debug("Adding files %r", filenames) new_files = [] for filename in filenames: filename = os.path.normpath(os.path.realpath(filename)) if filename not in self.files: file = open_file(filename) if file: self.files[filename] = file new_files.append(file) if new_files: if target is None or target is self.unmatched_files: self.unmatched_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): walk = os.walk(unicode(path)) def get_files(): try: root, dirs, files = walk.next() except StopIteration: return None else: self.window.set_statusbar_message(N_("Loading directory %s"), root) 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 get_file_lookup(self): """Return a FileLookup object.""" return FileLookup(self, config.setting["server_host"], config.setting["server_port"], self.browser_integration.port) def search(self, text, type, adv=False): """Search on the MusicBrainz website.""" lookup = self.get_file_lookup() getattr(lookup, type + "Search")(text, adv) def browser_lookup(self, item): """Lookup the object's metadata on the MusicBrainz website.""" lookup = self.get_file_lookup() metadata = item.metadata albumid = metadata["musicbrainz_albumid"] recordingid = metadata["musicbrainz_recordingid"] # Only lookup via MB IDs if matched to a DataObject; otherwise ignore and use metadata details if isinstance(item, Track) and recordingid: lookup.recordingLookup(recordingid) elif isinstance(item, Album) and albumid: lookup.albumLookup(albumid) else: lookup.tagLookup( 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, id, discid=None): id = self.mbid_redirects.get(id, id) album = self.albums.get(id) if album: return album album = Album(id, discid=discid) self.albums[id] = album self.album_added.emit(album) album.load() return album def load_nat(self, id, node=None): self.create_nats() nat = self.get_nat_by_id(id) if nat: return nat nat = NonAlbumTrack(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, id): if self.nats is not None: for nat in self.nats.tracks: if nat.id == id: return nat def get_release_group_by_id(self, id): return self.release_groups.setdefault(id, ReleaseGroup(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) 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, Track): files.extend(obj.linked_files) elif isinstance(obj, Album): self.remove_album(obj) 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: QtGui.QMessageBox.critical(self.window, _(u"CD Lookup Error"), _(u"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, QtGui.QAction): device = unicode(action.text()) 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)) @property def use_acoustid(self): return config.setting["fingerprinting_system"] == "acoustid" def analyze(self, objs): """Analyze the file(s).""" files = self.get_files_from_objects(objs) for file in files: file.set_pending() if self.use_acoustid: self._acoustid.analyze(file, partial(file._lookup_finished, '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.unmatched_files in objs: files = list(self.unmatched_files.files) else: files = self.get_files_from_objects(objs) fcmp = lambda a, b: ( cmp(a.discnumber, b.discnumber) or cmp(a.tracknumber, b.tracknumber) or cmp(a.base_filename, b.base_filename)) for name, artist, files in Cluster.cluster(files, 1.0): QtCore.QCoreApplication.processEvents() cluster = self.load_cluster(name, artist) for file in sorted(files, fcmp): 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.""" QtGui.QApplication.setOverrideCursor( QtGui.QCursor(QtCore.Qt.WaitCursor)) def restore_cursor(self): """Restores the cursor set by ``set_wait_cursor``.""" QtGui.QApplication.restoreOverrideCursor() def refresh(self, objs): for obj in objs: obj.load() @classmethod def instance(cls): return cls.__instance