diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 49d40eb11..b99fb4d17 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,6 +6,7 @@ variables: DISCID_VERSION: "0.6.1" PYTHON_DISCID_VERSION: "1.1.1" MUTAGEN_VERSION: "1.36" + PY2APP_VERSION: "0.11" package win: stage: package diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000..170fd5550 --- /dev/null +++ b/.mailmap @@ -0,0 +1,5 @@ +Frederik “Freso” S. Olesen +Lukáš Lalinský +Sambhav Kothari +Sophist-UK +Suhas diff --git a/.travis.yml b/.travis.yml index 7e49b3042..220d42b4b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,33 @@ -sudo: required -services: - - docker +os: linux +language: python +python: "2.7" +virtualenv: + system_site_packages: true +cache: + - apt + - pip +env: + global: + - PIP_INSTALL="pip install" + matrix: + - DISCID="" MUTAGEN="$PIP_INSTALL mutagen>=1.23" + - DISCID="$PIP_INSTALL discid" MUTAGEN="$PIP_INSTALL mutagen>=1.23" + - MUTAGEN="$PIP_INSTALL mutagen==1.34" +matrix: + include: + - os: osx + osx_image: xcode8.1 + language: generic before_install: - - docker build -t picard . -script: "docker run picard" + - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then bash scripts/setup-osx.sh; fi + - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sudo apt-get update -qq && sudo apt-get install -qq python-qt4 libdiscid0 libdiscid0-dev; $MUTAGEN; $DISCID; fi +install: + # Set up Picard + - python setup.py build_ext -i + - python setup.py build_locales -i +# Run the tests! +script: "python setup.py test" + # Tell people that tests were run notifications: irc: "chat.freenode.net#metabrainz" diff --git a/NEWS.txt b/NEWS.txt index 5c2fa95ca..e279b4d30 100644 --- a/NEWS.txt +++ b/NEWS.txt @@ -1,6 +1,158 @@ -Version ? - ? - * Picard now requires at least Python 2.7 - * Task: Update Picard logo/icons. (PICARD-760) +Version 1.4 - 2017-02-14 + * Bugfix: AcoustID submission fails with code 299 (PICARD-82) + * Bugfix: Ignoring "hip hop rap" folksonomy tags also ignores "rap", "hip hop", etc. (PICARD-335) + * Bugfix: Picard downloads multiple 'front' images instead of just first one. (PICARD-350) + * Bugfix: Saving hidden file with only an extension drops the extension (PICARD-357) + * Bugfix: Add directory opens in "wrong" dir (PICARD-366) + * Bugfix: Picard should de-duplicate work lists (PICARD-375) + * Bugfix: Tree selector in Options window is partially obscured, pane too narrow (PICARD-408) + * Bugfix: tag acoustid_id can not be removed or deleted in script, renaming or plugin (PICARD-419) + * Bugfix: Can't remove value from field (PICARD-546) + * Bugfix: Can't open Options (PICARD-592) + * Bugfix: "Tags from filenames" action stays enabled even if it is unavailable. (PICARD-688) + * Bugfix: Using the first image type as filename changes the name of front images (PICARD-701) + * Bugfix: Fingerprint Submission Failes if AcoustID tags are present and/or invalid (PICARD-706) + * Bugfix: Picard moves into the selected folder (PICARD-726) + * Bugfix: Picard does not support (recording) relationship credits (PICARD-730) + * Bugfix: Picard repeats/duplicates field data (PICARD-748) + * Bugfix: Number of pending web requests is not decremented on exceptions in the handler (PICARD-751) + * Bugfix: Divide by zero error in _convert_folksonomy_tags_to_genre when no tag at the release/release group level ( PICARD-753) + * Bugfix: Directory tree (file browser) not sorted for non-system drives under Windows (PICARD-754) + * Bugfix: Crash when loading release with only zero count tags (PICARD-759) + * Bugfix: No name and no window grouping in gnome-shell Alt-Tab app switcher (PICARD-761) + * Bugfix: Lookup in Browser does not and can not load HTTPS version of musicbrainz.org (PICARD-764) + * Bugfix: Unable to login using oauth via Picard options with Server Port set to 443 (PICARD-766) + * Bugfix: "AttributeError: 'MetadataBox' object has no attribute 'resize_columns'" when enabling the cover art box ( PICARD-775) + * Bugfix: Pre-gap tracks are not counted in absolutetracknumber (PICARD-778) + * Bugfix: CAA cover art provider runs even if cover art has already been loaded (PICARD-780) + * Bugfix: Toggling Embed Cover Art in Tags and restarting doesn't have the expected behavior (PICARD-782) + * Bugfix: XMLWS redirects incorrectly (PICARD-788) + * Bugfix: Handle empty collection-list in web server response (PICARD-798) + * Bugfix: Amazon Cover Art provider does not work (and does not have a lot of debug logging enabled) (PICARD-799) + * Bugfix: Cover Art from CAA release group is skipped even though it exists (PICARD-801) + * Bugfix: Multiple instances of history and log dialogs (PICARD-804) + * Bugfix: Empty string lookup (PICARD-805) + * Bugfix: Will not load album information on any albums (PICARD-811) + * Bugfix: Redirect URL is not encoded which leads to http 400 error. (PICARD-814) + * Bugfix: Not compatible with latest Mutagen (PICARD-833) + * Bugfix: Can't save any files. Get: "error: invalid literal for int() with base 10" (PICARD-834) + * Bugfix: Picard 1.3.2 shows cleartext username & password on status line when errors occur (PICARD-839) + * Bugfix: Cannot fetch cover art from amazon link contains https scheme. (PICARD-848) + * Bugfix: media-optical-modified.png icon still displayed after release save when two files match one track (PICARD-851) + * Bugfix: Release that Picard will not load (due to disc with just data track?) (PICARD-853) + * Bugfix: ValueError in metadata.py (PICARD-855) + * Bugfix: Improper detection of Gnome as a desktop environment and no support for gnome 3 (PICARD-857) + * Bugfix: Apparent non-functional tagger button (PICARD-858) + * Bugfix: Picard does not read Ogg/Opus files with an ".ogg" file exension (PICARD-859) + * Bugfix: Setting a large value in in $num function as length causes picard to become unresponsive (PICARD-865) + * Bugfix: id3 deletion needs to be improved (PICARD-867) + * Bugfix: id3v2.3 does not properly handle TMOO ( mood tag) (PICARD-868) + * Bugfix: Coverart providers duplicates on reset (PICARD-870) + * Bugfix: Restore defaults broken for plugins page and tagger scripts page (PICARD-873) + * Bugfix: Coverart providers erroneous save (PICARD-874) + * Bugfix: The metadatabox doesn't correctly show the tag selected (PICARD-876) + * Bugfix: Length tag for ID3 is no longer displayed in the metadata box (PICARD-881) + * Bugfix: Removed tags are not removed from the metadatabox after saving the file (PICARD-882) + * Bugfix: File Browser pane doesn't check for path type( file or folder) when setting home path/move files here ( PICARD-884) + * Bugfix: mov files return a +ve score for mp4 container leading to errors (PICARD-885) + * Bugfix: "Restore defaults" doesn't log out the user (PICARD-888) + * Bugfix: Broken 'Restore Defaults' (PICARD-907) + * Bugfix: Messagebox wraps and displays title inappropriately (PICARD-911) + * Bugfix: An “empty” track shouldn’t get an “excellent match” tooltip. (PICARD-914) + * Bugfix: In plugins list, some plugins don't show description (PICARD-915) + * Bugfix: Plugin restore defaults broken (PICARD-916) + * Bugfix: Does not use UI language but locale on Windows (PICARD-917) + * Bugfix: Preserve scripting splitter position (PICARD-925) + * Bugfix: Having trouble submitting AcoustIDs (PICARD-926) + * Bugfix: Cluster double‐click opens the Info… panel (PICARD-931) + * Bugfix: Status bar not cleared when selection changed (PICARD-937) + * Bugfix: Open containing folder not working for shared files over network (PICARD-942) + * Bugfix: Warning: Plugin directory '…/python2.7/site-packages/contrib/plugins' doesn't exist (PICARD-945) + * Bugfix: Additionnal files aren't moved anymore (PICARD-946) + * Bugfix: Search window error message does not appear translated (PICARD-947) + * Bugfix: Open Containing Folder duplicates (PICARD-950) + * Bugfix: Errors when directory / file names contain unicode characters (PICARD-958) + * New Feature: AIF support (ID3) (PICARD-42) + * New Feature: Test and integrate support for "local" cover art into Picard (PICARD-137) + * New Feature: Display infos (album, artist, tracklist) for clusters without release match (PICARD-680) + * New Feature: Add download plugin functionality to existing UI (PICARD-691) + * New Feature: Fallback on album artist's tags if no tags are found for album (PICARD-738) + * New Feature: Add m2a as a supported extension (PICARD-743) + * New Feature: MusicBrainz/AcoustID entities should be hyperlinked in Picard (PICARD-756) + * New Feature: Support key tag (PICARD-769) + * New Feature: Export / import settings (PICARD-901) + * New Feature: Search releases from within a Picard dialog (PICARD-927) + * New Feature: Searching tracks and displaying similar tracks in a dialog box (PICARD-928) + * New Feature: Search for artists from dialog (PICARD-929) + * Task: Picard default name files script refinement (PICARD-717) + * Task: Update Picard logo/icons (PICARD-760) + * Task: Link to the Scripting documentation on the Scripting options page (PICARD-779) + * Task: Remove contrib/plugins from the repository (PICARD-835) + * Task: Raise the required mutagen version to 1.22 (PICARD-841) + * Task: Renaming save_only_front_images_to_tags option to something more appropriate (PICARD-861) + * Task: Allow translators to finalize translations before releasing Picard 1.4 (PICARD-895) + * Task: Raise the required Python version to 2.7. (PICARD-904) + * Task: Bump Picard’s copyright date (PICARD-912) + * Task: Add Norwegian to UI languages (PICARD-982) + * Task: Provide ~video variable for video tracks (PICARD-652) + * Task: Improve error logging on AcoustId submission (PICARD-708) + * Improvement: Link to Picard Scripting page under 'File Naming' (PICARD-22) + * Improvement: Restore default settings button/s (PICARD-116) + * Improvement: Speed of Ogg tag writing/updating (PICARD-133) + * Improvement: Allow adding/removing tags to be preserved from context menu in the tag diff pane (PICARD-207) + * Improvement: Make it easier to remove everything currently loaded in Picard (PICARD-210) + * Improvement: Bring back keyboard shortcuts for editing tags (PICARD-222) + * Improvement: Case sensitivity for "Move additional files" option (PICARD-229) + * Improvement: Metadata comparison box shows that it intends to write (and has written) tags unsupported by underlyingfile format (PICARD-253) + * Improvement: Add more descriptive tooltips to buttons (PICARD-267) + * Improvement: Allow musicip_puid and acoustid_id to be cleared from tags (PICARD-268) + * Improvement: Make it possible to remove existing tags without clearing all tags (PICARD-287) + * Improvement: Disable recurse subdirectories should be added (PICARD-291) + * Improvement: display how many "pending files" left on lookup (PICARD-305) + * Improvement: Handle MP3 TSST/TIT3 (subtitle) tags better with ID3v2.3 (PICARD-307) + * Improvement: Customisable toolbars (PICARD-353) + * Improvement: Ignore file extension and try to read anyway (PICARD-359) + * Improvement: Make it possible to unset all performer (etc) tags (PICARD-384) + * Improvement: Progress tracking (PICARD-388) + * Improvement: Add ability to handle multiple tagger scripts (PICARD-404) + * Improvement: the option "select all" to save (PICARD-476) + * Improvement: Option to load only audio tracks, i.e. not DVD-Video, CD-ROM tracks (PICARD-514) + * Improvement: Picard should use OAuth for authentication (PICARD-615) + * Improvement: Improvements to WMA tags (PICARD-648) + * Improvement: Only ask to "log in now" once per session (PICARD-678) + * Improvement: Show codec info for MP4 files (PICARD-683) + * Improvement: "Play File" button should be renamed to "Open in Player" (PICARD-692) + * Improvement: ID3 padding not reduced can result in large files (PICARD-695) + * Improvement: Set option 'caa_approved_only' disabled by default (PICARD-705) + * Improvement: Validate fpcalc executable in options (PICARD-707) + * Improvement: Improve File Naming options (PICARD-733) + * Improvement: Add --long-version/-V option, outputting third parties libs versions as well as Picard version PICARD-734) + * Improvement: missing info in the help file (PICARD-740) + * Improvement: Pass command-line arguments to QtApplication (PICARD-773) + * Improvement: Use the more detailed icons in more places on windows (PICARD-777) + * Improvement: Use .ini configuration file on all platforms (PICARD-794) + * Improvement: Use python2 shebang as of PEP 0394 (PICARD-806) + * Improvement: Display existing covers in File Info dialog (PICARD-808) + * Improvement: Use HTTPS for external links (PICARD-818) + * Improvement: Install a scalable icon (PICARD-838) + * Improvement: Use HTTPS for requests to the plugins API on picard.musicbrainz.org (PICARD-852) + * Improvement: Use magic numbers to determine the audio file types instead of relying on extensions (PICARD-864) + * Improvement: Multi-scripting UI is very basic (PICARD-883) + * Improvement: Allow scripting functions to have arbitrary number of arguments (PICARD-887) + * Improvement: The "Restore defaults" confirmation buttons should follow the quit confirmation dialog in style PICARD-890) + * Improvement: Replace submit icon with AcoustID logo (PICARD-896) + * Improvement: Rename "Submit" button to "Submit AcoustIDs" (PICARD-897) + * Improvement: Use UTF-8 for ID3v2.4 by default instead of UTF-16 (PICARD-898) + * Improvement: Restore defaults is slightly broken for tags option page (PICARD-902) + * Improvement: Rearrange the action toolbar icons from left to right according to the expected user-flow (PICARD-908) + * Improvement: Add tooltips to “Restore all Defaults” and “Restore Defaults” (PICARD-913) + * Improvement: Make PICARD-883 UI have adjustable widths for list of scripts and script content (PICARD-918) + * Improvement: Move Options/Advanced/Scripting to Options/Scripting (PICARD-919) + * Improvement: Move UI options page up the options tree (PICARD-921) + * Improvement: Add $startswith and $endswith string functions (PICARD-923) + * Improvement: Make list of scripts smaller than script text by default (PICARD-924) + * Improvement: Wait for save thread pool to be finished before exit (PICARD-944) + * Improvement: New guess format functionality should use explicit buffer size (PICARD-970) Version 1.3.2 - 2015-01-07 * Bugfix: Fixed tags from filename dialog not opening on new installations diff --git a/README.md b/README.md index 9040b7016..227047107 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ MusicBrainz Picard Picard supports the majority of audio file formats, is capable of using audio fingerprints ([AcoustIDs](http://musicbrainz.org/doc/AcoustID)), performing CD lookups and [disc ID](http://musicbrainz.org/doc/Disc_ID) submissions, and it has excellent Unicode support. Additionally, there are several plugins available that extend Picard's features. -When tagging files, Picard uses an album-oriented approach. This approach allows it to utilize the MusicBrainz data as effectively as possible and correctly tag your music. For more information, [see the illustrated quick start guide to tagging](http://picard.musicbrainz.org/docs/guide/). +When tagging files, Picard uses an album-oriented approach. This approach allows it to utilize the MusicBrainz data as effectively as possible and correctly tag your music. For more information, [see the illustrated quick start guide to tagging](https://picard.musicbrainz.org/quick-start/). Picard is named after Captain Jean-Luc Picard from the TV series Star Trek: The Next Generation. diff --git a/picard/__init__.py b/picard/__init__.py index 33d691a36..d41598752 100644 --- a/picard/__init__.py +++ b/picard/__init__.py @@ -22,7 +22,7 @@ import re PICARD_APP_NAME = "Picard" PICARD_ORG_NAME = "MusicBrainz" -PICARD_VERSION = (1, 4, 0, 'dev', 7) +PICARD_VERSION = (1, 4, 1, 'dev', 1) # optional build version # it should be in the form '_' @@ -58,7 +58,7 @@ def version_to_string(version, short=False): return version_str -_version_re = re.compile("(\d+)[._](\d+)(?:[._](\d+)[._]?(?:(dev|final)[._]?(\d+))?)?$") +_version_re = re.compile(r"(\d+)[._](\d+)(?:[._](\d+)[._]?(?:(dev|final)[._]?(\d+))?)?$") def version_from_string(version_str): diff --git a/picard/album.py b/picard/album.py index 3cca79621..a4106df81 100644 --- a/picard/album.py +++ b/picard/album.py @@ -32,6 +32,7 @@ from picard.track import Track from picard.script import ScriptParser from picard.ui.item import Item from picard.util import format_time, mbid_validate +from picard.util.imagelist import ImageList, update_metadata_images from picard.util.textencoding import asciipunct from picard.cluster import Cluster from picard.collection import add_release_to_user_collections @@ -58,6 +59,7 @@ class Album(DataObject, Item): def __init__(self, id, discid=None): DataObject.__init__(self, id) self.metadata = Metadata() + self.orig_metadata = Metadata() self.tracks = [] self.loaded = False self.load_task = None @@ -71,6 +73,7 @@ class Album(DataObject, Item): self.errors = [] self.status = None self._album_artists = [] + self.update_metadata_images_enabled = True def __repr__(self): return '' % (self.id, self.metadata[u"album"]) @@ -83,6 +86,9 @@ class Album(DataObject, Item): for file in self.unmatched_files.iterfiles(): yield file + def enable_update_metadata_images(self, enabled): + self.update_metadata_images_enabled = enabled + def append_album_artist(self, id): """Append artist id to the list of album artists and return an AlbumArtist instance""" @@ -254,6 +260,7 @@ class Album(DataObject, Item): self._tracks_loaded = True if not self._requests: + self.enable_update_metadata_images(False) # Prepare parser for user's script if config.setting["enable_tagger_scripts"]: for s_pos, s_name, s_enabled, s_text in config.setting["list_of_scripts"]: @@ -275,6 +282,7 @@ class Album(DataObject, Item): self._new_metadata.strip_whitespace() for track in self.tracks: + track.metadata_images_changed.connect(self.update_metadata_images) for file in list(track.linked_files): file.move(self.unmatched_files) self.metadata = self._new_metadata @@ -284,6 +292,7 @@ class Album(DataObject, Item): self.loaded = True self.status = None self.match_files(self.unmatched_files.files) + self.enable_update_metadata_images(True) self.update() self.tagger.window.set_statusbar_message( N_('Album %(id)s loaded: %(artist)s - %(album)s'), @@ -382,10 +391,14 @@ class Album(DataObject, Item): def _add_file(self, track, file): self._files += 1 self.update(update_tracks=False) + file.metadata_images_changed.connect(self.update_metadata_images) + self.update_metadata_images() def _remove_file(self, track, file): self._files -= 1 self.update(update_tracks=False) + file.metadata_images_changed.disconnect(self.update_metadata_images) + self.update_metadata_images() def match_files(self, files, use_recordingid=True): """Match files to tracks on this album, based on metadata similarity or recordingid.""" @@ -455,7 +468,7 @@ class Album(DataObject, Item): return True def can_view_info(self): - return (self.loaded and self.metadata and self.metadata.images) or self.errors + return (self.loaded and (self.metadata.images or self.orig_metadata.images)) or self.errors def is_album_like(self): return True @@ -479,6 +492,8 @@ class Album(DataObject, Item): for track in self.tracks: if not track.is_complete(): return False + if self.get_num_unmatched_files(): + return False else: return True @@ -517,8 +532,21 @@ class Album(DataObject, Item): unsaved = self.get_num_unsaved_files() if unsaved: text += '; %d*' % (unsaved,) - text += ungettext("; %i image", "; %i images", - len(self.metadata.images)) % len(self.metadata.images) + # CoverArt.set_metadata uses the orig_metadata.images if metadata.images is empty + # in order to show existing cover art if there's no cover art for a release. So + # we do the same here in order to show the number of images consistently. + if self.metadata.images: + metadata = self.metadata + else: + metadata = self.orig_metadata + + number_of_images = len(metadata.images) + if getattr(metadata, 'has_common_images', True): + text += ungettext("; %i image", "; %i images", + number_of_images) % number_of_images + else: + text += ungettext("; %i image not in all tracks", "; %i different images among tracks", + number_of_images) % number_of_images return text + ')' else: return title @@ -550,6 +578,23 @@ class Album(DataObject, Item): self.tagger.albums[mbid] = self self.load(priority=True, refresh=True) + def update_metadata_images(self): + if not self.update_metadata_images_enabled: + return + + update_metadata_images(self) + + self.update(False) + + def keep_original_images(self): + self.enable_update_metadata_images(False) + for track in self.tracks: + track.keep_original_images() + for file in list(self.unmatched_files.files): + file.keep_original_images() + self.enable_update_metadata_images(True) + self.update_metadata_images() + class NatAlbum(Album): diff --git a/picard/cluster.py b/picard/cluster.py index 174d7e9d0..84931d088 100644 --- a/picard/cluster.py +++ b/picard/cluster.py @@ -31,6 +31,7 @@ from picard.metadata import Metadata from picard.similarity import similarity from picard.ui.item import Item from picard.util import format_time, album_artist_from_path +from picard.util.imagelist import ImageList, update_metadata_images from picard.const import QUERY_LIMIT @@ -64,14 +65,23 @@ class Cluster(QtCore.QObject, Item): def __len__(self): return len(self.files) + def _update_related_album(self): + if self.related_album: + self.related_album.update_metadata_images() + self.related_album.update() + def add_files(self, files): for file in files: self.metadata.length += file.metadata.length file._move(self) file.update(signal=False) + cover = file.metadata.get_single_front_image() + if cover and cover[0] not in self.metadata.images: + self.metadata.append_image(cover[0]) self.files.extend(files) self.metadata['totaltracks'] = len(self.files) self.item.add_files(files) + self._update_related_album() def add_file(self, file): self.metadata.length += file.metadata.length @@ -79,7 +89,11 @@ class Cluster(QtCore.QObject, Item): self.metadata['totaltracks'] = len(self.files) file._move(self) file.update(signal=False) + cover = file.metadata.get_single_front_image() + if cover and cover[0] not in self.metadata.images: + self.metadata.append_image(cover[0]) self.item.add_file(file) + self._update_related_album() def remove_file(self, file): self.metadata.length -= file.metadata.length @@ -88,6 +102,8 @@ class Cluster(QtCore.QObject, Item): self.item.remove_file(file) if not self.special and self.get_num_files() == 0: self.tagger.remove_cluster(self) + self.update_metadata_images() + self._update_related_album() def update(self): if self.item: @@ -263,6 +279,9 @@ class Cluster(QtCore.QObject, Item): yield album_name, artist_name, (files[i] for i in album) + def update_metadata_images(self): + update_metadata_images(self) + class UnmatchedFiles(Cluster): @@ -295,6 +314,9 @@ class UnmatchedFiles(Cluster): def can_view_info(self): return False + def can_remove(self): + return True + class ClusterList(list, Item): diff --git a/picard/config.py b/picard/config.py index 5f708decf..e263397f3 100644 --- a/picard/config.py +++ b/picard/config.py @@ -18,8 +18,6 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. from __future__ import print_function -import re -import sys from operator import itemgetter from PyQt4 import QtCore from picard import (PICARD_APP_NAME, PICARD_ORG_NAME, PICARD_VERSION, @@ -92,11 +90,13 @@ class Config(QtCore.QSettings): """Configuration.""" - def __init__(self, app): - """Initializes the configuration.""" + def __init__(self): + pass + + def __initialize(self): + """Common initializer method for :meth:`from_app` and + :meth:`from_file`.""" - QtCore.QSettings.__init__(self, QtCore.QSettings.IniFormat, - QtCore.QSettings.UserScope, PICARD_ORG_NAME, PICARD_APP_NAME, app) # If there are no settings, copy existing settings from old format # (registry on windows systems) if not self.allKeys(): @@ -115,6 +115,27 @@ class Config(QtCore.QSettings): self._version = version_from_string(self.application["version"]) self._upgrade_hooks = dict() + @classmethod + def from_app(cls, parent): + """Build a Config object using the default configuration file + location.""" + this = cls() + QtCore.QSettings.__init__(this, QtCore.QSettings.IniFormat, + QtCore.QSettings.UserScope, PICARD_ORG_NAME, + PICARD_APP_NAME, parent) + this.__initialize() + return this + + @classmethod + def from_file(cls, parent, filename): + """Build a Config object using a user-provided configuration file + path.""" + this = cls() + QtCore.QSettings.__init__(this, filename, QtCore.QSettings.IniFormat, + parent) + this.__initialize() + return this + def switchProfile(self, profilename): """Sets the current profile.""" key = u"profile/%s" % (profilename,) @@ -245,8 +266,12 @@ config = None setting = None persist = None -def _setup(app): + +def _setup(app, filename=None): global config, setting, persist - config = Config(app) + if filename is None: + config = Config.from_app(app) + else: + config = Config.from_file(app, filename) setting = config.setting persist = config.persist diff --git a/picard/const/__init__.py b/picard/const/__init__.py index 12766e489..878765398 100644 --- a/picard/const/__init__.py +++ b/picard/const/__init__.py @@ -48,7 +48,7 @@ MUSICBRAINZ_OAUTH_CLIENT_SECRET = 'xIsvXbIuntaLuRRhzuazOA' # Cover art archive URL and port CAA_HOST = "coverartarchive.org" -CAA_PORT = 80 +CAA_PORT = 443 # URLs PICARD_URLS = { @@ -113,3 +113,6 @@ PLUGINS_API = { # Default query limit QUERY_LIMIT = 25 + +# Maximum number of covers to draw in a stack in CoverArtThumbnail +MAX_COVERS_TO_STACK = 4 diff --git a/picard/const/languages.py b/picard/const/languages.py index e5c350b30..21b06e6e6 100644 --- a/picard/const/languages.py +++ b/picard/const/languages.py @@ -54,7 +54,7 @@ UI_LANGUAGES = [ #(u'kn', u'ಕನ್ನಡ', N_(u'Kannada')), #(u'ko', u'한국어', N_(u'Korean')), #(u'lt', u'Lietuvių', N_(u'Lithuanian')), - #(u'nb', u'Norsk bokmål', N_(u'Norwegian Bokmal')), + (u'nb', u'Norsk bokmål', N_(u'Norwegian Bokmal')), #(u'nds', u'Plattdüütsch', N_(u'Low German')), (u'nl', u'Nederlands', N_(u'Dutch')), #(u'oc', u'Occitan', N_(u'Occitan')), diff --git a/picard/coverart/image.py b/picard/coverart/image.py index 810a45deb..ff41cc976 100644 --- a/picard/coverart/image.py +++ b/picard/coverart/image.py @@ -67,6 +67,12 @@ class DataHash: finally: _datafile_mutex.unlock() + def __eq__(self, other): + return self._hash == other._hash + + def hash(self): + return self._hash + def delete_file(self): if self._filename: try: @@ -200,6 +206,22 @@ class CoverArtImage: def __str__(self): return unicode(self).encode('utf-8') + def __eq__(self, other): + if self and other: + if self.types and other.types: + return (self.datahash, self.types) == (other.datahash, other.types) + else: + return self.datahash == other.datahash + elif not self and not other: + return True + else: + return False + + def __hash__(self): + if self.datahash is None: + return 0 + return hash(self.datahash.hash()) + def set_data(self, data): """Store image data in a file, if data already exists in such file it will be re-used and no file write occurs diff --git a/picard/coverart/providers/local.py b/picard/coverart/providers/local.py index dac1dce74..b86698ec8 100644 --- a/picard/coverart/providers/local.py +++ b/picard/coverart/providers/local.py @@ -33,7 +33,7 @@ class ProviderOptionsLocal(ProviderOptions): Options for Local Files cover art provider """ - _DEFAULT_LOCAL_COVER_ART_REGEX = '^(?:cover|folder|albumart)(.*)\.(?:jpe?g|png|gif|tiff?)$' + _DEFAULT_LOCAL_COVER_ART_REGEX = r'^(?:cover|folder|albumart)(.*)\.(?:jpe?g|png|gif|tiff?)$' options = [ config.TextOption("setting", "local_cover_regex", diff --git a/picard/file.py b/picard/file.py index b2f45d14d..49a69476d 100644 --- a/picard/file.py +++ b/picard/file.py @@ -54,6 +54,8 @@ from picard.const import QUERY_LIMIT class File(QtCore.QObject, Item): + metadata_images_changed = QtCore.pyqtSignal() + UNDEFINED = -1 PENDING = 0 NORMAL = 1 @@ -98,12 +100,27 @@ class File(QtCore.QObject, Item): def load(self, callback): thread.run_task( - partial(self._load, self.filename), + partial(self._load_check, self.filename), partial(self._loading_finished, callback), priority=1) + def _load_check(self, filename): + # Check that file has not been removed since thread was queued + # Don't load if we are stopping. + if self.state != File.PENDING: + log.debug("File not loaded because it was removed: %r", self.filename) + return None + if self.tagger.stopping: + log.debug("File not loaded because %s is stopping: %r", PICARD_APP_NAME, self.filename) + return None + return self._load(filename) + + def _load(self, filename): + """Load metadata from the file.""" + raise NotImplementedError + def _loading_finished(self, callback, result=None, error=None): - if self.state != self.PENDING: + if self.state != File.PENDING or self.tagger.stopping: return if error is not None: self.error = str(error) @@ -156,14 +173,16 @@ class File(QtCore.QObject, Item): if acoustid: self.metadata["acoustid_id"] = acoustid + self.metadata_images_changed.emit() + + def keep_original_images(self): + self.metadata.images = self.orig_metadata.images[:] + self.update() + self.metadata_images_changed.emit() def has_error(self): return self.state == File.ERROR - def _load(self, filename): - """Load metadata from the file.""" - raise NotImplementedError - def save(self): self.set_pending() metadata = Metadata() @@ -176,6 +195,14 @@ class File(QtCore.QObject, Item): def _save_and_rename(self, old_filename, metadata): """Save the metadata.""" + # Check that file has not been removed since thread was queued + # Also don't save if we are stopping. + if self.state == File.REMOVED: + log.debug("File not saved because it was removed: %r", self.filename) + return None + if self.tagger.stopping: + log.debug("File not saved because %s is stopping: %r", PICARD_APP_NAME, self.filename) + return None new_filename = old_filename if not config.setting["dont_write_tags"]: encoded_old_filename = encode_filename(old_filename) @@ -222,6 +249,11 @@ class File(QtCore.QObject, Item): raise OSError def _saving_finished(self, result=None, error=None): + # Handle file removed before save + # Result is None if save was skipped + if ((self.state == File.REMOVED or self.tagger.stopping) + and result is None): + return old_filename = new_filename = self.filename if error is not None: self.error = str(error) @@ -244,11 +276,17 @@ class File(QtCore.QObject, Item): for k, v in temp_info.items(): self.orig_metadata[k] = v self.error = None - self.clear_pending() + # Force update to ensure file status icon changes immediately after save + self.clear_pending(force_update=True) self._add_path_to_metadata(self.orig_metadata) + self.metadata_images_changed.emit() - del self.tagger.files[old_filename] - self.tagger.files[new_filename] = self + if self.state != File.REMOVED: + del self.tagger.files[old_filename] + self.tagger.files[new_filename] = self + + if self.tagger.stopping: + log.debug("Save of %r completed before stopping Picard", self.filename) def _save(self, filename, metadata): """Save the metadata.""" @@ -445,9 +483,13 @@ class File(QtCore.QObject, Item): self.state = File.CHANGED break else: - self.similarity = 1.0 - if self.state in (File.CHANGED, File.NORMAL): - self.state = File.NORMAL + if (self.metadata.images and + self.orig_metadata.images != self.metadata.images): + self.state = File.CHANGED + else: + self.similarity = 1.0 + if self.state in (File.CHANGED, File.NORMAL): + self.state = File.NORMAL if signal: log.debug("Updating file %r", self) if self.item: @@ -611,10 +653,12 @@ class File(QtCore.QObject, Item): self.state = File.PENDING self.update() - def clear_pending(self): + def clear_pending(self, force_update=False): if self.state == File.PENDING: self.state = File.NORMAL self.update() + elif force_update: + self.update() def iterfiles(self, save=False): yield self diff --git a/picard/formats/__init__.py b/picard/formats/__init__.py index 63d66242d..d737b809f 100644 --- a/picard/formats/__init__.py +++ b/picard/formats/__init__.py @@ -86,107 +86,6 @@ def open(filename): return None -def _insert_bytes_no_mmap(fobj, size, offset, BUFFER_SIZE=2**16): - """Insert size bytes of empty space starting at offset. - - fobj must be an open file object, open rb+ or - equivalent. Mutagen tries to use mmap to resize the file, but - falls back to a significantly slower method if mmap fails. - """ - assert 0 < size - assert 0 <= offset - locked = False - fobj.seek(0, 2) - filesize = fobj.tell() - movesize = filesize - offset - fobj.write('\x00' * size) - fobj.flush() - try: - locked = _win32_locking(fobj, filesize, msvcrt.LK_LOCK) - fobj.truncate(filesize) - - fobj.seek(0, 2) - padsize = size - # Don't generate an enormous string if we need to pad - # the file out several megs. - while padsize: - addsize = min(BUFFER_SIZE, padsize) - fobj.write("\x00" * addsize) - padsize -= addsize - - fobj.seek(filesize, 0) - while movesize: - # At the start of this loop, fobj is pointing at the end - # of the data we need to move, which is of movesize length. - thismove = min(BUFFER_SIZE, movesize) - # Seek back however much we're going to read this frame. - fobj.seek(-thismove, 1) - nextpos = fobj.tell() - # Read it, so we're back at the end. - data = fobj.read(thismove) - # Seek back to where we need to write it. - fobj.seek(-thismove + size, 1) - # Write it. - fobj.write(data) - # And seek back to the end of the unmoved data. - fobj.seek(nextpos) - movesize -= thismove - - fobj.flush() - finally: - if locked: - _win32_locking(fobj, filesize, msvcrt.LK_UNLCK) - - -def _delete_bytes_no_mmap(fobj, size, offset, BUFFER_SIZE=2**16): - """Delete size bytes of empty space starting at offset. - - fobj must be an open file object, open rb+ or - equivalent. Mutagen tries to use mmap to resize the file, but - falls back to a significantly slower method if mmap fails. - """ - locked = False - assert 0 < size - assert 0 <= offset - fobj.seek(0, 2) - filesize = fobj.tell() - movesize = filesize - offset - size - assert 0 <= movesize - try: - if movesize > 0: - fobj.flush() - locked = _win32_locking(fobj, filesize, msvcrt.LK_LOCK) - fobj.seek(offset + size) - buf = fobj.read(BUFFER_SIZE) - while buf: - fobj.seek(offset) - fobj.write(buf) - offset += len(buf) - fobj.seek(offset + size) - buf = fobj.read(BUFFER_SIZE) - fobj.truncate(filesize - size) - fobj.flush() - finally: - if locked: - _win32_locking(fobj, filesize, msvcrt.LK_UNLCK) - - -def _win32_locking(fobj, nbytes, mode): - try: - fobj.seek(0) - msvcrt.locking(fobj.fileno(), mode, nbytes) - except IOError: - return False - else: - return True - - -if sys.platform == 'win32': - import msvcrt - _util.insert_bytes = _insert_bytes_no_mmap - _util.delete_bytes = _delete_bytes_no_mmap - - from picard.formats.id3 import ( AiffFile, MP3File, diff --git a/picard/formats/apev2.py b/picard/formats/apev2.py index ff3e8198d..70d2a9465 100644 --- a/picard/formats/apev2.py +++ b/picard/formats/apev2.py @@ -175,7 +175,7 @@ class APEv2File(File): for tag in metadata.deleted_tags: real_name = str(self._get_tag_name(tag)) if real_name in ('Lyrics', 'Comment', 'Performer'): - tag_type = "\(%s\)" % tag.split(':', 1)[1] + tag_type = r"\(%s\)" % tag.split(':', 1)[1] for item in tags.get(real_name): if re.search(tag_type, item): tags.get(real_name).remove(item) diff --git a/picard/formats/id3.py b/picard/formats/id3.py index d5d90b281..e8eee027d 100644 --- a/picard/formats/id3.py +++ b/picard/formats/id3.py @@ -175,6 +175,16 @@ class ID3File(File): 'TPOS': re.compile(r'^(?P\d+)(?:/(?P\d+))?$') } + def build_TXXX(self, encoding, desc, values): + """Construct and return a TXXX frame.""" + # This is here so that plugins can customize the behavior of TXXX + # frames in particular via subclassing. + # discussion: https://github.com/metabrainz/picard/pull/634 + # discussion: https://github.com/metabrainz/picard/pull/635 + # Used in the plugin "Compatible TXXX frames" + # PR: https://github.com/metabrainz/picard-plugins/pull/83 + return id3.TXXX(encoding=encoding, desc=desc, text=values) + def _load(self, filename): log.debug("Loading file %r", filename) file = self._get_file(encode_filename(filename)) @@ -365,7 +375,7 @@ class ID3File(File): if frameid == 'WCOP': # Only add WCOP if there is only one license URL, otherwise use TXXX:LICENSE if len(values) > 1 or not valid_urls: - tags.add(id3.TXXX(encoding=encoding, desc=self.__rtranslate_freetext[name], text=values)) + tags.add(self.build_TXXX(encoding, self.__rtranslate_freetext[name], values)) else: tags.add(id3.WCOP(url=values[0])) elif frameid == 'WOAR' and valid_urls: @@ -374,7 +384,7 @@ class ID3File(File): elif frameid.startswith('T'): if config.setting['write_id3v23']: if frameid == 'TMOO': - tags.add(id3.TXXX(encoding=encoding, desc='mood', text=values)) + tags.add(self.build_TXXX(encoding, 'mood', values)) # No need to care about the TMOO tag being added again as it is # automatically deleted by Mutagen if id2v23 is selected tags.add(getattr(id3, frameid)(encoding=encoding, text=values)) @@ -385,18 +395,18 @@ class ID3File(File): elif frameid == 'TSO2': tags.delall('TXXX:ALBUMARTISTSORT') elif name in self.__rtranslate_freetext: - tags.add(id3.TXXX(encoding=encoding, desc=self.__rtranslate_freetext[name], text=values)) + tags.add(self.build_TXXX(encoding, self.__rtranslate_freetext[name], values)) elif name.startswith('~id3:'): name = name[5:] if name.startswith('TXXX:'): - tags.add(id3.TXXX(encoding=encoding, desc=name[5:], text=values)) + tags.add(self.build_TXXX(encoding, name[5:], values)) else: frameclass = getattr(id3, name[:4], None) if frameclass: tags.add(frameclass(encoding=encoding, text=values)) # don't save private / already stored tags elif not name.startswith("~") and name not in self.__other_supported_tags: - tags.add(id3.TXXX(encoding=encoding, desc=name, text=values)) + tags.add(self.build_TXXX(encoding, name, values)) tags.add(tmcl) tags.add(tipl) diff --git a/picard/formats/vorbis.py b/picard/formats/vorbis.py index 81d1a4600..fce8ea882 100644 --- a/picard/formats/vorbis.py +++ b/picard/formats/vorbis.py @@ -227,7 +227,7 @@ class VCommentFile(File): real_name = self._get_tag_name(tag) if real_name and real_name in tags: if real_name in ('performer', 'comment'): - tag_type = "\(%s\)" % tag.split(':', 1)[1] + tag_type = r"\(%s\)" % tag.split(':', 1)[1] for item in tags.get(real_name): if re.search(tag_type, item): tags.get(real_name).remove(item) diff --git a/picard/mbxml.py b/picard/mbxml.py index 83e575ef0..5c832fa8f 100644 --- a/picard/mbxml.py +++ b/picard/mbxml.py @@ -202,7 +202,7 @@ def artist_credit_to_metadata(node, m, release=False): m["artist"] = artist m["artistsort"] = artistsort m["artists"] = artists - m["~artists_sort"] = artistsort + m["~artists_sort"] = artistssort def country_list_from_node(node): diff --git a/picard/metadata.py b/picard/metadata.py index 05e7bef8c..cbd608e65 100644 --- a/picard/metadata.py +++ b/picard/metadata.py @@ -25,6 +25,7 @@ from picard.util import ( linear_combination_of_weights, ) from picard.mbxml import artist_credit_from_node +from picard.util.imagelist import ImageList MULTI_VALUED_JOINER = '; ' @@ -45,13 +46,21 @@ class Metadata(dict): def __init__(self): super(Metadata, self).__init__() - self.images = [] + self.images = ImageList() self.deleted_tags = set() self.length = 0 + def __nonzero__(self): + return len(self) or len(self.images) + def append_image(self, coverartimage): self.images.append(coverartimage) + def set_front_image(self, coverartimage): + # First remove all front images + self.images[:] = [img for img in self.images if not img.is_front_image()] + self.images.append(coverartimage) + @property def images_to_be_saved_to_tags(self): if not config.setting["save_images_to_tags"]: @@ -233,7 +242,7 @@ class Metadata(dict): def clear(self): dict.clear(self) - self.images = [] + self.images = ImageList() self.length = 0 self.deleted_tags = set() diff --git a/picard/oauth.py b/picard/oauth.py index f88a2534a..2ec151200 100644 --- a/picard/oauth.py +++ b/picard/oauth.py @@ -74,7 +74,7 @@ class OAuthManager(object): MUSICBRAINZ_OAUTH_CLIENT_ID, "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", "scope": scopes} url = build_qurl(host, port, path="/oauth2/authorize", - queryargs=params, mblogin=True) + queryargs=params) return str(url.toEncoded()) def set_refresh_token(self, refresh_token, scopes): diff --git a/picard/script.py b/picard/script.py index 1fca623c0..e3d532473 100644 --- a/picard/script.py +++ b/picard/script.py @@ -127,7 +127,7 @@ def isidentif(ch): class ScriptParser(object): - """Tagger script parser. + r"""Tagger script parser. Grammar: text ::= [^$%] | '\$' | '\%' | '\(' | '\)' | '\,' @@ -373,7 +373,7 @@ def func_pad(parser, text, length, char): def func_strip(parser, text): - return re.sub("\s+", " ", text).strip() + return re.sub(r"\s+", " ", text).strip() def func_replace(parser, text, old, new): diff --git a/picard/similarity.py b/picard/similarity.py index 6a0bdc9fd..94d06472f 100644 --- a/picard/similarity.py +++ b/picard/similarity.py @@ -40,7 +40,7 @@ def similarity(a1, b1): return astrcmp(a2, b2) -_split_words_re = re.compile('\W+', re.UNICODE) +_split_words_re = re.compile(r'\W+', re.UNICODE) def similarity2(a, b): diff --git a/picard/tagger.py b/picard/tagger.py index 3e36ad40f..9c83f5a73 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -49,11 +49,9 @@ _orig_shutil_copystat = shutil.copystat shutil.copystat = _patched_shutil_copystat import picard.resources -import picard.plugins from picard.i18n import setup_gettext -from picard import (PICARD_APP_NAME, PICARD_ORG_NAME, - PICARD_FANCY_VERSION_STR, __version__, +from picard import (PICARD_APP_NAME, PICARD_ORG_NAME, PICARD_FANCY_VERSION_STR, log, acoustid, config) from picard.album import Album, NatAlbum from picard.browser.browser import BrowserIntegration @@ -107,7 +105,7 @@ class Tagger(QtGui.QApplication): QtGui.QApplication.__init__(self, ['MusicBrainz-Picard'] + unparsed_args) self.__class__.__instance = self - config._setup(self) + config._setup(self, picard_args.config_file) self._cmdline_files = picard_args.FILE self._autoupdate = autoupdate @@ -214,6 +212,7 @@ class Tagger(QtGui.QApplication): self.nats = None self.window = MainWindow() self.exit_cleanup = [] + self.stopping = False def register_cleanup(self, func): self.exit_cleanup.append(func) @@ -269,15 +268,15 @@ class Tagger(QtGui.QApplication): self.nats.update() def exit(self): - log.debug("exit") + 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() + self.run_cleanup() + QtCore.QCoreApplication.processEvents() def _run_init(self): if self._cmdline_files: @@ -334,6 +333,9 @@ class Tagger(QtGui.QApplication): 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) @@ -602,6 +604,8 @@ class Tagger(QtGui.QApplication): } ) self.remove_album(obj) + elif isinstance(obj, UnmatchedFiles): + files.extend(list(obj.files)) elif isinstance(obj, Cluster): self.remove_cluster(obj) if files: @@ -735,6 +739,9 @@ def process_picard_args(): parser = argparse.ArgumentParser( epilog="If one of the filenames begins with a hyphen, use -- to separate the options from the filenames." ) + parser.add_argument("-c", "--config-file", action='store', + default=None, + help="location of the configuration file") parser.add_argument("-d", "--debug", action='store_true', help="enable debug-level logging") parser.add_argument('-v', '--version', action='store_true', diff --git a/picard/track.py b/picard/track.py index 8cfbb1956..393d21a40 100644 --- a/picard/track.py +++ b/picard/track.py @@ -19,6 +19,7 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. from functools import partial +from PyQt4 import QtCore from picard import config, log from picard.metadata import Metadata, run_track_metadata_processors from picard.dataobj import DataObject @@ -27,6 +28,7 @@ from picard.mbxml import recording_to_metadata from picard.script import ScriptParser from picard.const import VARIOUS_ARTISTS_ID, SILENCE_TRACK_TITLE, DATA_TRACK_TITLE from picard.ui.item import Item +from picard.util.imagelist import ImageList, update_metadata_images import traceback @@ -44,12 +46,15 @@ class TrackArtist(DataObject): class Track(DataObject, Item): + metadata_images_changed = QtCore.pyqtSignal() + def __init__(self, id, album=None): DataObject.__init__(self, id) self.album = album self.linked_files = [] self.num_linked_files = 0 self.metadata = Metadata() + self.orig_metadata = Metadata() self._track_artists = [] def __repr__(self): @@ -61,6 +66,7 @@ class Track(DataObject, Item): self.num_linked_files += 1 self.album._add_file(self, file) self.update_file_metadata(file) + file.metadata_images_changed.connect(self.update_orig_metadata_images) def update_file_metadata(self, file): if file not in self.linked_files: @@ -78,11 +84,13 @@ class Track(DataObject, Item): self.num_linked_files -= 1 file.copy_metadata(file.orig_metadata) self.album._remove_file(self, file) + file.metadata_images_changed.disconnect(self.update_orig_metadata_images) self.update() def update(self): if self.item: self.item.update() + self.update_orig_metadata_images() def iterfiles(self, save=False): for file in self.linked_files: @@ -110,7 +118,7 @@ class Track(DataObject, Item): return True def can_view_info(self): - return self.num_linked_files == 1 + return self.num_linked_files == 1 or self.metadata.images def column(self, column): m = self.metadata @@ -217,6 +225,19 @@ class Track(DataObject, Item): tags = [s.strip().lower() for s in ignore_tags.split(',')] return tags + def update_orig_metadata_images(self): + update_metadata_images(self) + + def keep_original_images(self): + for file in self.linked_files: + file.keep_original_images() + if self.linked_files: + self.update_orig_metadata_images() + self.metadata.images = self.orig_metadata.images[:] + else: + self.metadata.images = [] + self.update() + class NonAlbumTrack(Track): diff --git a/picard/ui/coverartbox.py b/picard/ui/coverartbox.py index fc5158776..1071735bc 100644 --- a/picard/ui/coverartbox.py +++ b/picard/ui/coverartbox.py @@ -18,6 +18,7 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import os +import sys from functools import partial from PyQt4 import QtCore, QtGui, QtNetwork from picard import config, log @@ -25,21 +26,30 @@ from picard.album import Album from picard.coverart.image import CoverArtImage, CoverArtImageError from picard.track import Track from picard.file import File -from picard.util import encode_filename +from picard.util import imageinfo +from picard.util.lrucache import LRUCache +from picard.const import MAX_COVERS_TO_STACK + +if sys.platform == 'darwin': + try: + from Foundation import NSURL + NSURL_IMPORTED = True + except ImportError: + NSURL_IMPORTED = False + log.warning("Unable to import NSURL, file drag'n'drop might not work correctly") class ActiveLabel(QtGui.QLabel): - """Clickable QLabel.""" clicked = QtCore.pyqtSignal() - imageDropped = QtCore.pyqtSignal(QtCore.QUrl) + image_dropped = QtCore.pyqtSignal(QtCore.QUrl, QtCore.QByteArray) - def __init__(self, active=True, *args): + def __init__(self, active=True, drops=False, *args): QtGui.QLabel.__init__(self, *args) self.setMargin(0) self.setActive(active) - self.setAcceptDrops(False) + self.setAcceptDrops(drops) def setActive(self, active): self.active = active @@ -54,96 +64,174 @@ class ActiveLabel(QtGui.QLabel): def dragEnterEvent(self, event): for url in event.mimeData().urls(): - if url.scheme() in ('http', 'file'): + if url.scheme() in ('https', 'http', 'file'): event.acceptProposedAction() break def dropEvent(self, event): accepted = False + # Chromium includes the actual data of the dragged image in the drop event. This + # is useful for Google Images, where the url links to the page that contains the image + # so we use it if the downloaded url is not an image. + dropped_data = event.mimeData().data('application/octet-stream') for url in event.mimeData().urls(): - if url.scheme() in ('http', 'file'): + if url.scheme() in ('https', 'http', 'file'): accepted = True - self.imageDropped.emit(url) + self.image_dropped.emit(url, dropped_data) if accepted: event.acceptProposedAction() -class CoverArtBox(QtGui.QGroupBox): +class CoverArtThumbnail(ActiveLabel): - def __init__(self, parent): - QtGui.QGroupBox.__init__(self, "") - self.layout = QtGui.QVBoxLayout() - self.layout.setSpacing(0) - # Kills off any borders - self.setStyleSheet('''QGroupBox{background-color:none;border:1px;}''') - self.setFlat(True) - self.release = None + def __init__(self, active=False, drops=False, pixmap_cache=None, *args, **kwargs): + super(CoverArtThumbnail, self).__init__(active, drops, *args, **kwargs) self.data = None - self.item = None + self.has_common_images = None self.shadow = QtGui.QPixmap(":/images/CoverArtShadow.png") - self.coverArt = ActiveLabel(False, parent) - self.coverArt.setPixmap(self.shadow) - self.coverArt.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter) - self.coverArt.clicked.connect(self.open_release_page) - self.coverArt.imageDropped.connect(self.fetch_remote_image) - self.layout.addWidget(self.coverArt, 0) - self.setLayout(self.layout) + self.release = None + self.setPixmap(self.shadow) + self.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter) + self.clicked.connect(self.open_release_page) + self.image_dropped.connect(self.fetch_remote_image) + self.related_images = [] + self._pixmap_cache = pixmap_cache + self.current_pixmap_key = None + + def __eq__(self, other): + if len(self.data) or len(other.data): + return self.current_pixmap_key == other.current_pixmap_key + else: + return True def show(self): - self.__set_data(self.data, True) - QtGui.QGroupBox.show(self) + self.set_data(self.data, True) - def __set_data(self, data, force=False, pixmap=None): - if not force and self.data == data: + def decorate_cover(self, pixmap): + offx, offy, w, h = (1, 1, 121, 121) + cover = QtGui.QPixmap(self.shadow) + pixmap = pixmap.scaled(w, h, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) + painter = QtGui.QPainter(cover) + bgcolor = QtGui.QColor.fromRgb(0, 0, 0, 128) + painter.fillRect(QtCore.QRectF(offx, offy, w, h), bgcolor) + x = offx + (w - pixmap.width()) / 2 + y = offy + (h - pixmap.height()) / 2 + painter.drawPixmap(x, y, pixmap) + painter.end() + return cover + + def set_data(self, data, force=False, has_common_images=True): + if not force and self.data == data and self.has_common_images == has_common_images: return self.data = data - if not force and self.isHidden(): + self.has_common_images = has_common_images + + if not force and self.parent().isHidden(): return - cover = self.shadow - if self.data: - if pixmap is None: - pixmap = QtGui.QPixmap() - pixmap.loadFromData(self.data.data) - if not pixmap.isNull(): - offx, offy, w, h = (1, 1, 121, 121) - cover = QtGui.QPixmap(self.shadow) - pixmap = pixmap.scaled(w, h, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) - painter = QtGui.QPainter(cover) - bgcolor = QtGui.QColor.fromRgb(0, 0, 0, 128) - painter.fillRect(QtCore.QRectF(offx, offy, w, h), bgcolor) - x = offx + (w - pixmap.width()) / 2 - y = offy + (h - pixmap.height()) / 2 - painter.drawPixmap(x, y, pixmap) - painter.end() - self.coverArt.setPixmap(cover) + if not self.data: + self.setPixmap(self.shadow) + self.current_pixmap_key = None + return - def set_metadata(self, metadata, item): - self.item = item - data = None - if metadata and metadata.images: - for image in metadata.images: - if image.is_front_image(): - data = image - break + if len(self.data) == 1: + has_common_images = True + + w, h, displacements = (128, 128, 20) + key = hash(tuple(sorted(self.data)) + (has_common_images,)) + try: + pixmap = self._pixmap_cache[key] + except KeyError: + if len(self.data) == 1: + pixmap = QtGui.QPixmap() + pixmap.loadFromData(self.data[0].data) + pixmap = self.decorate_cover(pixmap) else: + limited = len(self.data) > MAX_COVERS_TO_STACK + if limited: + data_to_paint = data[:MAX_COVERS_TO_STACK - 1] + offset = displacements * len(data_to_paint) + else: + data_to_paint = data + offset = displacements * (len(data_to_paint) - 1) + stack_width, stack_height = (w + offset, h + offset) + pixmap = QtGui.QPixmap(stack_width, stack_height) + bgcolor = self.palette().color(QtGui.QPalette.Window) + painter = QtGui.QPainter(pixmap) + painter.fillRect(QtCore.QRectF(0, 0, stack_width, stack_height), bgcolor) + cx = stack_width - w / 2 + cy = h / 2 + if limited: + x, y = (cx - self.shadow.width() / 2, cy - self.shadow.height() / 2) + for i in range(3): + painter.drawPixmap(x, y, self.shadow) + x -= displacements / 3 + y += displacements / 3 + cx -= displacements + cy += displacements + else: + cx = stack_width - w / 2 + cy = h / 2 + for image in reversed(data_to_paint): + if isinstance(image, QtGui.QPixmap): + thumb = image + else: + thumb = QtGui.QPixmap() + thumb.loadFromData(image.data) + thumb = self.decorate_cover(thumb) + x, y = (cx - thumb.width() / 2, cy - thumb.height() / 2) + painter.drawPixmap(x, y, thumb) + cx -= displacements + cy += displacements + if not has_common_images: + color = QtGui.QColor("darkgoldenrod") + border_length = 10 + for k in range(border_length): + color.setAlpha(255 - k * 255 / border_length) + painter.setPen(color) + painter.drawLine(x, y - k - 1, x + 121 + k + 1, y - k - 1) + painter.drawLine(x + 121 + k + 2, y - 1 - k, x + 121 + k + 2, y + 121 + 4) + for k in range(5): + bgcolor.setAlpha(80 + k * 255 / 7) + painter.setPen(bgcolor) + painter.drawLine(x + 121 + 2, y + 121 + 2 + k, x + 121 + border_length + 2, y + 121 + 2 + k) + painter.end() + pixmap = pixmap.scaled(w, h, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) + self._pixmap_cache[key] = pixmap + + self.setPixmap(pixmap) + self.current_pixmap_key = key + + def set_metadata(self, metadata): + data = None + self.related_images = [] + if metadata and metadata.images: + self.related_images = metadata.images + data = [image for image in metadata.images if image.is_front_image()] + if not data: # There's no front image, choose the first one available - data = metadata.images[0] - self.__set_data(data) - if item and metadata: - self.coverArt.setAcceptDrops(True) - else: - self.coverArt.setAcceptDrops(False) + data = [metadata.images[0]] + has_common_images = getattr(metadata, 'has_common_images', True) + self.set_data(data, has_common_images=has_common_images) release = None if metadata: release = metadata.get("musicbrainz_albumid", None) if release: - self.coverArt.setActive(True) - self.coverArt.setToolTip(_(u"View release on MusicBrainz")) + self.setActive(True) + text = _(u"View release on MusicBrainz") else: - self.coverArt.setActive(False) - self.coverArt.setToolTip("") + self.setActive(False) + text = "" + if hasattr(metadata, 'has_common_images'): + if has_common_images: + note = _(u'Common images on all tracks') + else: + note = _(u'Tracks contain different images') + if text: + text += '
' + text += '%s' % note + self.setToolTip(text) self.release = release def open_release_page(self): @@ -151,59 +239,224 @@ class CoverArtBox(QtGui.QGroupBox): lookup.albumLookup(self.release) def fetch_remote_image(self, url): + return self.parent().fetch_remote_image(url) + + +def set_image_replace(obj, coverartimage): + obj.metadata.set_front_image(coverartimage) + + +def set_image_append(obj, coverartimage): + obj.metadata.append_image(coverartimage) + + +class CoverArtBox(QtGui.QGroupBox): + + def __init__(self, parent): + QtGui.QGroupBox.__init__(self, "") + self.layout = QtGui.QVBoxLayout() + self.layout.setSpacing(6) + self.parent = parent + # Kills off any borders + self.setStyleSheet('''QGroupBox{background-color:none;border:1px;}''') + self.setFlat(True) + self.item = None + self.pixmap_cache = LRUCache(40) + self.cover_art_label = QtGui.QLabel('') + self.cover_art_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter) + self.cover_art = CoverArtThumbnail(False, True, self.pixmap_cache, parent) + spacerItem = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + self.orig_cover_art_label = QtGui.QLabel('') + self.orig_cover_art = CoverArtThumbnail(False, False, self.pixmap_cache, parent) + self.orig_cover_art_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter) + self.show_details_button = QtGui.QPushButton(_(u'Show more details'), self) + self.layout.addWidget(self.cover_art_label) + self.layout.addWidget(self.cover_art) + self.layout.addWidget(self.orig_cover_art_label) + self.layout.addWidget(self.orig_cover_art) + self.layout.addWidget(self.show_details_button) + self.layout.addSpacerItem(spacerItem) + self.setLayout(self.layout) + self.orig_cover_art.setHidden(True) + self.show_details_button.setHidden(True) + self.show_details_button.clicked.connect(self.show_cover_art_info) + + def show_cover_art_info(self): + self.parent.view_info(default_tab=1) + + def update_display(self, force=False): + if self.isHidden(): + if not force: + # If the Cover art box is hidden and selection is updated + # we should not update the display of child widgets + return + else: + # Coverart box display was toggled. + # Update the pixmaps and display them + self.cover_art.show() + self.orig_cover_art.show() + + # We want to show the 2 coverarts only if they are different + # and orig_cover_art data is set and not the default cd shadow + if self.orig_cover_art.data is None or self.cover_art == self.orig_cover_art: + self.show_details_button.setVisible(bool(self.item and self.item.can_view_info())) + self.orig_cover_art.setVisible(False) + self.cover_art_label.setText('') + self.orig_cover_art_label.setText('') + else: + self.show_details_button.setVisible(True) + self.orig_cover_art.setVisible(True) + self.cover_art_label.setText(_(u'New Cover Art')) + self.orig_cover_art_label.setText(_(u'Original Cover Art')) + + def show(self): + self.update_display(True) + super(CoverArtBox, self).show() + + def set_metadata(self, metadata, orig_metadata, item): + if not metadata or not metadata.images: + self.cover_art.set_metadata(orig_metadata) + else: + self.cover_art.set_metadata(metadata) + self.orig_cover_art.set_metadata(orig_metadata) + self.item = item + self.update_display() + + def fetch_remote_image(self, url, fallback_data=None): if self.item is None: return - if url.scheme() == 'http': + if url.scheme() in ('https', 'http'): path = url.encodedPath() if url.hasQuery(): path += '?' + url.encodedQuery() - self.tagger.xmlws.get(url.encodedHost(), url.port(80), path, - partial(self.on_remote_image_fetched, url), + if url.scheme() == 'https': + port = 443 + else: + port = 80 + self.tagger.xmlws.get(str(url.encodedHost()), url.port(port), str(path), + partial(self.on_remote_image_fetched, url, fallback_data=fallback_data), xml=False, priority=True, important=True) elif url.scheme() == 'file': - path = encode_filename(unicode(url.toLocalFile())) - if os.path.exists(path): + log.debug("Dropped the URL: %r", url.toString(QtCore.QUrl.RemoveUserInfo)) + if sys.platform == 'darwin' and unicode(url.path()).startswith('/.file/id='): + # Workaround for https://bugreports.qt.io/browse/QTBUG-40449 + # OSX Urls follow the NSURL scheme and need to be converted + if NSURL_IMPORTED: + path = os.path.normpath(os.path.realpath(unicode(NSURL.URLWithString_(str(url.toString())).filePathURL().path()).rstrip("\0"))) + log.debug('OSX NSURL path detected. Dropped File is: %r', path) + else: + log.error("Unable to get appropriate file path for %r", url.toString(QtCore.QUrl.RemoveUserInfo)) + else: + # Dropping a file from iTunes gives a path with a NULL terminator + path = os.path.normpath(os.path.realpath(unicode(url.toLocalFile()).rstrip("\0"))) + if path and os.path.exists(path): mime = 'image/png' if path.lower().endswith('.png') else 'image/jpeg' with open(path, 'rb') as f: data = f.read() self.load_remote_image(url, mime, data) - def on_remote_image_fetched(self, url, data, reply, error): + def on_remote_image_fetched(self, url, data, reply, error, fallback_data=None): mime = reply.header(QtNetwork.QNetworkRequest.ContentTypeHeader) if mime in ('image/jpeg', 'image/png'): self.load_remote_image(url, mime, data) - elif reply.url().hasQueryItem("imgurl"): + elif url.hasQueryItem("imgurl"): # This may be a google images result, try to get the URL which is encoded in the query - url = QtCore.QUrl(reply.url().queryItemValue("imgurl")) + url = QtCore.QUrl(url.queryItemValue("imgurl")) self.fetch_remote_image(url) else: - log.warning("Can't load image with MIME-Type %s", mime) + log.warning("Can't load remote image with MIME-Type %s", mime) + if fallback_data: + # Tests for image format obtained from file-magic + try: + mime = imageinfo.identify(fallback_data)[2] + except imageinfo.IdentificationError as e: + log.error("Unable to identify dropped data format: %s" % e) + else: + log.debug("Trying the dropped %s data", mime) + self.load_remote_image(url, mime, fallback_data) def load_remote_image(self, url, mime, data): try: coverartimage = CoverArtImage( url=url.toString(), + types=[u'front'], data=data ) except CoverArtImageError as e: log.warning("Can't load image: %s" % unicode(e)) return - pixmap = QtGui.QPixmap() - pixmap.loadFromData(data) - self.__set_data([mime, data], pixmap=pixmap) + + if config.setting["load_image_behavior"] == 'replace': + set_image = set_image_replace + else: + set_image = set_image_append + if isinstance(self.item, Album): album = self.item - album.metadata.append_image(coverartimage) + album.enable_update_metadata_images(False) + set_image(album, coverartimage) for track in album.tracks: - track.metadata.append_image(coverartimage) + set_image(track, coverartimage) + track.metadata_images_changed.emit() for file in album.iterfiles(): - file.metadata.append_image(coverartimage) + set_image(file, coverartimage) + file.metadata_images_changed.emit() + file.update() + album.enable_update_metadata_images(True) + album.update_metadata_images() + album.update(False) elif isinstance(self.item, Track): track = self.item - track.metadata.append_image(coverartimage) + track.album.enable_update_metadata_images(False) + set_image(track, coverartimage) + track.metadata_images_changed.emit() for file in track.iterfiles(): - file.metadata.append_image(coverartimage) + set_image(file, coverartimage) + file.metadata_images_changed.emit() + file.update() + track.album.enable_update_metadata_images(True) + track.album.update_metadata_images() + track.album.update(False) elif isinstance(self.item, File): file = self.item - file.metadata.append_image(coverartimage) + set_image(file, coverartimage) + file.metadata_images_changed.emit() + file.update() + self.cover_art.set_metadata(self.item.metadata) + self.show() + + def set_load_image_behavior(self, behavior): + config.setting["load_image_behavior"] = behavior + + def contextMenuEvent(self, event): + menu = QtGui.QMenu(self) + if self.show_details_button.isVisible(): + name = _(u'Show more details...') + show_more_details_action = QtGui.QAction(name, self.parent) + show_more_details_action.triggered.connect(self.show_cover_art_info) + menu.addAction(show_more_details_action) + + if self.orig_cover_art.isVisible(): + name = _(u'Keep original cover art') + use_orig_value_action = QtGui.QAction(name, self.parent) + use_orig_value_action.triggered.connect(self.item.keep_original_images) + menu.addAction(use_orig_value_action) + + if not menu.isEmpty(): + menu.addSeparator() + + load_image_behavior_group = QtGui.QActionGroup(self.parent, exclusive=True) + action = load_image_behavior_group.addAction(QtGui.QAction(_(u'Replace front cover art on drop'), self.parent, checkable=True)) + action.triggered.connect(partial(self.set_load_image_behavior, behavior='replace')) + if config.setting["load_image_behavior"] == 'replace': + action.setChecked(True) + menu.addAction(action) + action = load_image_behavior_group.addAction(QtGui.QAction(_(u'Append front cover art on drop'), self.parent, checkable=True)) + action.triggered.connect(partial(self.set_load_image_behavior, behavior='append')) + if config.setting["load_image_behavior"] == 'append': + action.setChecked(True) + menu.addAction(action) + + menu.exec_(event.globalPos()) + event.accept() diff --git a/picard/ui/infodialog.py b/picard/ui/infodialog.py index b4a90a2c0..6fd285a84 100644 --- a/picard/ui/infodialog.py +++ b/picard/ui/infodialog.py @@ -24,6 +24,7 @@ from PyQt4 import QtGui, QtCore from picard import log from picard.file import File from picard.track import Track +from picard.album import Album from picard.coverart.image import CoverArtImageIOError from picard.util import format_time, encode_filename, bytes2human, webbrowser2, union_sorted_lists from picard.ui import PicardDialog @@ -58,7 +59,7 @@ class ArtworkTable(QtGui.QTableWidget): image_label = QtGui.QLabel() text_label = QtGui.QLabel() layout = QtGui.QVBoxLayout() - image_label.setPixmap(pixmap.scaled(170,170,QtCore.Qt.KeepAspectRatio, + image_label.setPixmap(pixmap.scaled(170, 170, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) image_label.setAlignment(QtCore.Qt.AlignCenter) text_label.setText(text) @@ -98,14 +99,29 @@ class InfoDialog(PicardDialog): def __init__(self, obj, parent=None): PicardDialog.__init__(self, parent) self.obj = obj + self.images = [] + self.existing_images = [] self.ui = Ui_InfoDialog() self.display_existing_artwork = False - if isinstance(obj, File) and isinstance(obj.parent, Track) or \ - isinstance(obj, Track): - # Display existing artwork only if selected object is track object - # or linked to a track object - self.display_existing_artwork = True + if (isinstance(obj, File) and + isinstance(obj.parent, Track) or + isinstance(obj, Track) or + (isinstance(obj, Album) and obj.get_num_total_files() > 0)): + # Display existing artwork only if selected object is track object + # or linked to a track object or it's an album with files + if (getattr(obj, 'orig_metadata', None) is not None and + obj.orig_metadata.images and + obj.orig_metadata.images != obj.metadata.images): + self.display_existing_artwork = True + self.existing_images = obj.orig_metadata.images + + if obj.metadata.images: + self.images = obj.metadata.images + if not self.images and self.existing_images: + self.images = self.existing_images + self.existing_images = [] + self.display_existing_artwork = False self.ui.setupUi(self) self.ui.buttonBox.accepted.connect(self.accept) self.ui.buttonBox.rejected.connect(self.reject) @@ -185,9 +201,9 @@ class InfoDialog(PicardDialog): """Display image type in Type column. If both existing covers and new covers are to be displayed, take union of both cover types list. """ - types = [image.types_as_string() for image in self.obj.metadata.images] + types = [image.types_as_string() for image in self.images] if self.display_existing_artwork: - existing_types = [image.types_as_string() for image in self.obj.orig_metadata.images] + existing_types = [image.types_as_string() for image in self.existing_images] # Merge both types and existing types list in sorted order. types = union_sorted_lists(types, existing_types) for row, type in enumerate(types): @@ -198,21 +214,13 @@ class InfoDialog(PicardDialog): self.artwork_table.setCellWidget(row, self.artwork_table._type_col, type_wgt) self.artwork_table.setItem(row, self.artwork_table._type_col, item) - def arrange_images(self): - def get_image_type(image): - return image.types_as_string() - self.obj.metadata.images.sort(key=get_image_type) - if self.display_existing_artwork: - self.obj.orig_metadata.images.sort(key=get_image_type) - def _display_artwork_tab(self): - if not self.obj.metadata.images: + if not self.images: self.tab_hide(self.ui.artwork_tab) - self.arrange_images() self._display_artwork_type() - self._display_artwork(self.obj.metadata.images, self.artwork_table._new_cover_col) - if self.display_existing_artwork: - self._display_artwork(self.obj.orig_metadata.images, self.artwork_table._existing_cover_col) + self._display_artwork(self.images, self.artwork_table._new_cover_col) + if self.existing_images: + self._display_artwork(self.existing_images, self.artwork_table._existing_cover_col) self.artwork_table.itemDoubleClicked.connect(self.show_item) def tab_hide(self, widget): @@ -236,8 +244,8 @@ class FileInfoDialog(InfoDialog): InfoDialog.__init__(self, file, parent) self.setWindowTitle(_("Info") + " - " + file.base_filename) - def _display_info_tab(self): - file = self.obj + @staticmethod + def format_file_info(file): info = [] info.append((_('Filename:'), file.filename)) if '~format' in file.orig_metadata: @@ -265,9 +273,13 @@ class FileInfoDialog(InfoDialog): else: ch = str(ch) info.append((_('Channels:'), ch)) - text = '
'.join(map(lambda i: '%s
%s' % + return '
'.join(map(lambda i: '%s
%s' % (cgi.escape(i[0]), cgi.escape(i[1])), info)) + + def _display_info_tab(self): + file = self.obj + text = FileInfoDialog.format_file_info(file) self.ui.info.setText(text) @@ -297,6 +309,30 @@ class AlbumInfoDialog(InfoDialog): self.tab_hide(tab) +class TrackInfoDialog(FileInfoDialog): + + def __init__(self, track, parent=None): + InfoDialog.__init__(self, track, parent) + self.setWindowTitle(_("Track Info")) + + def _display_info_tab(self): + track = self.obj + tab = self.ui.info_tab + tabWidget = self.ui.tabWidget + tab_index = tabWidget.indexOf(tab) + if track.num_linked_files == 0: + tabWidget.setTabText(tab_index, _("&Info")) + self.tab_hide(tab) + return + + tabWidget.setTabText(tab_index, _("&Info")) + text = ungettext("%i file in this track", "%i files in this track", + track.num_linked_files) % track.num_linked_files + info_files = [FileInfoDialog.format_file_info(file) for file in track.linked_files] + text += '
' + '
'.join(info_files) + self.ui.info.setText(text) + + class ClusterInfoDialog(InfoDialog): def __init__(self, cluster, parent=None): diff --git a/picard/ui/itemviews.py b/picard/ui/itemviews.py index eebe934ef..f08cda2b0 100644 --- a/picard/ui/itemviews.py +++ b/picard/ui/itemviews.py @@ -19,6 +19,7 @@ import os import re +import sys from functools import partial from PyQt4 import QtCore, QtGui from picard import config, log @@ -31,6 +32,14 @@ from picard.plugin import ExtensionPoint from picard.ui.ratingwidget import RatingWidget from picard.ui.collectionmenu import CollectionMenu +if sys.platform == 'darwin': + try: + from Foundation import NSURL + NSURL_IMPORTED = True + except ImportError: + NSURL_IMPORTED = False + log.warning("Unable to import NSURL, file drag'n'drop might not work correctly") + class BaseAction(QtGui.QAction): NAME = "Unknown" @@ -467,9 +476,20 @@ class BaseTreeView(QtGui.QTreeWidget): files = [] new_files = [] for url in urls: + log.debug("Dropped the URL: %r", url.toString(QtCore.QUrl.RemoveUserInfo)) if url.scheme() == "file" or not url.scheme(): - # Dropping a file from iTunes gives a filename with a NULL terminator - filename = os.path.normpath(os.path.realpath(unicode(url.toLocalFile()).rstrip("\0"))) + if sys.platform == 'darwin' and unicode(url.path()).startswith('/.file/id='): + # Workaround for https://bugreports.qt.io/browse/QTBUG-40449 + # OSX Urls follow the NSURL scheme and need to be converted + if NSURL_IMPORTED: + filename = os.path.normpath(os.path.realpath(unicode(NSURL.URLWithString_(str(url.toString())).filePathURL().path()).rstrip("\0"))) + log.debug('OSX NSURL path detected. Dropped File is: %r', filename) + else: + log.error("Unable to get appropriate file path for %r", url.toString(QtCore.QUrl.RemoveUserInfo)) + continue + else: + # Dropping a file from iTunes gives a filename with a NULL terminator + filename = os.path.normpath(os.path.realpath(unicode(url.toLocalFile()).rstrip("\0"))) file = BaseTreeView.tagger.files.get(filename) if file: files.append(file) @@ -509,8 +529,6 @@ class BaseTreeView(QtGui.QTreeWidget): # text/uri-list urls = data.urls() if urls: - if target is None: - target = self.tagger.unmatched_files self.drop_urls(urls, target) handled = True # application/picard.album-list diff --git a/picard/ui/mainwindow.py b/picard/ui/mainwindow.py index c4d9c1f8a..16ef11126 100644 --- a/picard/ui/mainwindow.py +++ b/picard/ui/mainwindow.py @@ -33,7 +33,7 @@ from picard.ui.metadatabox import MetadataBox from picard.ui.filebrowser import FileBrowser from picard.ui.tagsfromfilenames import TagsFromFileNamesDialog from picard.ui.options.dialog import OptionsDialog -from picard.ui.infodialog import FileInfoDialog, AlbumInfoDialog, ClusterInfoDialog +from picard.ui.infodialog import FileInfoDialog, AlbumInfoDialog, TrackInfoDialog, ClusterInfoDialog from picard.ui.infostatus import InfoStatus from picard.ui.passworddialog import PasswordDialog, ProxyDialog from picard.ui.logview import LogView, HistoryView @@ -68,6 +68,7 @@ class MainWindow(QtGui.QMainWindow): config.Option("persist", "bottom_splitter_state", QtCore.QByteArray()), config.BoolOption("persist", "window_maximized", False), config.BoolOption("persist", "view_cover_art", True), + config.BoolOption("persist", "view_toolbar", True), config.BoolOption("persist", "view_file_browser", False), config.TextOption("persist", "current_directory", ""), ] @@ -192,6 +193,7 @@ class MainWindow(QtGui.QMainWindow): config.persist["window_size"] = self.size() config.persist["window_maximized"] = isMaximized config.persist["view_cover_art"] = self.show_cover_art_action.isChecked() + config.persist["view_toolbar"] = self.show_toolbar_action.isChecked() config.persist["view_file_browser"] = self.show_file_browser_action.isChecked() config.persist["bottom_splitter_state"] = self.centralWidget().saveState() self.file_browser.save_state() @@ -404,6 +406,12 @@ class MainWindow(QtGui.QMainWindow): self.show_cover_art_action.setChecked(True) self.show_cover_art_action.triggered.connect(self.show_cover_art) + self.show_toolbar_action = QtGui.QAction(_(u"&Actions"), self) + self.show_toolbar_action.setCheckable(True) + if config.persist["view_toolbar"]: + self.show_toolbar_action.setChecked(True) + self.show_toolbar_action.triggered.connect(self.show_toolbar) + self.search_action = QtGui.QAction(icontheme.lookup('system-search'), _(u"Search"), self) self.search_action.setEnabled(False) self.search_action.triggered.connect(self.search) @@ -417,6 +425,7 @@ class MainWindow(QtGui.QMainWindow): self.analyze_action = QtGui.QAction(icontheme.lookup('picard-analyze'), _(u"&Scan"), self) self.analyze_action.setStatusTip(_(u"Use AcoustID audio fingerprint to identify the files by the actual music, even if they have no metadata")) self.analyze_action.setEnabled(False) + self.analyze_action.setToolTip(_(u'Identify the file using its AcoustID audio fingerprint')) # TR: Keyboard shortcut for "Analyze" self.analyze_action.setShortcut(QtGui.QKeySequence(_(u"Ctrl+Y"))) self.analyze_action.triggered.connect(self.analyze) @@ -536,7 +545,7 @@ class MainWindow(QtGui.QMainWindow): menu.addAction(self.show_file_browser_action) menu.addAction(self.show_cover_art_action) menu.addSeparator() - menu.addAction(self.toolbar_toggle_action) + menu.addAction(self.show_toolbar_action) menu.addAction(self.search_toolbar_toggle_action) menu = self.menuBar().addMenu(_(u"&Options")) menu.addAction(self.enable_renaming_action) @@ -585,7 +594,6 @@ class MainWindow(QtGui.QMainWindow): self.removeToolBar(self.toolbar) self.toolbar = toolbar = QtGui.QToolBar(_(u"Actions")) self.insertToolBar(self.search_toolbar, self.toolbar) - self.toolbar_toggle_action = self.toolbar.toggleViewAction() self.update_toolbar_style() toolbar.setObjectName("main_toolbar") @@ -614,6 +622,7 @@ class MainWindow(QtGui.QMainWindow): button.setMenu(self.cd_lookup_menu) elif action == 'separator': toolbar.addSeparator() + self.show_toolbar() def create_search_toolbar(self): self.search_toolbar = toolbar = self.addToolBar(_(u"Search")) @@ -638,27 +647,30 @@ class MainWindow(QtGui.QMainWindow): hbox.addWidget(self.search_button) toolbar.addWidget(search_panel) - def set_tab_order(self): tab_order = self.setTabOrder tw = self.toolbar.widgetForAction + prev_action = None + current_action = None + # Setting toolbar widget tab-orders for accessibility + for action in config.setting['toolbar_layout']: + if action != 'separator': + try: + current_action = tw(getattr(self, action)) + except AttributeError: + # No need to log warnings since we have already + # done it once in create_toolbar + pass - # toolbar - tab_order(tw(self.add_directory_action), tw(self.add_files_action)) - tab_order(tw(self.add_files_action), tw(self.play_file_action)) - tab_order(tw(self.play_file_action), tw(self.save_action)) - tab_order(tw(self.save_action), tw(self.submit_acoustid_action)) - tab_order(tw(self.submit_acoustid_action), tw(self.cd_lookup_action)) - tab_order(tw(self.cd_lookup_action), tw(self.cluster_action)) - tab_order(tw(self.cluster_action), tw(self.autotag_action)) - tab_order(tw(self.autotag_action), tw(self.analyze_action)) - tab_order(tw(self.analyze_action), tw(self.view_info_action)) - tab_order(tw(self.view_info_action), tw(self.remove_action)) - tab_order(tw(self.remove_action), tw(self.browser_lookup_action)) - tab_order(tw(self.browser_lookup_action), self.search_combo) + if prev_action is not None and prev_action != current_action: + tab_order(prev_action, current_action) + + prev_action = current_action + + tab_order(prev_action, self.search_combo) tab_order(self.search_combo, self.search_edit) tab_order(self.search_edit, self.search_button) - # panels + # Panels tab_order(self.search_button, self.file_browser) tab_order(self.file_browser, self.panel.views[0]) tab_order(self.panel.views[0], self.panel.views[1]) @@ -723,7 +735,7 @@ class MainWindow(QtGui.QMainWindow): dir_count = len(dir_list) if dir_count: - parent = os.path.dirname(dir_list[0]) + parent = os.path.dirname(dir_list[0]) if dir_count > 1 else dir_list[0] config.persist["current_directory"] = parent if dir_count > 1: self.set_statusbar_message( @@ -825,16 +837,20 @@ class MainWindow(QtGui.QMainWindow): dialog.show_similar_albums(obj) dialog.exec_() - def view_info(self): + def view_info(self, default_tab=0): if isinstance(self.selected_objects[0], Album): album = self.selected_objects[0] dialog = AlbumInfoDialog(album, self) elif isinstance(self.selected_objects[0], Cluster): cluster = self.selected_objects[0] dialog = ClusterInfoDialog(cluster, self) + elif isinstance(self.selected_objects[0], Track): + track = self.selected_objects[0] + dialog = TrackInfoDialog(track, self) else: file = self.tagger.get_files_from_objects(self.selected_objects)[0] dialog = FileInfoDialog(file, self) + dialog.ui.tabWidget.setCurrentIndex(default_tab) dialog.exec_() def cluster(self): @@ -899,6 +915,7 @@ class MainWindow(QtGui.QMainWindow): self.update_actions() metadata = None + orig_metadata = None obj = None # Clear any existing status bar messages @@ -908,6 +925,7 @@ class MainWindow(QtGui.QMainWindow): obj = list(objects)[0] if isinstance(obj, File): metadata = obj.metadata + orig_metadata = obj.orig_metadata if obj.state == obj.ERROR: msg = N_("%(filename)s (error: %(error)s)") mparms = { @@ -924,6 +942,7 @@ class MainWindow(QtGui.QMainWindow): metadata = obj.metadata if obj.num_linked_files == 1: file = obj.linked_files[0] + orig_metadata = file.orig_metadata if file.state == File.ERROR: msg = N_("%(filename)s (%(similarity)d%%) (error: %(error)s)") mparms = { @@ -939,12 +958,15 @@ class MainWindow(QtGui.QMainWindow): } self.set_statusbar_message(msg, mparms, echo=None, history=None) + elif isinstance(obj, Album): + metadata = obj.metadata + orig_metadata = obj.orig_metadata elif obj.can_edit_tags(): metadata = obj.metadata self.metadata_box.selection_dirty = True self.metadata_box.update() - self.cover_art_box.set_metadata(metadata, obj) + self.cover_art_box.set_metadata(metadata, orig_metadata, obj) self.selection_updated.emit(objects) def show_cover_art(self): @@ -954,6 +976,13 @@ class MainWindow(QtGui.QMainWindow): else: self.cover_art_box.hide() + def show_toolbar(self): + """Show/hide the Action toolbar.""" + if self.show_toolbar_action.isChecked(): + self.toolbar.show() + else: + self.toolbar.hide() + def show_file_browser(self): """Show/hide the file browser.""" if self.show_file_browser_action.isChecked(): diff --git a/picard/ui/options/general.py b/picard/ui/options/general.py index 822e47155..2978d6708 100644 --- a/picard/ui/options/general.py +++ b/picard/ui/options/general.py @@ -37,7 +37,7 @@ class GeneralOptionsPage(OptionsPage): options = [ config.TextOption("setting", "server_host", MUSICBRAINZ_SERVERS[0]), - config.IntOption("setting", "server_port", 80), + config.IntOption("setting", "server_port", 443), config.TextOption("persist", "oauth_refresh_token", ""), config.BoolOption("setting", "analyze_new_files", False), config.BoolOption("setting", "ignore_file_mbids", False), diff --git a/picard/ui/options/interface.py b/picard/ui/options/interface.py index 554d9f4bc..13ae2c530 100644 --- a/picard/ui/options/interface.py +++ b/picard/ui/options/interface.py @@ -98,6 +98,7 @@ class InterfaceOptionsPage(OptionsPage): config.TextOption("setting", "ui_language", u""), config.BoolOption("setting", "starting_directory", False), config.TextOption("setting", "starting_directory_path", ""), + config.TextOption("setting", "load_image_behavior", "append"), config.ListOption("setting", "toolbar_layout", [ 'add_directory_action', 'add_files_action', @@ -274,6 +275,7 @@ class InterfaceOptionsPage(OptionsPage): widget = widget.parent() # Call the main window's create toolbar method widget.create_action_toolbar() + widget.set_tab_order() class ToolbarListItem(QtGui.QListWidgetItem): diff --git a/picard/ui/searchdialog.py b/picard/ui/searchdialog.py index 974d75952..614a56dde 100644 --- a/picard/ui/searchdialog.py +++ b/picard/ui/searchdialog.py @@ -281,11 +281,11 @@ class SearchDialog(PicardDialog): def network_error(self, reply, error): error_msg = _("Following error occurred while fetching results:

" - "Network request error for %s:
%s (QT code %d, HTTP code %s)
" % ( + "Network request error for %s:
%s (QT code %d, HTTP code %s)
") % ( reply.request().url().toString(QtCore.QUrl.RemoveUserInfo), reply.errorString(), error, - repr(reply.attribute(QtNetwork.QNetworkRequest.HttpStatusCodeAttribute))) + repr(reply.attribute(QtNetwork.QNetworkRequest.HttpStatusCodeAttribute)) ) self.show_error(error_msg, show_retry_button=True) diff --git a/picard/ui/tagsfromfilenames.py b/picard/ui/tagsfromfilenames.py index 34bb3d416..3b377b5da 100644 --- a/picard/ui/tagsfromfilenames.py +++ b/picard/ui/tagsfromfilenames.py @@ -70,7 +70,7 @@ class TagsFromFileNamesDialog(PicardDialog): item = QtGui.QTreeWidgetItem(self.ui.files) item.setText(0, os.path.basename(file.filename)) self.items.append(item) - self._tag_re = re.compile("(%\w+%)") + self._tag_re = re.compile(r"(%\w+%)") self.numeric_tags = ('tracknumber', 'totaltracks', 'discnumber', 'totaldiscs') def parse_format(self): @@ -82,9 +82,9 @@ class TagsFromFileNamesDialog(PicardDialog): name = part[1:-1] columns.append(name) if name in self.numeric_tags: - format_re.append('(?P<' + name + '>\d+)') + format_re.append('(?P<' + name + r'>\d+)') elif name in ('date'): - format_re.append('(?P<' + name + '>\d+(?:-\d+(?:-\d+)?)?)') + format_re.append('(?P<' + name + r'>\d+(?:-\d+(?:-\d+)?)?)') else: format_re.append('(?P<' + name + '>[^/]*?)') else: diff --git a/picard/ui/ui_options_interface.py b/picard/ui/ui_options_interface.py index 2f5cff954..cbbf1eeb4 100644 --- a/picard/ui/ui_options_interface.py +++ b/picard/ui/ui_options_interface.py @@ -93,13 +93,13 @@ class Ui_InterfaceOptionsPage(object): self.edit_box_layout = QtGui.QHBoxLayout(self.edit_button_box) self.edit_box_layout.setMargin(0) self.edit_box_layout.setObjectName(_fromUtf8("edit_box_layout")) - self.add_button = QtGui.QToolButton(self.edit_button_box) + self.add_button = QtGui.QPushButton(self.edit_button_box) self.add_button.setObjectName(_fromUtf8("add_button")) self.edit_box_layout.addWidget(self.add_button) - self.insert_separator_button = QtGui.QToolButton(self.edit_button_box) + self.insert_separator_button = QtGui.QPushButton(self.edit_button_box) self.insert_separator_button.setObjectName(_fromUtf8("insert_separator_button")) self.edit_box_layout.addWidget(self.insert_separator_button) - spacerItem1 = QtGui.QSpacerItem(60, 20, QtGui.QSizePolicy.MinimumExpanding, QtGui.QSizePolicy.Minimum) + spacerItem1 = QtGui.QSpacerItem(50, 20, QtGui.QSizePolicy.MinimumExpanding, QtGui.QSizePolicy.Minimum) self.edit_box_layout.addItem(spacerItem1) self.up_button = QtGui.QToolButton(self.edit_button_box) self.up_button.setArrowType(QtCore.Qt.UpArrow) @@ -109,7 +109,7 @@ class Ui_InterfaceOptionsPage(object): self.down_button.setArrowType(QtCore.Qt.DownArrow) self.down_button.setObjectName(_fromUtf8("down_button")) self.edit_box_layout.addWidget(self.down_button) - self.remove_button = QtGui.QToolButton(self.edit_button_box) + self.remove_button = QtGui.QPushButton(self.edit_button_box) self.remove_button.setObjectName(_fromUtf8("remove_button")) self.edit_box_layout.addWidget(self.remove_button) self.verticalLayout.addWidget(self.edit_button_box) diff --git a/picard/ui/ui_options_script.py b/picard/ui/ui_options_script.py index c13f2e4dd..65b58a577 100644 --- a/picard/ui/ui_options_script.py +++ b/picard/ui/ui_options_script.py @@ -44,11 +44,14 @@ class Ui_ScriptingOptionsPage(object): self.verticalLayout_3.setMargin(0) self.verticalLayout_3.setSpacing(6) self.verticalLayout_3.setObjectName(_fromUtf8("verticalLayout_3")) - self.add_script = QtGui.QToolButton(self.groupBox) - self.add_script.setToolButtonStyle(QtCore.Qt.ToolButtonTextOnly) - self.add_script.setAutoRaise(False) + self.horizontalLayout = QtGui.QHBoxLayout() + self.horizontalLayout.setObjectName(_fromUtf8("horizontalLayout")) + self.add_script = QtGui.QPushButton(self.groupBox) self.add_script.setObjectName(_fromUtf8("add_script")) - self.verticalLayout_3.addWidget(self.add_script) + self.horizontalLayout.addWidget(self.add_script) + spacerItem = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) + self.horizontalLayout.addItem(spacerItem) + self.verticalLayout_3.addLayout(self.horizontalLayout) self.splitter = QtGui.QSplitter(self.groupBox) self.splitter.setOrientation(QtCore.Qt.Horizontal) self.splitter.setChildrenCollapsible(False) diff --git a/picard/util/__init__.py b/picard/util/__init__.py index c5d392f78..b573cda41 100644 --- a/picard/util/__init__.py +++ b/picard/util/__init__.py @@ -383,25 +383,21 @@ def album_artist_from_path(filename, album, artist): return album, artist -def build_qurl(host, port=80, path=None, mblogin=False, queryargs=None): +def build_qurl(host, port=80, path=None, queryargs=None): """ Builds and returns a QUrl object from `host`, `port` and `path` and automatically enables HTTPS if necessary. - Setting `mblogin` to True forces HTTPS on MusicBrainz' servers. - Encoded query arguments can be provided in `queryargs`, a dictionary mapping field names to values. """ url = QtCore.QUrl() url.setHost(host) url.setPort(port) - if (# Login is required and we're contacting an MB server - (mblogin and host in MUSICBRAINZ_SERVERS and port == 80) or - # Or we're contacting some other server via HTTPS. - port == 443): - url.setScheme("https") - url.setPort(443) + + if (host in MUSICBRAINZ_SERVERS or port == 443): + url.setScheme("https") + url.setPort(443) else: url.setScheme("http") diff --git a/picard/util/filenaming.py b/picard/util/filenaming.py index 3262edd90..b5bf3ad97 100644 --- a/picard/util/filenaming.py +++ b/picard/util/filenaming.py @@ -155,7 +155,7 @@ def _shorten_to_utf16_ratio(text, ratio): def _make_win_short_filename(relpath, reserved=0): - """Shorten a relative file path according to WinAPI quirks. + r"""Shorten a relative file path according to WinAPI quirks. relpath: The file's path. reserved: Number of characters reserved for the parent path to be joined with, diff --git a/picard/util/imagelist.py b/picard/util/imagelist.py new file mode 100644 index 000000000..7fccf3d0f --- /dev/null +++ b/picard/util/imagelist.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2017 Antonio Larrosa +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + +def get_image_type(image): + return image.types_as_string() + + +class ImageList(list): + + def __eq__(self, other): + return sorted(self, key=get_image_type) == sorted(other, key=get_image_type) + + def __getslice__(self, i, j): + length = len(self) + i = max(0, min(i, length)) + j = max(0, min(j, length)) + return ImageList([self[it] for it in range(i, j)]) + + +def _process_images(state, src_obj): + from picard.track import Track + + # Check new images + if state.update_new_metadata: + if state.first_new_obj: + state.new_images = src_obj.metadata.images[:] + state.first_new_obj = False + else: + if state.new_images != src_obj.metadata.images: + state.has_common_new_images = False + state.new_images.extend([image for image in src_obj.metadata.images if image not in state.new_images]) + + if state.update_orig_metadata and not isinstance(src_obj, Track): + # Check orig images, but not for Tracks (which don't have a useful orig_metadata) + if state.first_orig_obj: + state.orig_images = src_obj.orig_metadata.images[:] + state.first_orig_obj = False + else: + if state.orig_images != src_obj.orig_metadata.images: + state.has_common_orig_images = False + state.orig_images.extend([image for image in src_obj.orig_metadata.images if image not in state.orig_images]) + + +class State: + def __init__(self): + self.new_images = ImageList() + self.orig_images = ImageList() + self.has_common_new_images = True + self.has_common_orig_images = True + self.first_new_obj = True + self.first_orig_obj = True + # The next variables specify what will be updated + self.update_new_metadata = False + self.update_orig_metadata = False + + +# TODO: use functools.singledispatch when py3 is supported +def update_metadata_images(obj): + from picard.track import Track + from picard.cluster import Cluster + from picard.album import Album + + state = State() + + if isinstance(obj, Album): + sources = [] + for track in obj.tracks: + sources.append(track) + sources += track.linked_files + sources += obj.unmatched_files.files + state.update_new_metadata = True + state.update_orig_metadata = True + elif isinstance(obj, Track): + sources = obj.linked_files + state.update_orig_metadata = True + elif isinstance(obj, Cluster): + sources = obj.files + state.update_new_metadata = True + + for src_obj in sources: + _process_images(state, src_obj) + + if state.update_new_metadata: + obj.metadata.images = state.new_images + obj.metadata.has_common_images = state.has_common_new_images + + if state.update_orig_metadata: + obj.orig_metadata.images = state.orig_images + obj.orig_metadata.has_common_images = state.has_common_orig_images diff --git a/picard/util/lrucache.py b/picard/util/lrucache.py new file mode 100644 index 000000000..42c147970 --- /dev/null +++ b/picard/util/lrucache.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2017 Antonio Larrosa +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + +class LRUCache(dict): + """ + Helper class to cache items using a Least Recently Used policy. + + It's originally used to cache generated pixmaps in the CoverArtBox object + but it's generic enough to be used for other purposes if necessary. + The cache will never hold more than max_size items and the item least + recently used will be discarded. + + >>> cache = LRUCache(3) + >>> cache['item1'] = 'some value' + >>> cache['item2'] = 'some other value' + >>> cache['item3'] = 'yet another value' + >>> cache['item1'] + 'some value' + >>> cache['item4'] = 'This will push item 2 out of the cache' + >>> cache['item2'] + Traceback (most recent call last): + File "", line 1, in + File "lrucache.py", line 48, in __getitem__ + return super(LRUCache, self).__getitem__(key) + KeyError: 'item2' + >>> cache['item5'] = 'This will push item3 out of the cache' + >>> cache['item3'] + Traceback (most recent call last): + File "", line 1, in + File "lrucache.py", line 48, in __getitem__ + return super(LRUCache, self).__getitem__(key) + KeyError: 'item3' + >>> cache['item1'] + 'some value' + """ + + def __init__(self, max_size): + self._ordered_keys = [] + self._max_size = max_size + + def __getitem__(self, key): + if key in self: + self._ordered_keys.remove(key) + self._ordered_keys.insert(0, key) + return super(LRUCache, self).__getitem__(key) + + def __setitem__(self, key, value): + if key in self: + self._ordered_keys.remove(key) + self._ordered_keys.insert(0, key) + + r = super(LRUCache, self).__setitem__(key, value) + + if len(self) > self._max_size: + item = self._ordered_keys.pop() + super(LRUCache, self).__delitem__(item) + + return r + + def __delitem__(self, key): + self._ordered_keys.remove(key) + super(LRUCache, self).__delitem__(key) diff --git a/picard/webservice.py b/picard/webservice.py index ad7e234d4..429f75319 100644 --- a/picard/webservice.py +++ b/picard/webservice.py @@ -189,8 +189,7 @@ class XmlWebService(QtCore.QObject): def _start_request_continue(self, method, host, port, path, data, handler, xml, mblogin=False, cacheloadcontrol=None, refresh=None, access_token=None, queryargs=None): - url = build_qurl(host, port, path=path, mblogin=mblogin, - queryargs=queryargs) + url = build_qurl(host, port, path=path, queryargs=queryargs) request = QtNetwork.QNetworkRequest(url) if mblogin and access_token: request.setRawHeader("Authorization", "Bearer %s" % access_token) diff --git a/po/attributes/da.po b/po/attributes/da.po index 1d89e2ace..57b239d2d 100644 --- a/po/attributes/da.po +++ b/po/attributes/da.po @@ -1,7 +1,7 @@ # Translators: # Translators: -# Freso , 2012 -# Freso , 2014-2015 +# Frederik “Freso” S. Olesen , 2012 +# Frederik “Freso” S. Olesen , 2014-2015 msgid "" msgstr "" "Project-Id-Version: MusicBrainz\n" diff --git a/po/attributes/es.po b/po/attributes/es.po index e5ef08470..6edb74cdc 100644 --- a/po/attributes/es.po +++ b/po/attributes/es.po @@ -27,8 +27,8 @@ msgid "" msgstr "" "Project-Id-Version: MusicBrainz\n" -"PO-Revision-Date: 2017-01-16 20:46+0000\n" -"Last-Translator: Laurent Monin \n" +"PO-Revision-Date: 2017-03-08 06:20+0000\n" +"Last-Translator: Abby Zla \n" "Language-Team: Spanish (http://www.transifex.com/musicbrainz/musicbrainz/language/es/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -230,7 +230,7 @@ msgid "" "A place whose main purpose is to host outdoor sport events, typically " "consisting of a pitch surrounded by a structure for spectators with no roof," " or a roof which can be retracted." -msgstr "" +msgstr "Un lugar cuyo principal propósito es acoger eventos deportivos al aire libre, consiste típicamente en un campo rodeado por una estructura para los espectadores sin techo, o un techo que puede ser replegado." #: DB:work_type/description:28 msgctxt "work_type" @@ -513,7 +513,7 @@ msgctxt "event_type" msgid "" "An individual concert by a single artist or collaboration, often with " "supporting artists who perform before the main act." -msgstr "" +msgstr "Un concierto individual por un solo artista o en colaboración, frecuentemente con artistas de apoyo que se presentan antes del acto principal." #: DB:work_type/description:10 msgctxt "work_type" @@ -546,7 +546,7 @@ msgid "" "An unofficial/underground release that was not sanctioned by the artist " "and/or the record company. This includes unofficial live recordings and " "pirated releases." -msgstr "" +msgstr "Una edición no oficial/ oculta que no fue autorizada por el artista y/o la compañía discográfica. Esto incluye grabaciones no oficiales o ediciones piratas." #: DB:work_type/description:20 msgctxt "work_type" diff --git a/po/attributes/nb.po b/po/attributes/nb.po index b8057e71c..9a05e11ce 100644 --- a/po/attributes/nb.po +++ b/po/attributes/nb.po @@ -1,14 +1,14 @@ # Translators: # Translators: -# CatQuest, The Endeavouring Cat, 2015 +# CatQuest, The Endeavouring Cat, 2015,2017 # Kurt-Håkon Eilertsen , 2014 # CatQuest, The Endeavouring Cat, 2012 # CatQuest, The Endeavouring Cat, 2014 msgid "" msgstr "" "Project-Id-Version: MusicBrainz\n" -"PO-Revision-Date: 2017-01-16 20:46+0000\n" -"Last-Translator: Laurent Monin \n" +"PO-Revision-Date: 2017-02-14 11:49+0000\n" +"Last-Translator: CatQuest, The Endeavouring Cat\n" "Language-Team: Norwegian Bokmål (http://www.transifex.com/musicbrainz/musicbrainz/language/nb/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -54,12 +54,12 @@ msgstr "12\" Vinyl" #: DB:medium_format/name:49 msgctxt "medium_format" msgid "3.5\" Floppy Disk" -msgstr "" +msgstr "3.5\" Diskett" #: DB:medium_format/name:52 msgctxt "medium_format" msgid "7\" Flexi-disc" -msgstr "" +msgstr "7\" Flexidisk" #: DB:medium_format/name:56 msgctxt "medium_format" @@ -251,7 +251,7 @@ msgstr "" #: DB:series_type/description:3 msgctxt "series_type" msgid "A series of recordings." -msgstr "" +msgstr "En serie av innspillinger" #: DB:series_type/description:7 msgctxt "series_type" @@ -816,7 +816,7 @@ msgstr "Behāg" #: DB:work_type/name:26 msgctxt "work_type" msgid "Beijing opera" -msgstr "Beijing opera" +msgstr "Beijing operaen" #: DB:work_type/description:26 msgctxt "work_type" @@ -973,7 +973,7 @@ msgstr "Blu-ray" #: DB:medium_format/name:35 msgctxt "medium_format" msgid "Blu-spec CD" -msgstr "" +msgstr "Blu-spec CD" #: DB:release_packaging/name:9 msgctxt "release_packaging" @@ -988,12 +988,12 @@ msgstr "Hefte" #: DB:release_status/name:3 msgctxt "release_status" msgid "Bootleg" -msgstr "Ulovlig" +msgstr "" #: DB:label_type/name:5 msgctxt "label_type" msgid "Bootleg Production" -msgstr "Ulovlig produksjon" +msgstr "" #: DB:work_attribute_type_allowed_value/value:607 msgctxt "work_attribute_type_allowed_value" @@ -1003,7 +1003,7 @@ msgstr "Bozlak" #: DB:release_group_primary_type/name:12 msgctxt "release_group_primary_type" msgid "Broadcast" -msgstr "Kringkaste" +msgstr "Kringkasting" #: DB:work_attribute_type_allowed_value/value:62 msgctxt "work_attribute_type_allowed_value" @@ -1118,7 +1118,7 @@ msgstr "CDV" #: DB:medium_format/name:60 msgctxt "medium_format" msgid "CED" -msgstr "" +msgstr "CED" #: DB:work_attribute_type_allowed_value/value:63 msgctxt "work_attribute_type_allowed_value" @@ -1148,12 +1148,12 @@ msgstr "Kantate" #: DB:release_packaging/name:4 msgctxt "release_packaging" msgid "Cardboard/Paper Sleeve" -msgstr "Kortbord/Papirarm" +msgstr "" #: DB:medium_format/name:9 msgctxt "medium_format" msgid "Cartridge" -msgstr "Patron" +msgstr "Kassett (generell)" #: DB:work_attribute_type_allowed_value/value:66 msgctxt "work_attribute_type_allowed_value" @@ -1163,12 +1163,12 @@ msgstr "Carturdaśa rāgamālika" #: DB:medium_format/name:8 msgctxt "medium_format" msgid "Cassette" -msgstr "Kassett" +msgstr "Musikkassett" #: DB:release_packaging/name:8 msgctxt "release_packaging" msgid "Cassette Case" -msgstr "Kassett etui" +msgstr "Kassettetui" #: DB:series_type/name:5 msgctxt "series_type" @@ -1193,7 +1193,7 @@ msgstr "Cevher" #: DB:artist_type/name:4 msgctxt "artist_type" msgid "Character" -msgstr "Karakter" +msgstr "Rolle" #: DB:artist_type/name:6 msgctxt "artist_type" @@ -1244,7 +1244,7 @@ msgstr "" #: DB:medium_format/name:61 msgctxt "medium_format" msgid "Copy Control CD" -msgstr "" +msgstr "Copy Control CD" #: DB:medium_format/description:61 msgctxt "medium_format" @@ -1359,7 +1359,7 @@ msgstr "DJ-miks" #: DB:medium_format/name:44 msgctxt "medium_format" msgid "DTS CD" -msgstr "" +msgstr "DTS CD" #: DB:medium_format/name:2 msgctxt "medium_format" @@ -1379,22 +1379,22 @@ msgstr "DVD-Video" #: DB:medium_format/name:47 msgctxt "medium_format" msgid "DVDplus" -msgstr "" +msgstr "DVDplus" #: DB:medium_format/name:70 msgctxt "medium_format" msgid "DVDplus (CD side)" -msgstr "" +msgstr "DVDplus (CD side)" #: DB:medium_format/name:68 msgctxt "medium_format" msgid "DVDplus (DVD-Audio side)" -msgstr "" +msgstr "DVDplus (DVD-Audio side)" #: DB:medium_format/name:69 msgctxt "medium_format" msgid "DVDplus (DVD-Video side)" -msgstr "" +msgstr "DVDplus (DVD-Video side)" #: DB:work_attribute_type_allowed_value/value:343 msgctxt "work_attribute_type_allowed_value" @@ -1444,7 +1444,7 @@ msgstr "Darbārī kānaḍa" #: DB:medium_format/name:43 msgctxt "medium_format" msgid "Data CD" -msgstr "" +msgstr "Data CD" #: DB:release_group_secondary_type/name:10 msgctxt "release_group_secondary_type" @@ -1599,7 +1599,7 @@ msgstr "Distributør" #: DB:area_type/name:5 msgctxt "area_type" msgid "District" -msgstr "Område" +msgstr "Distrikt" #: DB:area_type/description:5 msgctxt "area_type" @@ -1624,17 +1624,17 @@ msgstr "DualDisc" #: DB:medium_format/name:67 msgctxt "medium_format" msgid "DualDisc (CD side)" -msgstr "" +msgstr "DualDisc (CD side)" #: DB:medium_format/name:65 msgctxt "medium_format" msgid "DualDisc (DVD-Audio side)" -msgstr "" +msgstr "DualDisc (DVD-Audio side)" #: DB:medium_format/name:66 msgctxt "medium_format" msgid "DualDisc (DVD-Video side)" -msgstr "" +msgstr "DualDisc (DVD-Video side)" #: DB:work_attribute_type_allowed_value/value:612 msgctxt "work_attribute_type_allowed_value" @@ -1769,7 +1769,7 @@ msgstr "" #: DB:instrument_type/name:4 msgctxt "instrument_type" msgid "Electronic instrument" -msgstr "elektronisk instrument" +msgstr "Elektronisk instrument" #: DB:medium_format/name:42 msgctxt "medium_format" @@ -1794,17 +1794,17 @@ msgstr "Evcara" #: DB:editor_collection_type/name:4 msgctxt "collection_type" msgid "Event" -msgstr "" +msgstr "Arrangement" #: DB:series_type/name:6 msgctxt "series_type" msgid "Event" -msgstr "" +msgstr "Arrangement" #: DB:event_alias_type/name:1 msgctxt "alias_type" msgid "Event name" -msgstr "" +msgstr "Arrangementnavn" #: DB:work_attribute_type_allowed_value/value:729 msgctxt "work_attribute_type_allowed_value" @@ -1994,7 +1994,7 @@ msgstr "Fireng-i Fer" #: DB:medium_format/name:51 msgctxt "medium_format" msgid "Flexi-disc" -msgstr "" +msgstr "Flexidisk" #: DB:medium_format/description:51 msgctxt "medium_format" @@ -2012,7 +2012,7 @@ msgstr "" #: DB:area_alias_type/name:2 msgctxt "alias_type" msgid "Formal name" -msgstr "Formelt navn" +msgstr "" #: DB:work_attribute_type_allowed_value/value:735 msgctxt "work_attribute_type_allowed_value" @@ -2082,7 +2082,7 @@ msgstr "Garuḍadhvani" #: DB:release_packaging/name:12 msgctxt "release_packaging" msgid "Gatefold Cover" -msgstr "Sammenbrettet forside" +msgstr "Digipak" #: DB:work_attribute_type_allowed_value/value:99 msgctxt "work_attribute_type_allowed_value" @@ -2467,17 +2467,17 @@ msgstr "Huzi" #: DB:medium_format/name:38 msgctxt "medium_format" msgid "Hybrid SACD" -msgstr "" +msgstr "Hybrid SACD" #: DB:medium_format/name:63 msgctxt "medium_format" msgid "Hybrid SACD (CD layer)" -msgstr "" +msgstr "Hybrid SACD (CD lag)" #: DB:medium_format/name:64 msgctxt "medium_format" msgid "Hybrid SACD (SACD layer)" -msgstr "" +msgstr "Hybrid SACD (SACD lag)" #: DB:work_attribute_type_allowed_value/value:418 msgctxt "work_attribute_type_allowed_value" @@ -2564,7 +2564,7 @@ msgstr "Instrument" #: DB:instrument_alias_type/name:1 msgctxt "alias_type" msgid "Instrument name" -msgstr "Instrument navn" +msgstr "Instrumentnavn" #: DB:release_group_secondary_type/name:4 msgctxt "release_group_secondary_type" @@ -3167,7 +3167,7 @@ msgstr "Mandāri" #: DB:series_ordering_type/name:2 msgctxt "series_ordering_type" msgid "Manual" -msgstr "Håndbok" +msgstr "Manual" #: DB:work_attribute_type_allowed_value/value:172 msgctxt "work_attribute_type_allowed_value" @@ -3862,7 +3862,7 @@ msgstr "Annet" #: DB:place_type/name:3 msgctxt "place_type" msgid "Other" -msgstr "Andre" +msgstr "Annet" #: DB:release_group_primary_type/name:11 msgctxt "release_group_primary_type" @@ -3877,7 +3877,7 @@ msgstr "Annet" #: DB:instrument_type/name:5 msgctxt "instrument_type" msgid "Other instrument" -msgstr "Andre instrument" +msgstr "Annet instrument" #: DB:work_type/name:12 msgctxt "work_type" @@ -4017,7 +4017,7 @@ msgstr "Produksjon" #: DB:release_status/name:2 msgctxt "release_status" msgid "Promotion" -msgstr "Reklame" +msgstr "Promoplate" #: DB:work_type/name:23 msgctxt "work_type" @@ -4382,7 +4382,7 @@ msgstr "" #: DB:medium_format/name:62 msgctxt "medium_format" msgid "SD Card" -msgstr "" +msgstr "SDkort" #: DB:work_attribute_type/name:8 msgctxt "work_attribute_type" @@ -4681,12 +4681,12 @@ msgstr "Lydspor" #: DB:cover_art_archive.art_type/name:6 msgctxt "cover_art_type" msgid "Spine" -msgstr "" +msgstr "Rygg" #: DB:release_group_secondary_type/name:3 msgctxt "release_group_secondary_type" msgid "Spokenword" -msgstr "Uttalt ord" +msgstr "Taleplate" #: DB:place_type/name:4 msgctxt "place_type" @@ -5075,7 +5075,7 @@ msgstr "Spor" #: DB:cover_art_archive.art_type/name:9 msgctxt "cover_art_type" msgid "Tray" -msgstr "" +msgstr "Innlegg" #: DB:work_attribute_type_allowed_value/value:775 msgctxt "work_attribute_type_allowed_value" @@ -5170,7 +5170,7 @@ msgstr "VCD" #: DB:medium_format/name:59 msgctxt "medium_format" msgid "VHD" -msgstr "" +msgstr "Videokassett" #: DB:medium_format/name:21 msgctxt "medium_format" diff --git a/po/attributes/nl.po b/po/attributes/nl.po index 8fe51652e..f0323cafc 100644 --- a/po/attributes/nl.po +++ b/po/attributes/nl.po @@ -1,5 +1,6 @@ # Translators: # Translators: +# reneweesp , 2017 # Maurits Meulenbelt