beets-1.4.6/0000755000076500000240000000000013216774613013602 5ustar asampsonstaff00000000000000beets-1.4.6/man/0000755000076500000240000000000013216774613014355 5ustar asampsonstaff00000000000000beets-1.4.6/man/beetsconfig.50000644000076500000240000007623413216774613016747 0ustar asampsonstaff00000000000000.\" Man page generated from reStructuredText. . .TH "BEETSCONFIG" "5" "Dec 21, 2017" "1.4" "beets" .SH NAME beetsconfig \- beets configuration file . .nr rst2man-indent-level 0 . .de1 rstReportMargin \\$1 \\n[an-margin] level \\n[rst2man-indent-level] level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] - \\n[rst2man-indent0] \\n[rst2man-indent1] \\n[rst2man-indent2] .. .de1 INDENT .\" .rstReportMargin pre: . RS \\$1 . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] . nr rst2man-indent-level +1 .\" .rstReportMargin post: .. .de UNINDENT . RE .\" indent \\n[an-margin] .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] .nr rst2man-indent-level -1 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. .sp Beets has an extensive configuration system that lets you customize nearly every aspect of its operation. To configure beets, you create a file called \fBconfig.yaml\fP\&. The location of the file depend on your platform (type \fBbeet config \-p\fP to see the path on your system): .INDENT 0.0 .IP \(bu 2 On Unix\-like OSes, write \fB~/.config/beets/config.yaml\fP\&. .IP \(bu 2 On Windows, use \fB%APPDATA%\ebeets\econfig.yaml\fP\&. This is usually in a directory like \fBC:\eUsers\eYou\eAppData\eRoaming\fP\&. .IP \(bu 2 On OS X, you can use either the Unix location or \fB~/Library/Application Support/beets/config.yaml\fP\&. .UNINDENT .sp You can launch your text editor to create or update your configuration by typing \fBbeet config \-e\fP\&. (See the config\-cmd command for details.) It is also possible to customize the location of the configuration file and even use multiple layers of configuration. See \fI\%Configuration Location\fP, below. .sp The config file uses \fI\%YAML\fP syntax. You can use the full power of YAML, but most configuration options are simple key/value pairs. This means your config file will look like this: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C option: value another_option: foo bigger_option: key: value foo: bar .ft P .fi .UNINDENT .UNINDENT .sp In YAML, you will need to use spaces (not tabs!) to indent some lines. If you have questions about more sophisticated syntax, take a look at the \fI\%YAML\fP documentation. .sp The rest of this page enumerates the dizzying litany of configuration options available in beets. You might also want to see an \fI\%example\fP\&. .INDENT 0.0 .IP \(bu 2 \fI\%Global Options\fP .INDENT 2.0 .IP \(bu 2 \fI\%library\fP .IP \(bu 2 \fI\%directory\fP .IP \(bu 2 \fI\%plugins\fP .IP \(bu 2 \fI\%include\fP .IP \(bu 2 \fI\%pluginpath\fP .IP \(bu 2 \fI\%ignore\fP .IP \(bu 2 \fI\%ignore_hidden\fP .IP \(bu 2 \fI\%replace\fP .IP \(bu 2 \fI\%asciify_paths\fP .IP \(bu 2 \fI\%art_filename\fP .IP \(bu 2 \fI\%threaded\fP .IP \(bu 2 \fI\%format_item\fP .IP \(bu 2 \fI\%format_album\fP .IP \(bu 2 \fI\%sort_item\fP .IP \(bu 2 \fI\%sort_album\fP .IP \(bu 2 \fI\%sort_case_insensitive\fP .IP \(bu 2 \fI\%original_date\fP .IP \(bu 2 \fI\%per_disc_numbering\fP .IP \(bu 2 \fI\%terminal_encoding\fP .IP \(bu 2 \fI\%clutter\fP .IP \(bu 2 \fI\%max_filename_length\fP .IP \(bu 2 \fI\%id3v23\fP .IP \(bu 2 \fI\%va_name\fP .UNINDENT .IP \(bu 2 \fI\%UI Options\fP .INDENT 2.0 .IP \(bu 2 \fI\%color\fP .IP \(bu 2 \fI\%colors\fP .UNINDENT .IP \(bu 2 \fI\%Importer Options\fP .INDENT 2.0 .IP \(bu 2 \fI\%write\fP .IP \(bu 2 \fI\%copy\fP .IP \(bu 2 \fI\%move\fP .IP \(bu 2 \fI\%link\fP .IP \(bu 2 \fI\%hardlink\fP .IP \(bu 2 \fI\%resume\fP .IP \(bu 2 \fI\%incremental\fP .IP \(bu 2 \fI\%from_scratch\fP .IP \(bu 2 \fI\%quiet_fallback\fP .IP \(bu 2 \fI\%none_rec_action\fP .IP \(bu 2 \fI\%timid\fP .IP \(bu 2 \fI\%log\fP .IP \(bu 2 \fI\%default_action\fP .IP \(bu 2 \fI\%languages\fP .IP \(bu 2 \fI\%detail\fP .IP \(bu 2 \fI\%group_albums\fP .IP \(bu 2 \fI\%autotag\fP .IP \(bu 2 \fI\%duplicate_action\fP .IP \(bu 2 \fI\%bell\fP .IP \(bu 2 \fI\%set_fields\fP .UNINDENT .IP \(bu 2 \fI\%MusicBrainz Options\fP .INDENT 2.0 .IP \(bu 2 \fI\%searchlimit\fP .UNINDENT .IP \(bu 2 \fI\%Autotagger Matching Options\fP .INDENT 2.0 .IP \(bu 2 \fI\%max_rec\fP .IP \(bu 2 \fI\%preferred\fP .IP \(bu 2 \fI\%ignored\fP .IP \(bu 2 \fI\%required\fP .UNINDENT .IP \(bu 2 \fI\%Path Format Configuration\fP .IP \(bu 2 \fI\%Configuration Location\fP .INDENT 2.0 .IP \(bu 2 \fI\%Environment Variable\fP .IP \(bu 2 \fI\%Command\-Line Option\fP .IP \(bu 2 \fI\%Default Location\fP .UNINDENT .IP \(bu 2 \fI\%Example\fP .UNINDENT .SH GLOBAL OPTIONS .sp These options control beets’ global operation. .SS library .sp Path to the beets library file. By default, beets will use a file called \fBlibrary.db\fP alongside your configuration file. .SS directory .sp The directory to which files will be copied/moved when adding them to the library. Defaults to a folder called \fBMusic\fP in your home directory. .SS plugins .sp A space\-separated list of plugin module names to load. See using\-plugins\&. .SS include .sp A space\-separated list of extra configuration files to include. Filenames are relative to the directory containing \fBconfig.yaml\fP\&. .SS pluginpath .sp Directories to search for plugins. Each Python file or directory in a plugin path represents a plugin and should define a subclass of \fBBeetsPlugin\fP\&. A plugin can then be loaded by adding the filename to the \fIplugins\fP configuration. The plugin path can either be a single string or a list of strings—so, if you have multiple paths, format them as a YAML list like so: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C pluginpath: \- /path/one \- /path/two .ft P .fi .UNINDENT .UNINDENT .SS ignore .sp A list of glob patterns specifying file and directory names to be ignored when importing. By default, this consists of \fB\&.*\fP, \fB*~\fP, \fBSystem Volume Information\fP, \fBlost+found\fP (i.e., beets ignores Unix\-style hidden files, backup files, and directories that appears at the root of some Linux and Windows filesystems). .SS ignore_hidden .sp Either \fByes\fP or \fBno\fP; whether to ignore hidden files when importing. On Windows, the “Hidden” property of files is used to detect whether or not a file is hidden. On OS X, the file’s “IsHidden” flag is used to detect whether or not a file is hidden. On both OS X and other platforms (excluding Windows), files (and directories) starting with a dot are detected as hidden files. .SS replace .sp A set of regular expression/replacement pairs to be applied to all filenames created by beets. Typically, these replacements are used to avoid confusing problems or errors with the filesystem (for example, leading dots, which hide files on Unix, and trailing whitespace, which is illegal on Windows). To override these substitutions, specify a mapping from regular expression to replacement strings. For example, \fB[xy]: z\fP will make beets replace all instances of the characters \fBx\fP or \fBy\fP with the character \fBz\fP\&. .sp If you do change this value, be certain that you include at least enough substitutions to avoid causing errors on your operating system. Here are the default substitutions used by beets, which are sufficient to avoid unexpected behavior on all popular platforms: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C replace: \(aq[\e\e/]\(aq: _ \(aq^\e.\(aq: _ \(aq[\ex00\-\ex1f]\(aq: _ \(aq[<>:"\e?\e*\e|]\(aq: _ \(aq\e.$\(aq: _ \(aq\es+$\(aq: \(aq\(aq \(aq^\es+\(aq: \(aq\(aq \(aq^\-\(aq: _ .ft P .fi .UNINDENT .UNINDENT .sp These substitutions remove forward and back slashes, leading dots, and control characters—all of which is a good idea on any OS. The fourth line removes the Windows “reserved characters” (useful even on Unix for for compatibility with Windows\-influenced network filesystems like Samba). Trailing dots and trailing whitespace, which can cause problems on Windows clients, are also removed. .sp When replacements other than the defaults are used, it is possible that they will increase the length of the path. In the scenario where this leads to a conflict with the maximum filename length, the default replacements will be used to resolve the conflict and beets will display a warning. .sp Note that paths might contain special characters such as typographical quotes (\fB“”\fP). With the configuration above, those will not be replaced as they don’t match the typewriter quote (\fB"\fP). To also strip these special characters, you can either add them to the replacement list or use the \fI\%asciify_paths\fP configuration option below. .SS asciify_paths .sp Convert all non\-ASCII characters in paths to ASCII equivalents. .sp For example, if your path template for singletons is \fBsingletons/$title\fP and the title of a track is “Café”, then the track will be saved as \fBsingletons/Cafe.mp3\fP\&. The changes take place before applying the \fI\%replace\fP configuration and are roughly equivalent to wrapping all your path templates in the \fB%asciify{}\fP template function\&. .sp Default: \fBno\fP\&. .SS art_filename .sp When importing album art, the name of the file (without extension) where the cover art image should be placed. This is a template string, so you can use any of the syntax available to /reference/pathformat\&. Defaults to \fBcover\fP (i.e., images will be named \fBcover.jpg\fP or \fBcover.png\fP and placed in the album’s directory). .SS threaded .sp Either \fByes\fP or \fBno\fP, indicating whether the autotagger should use multiple threads. This makes things substantially faster by overlapping work: for example, it can copy files for one album in parallel with looking up data in MusicBrainz for a different album. You may want to disable this when debugging problems with the autotagger. Defaults to \fByes\fP\&. .SS format_item .sp Format to use when listing \fIindividual items\fP with the list\-cmd command and other commands that need to print out items. Defaults to \fB$artist \- $album \- $title\fP\&. The \fB\-f\fP command\-line option overrides this setting. .sp It used to be named \fIlist_format_item\fP\&. .SS format_album .sp Format to use when listing \fIalbums\fP with list\-cmd and other commands. Defaults to \fB$albumartist \- $album\fP\&. The \fB\-f\fP command\-line option overrides this setting. .sp It used to be named \fIlist_format_album\fP\&. .SS sort_item .sp Default sort order to use when fetching items from the database. Defaults to \fBartist+ album+ disc+ track+\fP\&. Explicit sort orders override this default. .SS sort_album .sp Default sort order to use when fetching albums from the database. Defaults to \fBalbumartist+ album+\fP\&. Explicit sort orders override this default. .SS sort_case_insensitive .sp Either \fByes\fP or \fBno\fP, indicating whether the case should be ignored when sorting lexicographic fields. When set to \fBno\fP, lower\-case values will be placed after upper\-case values (e.g., \fIBar Qux foo\fP), while \fByes\fP would result in the more expected \fIBar foo Qux\fP\&. Default: \fByes\fP\&. .SS original_date .sp Either \fByes\fP or \fBno\fP, indicating whether matched albums should have their \fByear\fP, \fBmonth\fP, and \fBday\fP fields set to the release date of the \fIoriginal\fP version of an album rather than the selected version of the release. That is, if this option is turned on, then \fByear\fP will always equal \fBoriginal_year\fP and so on. Default: \fBno\fP\&. .SS per_disc_numbering .sp A boolean controlling the track numbering style on multi\-disc releases. By default (\fBper_disc_numbering: no\fP), tracks are numbered per\-release, so the first track on the second disc has track number N+1 where N is the number of tracks on the first disc. If this \fBper_disc_numbering\fP is enabled, then the first (non\-pregap) track on each disc always has track number 1. .sp If you enable \fBper_disc_numbering\fP, you will likely want to change your \fI\%Path Format Configuration\fP also to include \fB$disc\fP before \fB$track\fP to make filenames sort correctly in album directories. For example, you might want to use a path format like this: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C paths: default: $albumartist/$album%aunique{}/$disc\-$track $title .ft P .fi .UNINDENT .UNINDENT .sp When this option is off (the default), even “pregap” hidden tracks are numbered from one, not zero, so other track numbers may appear to be bumped up by one. When it is on, the pregap track for each disc can be numbered zero. .SS terminal_encoding .sp The text encoding, as \fI\%known to Python\fP, to use for messages printed to the standard output. It’s also used to read messages from the standard input. By default, this is determined automatically from the locale environment variables. .SS clutter .sp When beets imports all the files in a directory, it tries to remove the directory if it’s empty. A directory is considered empty if it only contains files whose names match the glob patterns in \fIclutter\fP, which should be a list of strings. The default list consists of “Thumbs.DB” and “.DS_Store”. .sp The importer only removes recursively searched subdirectories—the top\-level directory you specify on the command line is never deleted. .SS max_filename_length .sp Set the maximum number of characters in a filename, after which names will be truncated. By default, beets tries to ask the filesystem for the correct maximum. .SS id3v23 .sp By default, beets writes MP3 tags using the ID3v2.4 standard, the latest version of ID3. Enable this option to instead use the older ID3v2.3 standard, which is preferred by certain older software such as Windows Media Player. .SS va_name .sp Sets the albumartist for various\-artist compilations. Defaults to \fB\(aqVarious Artists\(aq\fP (the MusicBrainz standard). Affects other sources, such as /plugins/discogs, too. .SH UI OPTIONS .sp The options that allow for customization of the visual appearance of the console interface. .sp These options are available in this section: .SS color .sp Either \fByes\fP or \fBno\fP; whether to use color in console output (currently only in the \fBimport\fP command). Turn this off if your terminal doesn’t support ANSI colors. .sp \fBNOTE:\fP .INDENT 0.0 .INDENT 3.5 The \fIcolor\fP option was previously a top\-level configuration. This is still respected, but a deprecation message will be shown until your top\-level \fIcolor\fP configuration has been nested under \fIui\fP\&. .UNINDENT .UNINDENT .SS colors .sp The colors that are used throughout the user interface. These are only used if the \fBcolor\fP option is set to \fByes\fP\&. For example, you might have a section in your configuration file that looks like this: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C ui: color: yes colors: text_success: green text_warning: yellow text_error: red text_highlight: red text_highlight_minor: lightgray action_default: turquoise action: blue .ft P .fi .UNINDENT .UNINDENT .sp Available colors: black, darkred, darkgreen, brown (darkyellow), darkblue, purple (darkmagenta), teal (darkcyan), lightgray, darkgray, red, green, yellow, blue, fuchsia (magenta), turquoise (cyan), white .SH IMPORTER OPTIONS .sp The options that control the import\-cmd command are indented under the \fBimport:\fP key. For example, you might have a section in your configuration file that looks like this: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C import: write: yes copy: yes resume: no .ft P .fi .UNINDENT .UNINDENT .sp These options are available in this section: .SS write .sp Either \fByes\fP or \fBno\fP, controlling whether metadata (e.g., ID3) tags are written to files when using \fBbeet import\fP\&. Defaults to \fByes\fP\&. The \fB\-w\fP and \fB\-W\fP command\-line options override this setting. .SS copy .sp Either \fByes\fP or \fBno\fP, indicating whether to \fBcopy\fP files into the library directory when using \fBbeet import\fP\&. Defaults to \fByes\fP\&. Can be overridden with the \fB\-c\fP and \fB\-C\fP command\-line options. .sp The option is ignored if \fBmove\fP is enabled (i.e., beets can move or copy files but it doesn’t make sense to do both). .SS move .sp Either \fByes\fP or \fBno\fP, indicating whether to \fBmove\fP files into the library directory when using \fBbeet import\fP\&. Defaults to \fBno\fP\&. .sp The effect is similar to the \fBcopy\fP option but you end up with only one copy of the imported file. (“Moving” works even across filesystems; if necessary, beets will copy and then delete when a simple rename is impossible.) Moving files can be risky—it’s a good idea to keep a backup in case beets doesn’t do what you expect with your files. .sp This option \fIoverrides\fP \fBcopy\fP, so enabling it will always move (and not copy) files. The \fB\-c\fP switch to the \fBbeet import\fP command, however, still takes precedence. .SS link .sp Either \fByes\fP or \fBno\fP, indicating whether to use symbolic links instead of moving or copying files. (It conflicts with the \fBmove\fP, \fBcopy\fP and \fBhardlink\fP options.) Defaults to \fBno\fP\&. .sp This option only works on platforms that support symbolic links: i.e., Unixes. It will fail on Windows. .sp It’s likely that you’ll also want to set \fBwrite\fP to \fBno\fP if you use this option to preserve the metadata on the linked files. .SS hardlink .sp Either \fByes\fP or \fBno\fP, indicating whether to use hard links instead of moving or copying or symlinking files. (It conflicts with the \fBmove\fP, \fBcopy\fP, and \fBlink\fP options.) Defaults to \fBno\fP\&. .sp As with symbolic links (see \fI\%link\fP, above), this will not work on Windows and you will want to set \fBwrite\fP to \fBno\fP\&. Otherwise, metadata on the original file will be modified. .SS resume .sp Either \fByes\fP, \fBno\fP, or \fBask\fP\&. Controls whether interrupted imports should be resumed. “Yes” means that imports are always resumed when possible; “no” means resuming is disabled entirely; “ask” (the default) means that the user should be prompted when resuming is possible. The \fB\-p\fP and \fB\-P\fP flags correspond to the “yes” and “no” settings and override this option. .SS incremental .sp Either \fByes\fP or \fBno\fP, controlling whether imported directories are recorded and whether these recorded directories are skipped. This corresponds to the \fB\-i\fP flag to \fBbeet import\fP\&. .SS from_scratch .sp Either \fByes\fP or \fBno\fP (default), controlling whether existing metadata is discarded when a match is applied. This corresponds to the \fB\-\-from_scratch\fP flag to \fBbeet import\fP\&. .SS quiet_fallback .sp Either \fBskip\fP (default) or \fBasis\fP, specifying what should happen in quiet mode (see the \fB\-q\fP flag to \fBimport\fP, above) when there is no strong recommendation. .SS none_rec_action .sp Either \fBask\fP (default), \fBasis\fP or \fBskip\fP\&. Specifies what should happen during an interactive import session when there is no recommendation. Useful when you are only interested in processing medium and strong recommendations interactively. .SS timid .sp Either \fByes\fP or \fBno\fP, controlling whether the importer runs in \fItimid\fP mode, in which it asks for confirmation on every autotagging match, even the ones that seem very close. Defaults to \fBno\fP\&. The \fB\-t\fP command\-line flag controls the same setting. .SS log .sp Specifies a filename where the importer’s log should be kept. By default, no log is written. This can be overridden with the \fB\-l\fP flag to \fBimport\fP\&. .SS default_action .sp One of \fBapply\fP, \fBskip\fP, \fBasis\fP, or \fBnone\fP, indicating which option should be the \fIdefault\fP when selecting an action for a given match. This is the action that will be taken when you type return without an option letter. The default is \fBapply\fP\&. .SS languages .sp A list of locale names to search for preferred aliases. For example, setting this to “en” uses the transliterated artist name “Pyotr Ilyich Tchaikovsky” instead of the Cyrillic script for the composer’s name when tagging from MusicBrainz. Defaults to an empty list, meaning that no language is preferred. .SS detail .sp Whether the importer UI should show detailed information about each match it finds. When enabled, this mode prints out the title of every track, regardless of whether it matches the original metadata. (The default behavior only shows changes.) Default: \fBno\fP\&. .SS group_albums .sp By default, the beets importer groups tracks into albums based on the directories they reside in. This option instead uses files’ metadata to partition albums. Enable this option if you have directories that contain tracks from many albums mixed together. .sp The \fB\-\-group\-albums\fP or \fB\-g\fP option to the import\-cmd command is equivalent, and the \fIG\fP interactive option invokes the same workflow. .sp Default: \fBno\fP\&. .SS autotag .sp By default, the beets importer always attempts to autotag new music. If most of your collection consists of obscure music, you may be interested in disabling autotagging by setting this option to \fBno\fP\&. (You can re\-enable it with the \fB\-a\fP flag to the import\-cmd command.) .sp Default: \fByes\fP\&. .SS duplicate_action .sp Either \fBskip\fP, \fBkeep\fP, \fBremove\fP, \fBmerge\fP or \fBask\fP\&. Controls how duplicates are treated in import task. “skip” means that new item(album or track) will be skipped; “keep” means keep both old and new items; “remove” means remove old item; “merge” means merge into one album; “ask” means the user should be prompted for the action each time. The default is \fBask\fP\&. .SS bell .sp Ring the terminal bell to get your attention when the importer needs your input. .sp Default: \fBno\fP\&. .SS set_fields .sp A dictionary indicating fields to set to values for newly imported music. Here’s an example: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C set_fields: genre: \(aqTo Listen\(aq collection: \(aqUnordered\(aq .ft P .fi .UNINDENT .UNINDENT .sp Other field/value pairs supplied via the \fB\-\-set\fP option on the command\-line override any settings here for fields with the same name. .sp Default: \fB{}\fP (empty). .SH MUSICBRAINZ OPTIONS .sp If you run your own \fI\%MusicBrainz\fP server, you can instruct beets to use it instead of the main server. Use the \fBhost\fP and \fBratelimit\fP options under a \fBmusicbrainz:\fP header, like so: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C musicbrainz: host: localhost:5000 ratelimit: 100 .ft P .fi .UNINDENT .UNINDENT .sp The \fBhost\fP key, of course, controls the Web server hostname (and port, optionally) that will be contacted by beets (default: musicbrainz.org). The \fBratelimit\fP option, an integer, controls the number of Web service requests per second (default: 1). \fBDo not change the rate limit setting\fP if you’re using the main MusicBrainz server—on this public server, you’re \fI\%limited\fP to one request per second. .SS searchlimit .sp The number of matches returned when sending search queries to the MusicBrainz server. .sp Default: \fB5\fP\&. .SH AUTOTAGGER MATCHING OPTIONS .sp You can configure some aspects of the logic beets uses when automatically matching MusicBrainz results under the \fBmatch:\fP section. To control how \fItolerant\fP the autotagger is of differences, use the \fBstrong_rec_thresh\fP option, which reflects the distance threshold below which beets will make a “strong recommendation” that the metadata be used. Strong recommendations are accepted automatically (except in “timid” mode), so you can use this to make beets ask your opinion more or less often. .sp The threshold is a \fIdistance\fP value between 0.0 and 1.0, so you can think of it as the opposite of a \fIsimilarity\fP value. For example, if you want to automatically accept any matches above 90% similarity, use: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C match: strong_rec_thresh: 0.10 .ft P .fi .UNINDENT .UNINDENT .sp The default strong recommendation threshold is 0.04. .sp The \fBmedium_rec_thresh\fP and \fBrec_gap_thresh\fP options work similarly. When a match is below the \fImedium\fP recommendation threshold or the distance between it and the next\-best match is above the \fIgap\fP threshold, the importer will suggest that match but not automatically confirm it. Otherwise, you’ll see a list of options to choose from. .SS max_rec .sp As mentioned above, autotagger matches have \fIrecommendations\fP that control how the UI behaves for a certain quality of match. The recommendation for a certain match is based on the overall distance calculation. But you can also control the recommendation when a specific distance penalty is applied by defining \fImaximum\fP recommendations for each field: .sp To define maxima, use keys under \fBmax_rec:\fP in the \fBmatch\fP section. The defaults are “medium” for missing and unmatched tracks and “strong” (i.e., no maximum) for everything else: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C match: max_rec: missing_tracks: medium unmatched_tracks: medium .ft P .fi .UNINDENT .UNINDENT .sp If a recommendation is higher than the configured maximum and the indicated penalty is applied, the recommendation is downgraded. The setting for each field can be one of \fBnone\fP, \fBlow\fP, \fBmedium\fP or \fBstrong\fP\&. When the maximum recommendation is \fBstrong\fP, no “downgrading” occurs. The available penalty names here are: .INDENT 0.0 .IP \(bu 2 source .IP \(bu 2 artist .IP \(bu 2 album .IP \(bu 2 media .IP \(bu 2 mediums .IP \(bu 2 year .IP \(bu 2 country .IP \(bu 2 label .IP \(bu 2 catalognum .IP \(bu 2 albumdisambig .IP \(bu 2 album_id .IP \(bu 2 tracks .IP \(bu 2 missing_tracks .IP \(bu 2 unmatched_tracks .IP \(bu 2 track_title .IP \(bu 2 track_artist .IP \(bu 2 track_index .IP \(bu 2 track_length .IP \(bu 2 track_id .UNINDENT .SS preferred .sp In addition to comparing the tagged metadata with the match metadata for similarity, you can also specify an ordered list of preferred countries and media types. .sp A distance penalty will be applied if the country or media type from the match metadata doesn’t match. The specified values are preferred in descending order (i.e., the first item will be most preferred). Each item may be a regular expression, and will be matched case insensitively. The number of media will be stripped when matching preferred media (e.g. “2x” in “2xCD”). .sp You can also tell the autotagger to prefer matches that have a release year closest to the original year for an album. .sp Here’s an example: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C match: preferred: countries: [\(aqUS\(aq, \(aqGB|UK\(aq] media: [\(aqCD\(aq, \(aqDigital Media|File\(aq] original_year: yes .ft P .fi .UNINDENT .UNINDENT .sp By default, none of these options are enabled. .SS ignored .sp You can completely avoid matches that have certain penalties applied by adding the penalty name to the \fBignored\fP setting: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C match: ignored: missing_tracks unmatched_tracks .ft P .fi .UNINDENT .UNINDENT .sp The available penalties are the same as those for the \fI\%max_rec\fP setting. .SS required .sp You can avoid matches that lack certain required information. Add the tags you want to enforce to the \fBrequired\fP setting: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C match: required: year label catalognum country .ft P .fi .UNINDENT .UNINDENT .sp No tags are required by default. .SH PATH FORMAT CONFIGURATION .sp You can also configure the directory hierarchy beets uses to store music. These settings appear under the \fBpaths:\fP key. Each string is a template string that can refer to metadata fields like \fB$artist\fP or \fB$title\fP\&. The filename extension is added automatically. At the moment, you can specify three special paths: \fBdefault\fP for most releases, \fBcomp\fP for “various artist” releases with no dominant artist, and \fBsingleton\fP for non\-album tracks. The defaults look like this: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C paths: default: $albumartist/$album%aunique{}/$track $title singleton: Non\-Album/$artist/$title comp: Compilations/$album%aunique{}/$track $title .ft P .fi .UNINDENT .UNINDENT .sp Note the use of \fB$albumartist\fP instead of \fB$artist\fP; this ensures that albums will be well\-organized. For more about these format strings, see pathformat\&. The \fBaunique{}\fP function ensures that identically\-named albums are placed in different directories; see aunique for details. .sp In addition to \fBdefault\fP, \fBcomp\fP, and \fBsingleton\fP, you can condition path queries based on beets queries (see /reference/query). This means that a config file like this: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C paths: albumtype:soundtrack: Soundtracks/$album/$track $title .ft P .fi .UNINDENT .UNINDENT .sp will place soundtrack albums in a separate directory. The queries are tested in the order they appear in the configuration file, meaning that if an item matches multiple queries, beets will use the path format for the \fIfirst\fP matching query. .sp Note that the special \fBsingleton\fP and \fBcomp\fP path format conditions are, in fact, just shorthand for the explicit queries \fBsingleton:true\fP and \fBcomp:true\fP\&. In contrast, \fBdefault\fP is special and has no query equivalent: the \fBdefault\fP format is only used if no queries match. .SH CONFIGURATION LOCATION .sp The beets configuration file is usually located in a standard location that depends on your OS, but there are a couple of ways you can tell beets where to look. .SS Environment Variable .sp First, you can set the \fBBEETSDIR\fP environment variable to a directory containing a \fBconfig.yaml\fP file. This replaces your configuration in the default location. This also affects where auxiliary files, like the library database, are stored by default (that’s where relative paths are resolved to). This environment variable is useful if you need to manage multiple beets libraries with separate configurations. .SS Command\-Line Option .sp Alternatively, you can use the \fB\-\-config\fP command\-line option to indicate a YAML file containing options that will then be merged with your existing options (from \fBBEETSDIR\fP or the default locations). This is useful if you want to keep your configuration mostly the same but modify a few options as a batch. For example, you might have different strategies for importing files, each with a different set of importer options. .SS Default Location .sp In the absence of a \fBBEETSDIR\fP variable, beets searches a few places for your configuration, depending on the platform: .INDENT 0.0 .IP \(bu 2 On Unix platforms, including OS X:\fB~/.config/beets\fP and then \fB$XDG_CONFIG_DIR/beets\fP, if the environment variable is set. .IP \(bu 2 On OS X, we also search \fB~/Library/Application Support/beets\fP before the Unixy locations. .IP \(bu 2 On Windows: \fB~\eAppData\eRoaming\ebeets\fP, and then \fB%APPDATA%\ebeets\fP, if the environment variable is set. .UNINDENT .sp Beets uses the first directory in your platform’s list that contains \fBconfig.yaml\fP\&. If no config file exists, the last path in the list is used. .SH EXAMPLE .sp Here’s an example file: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C directory: /var/mp3 import: copy: yes write: yes log: beetslog.txt art_filename: albumart plugins: bpd pluginpath: ~/beets/myplugins ui: color: yes paths: default: $genre/$albumartist/$album/$track $title singleton: Singletons/$artist \- $title comp: $genre/$album/$track $title albumtype:soundtrack: Soundtracks/$album/$track $title .ft P .fi .UNINDENT .UNINDENT .SH SEE ALSO .sp \fBhttp://beets.readthedocs.org/\fP .sp \fBbeet(1)\fP .SH AUTHOR Adrian Sampson .SH COPYRIGHT 2016, Adrian Sampson .\" Generated by docutils manpage writer. . beets-1.4.6/man/beet.10000644000076500000240000004507213216774613015366 0ustar asampsonstaff00000000000000.\" Man page generated from reStructuredText. . .TH "BEET" "1" "Dec 21, 2017" "1.4" "beets" .SH NAME beet \- music tagger and library organizer . .nr rst2man-indent-level 0 . .de1 rstReportMargin \\$1 \\n[an-margin] level \\n[rst2man-indent-level] level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] - \\n[rst2man-indent0] \\n[rst2man-indent1] \\n[rst2man-indent2] .. .de1 INDENT .\" .rstReportMargin pre: . RS \\$1 . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] . nr rst2man-indent-level +1 .\" .rstReportMargin post: .. .de UNINDENT . RE .\" indent \\n[an-margin] .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] .nr rst2man-indent-level -1 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. .SH SYNOPSIS .nf \fBbeet\fP [\fIargs\fP…] \fIcommand\fP [\fIargs\fP…] \fBbeet help\fP \fIcommand\fP .fi .sp .SH COMMANDS .SS import .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet import [\-CWAPRqst] [\-l LOGPATH] PATH... beet import [options] \-L QUERY .ft P .fi .UNINDENT .UNINDENT .sp Add music to your library, attempting to get correct tags for it from MusicBrainz. .sp Point the command at some music: directories, single files, or compressed archives. The music will be copied to a configurable directory structure and added to a library database. The command is interactive and will try to get you to verify MusicBrainz tags that it thinks are suspect. See the autotagging guide for detail on how to use the interactive tag\-correction flow. .sp Directories passed to the import command can contain either a single album or many, in which case the leaf directories will be considered albums (the latter case is true of typical Artist/Album organizations and many people’s “downloads” folders). The path can also be a single song or an archive. Beets supports \fIzip\fP and \fItar\fP archives out of the box. To extract \fIrar\fP files, install the \fI\%rarfile\fP package and the \fIunrar\fP command. .sp Optional command flags: .INDENT 0.0 .IP \(bu 2 By default, the command copies files your the library directory and updates the ID3 tags on your music. In order to move the files, instead of copying, use the \fB\-m\fP (move) option. If you’d like to leave your music files untouched, try the \fB\-C\fP (don’t copy) and \fB\-W\fP (don’t write tags) options. You can also disable this behavior by default in the configuration file (below). .IP \(bu 2 Also, you can disable the autotagging behavior entirely using \fB\-A\fP (don’t autotag)—then your music will be imported with its existing metadata. .IP \(bu 2 During a long tagging import, it can be useful to keep track of albums that weren’t tagged successfully—either because they’re not in the MusicBrainz database or because something’s wrong with the files. Use the \fB\-l\fP option to specify a filename to log every time you skip an album or import it “as\-is” or an album gets skipped as a duplicate. .IP \(bu 2 Relatedly, the \fB\-q\fP (quiet) option can help with large imports by autotagging without ever bothering to ask for user input. Whenever the normal autotagger mode would ask for confirmation, the quiet mode pessimistically skips the album. The quiet mode also disables the tagger’s ability to resume interrupted imports. .IP \(bu 2 Speaking of resuming interrupted imports, the tagger will prompt you if it seems like the last import of the directory was interrupted (by you or by a crash). If you want to skip this prompt, you can say “yes” automatically by providing \fB\-p\fP or “no” using \fB\-P\fP\&. The resuming feature can be disabled by default using a configuration option (see below). .IP \(bu 2 If you want to import only the \fInew\fP stuff from a directory, use the \fB\-i\fP option to run an \fIincremental\fP import. With this flag, beets will keep track of every directory it ever imports and avoid importing them again. This is useful if you have an “incoming” directory that you periodically add things to. To get this to work correctly, you’ll need to use an incremental import \fIevery time\fP you run an import on the directory in question—including the first time, when no subdirectories will be skipped. So consider enabling the \fBincremental\fP configuration option. .IP \(bu 2 When beets applies metadata to your music, it will retain the value of any existing tags that weren’t overwritten, and import them into the database. You may prefer to only use existing metadata for finding matches, and to erase it completely when new metadata is applied. You can enforce this behavior with the \fB\-\-from\-scratch\fP option, or the \fBfrom_scratch\fP configuration option. .IP \(bu 2 By default, beets will proceed without asking if it finds a very close metadata match. To disable this and have the importer ask you every time, use the \fB\-t\fP (for \fItimid\fP) option. .IP \(bu 2 The importer typically works in a whole\-album\-at\-a\-time mode. If you instead want to import individual, non\-album tracks, use the \fIsingleton\fP mode by supplying the \fB\-s\fP option. .IP \(bu 2 If you have an album that’s split across several directories under a common top directory, use the \fB\-\-flat\fP option. This takes all the music files under the directory (recursively) and treats them as a single large album instead of as one album per directory. This can help with your more stubborn multi\-disc albums. .IP \(bu 2 Similarly, if you have one directory that contains multiple albums, use the \fB\-\-group\-albums\fP option to split the files based on their metadata before matching them as separate albums. .IP \(bu 2 If you want to preview which files would be imported, use the \fB\-\-pretend\fP option. If set, beets will just print a list of files that it would otherwise import. .IP \(bu 2 If you already have a metadata backend ID that matches the items to be imported, you can instruct beets to restrict the search to that ID instead of searching for other candidates by using the \fB\-\-search\-id SEARCH_ID\fP option. Multiple IDs can be specified by simply repeating the option several times. .IP \(bu 2 You can supply \fB\-\-set field=value\fP to assign \fIfield\fP to \fIvalue\fP on import. These assignments will merge with (and possibly override) the set_fields configuration dictionary. You can use the option multiple times on the command line, like so: .INDENT 2.0 .INDENT 3.5 .sp .nf .ft C beet import \-\-set genre="Alternative Rock" \-\-set mood="emotional" .ft P .fi .UNINDENT .UNINDENT .UNINDENT .SS list .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet list [\-apf] QUERY .ft P .fi .UNINDENT .UNINDENT .sp Queries the database for music. .sp Want to search for “Gronlandic Edit” by of Montreal? Try \fBbeet list gronlandic\fP\&. Maybe you want to see everything released in 2009 with “vegetables” in the title? Try \fBbeet list year:2009 title:vegetables\fP\&. You can also specify the sort order. (Read more in query\&.) .sp You can use the \fB\-a\fP switch to search for albums instead of individual items. In this case, the queries you use are restricted to album\-level fields: for example, you can search for \fByear:1969\fP but query parts for item\-level fields like \fBtitle:foo\fP will be ignored. Remember that \fBartist\fP is an item\-level field; \fBalbumartist\fP is the corresponding album field. .sp The \fB\-p\fP option makes beets print out filenames of matched items, which might be useful for piping into other Unix commands (such as \fI\%xargs\fP). Similarly, the \fB\-f\fP option lets you specify a specific format with which to print every album or track. This uses the same template syntax as beets’ path formats\&. For example, the command \fBbeet ls \-af \(aq$album: $tracktotal\(aq beatles\fP prints out the number of tracks on each Beatles album. In Unix shells, remember to enclose the template argument in single quotes to avoid environment variable expansion. .SS remove .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet remove [\-adf] QUERY .ft P .fi .UNINDENT .UNINDENT .sp Remove music from your library. .sp This command uses the same query syntax as the \fBlist\fP command. You’ll be shown a list of the files that will be removed and asked to confirm. By default, this just removes entries from the library database; it doesn’t touch the files on disk. To actually delete the files, use \fBbeet remove \-d\fP\&. If you do not want to be prompted to remove the files, use \fBbeet remove \-f\fP\&. .SS modify .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet modify [\-MWay] [\-f FORMAT] QUERY [FIELD=VALUE...] [FIELD!...] .ft P .fi .UNINDENT .UNINDENT .sp Change the metadata for items or albums in the database. .sp Supply a query matching the things you want to change and a series of \fBfield=value\fP pairs. For example, \fBbeet modify genius of love artist="Tom Tom Club"\fP will change the artist for the track “Genius of Love.” To remove fields (which is only possible for flexible attributes), follow a field name with an exclamation point: \fBfield!\fP\&. .sp The \fB\-a\fP switch operates on albums instead of individual tracks. Without this flag, the command will only change \fItrack\-level\fP data, even if all the tracks belong to the same album. If you want to change an \fIalbum\-level\fP field, such as \fByear\fP or \fBalbumartist\fP, you’ll want to use the \fB\-a\fP flag to avoid a confusing situation where the data for individual tracks conflicts with the data for the whole album. .sp Items will automatically be moved around when necessary if they’re in your library directory, but you can disable that with \fB\-M\fP\&. Tags will be written to the files according to the settings you have for imports, but these can be overridden with \fB\-w\fP (write tags, the default) and \fB\-W\fP (don’t write tags). .sp When you run the \fBmodify\fP command, it prints a list of all affected items in the library and asks for your permission before making any changes. You can then choose to abort the change (type \fIn\fP), confirm (\fIy\fP), or interactively choose some of the items (\fIs\fP). In the latter case, the command will prompt you for every matching item or album and invite you to type \fIy\fP or \fIn\fP\&. This option lets you choose precisely which data to change without spending too much time to carefully craft a query. To skip the prompts entirely, use the \fB\-y\fP option. .SS move .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet move [\-capt] [\-d DIR] QUERY .ft P .fi .UNINDENT .UNINDENT .sp Move or copy items in your library. .sp This command, by default, acts as a library consolidator: items matching the query are renamed into your library directory structure. By specifying a destination directory with \fB\-d\fP manually, you can move items matching a query anywhere in your filesystem. The \fB\-c\fP option copies files instead of moving them. As with other commands, the \fB\-a\fP option matches albums instead of items. The \fB\-e\fP flag (for “export”) copies files without changing the database. .sp To perform a “dry run”, just use the \fB\-p\fP (for “pretend”) flag. This will show you a list of files that would be moved but won’t actually change anything on disk. The \fB\-t\fP option sets the timid mode which will ask again before really moving or copying the files. .SS update .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet update [\-F] FIELD [\-aM] QUERY .ft P .fi .UNINDENT .UNINDENT .sp Update the library (and, optionally, move files) to reflect out\-of\-band metadata changes and file deletions. .sp This will scan all the matched files and read their tags, populating the database with the new values. By default, files will be renamed according to their new metadata; disable this with \fB\-M\fP\&. Beets will skip files if their modification times have not changed, so any out\-of\-band metadata changes must also update these for \fBbeet update\fP to recognise that the files have been edited. .sp To perform a “dry run” of an update, just use the \fB\-p\fP (for “pretend”) flag. This will show you all the proposed changes but won’t actually change anything on disk. .sp By default, all the changed metadata will be populated back to the database. If you only want certain fields to be written, specify them with the \fB\(ga\-F\(ga\fP flags (which can be used multiple times). For the list of supported fields, please see \fB\(gabeet fields\(ga\fP\&. .sp When an updated track is part of an album, the album\-level fields of \fIall\fP tracks from the album are also updated. (Specifically, the command copies album\-level data from the first track on the album and applies it to the rest of the tracks.) This means that, if album\-level fields aren’t identical within an album, some changes shown by the \fBupdate\fP command may be overridden by data from other tracks on the same album. This means that running the \fBupdate\fP command multiple times may show the same changes being applied. .SS write .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet write [\-pf] [QUERY] .ft P .fi .UNINDENT .UNINDENT .sp Write metadata from the database into files’ tags. .sp When you make changes to the metadata stored in beets’ library database (during import or with the \fI\%modify\fP command, for example), you often have the option of storing changes only in the database, leaving your files untouched. The \fBwrite\fP command lets you later change your mind and write the contents of the database into the files. By default, this writes the changes only if there is a difference between the database and the tags in the file. .sp You can think of this command as the opposite of \fI\%update\fP\&. .sp The \fB\-p\fP option previews metadata changes without actually applying them. .sp The \fB\-f\fP option forces a write to the file, even if the file tags match the database. This is useful for making sure that enabled plugins that run on write (e.g., the Scrub and Zero plugins) are run on the file. .SS stats .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet stats [\-e] [QUERY] .ft P .fi .UNINDENT .UNINDENT .sp Show some statistics on your entire library (if you don’t provide a query) or the matched items (if you do). .sp By default, the command calculates file sizes using their bitrate and duration. The \fB\-e\fP (\fB\-\-exact\fP) option reads the exact sizes of each file (but is slower). The exact mode also outputs the exact duration in seconds. .SS fields .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet fields .ft P .fi .UNINDENT .UNINDENT .sp Show the item and album metadata fields available for use in query and pathformat\&. The listing includes any template fields provided by plugins and any flexible attributes you’ve manually assigned to your items and albums. .SS config .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet config [\-pdc] beet config \-e .ft P .fi .UNINDENT .UNINDENT .sp Show or edit the user configuration. This command does one of three things: .INDENT 0.0 .IP \(bu 2 With no options, print a YAML representation of the current user configuration. With the \fB\-\-default\fP option, beets’ default options are also included in the dump. .IP \(bu 2 The \fB\-\-path\fP option instead shows the path to your configuration file. This can be combined with the \fB\-\-default\fP flag to show where beets keeps its internal defaults. .IP \(bu 2 By default, sensitive information like passwords is removed when dumping the configuration. The \fB\-\-clear\fP option includes this sensitive data. .IP \(bu 2 With the \fB\-\-edit\fP option, beets attempts to open your config file for editing. It first tries the \fB$EDITOR\fP environment variable and then a fallback option depending on your platform: \fBopen\fP on OS X, \fBxdg\-open\fP on Unix, and direct invocation on Windows. .UNINDENT .SH GLOBAL FLAGS .sp Beets has a few “global” flags that affect all commands. These must appear between the executable name (\fBbeet\fP) and the command—for example, \fBbeet \-v import ...\fP\&. .INDENT 0.0 .IP \(bu 2 \fB\-l LIBPATH\fP: specify the library database file to use. .IP \(bu 2 \fB\-d DIRECTORY\fP: specify the library root directory. .IP \(bu 2 \fB\-v\fP: verbose mode; prints out a deluge of debugging information. Please use this flag when reporting bugs. You can use it twice, as in \fB\-vv\fP, to make beets even more verbose. .IP \(bu 2 \fB\-c FILE\fP: read a specified YAML configuration file\&. This configuration works as an overlay: rather than replacing your normal configuration options entirely, the two are merged. Any individual options set in this config file will override the corresponding settings in your base configuration. .UNINDENT .sp Beets also uses the \fBBEETSDIR\fP environment variable to look for configuration and data. .SH SHELL COMPLETION .sp Beets includes support for shell command completion. The command \fBbeet completion\fP prints out a \fI\%bash\fP 3.2 script; to enable completion put a line like this into your \fB\&.bashrc\fP or similar file: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C eval "$(beet completion)" .ft P .fi .UNINDENT .UNINDENT .sp Or, to avoid slowing down your shell startup time, you can pipe the \fBbeet completion\fP output to a file and source that instead. .sp You will also need to source the \fI\%bash\-completion\fP script, which is probably available via your package manager. On OS X, you can install it via Homebrew with \fBbrew install bash\-completion\fP; Homebrew will give you instructions for sourcing the script. .sp The completion script suggests names of subcommands and (after typing \fB\-\fP) options of the given command. If you are using a command that accepts a query, the script will also complete field names. .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C beet list ar[TAB] # artist: artist_credit: artist_sort: artpath: beet list artp[TAB] beet list artpath\e: .ft P .fi .UNINDENT .UNINDENT .sp (Don’t worry about the slash in front of the colon: this is a escape sequence for the shell and won’t be seen by beets.) .sp Completion of plugin commands only works for those plugins that were enabled when running \fBbeet completion\fP\&. If you add a plugin later on you will want to re\-generate the script. .SS zsh .sp If you use zsh, take a look at the included \fI\%completion script\fP\&. The script should be placed in a directory that is part of your \fBfpath\fP, and \fInot\fP sourced in your \fB\&.zshrc\fP\&. Running \fBecho $fpath\fP will give you a list of valid directories. .sp Another approach is to use zsh’s bash completion compatibility. This snippet defines some bash\-specific functions to make this work without errors: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C autoload bashcompinit bashcompinit _get_comp_words_by_ref() { :; } compopt() { :; } _filedir() { :; } eval "$(beet completion)" .ft P .fi .UNINDENT .UNINDENT .SH SEE ALSO .sp \fBhttp://beets.readthedocs.org/\fP .sp \fBbeetsconfig(5)\fP .SH AUTHOR Adrian Sampson .SH COPYRIGHT 2016, Adrian Sampson .\" Generated by docutils manpage writer. . beets-1.4.6/PKG-INFO0000644000076500000240000001271513216774613014705 0ustar asampsonstaff00000000000000Metadata-Version: 1.1 Name: beets Version: 1.4.6 Summary: music tagger and library organizer Home-page: http://beets.io/ Author: Adrian Sampson Author-email: adrian@radbox.org License: MIT Description-Content-Type: UNKNOWN Description: .. image:: http://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets .. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://codecov.io/github/beetbox/beets .. image:: https://travis-ci.org/beetbox/beets.svg?branch=master :target: https://travis-ci.org/beetbox/beets Beets is the media library management system for obsessive-compulsive music geeks. The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It then provides a bouquet of tools for manipulating and accessing your music. Here's an example of beets' brainy tag corrector doing its thing:: $ beet import ~/music/ladytron Tagging: Ladytron - Witching Hour (Similarity: 98.4%) * Last One Standing -> The Last One Standing * Beauty -> Beauty*2 * White Light Generation -> Whitelightgenerator * All the Way -> All the Way... Because beets is designed as a library, it can do almost anything you can imagine for your music collection. Via `plugins`_, beets becomes a panacea: - Fetch or calculate all the metadata you could possibly need: `album art`_, `lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic fingerprints`_. - Get metadata from `MusicBrainz`_, `Discogs`_, and `Beatport`_. Or guess metadata using songs' filenames or their acoustic fingerprints. - `Transcode audio`_ to any format you like. - Check your library for `duplicate tracks and albums`_ or for `albums that are missing tracks`_. - Clean up crufty tags left behind by other, less-awesome tools. - Embed and extract album art from files' metadata. - Browse your music library graphically through a Web browser and play it in any browser that supports `HTML5 Audio`_. - Analyze music files' metadata from the command line. - Listen to your library with a music player that speaks the `MPD`_ protocol and works with a staggering variety of interfaces. If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. .. _plugins: http://beets.readthedocs.org/page/plugins/ .. _MPD: http://www.musicpd.org/ .. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/ .. _writing your own plugin: http://beets.readthedocs.org/page/dev/plugins.html .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html .. _albums that are missing tracks: http://beets.readthedocs.org/page/plugins/missing.html .. _duplicate tracks and albums: http://beets.readthedocs.org/page/plugins/duplicates.html .. _Transcode audio: http://beets.readthedocs.org/page/plugins/convert.html .. _Discogs: http://www.discogs.com/ .. _acoustic fingerprints: http://beets.readthedocs.org/page/plugins/chroma.html .. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html .. _tempos: http://beets.readthedocs.org/page/plugins/acousticbrainz.html .. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html .. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html .. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html .. _MusicBrainz: http://musicbrainz.org/ .. _Beatport: https://www.beatport.com Read More --------- Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for news and updates. You can install beets by typing ``pip install beets``. Then check out the `Getting Started`_ guide. .. _its Web site: http://beets.io/ .. _Getting Started: http://beets.readthedocs.org/page/guides/main.html .. _@b33ts: http://twitter.com/b33ts/ Authors ------- Beets is by `Adrian Sampson`_ with a supporting cast of thousands. For help, please contact the `mailing list`_. .. _mailing list: https://groups.google.com/forum/#!forum/beets-users .. _Adrian Sampson: http://homes.cs.washington.edu/~asampson/ Platform: ALL Classifier: Topic :: Multimedia :: Sound/Audio Classifier: Topic :: Multimedia :: Sound/Audio :: Players :: MP3 Classifier: License :: OSI Approved :: MIT License Classifier: Environment :: Console Classifier: Environment :: Web Environment Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: Implementation :: CPython beets-1.4.6/LICENSE0000644000076500000240000000207013025125202014564 0ustar asampsonstaff00000000000000The MIT License Copyright (c) 2010-2016 Adrian Sampson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. beets-1.4.6/test/0000755000076500000240000000000013216774613014561 5ustar asampsonstaff00000000000000beets-1.4.6/test/test_ui_importer.py0000644000076500000240000001135313025125203020512 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests the TerminalImportSession. The tests are the same as in the test_importer module. But here the test importer inherits from ``TerminalImportSession``. So we test this class, too. """ from __future__ import division, absolute_import, print_function import unittest from test._common import DummyIO from test import test_importer from beets.ui.commands import TerminalImportSession from beets import importer from beets import config import six class TestTerminalImportSession(TerminalImportSession): def __init__(self, *args, **kwargs): self.io = kwargs.pop('io') super(TestTerminalImportSession, self).__init__(*args, **kwargs) self._choices = [] default_choice = importer.action.APPLY def add_choice(self, choice): self._choices.append(choice) def clear_choices(self): self._choices = [] def choose_match(self, task): self._add_choice_input() return super(TestTerminalImportSession, self).choose_match(task) def choose_item(self, task): self._add_choice_input() return super(TestTerminalImportSession, self).choose_item(task) def _add_choice_input(self): try: choice = self._choices.pop(0) except IndexError: choice = self.default_choice if choice == importer.action.APPLY: self.io.addinput(u'A') elif choice == importer.action.ASIS: self.io.addinput(u'U') elif choice == importer.action.ALBUMS: self.io.addinput(u'G') elif choice == importer.action.TRACKS: self.io.addinput(u'T') elif choice == importer.action.SKIP: self.io.addinput(u'S') elif isinstance(choice, int): self.io.addinput(u'M') self.io.addinput(six.text_type(choice)) self._add_choice_input() else: raise Exception(u'Unknown choice %s' % choice) class TerminalImportSessionSetup(object): """Overwrites test_importer.ImportHelper to provide a terminal importer """ def _setup_import_session(self, import_dir=None, delete=False, threaded=False, copy=True, singletons=False, move=False, autotag=True): config['import']['copy'] = copy config['import']['delete'] = delete config['import']['timid'] = True config['threaded'] = False config['import']['singletons'] = singletons config['import']['move'] = move config['import']['autotag'] = autotag config['import']['resume'] = False if not hasattr(self, 'io'): self.io = DummyIO() self.io.install() self.importer = TestTerminalImportSession( self.lib, loghandler=None, query=None, io=self.io, paths=[import_dir or self.import_dir], ) class NonAutotaggedImportTest(TerminalImportSessionSetup, test_importer.NonAutotaggedImportTest): pass class ImportTest(TerminalImportSessionSetup, test_importer.ImportTest): pass class ImportSingletonTest(TerminalImportSessionSetup, test_importer.ImportSingletonTest): pass class ImportTracksTest(TerminalImportSessionSetup, test_importer.ImportTracksTest): pass class ImportCompilationTest(TerminalImportSessionSetup, test_importer.ImportCompilationTest): pass class ImportExistingTest(TerminalImportSessionSetup, test_importer.ImportExistingTest): pass class ChooseCandidateTest(TerminalImportSessionSetup, test_importer.ChooseCandidateTest): pass class GroupAlbumsImportTest(TerminalImportSessionSetup, test_importer.GroupAlbumsImportTest): pass class GlobalGroupAlbumsImportTest(TerminalImportSessionSetup, test_importer.GlobalGroupAlbumsImportTest): pass def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_discogs.py0000644000076500000240000003507313032602010017606 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for discogs plugin. """ from __future__ import division, absolute_import, print_function import unittest from test import _common from test._common import Bag from test.helper import capture_log from beetsplug.discogs import DiscogsPlugin class DGAlbumInfoTest(_common.TestCase): def _make_release(self, tracks=None): """Returns a Bag that mimics a discogs_client.Release. The list of elements on the returned Bag is incomplete, including just those required for the tests on this class.""" data = { 'id': 'ALBUM ID', 'uri': 'ALBUM URI', 'title': 'ALBUM TITLE', 'year': '3001', 'artists': [{ 'name': 'ARTIST NAME', 'id': 'ARTIST ID', 'join': ',' }], 'formats': [{ 'descriptions': ['FORMAT DESC 1', 'FORMAT DESC 2'], 'name': 'FORMAT', 'qty': 1 }], 'labels': [{ 'name': 'LABEL NAME', 'catno': 'CATALOG NUMBER', }], 'tracklist': [] } if tracks: for recording in tracks: data['tracklist'].append(recording) return Bag(data=data, # Make some fields available as properties, as they are # accessed by DiscogsPlugin methods. title=data['title'], artists=[Bag(data=d) for d in data['artists']]) def _make_track(self, title, position='', duration='', type_=None): track = { 'title': title, 'position': position, 'duration': duration } if type_ is not None: # Test samples on discogs_client do not have a 'type_' field, but # the API seems to return it. Values: 'track' for regular tracks, # 'heading' for descriptive texts (ie. not real tracks - 12.13.2). track['type_'] = type_ return track def _make_release_from_positions(self, positions): """Return a Bag that mimics a discogs_client.Release with a tracklist where tracks have the specified `positions`.""" tracks = [self._make_track('TITLE%s' % i, position) for (i, position) in enumerate(positions, start=1)] return self._make_release(tracks) def test_parse_media_for_tracks(self): tracks = [self._make_track('TITLE ONE', '1', '01:01'), self._make_track('TITLE TWO', '2', '02:02')] release = self._make_release(tracks=tracks) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(d.media, 'FORMAT') self.assertEqual(t[0].media, d.media) self.assertEqual(t[1].media, d.media) def test_parse_medium_numbers_single_medium(self): release = self._make_release_from_positions(['1', '2']) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(d.mediums, 1) self.assertEqual(t[0].medium, 1) self.assertEqual(t[0].medium_total, 1) self.assertEqual(t[1].medium, 1) self.assertEqual(t[0].medium_total, 1) def test_parse_medium_numbers_two_mediums(self): release = self._make_release_from_positions(['1-1', '2-1']) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(d.mediums, 2) self.assertEqual(t[0].medium, 1) self.assertEqual(t[0].medium_total, 2) self.assertEqual(t[1].medium, 2) self.assertEqual(t[1].medium_total, 2) def test_parse_medium_numbers_two_mediums_two_sided(self): release = self._make_release_from_positions(['A1', 'B1', 'C1']) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(d.mediums, 2) self.assertEqual(t[0].medium, 1) self.assertEqual(t[0].medium_total, 2) self.assertEqual(t[0].medium_index, 1) self.assertEqual(t[1].medium, 1) self.assertEqual(t[1].medium_total, 2) self.assertEqual(t[1].medium_index, 2) self.assertEqual(t[2].medium, 2) self.assertEqual(t[2].medium_total, 2) self.assertEqual(t[2].medium_index, 1) def test_parse_track_indices(self): release = self._make_release_from_positions(['1', '2']) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(t[0].medium_index, 1) self.assertEqual(t[0].index, 1) self.assertEqual(t[0].medium_total, 1) self.assertEqual(t[1].medium_index, 2) self.assertEqual(t[1].index, 2) self.assertEqual(t[1].medium_total, 1) def test_parse_track_indices_several_media(self): release = self._make_release_from_positions(['1-1', '1-2', '2-1', '3-1']) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(d.mediums, 3) self.assertEqual(t[0].medium_index, 1) self.assertEqual(t[0].index, 1) self.assertEqual(t[0].medium_total, 3) self.assertEqual(t[1].medium_index, 2) self.assertEqual(t[1].index, 2) self.assertEqual(t[1].medium_total, 3) self.assertEqual(t[2].medium_index, 1) self.assertEqual(t[2].index, 3) self.assertEqual(t[2].medium_total, 3) self.assertEqual(t[3].medium_index, 1) self.assertEqual(t[3].index, 4) self.assertEqual(t[3].medium_total, 3) def test_parse_position(self): """Test the conversion of discogs `position` to medium, medium_index and subtrack_index.""" # List of tuples (discogs_position, (medium, medium_index, subindex) positions = [('1', (None, '1', None)), ('A12', ('A', '12', None)), ('12-34', ('12-', '34', None)), ('CD1-1', ('CD1-', '1', None)), ('1.12', (None, '1', '12')), ('12.a', (None, '12', 'A')), ('12.34', (None, '12', '34')), ('1ab', (None, '1', 'AB')), # Non-standard ('IV', ('IV', None, None)), ] d = DiscogsPlugin() for position, expected in positions: self.assertEqual(d.get_track_index(position), expected) def test_parse_tracklist_without_sides(self): """Test standard Discogs position 12.2.9#1: "without sides".""" release = self._make_release_from_positions(['1', '2', '3']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) def test_parse_tracklist_with_sides(self): """Test standard Discogs position 12.2.9#2: "with sides".""" release = self._make_release_from_positions(['A1', 'A2', 'B1', 'B2']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) # 2 sides = 1 LP self.assertEqual(len(d.tracks), 4) def test_parse_tracklist_multiple_lp(self): """Test standard Discogs position 12.2.9#3: "multiple LP".""" release = self._make_release_from_positions(['A1', 'A2', 'B1', 'C1']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 2) # 3 sides = 1 LP + 1 LP self.assertEqual(len(d.tracks), 4) def test_parse_tracklist_multiple_cd(self): """Test standard Discogs position 12.2.9#4: "multiple CDs".""" release = self._make_release_from_positions(['1-1', '1-2', '2-1', '3-1']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 3) self.assertEqual(len(d.tracks), 4) def test_parse_tracklist_non_standard(self): """Test non standard Discogs position.""" release = self._make_release_from_positions(['I', 'II', 'III', 'IV']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 4) def test_parse_tracklist_subtracks_dot(self): """Test standard Discogs position 12.2.9#5: "sub tracks, dots".""" release = self._make_release_from_positions(['1', '2.1', '2.2', '3']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) release = self._make_release_from_positions(['A1', 'A2.1', 'A2.2', 'A3']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) def test_parse_tracklist_subtracks_letter(self): """Test standard Discogs position 12.2.9#5: "sub tracks, letter".""" release = self._make_release_from_positions(['A1', 'A2a', 'A2b', 'A3']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) release = self._make_release_from_positions(['A1', 'A2.a', 'A2.b', 'A3']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) def test_parse_tracklist_subtracks_extra_material(self): """Test standard Discogs position 12.2.9#6: "extra material".""" release = self._make_release_from_positions(['1', '2', 'Video 1']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 2) self.assertEqual(len(d.tracks), 3) def test_parse_tracklist_subtracks_indices(self): """Test parsing of subtracks that include index tracks.""" release = self._make_release_from_positions(['', '', '1.1', '1.2']) # Track 1: Index track with medium title release.data['tracklist'][0]['title'] = 'MEDIUM TITLE' # Track 2: Index track with track group title release.data['tracklist'][1]['title'] = 'TRACK GROUP TITLE' d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(d.tracks[0].disctitle, 'MEDIUM TITLE') self.assertEqual(len(d.tracks), 1) self.assertEqual(d.tracks[0].title, 'TRACK GROUP TITLE') def test_parse_tracklist_subtracks_nested_logical(self): """Test parsing of subtracks defined inside a index track that are logical subtracks (ie. should be grouped together into a single track). """ release = self._make_release_from_positions(['1', '', '3']) # Track 2: Index track with track group title, and sub_tracks release.data['tracklist'][1]['title'] = 'TRACK GROUP TITLE' release.data['tracklist'][1]['sub_tracks'] = [ self._make_track('TITLE ONE', '2.1', '01:01'), self._make_track('TITLE TWO', '2.2', '02:02') ] d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) self.assertEqual(d.tracks[1].title, 'TRACK GROUP TITLE') def test_parse_tracklist_subtracks_nested_physical(self): """Test parsing of subtracks defined inside a index track that are physical subtracks (ie. should not be grouped together). """ release = self._make_release_from_positions(['1', '', '4']) # Track 2: Index track with track group title, and sub_tracks release.data['tracklist'][1]['title'] = 'TRACK GROUP TITLE' release.data['tracklist'][1]['sub_tracks'] = [ self._make_track('TITLE ONE', '2', '01:01'), self._make_track('TITLE TWO', '3', '02:02') ] d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 4) self.assertEqual(d.tracks[1].title, 'TITLE ONE') self.assertEqual(d.tracks[2].title, 'TITLE TWO') def test_parse_tracklist_disctitles(self): """Test parsing of index tracks that act as disc titles.""" release = self._make_release_from_positions(['', '1-1', '1-2', '', '2-1']) # Track 1: Index track with medium title (Cd1) release.data['tracklist'][0]['title'] = 'MEDIUM TITLE CD1' # Track 4: Index track with medium title (Cd2) release.data['tracklist'][3]['title'] = 'MEDIUM TITLE CD2' d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 2) self.assertEqual(d.tracks[0].disctitle, 'MEDIUM TITLE CD1') self.assertEqual(d.tracks[1].disctitle, 'MEDIUM TITLE CD1') self.assertEqual(d.tracks[2].disctitle, 'MEDIUM TITLE CD2') self.assertEqual(len(d.tracks), 3) def test_parse_minimal_release(self): """Test parsing of a release with the minimal amount of information.""" data = {'id': 123, 'tracklist': [self._make_track('A', '1', '01:01')], 'artists': [{'name': 'ARTIST NAME', 'id': 321, 'join': ''}], 'title': 'TITLE'} release = Bag(data=data, title=data['title'], artists=[Bag(data=d) for d in data['artists']]) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.artist, 'ARTIST NAME') self.assertEqual(d.album, 'TITLE') self.assertEqual(len(d.tracks), 1) def test_parse_release_without_required_fields(self): """Test parsing of a release that does not have the required fields.""" release = Bag(data={}, refresh=lambda *args: None) with capture_log() as logs: d = DiscogsPlugin().get_album_info(release) self.assertEqual(d, None) self.assertIn('Release does not contain the required fields', logs[0]) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_library.py0000644000076500000240000012661413040564606017642 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for non-query database functions of Item. """ from __future__ import division, absolute_import, print_function import os import os.path import stat import shutil import re import unicodedata import sys import time import unittest from test import _common from test._common import item import beets.library import beets.mediafile import beets.dbcore.query from beets import util from beets import plugins from beets import config from beets.mediafile import MediaFile from beets.util import syspath, bytestring_path from test.helper import TestHelper import six # Shortcut to path normalization. np = util.normpath class LoadTest(_common.LibTestCase): def test_load_restores_data_from_db(self): original_title = self.i.title self.i.title = u'something' self.i.load() self.assertEqual(original_title, self.i.title) def test_load_clears_dirty_flags(self): self.i.artist = u'something' self.assertTrue('artist' in self.i._dirty) self.i.load() self.assertTrue('artist' not in self.i._dirty) class StoreTest(_common.LibTestCase): def test_store_changes_database_value(self): self.i.year = 1987 self.i.store() new_year = self.lib._connection().execute( 'select year from items where ' 'title="the title"').fetchone()['year'] self.assertEqual(new_year, 1987) def test_store_only_writes_dirty_fields(self): original_genre = self.i.genre self.i._values_fixed['genre'] = u'beatboxing' # change w/o dirtying self.i.store() new_genre = self.lib._connection().execute( 'select genre from items where ' 'title="the title"').fetchone()['genre'] self.assertEqual(new_genre, original_genre) def test_store_clears_dirty_flags(self): self.i.composer = u'tvp' self.i.store() self.assertTrue('composer' not in self.i._dirty) class AddTest(_common.TestCase): def setUp(self): super(AddTest, self).setUp() self.lib = beets.library.Library(':memory:') self.i = item() def test_item_add_inserts_row(self): self.lib.add(self.i) new_grouping = self.lib._connection().execute( 'select grouping from items ' 'where composer="the composer"').fetchone()['grouping'] self.assertEqual(new_grouping, self.i.grouping) def test_library_add_path_inserts_row(self): i = beets.library.Item.from_path( os.path.join(_common.RSRC, b'full.mp3') ) self.lib.add(i) new_grouping = self.lib._connection().execute( 'select grouping from items ' 'where composer="the composer"').fetchone()['grouping'] self.assertEqual(new_grouping, self.i.grouping) class RemoveTest(_common.LibTestCase): def test_remove_deletes_from_db(self): self.i.remove() c = self.lib._connection().execute('select * from items') self.assertEqual(c.fetchone(), None) class GetSetTest(_common.TestCase): def setUp(self): super(GetSetTest, self).setUp() self.i = item() def test_set_changes_value(self): self.i.bpm = 4915 self.assertEqual(self.i.bpm, 4915) def test_set_sets_dirty_flag(self): self.i.comp = not self.i.comp self.assertTrue('comp' in self.i._dirty) def test_set_does_not_dirty_if_value_unchanged(self): self.i.title = self.i.title self.assertTrue('title' not in self.i._dirty) def test_invalid_field_raises_attributeerror(self): self.assertRaises(AttributeError, getattr, self.i, u'xyzzy') class DestinationTest(_common.TestCase): def setUp(self): super(DestinationTest, self).setUp() self.lib = beets.library.Library(':memory:') self.i = item(self.lib) def tearDown(self): super(DestinationTest, self).tearDown() self.lib._connection().close() # Reset config if it was changed in test cases config.clear() config.read(user=False, defaults=True) def test_directory_works_with_trailing_slash(self): self.lib.directory = b'one/' self.lib.path_formats = [(u'default', u'two')] self.assertEqual(self.i.destination(), np('one/two')) def test_directory_works_without_trailing_slash(self): self.lib.directory = b'one' self.lib.path_formats = [(u'default', u'two')] self.assertEqual(self.i.destination(), np('one/two')) def test_destination_substitues_metadata_values(self): self.lib.directory = b'base' self.lib.path_formats = [(u'default', u'$album/$artist $title')] self.i.title = 'three' self.i.artist = 'two' self.i.album = 'one' self.assertEqual(self.i.destination(), np('base/one/two three')) def test_destination_preserves_extension(self): self.lib.directory = b'base' self.lib.path_formats = [(u'default', u'$title')] self.i.path = 'hey.audioformat' self.assertEqual(self.i.destination(), np('base/the title.audioformat')) def test_lower_case_extension(self): self.lib.directory = b'base' self.lib.path_formats = [(u'default', u'$title')] self.i.path = 'hey.MP3' self.assertEqual(self.i.destination(), np('base/the title.mp3')) def test_destination_pads_some_indices(self): self.lib.directory = b'base' self.lib.path_formats = [(u'default', u'$track $tracktotal $disc $disctotal $bpm')] self.i.track = 1 self.i.tracktotal = 2 self.i.disc = 3 self.i.disctotal = 4 self.i.bpm = 5 self.assertEqual(self.i.destination(), np('base/01 02 03 04 5')) def test_destination_pads_date_values(self): self.lib.directory = b'base' self.lib.path_formats = [(u'default', u'$year-$month-$day')] self.i.year = 1 self.i.month = 2 self.i.day = 3 self.assertEqual(self.i.destination(), np('base/0001-02-03')) def test_destination_escapes_slashes(self): self.i.album = 'one/two' dest = self.i.destination() self.assertTrue(b'one' in dest) self.assertTrue(b'two' in dest) self.assertFalse(b'one/two' in dest) def test_destination_escapes_leading_dot(self): self.i.album = '.something' dest = self.i.destination() self.assertTrue(b'something' in dest) self.assertFalse(b'/.' in dest) def test_destination_preserves_legitimate_slashes(self): self.i.artist = 'one' self.i.album = 'two' dest = self.i.destination() self.assertTrue(os.path.join(b'one', b'two') in dest) def test_destination_long_names_truncated(self): self.i.title = u'X' * 300 self.i.artist = u'Y' * 300 for c in self.i.destination().split(util.PATH_SEP): self.assertTrue(len(c) <= 255) def test_destination_long_names_keep_extension(self): self.i.title = u'X' * 300 self.i.path = b'something.extn' dest = self.i.destination() self.assertEqual(dest[-5:], b'.extn') def test_distination_windows_removes_both_separators(self): self.i.title = 'one \\ two / three.mp3' with _common.platform_windows(): p = self.i.destination() self.assertFalse(b'one \\ two' in p) self.assertFalse(b'one / two' in p) self.assertFalse(b'two \\ three' in p) self.assertFalse(b'two / three' in p) def test_path_with_format(self): self.lib.path_formats = [(u'default', u'$artist/$album ($format)')] p = self.i.destination() self.assertTrue(b'(FLAC)' in p) def test_heterogeneous_album_gets_single_directory(self): i1, i2 = item(), item() self.lib.add_album([i1, i2]) i1.year, i2.year = 2009, 2010 self.lib.path_formats = [(u'default', u'$album ($year)/$track $title')] dest1, dest2 = i1.destination(), i2.destination() self.assertEqual(os.path.dirname(dest1), os.path.dirname(dest2)) def test_default_path_for_non_compilations(self): self.i.comp = False self.lib.add_album([self.i]) self.lib.directory = b'one' self.lib.path_formats = [(u'default', u'two'), (u'comp:true', u'three')] self.assertEqual(self.i.destination(), np('one/two')) def test_singleton_path(self): i = item(self.lib) self.lib.directory = b'one' self.lib.path_formats = [ (u'default', u'two'), (u'singleton:true', u'four'), (u'comp:true', u'three'), ] self.assertEqual(i.destination(), np('one/four')) def test_comp_before_singleton_path(self): i = item(self.lib) i.comp = True self.lib.directory = b'one' self.lib.path_formats = [ (u'default', u'two'), (u'comp:true', u'three'), (u'singleton:true', u'four'), ] self.assertEqual(i.destination(), np('one/three')) def test_comp_path(self): self.i.comp = True self.lib.add_album([self.i]) self.lib.directory = b'one' self.lib.path_formats = [ (u'default', u'two'), (u'comp:true', u'three'), ] self.assertEqual(self.i.destination(), np('one/three')) def test_albumtype_query_path(self): self.i.comp = True self.lib.add_album([self.i]) self.i.albumtype = u'sometype' self.lib.directory = b'one' self.lib.path_formats = [ (u'default', u'two'), (u'albumtype:sometype', u'four'), (u'comp:true', u'three'), ] self.assertEqual(self.i.destination(), np('one/four')) def test_albumtype_path_fallback_to_comp(self): self.i.comp = True self.lib.add_album([self.i]) self.i.albumtype = u'sometype' self.lib.directory = b'one' self.lib.path_formats = [ (u'default', u'two'), (u'albumtype:anothertype', u'four'), (u'comp:true', u'three'), ] self.assertEqual(self.i.destination(), np('one/three')) def test_get_formatted_does_not_replace_separators(self): with _common.platform_posix(): name = os.path.join('a', 'b') self.i.title = name newname = self.i.formatted().get('title') self.assertEqual(name, newname) def test_get_formatted_pads_with_zero(self): with _common.platform_posix(): self.i.track = 1 name = self.i.formatted().get('track') self.assertTrue(name.startswith('0')) def test_get_formatted_uses_kbps_bitrate(self): with _common.platform_posix(): self.i.bitrate = 12345 val = self.i.formatted().get('bitrate') self.assertEqual(val, u'12kbps') def test_get_formatted_uses_khz_samplerate(self): with _common.platform_posix(): self.i.samplerate = 12345 val = self.i.formatted().get('samplerate') self.assertEqual(val, u'12kHz') def test_get_formatted_datetime(self): with _common.platform_posix(): self.i.added = 1368302461.210265 val = self.i.formatted().get('added') self.assertTrue(val.startswith('2013')) def test_get_formatted_none(self): with _common.platform_posix(): self.i.some_other_field = None val = self.i.formatted().get('some_other_field') self.assertEqual(val, u'') def test_artist_falls_back_to_albumartist(self): self.i.artist = u'' self.i.albumartist = u'something' self.lib.path_formats = [(u'default', u'$artist')] p = self.i.destination() self.assertEqual(p.rsplit(util.PATH_SEP, 1)[1], b'something') def test_albumartist_falls_back_to_artist(self): self.i.artist = u'trackartist' self.i.albumartist = u'' self.lib.path_formats = [(u'default', u'$albumartist')] p = self.i.destination() self.assertEqual(p.rsplit(util.PATH_SEP, 1)[1], b'trackartist') def test_artist_overrides_albumartist(self): self.i.artist = u'theartist' self.i.albumartist = u'something' self.lib.path_formats = [(u'default', u'$artist')] p = self.i.destination() self.assertEqual(p.rsplit(util.PATH_SEP, 1)[1], b'theartist') def test_albumartist_overrides_artist(self): self.i.artist = u'theartist' self.i.albumartist = u'something' self.lib.path_formats = [(u'default', u'$albumartist')] p = self.i.destination() self.assertEqual(p.rsplit(util.PATH_SEP, 1)[1], b'something') def test_unicode_normalized_nfd_on_mac(self): instr = unicodedata.normalize('NFC', u'caf\xe9') self.lib.path_formats = [(u'default', instr)] dest = self.i.destination(platform='darwin', fragment=True) self.assertEqual(dest, unicodedata.normalize('NFD', instr)) def test_unicode_normalized_nfc_on_linux(self): instr = unicodedata.normalize('NFD', u'caf\xe9') self.lib.path_formats = [(u'default', instr)] dest = self.i.destination(platform='linux', fragment=True) self.assertEqual(dest, unicodedata.normalize('NFC', instr)) def test_non_mbcs_characters_on_windows(self): oldfunc = sys.getfilesystemencoding sys.getfilesystemencoding = lambda: 'mbcs' try: self.i.title = u'h\u0259d' self.lib.path_formats = [(u'default', u'$title')] p = self.i.destination() self.assertFalse(b'?' in p) # We use UTF-8 to encode Windows paths now. self.assertTrue(u'h\u0259d'.encode('utf-8') in p) finally: sys.getfilesystemencoding = oldfunc def test_unicode_extension_in_fragment(self): self.lib.path_formats = [(u'default', u'foo')] self.i.path = util.bytestring_path(u'bar.caf\xe9') dest = self.i.destination(platform='linux', fragment=True) self.assertEqual(dest, u'foo.caf\xe9') def test_asciify_and_replace(self): config['asciify_paths'] = True self.lib.replacements = [(re.compile(u'"'), u'q')] self.lib.directory = b'lib' self.lib.path_formats = [(u'default', u'$title')] self.i.title = u'\u201c\u00f6\u2014\u00cf\u201d' self.assertEqual(self.i.destination(), np('lib/qo--Iq')) def test_asciify_character_expanding_to_slash(self): config['asciify_paths'] = True self.lib.directory = b'lib' self.lib.path_formats = [(u'default', u'$title')] self.i.title = u'ab\xa2\xbdd' self.assertEqual(self.i.destination(), np('lib/abC_1_2d')) def test_destination_with_replacements(self): self.lib.directory = b'base' self.lib.replacements = [(re.compile(r'a'), u'e')] self.lib.path_formats = [(u'default', u'$album/$title')] self.i.title = u'foo' self.i.album = u'bar' self.assertEqual(self.i.destination(), np('base/ber/foo')) @unittest.skip('unimplemented: #359') def test_destination_with_empty_component(self): self.lib.directory = b'base' self.lib.replacements = [(re.compile(r'^$'), u'_')] self.lib.path_formats = [(u'default', u'$album/$artist/$title')] self.i.title = u'three' self.i.artist = u'' self.i.albumartist = u'' self.i.album = u'one' self.assertEqual(self.i.destination(), np('base/one/_/three')) @unittest.skip('unimplemented: #359') def test_destination_with_empty_final_component(self): self.lib.directory = b'base' self.lib.replacements = [(re.compile(r'^$'), u'_')] self.lib.path_formats = [(u'default', u'$album/$title')] self.i.title = u'' self.i.album = u'one' self.i.path = 'foo.mp3' self.assertEqual(self.i.destination(), np('base/one/_.mp3')) def test_legalize_path_one_for_one_replacement(self): # Use a replacement that should always replace the last X in any # path component with a Z. self.lib.replacements = [ (re.compile(r'X$'), u'Z'), ] # Construct an item whose untruncated path ends with a Y but whose # truncated version ends with an X. self.i.title = u'X' * 300 + u'Y' # The final path should reflect the replacement. dest = self.i.destination() self.assertEqual(dest[-2:], b'XZ') def test_legalize_path_one_for_many_replacement(self): # Use a replacement that should always replace the last X in any # path component with four Zs. self.lib.replacements = [ (re.compile(r'X$'), u'ZZZZ'), ] # Construct an item whose untruncated path ends with a Y but whose # truncated version ends with an X. self.i.title = u'X' * 300 + u'Y' # The final path should ignore the user replacement and create a path # of the correct length, containing Xs. dest = self.i.destination() self.assertEqual(dest[-2:], b'XX') class ItemFormattedMappingTest(_common.LibTestCase): def test_formatted_item_value(self): formatted = self.i.formatted() self.assertEqual(formatted['artist'], u'the artist') def test_get_unset_field(self): formatted = self.i.formatted() with self.assertRaises(KeyError): formatted['other_field'] def test_get_method_with_default(self): formatted = self.i.formatted() self.assertEqual(formatted.get('other_field'), u'') def test_get_method_with_specified_default(self): formatted = self.i.formatted() self.assertEqual(formatted.get('other_field', u'default'), u'default') def test_item_precedence(self): album = self.lib.add_album([self.i]) album['artist'] = u'foo' album.store() self.assertNotEqual(u'foo', self.i.formatted().get('artist')) def test_album_flex_field(self): album = self.lib.add_album([self.i]) album['flex'] = u'foo' album.store() self.assertEqual(u'foo', self.i.formatted().get('flex')) def test_album_field_overrides_item_field_for_path(self): # Make the album inconsistent with the item. album = self.lib.add_album([self.i]) album.album = u'foo' album.store() self.i.album = u'bar' self.i.store() # Ensure the album takes precedence. formatted = self.i.formatted(for_path=True) self.assertEqual(formatted['album'], u'foo') def test_artist_falls_back_to_albumartist(self): self.i.artist = u'' formatted = self.i.formatted() self.assertEqual(formatted['artist'], u'the album artist') def test_albumartist_falls_back_to_artist(self): self.i.albumartist = u'' formatted = self.i.formatted() self.assertEqual(formatted['albumartist'], u'the artist') def test_both_artist_and_albumartist_empty(self): self.i.artist = u'' self.i.albumartist = u'' formatted = self.i.formatted() self.assertEqual(formatted['albumartist'], u'') class PathFormattingMixin(object): """Utilities for testing path formatting.""" def _setf(self, fmt): self.lib.path_formats.insert(0, (u'default', fmt)) def _assert_dest(self, dest, i=None): if i is None: i = self.i with _common.platform_posix(): actual = i.destination() self.assertEqual(actual, dest) class DestinationFunctionTest(_common.TestCase, PathFormattingMixin): def setUp(self): super(DestinationFunctionTest, self).setUp() self.lib = beets.library.Library(':memory:') self.lib.directory = b'/base' self.lib.path_formats = [(u'default', u'path')] self.i = item(self.lib) def tearDown(self): super(DestinationFunctionTest, self).tearDown() self.lib._connection().close() def test_upper_case_literal(self): self._setf(u'%upper{foo}') self._assert_dest(b'/base/FOO') def test_upper_case_variable(self): self._setf(u'%upper{$title}') self._assert_dest(b'/base/THE TITLE') def test_title_case_variable(self): self._setf(u'%title{$title}') self._assert_dest(b'/base/The Title') def test_asciify_variable(self): self._setf(u'%asciify{ab\xa2\xbdd}') self._assert_dest(b'/base/abC_1_2d') def test_left_variable(self): self._setf(u'%left{$title, 3}') self._assert_dest(b'/base/the') def test_right_variable(self): self._setf(u'%right{$title,3}') self._assert_dest(b'/base/tle') def test_if_false(self): self._setf(u'x%if{,foo}') self._assert_dest(b'/base/x') def test_if_false_value(self): self._setf(u'x%if{false,foo}') self._assert_dest(b'/base/x') def test_if_true(self): self._setf(u'%if{bar,foo}') self._assert_dest(b'/base/foo') def test_if_else_false(self): self._setf(u'%if{,foo,baz}') self._assert_dest(b'/base/baz') def test_if_else_false_value(self): self._setf(u'%if{false,foo,baz}') self._assert_dest(b'/base/baz') def test_if_int_value(self): self._setf(u'%if{0,foo,baz}') self._assert_dest(b'/base/baz') def test_nonexistent_function(self): self._setf(u'%foo{bar}') self._assert_dest(b'/base/%foo{bar}') def test_if_def_field_return_self(self): self.i.bar = 3 self._setf(u'%ifdef{bar}') self._assert_dest(b'/base/3') def test_if_def_field_not_defined(self): self._setf(u' %ifdef{bar}/$artist') self._assert_dest(b'/base/the artist') def test_if_def_field_not_defined_2(self): self._setf(u'$artist/%ifdef{bar}') self._assert_dest(b'/base/the artist') def test_if_def_true(self): self._setf(u'%ifdef{artist,cool}') self._assert_dest(b'/base/cool') def test_if_def_true_complete(self): self.i.series = "Now" self._setf(u'%ifdef{series,$series Series,Albums}/$album') self._assert_dest(b'/base/Now Series/the album') def test_if_def_false_complete(self): self._setf(u'%ifdef{plays,$plays,not_played}') self._assert_dest(b'/base/not_played') def test_first(self): self.i.genres = "Pop; Rock; Classical Crossover" self._setf(u'%first{$genres}') self._assert_dest(b'/base/Pop') def test_first_skip(self): self.i.genres = "Pop; Rock; Classical Crossover" self._setf(u'%first{$genres,1,2}') self._assert_dest(b'/base/Classical Crossover') def test_first_different_sep(self): self._setf(u'%first{Alice / Bob / Eve,2,0, / , & }') self._assert_dest(b'/base/Alice & Bob') class DisambiguationTest(_common.TestCase, PathFormattingMixin): def setUp(self): super(DisambiguationTest, self).setUp() self.lib = beets.library.Library(':memory:') self.lib.directory = b'/base' self.lib.path_formats = [(u'default', u'path')] self.i1 = item() self.i1.year = 2001 self.lib.add_album([self.i1]) self.i2 = item() self.i2.year = 2002 self.lib.add_album([self.i2]) self.lib._connection().commit() self._setf(u'foo%aunique{albumartist album,year}/$title') def tearDown(self): super(DisambiguationTest, self).tearDown() self.lib._connection().close() def test_unique_expands_to_disambiguating_year(self): self._assert_dest(b'/base/foo [2001]/the title', self.i1) def test_unique_with_default_arguments_uses_albumtype(self): album2 = self.lib.get_album(self.i1) album2.albumtype = u'bar' album2.store() self._setf(u'foo%aunique{}/$title') self._assert_dest(b'/base/foo [bar]/the title', self.i1) def test_unique_expands_to_nothing_for_distinct_albums(self): album2 = self.lib.get_album(self.i2) album2.album = u'different album' album2.store() self._assert_dest(b'/base/foo/the title', self.i1) def test_use_fallback_numbers_when_identical(self): album2 = self.lib.get_album(self.i2) album2.year = 2001 album2.store() self._assert_dest(b'/base/foo [1]/the title', self.i1) self._assert_dest(b'/base/foo [2]/the title', self.i2) def test_unique_falls_back_to_second_distinguishing_field(self): self._setf(u'foo%aunique{albumartist album,month year}/$title') self._assert_dest(b'/base/foo [2001]/the title', self.i1) def test_unique_sanitized(self): album2 = self.lib.get_album(self.i2) album2.year = 2001 album1 = self.lib.get_album(self.i1) album1.albumtype = u'foo/bar' album2.store() album1.store() self._setf(u'foo%aunique{albumartist album,albumtype}/$title') self._assert_dest(b'/base/foo [foo_bar]/the title', self.i1) def test_drop_empty_disambig_string(self): album1 = self.lib.get_album(self.i1) album1.albumdisambig = None album2 = self.lib.get_album(self.i2) album2.albumdisambig = u'foo' album1.store() album2.store() self._setf(u'foo%aunique{albumartist album,albumdisambig}/$title') self._assert_dest(b'/base/foo/the title', self.i1) def test_change_brackets(self): self._setf(u'foo%aunique{albumartist album,year,()}/$title') self._assert_dest(b'/base/foo (2001)/the title', self.i1) def test_remove_brackets(self): self._setf(u'foo%aunique{albumartist album,year,}/$title') self._assert_dest(b'/base/foo 2001/the title', self.i1) class PluginDestinationTest(_common.TestCase): def setUp(self): super(PluginDestinationTest, self).setUp() # Mock beets.plugins.item_field_getters. self._tv_map = {} def field_getters(): getters = {} for key, value in self._tv_map.items(): getters[key] = lambda _: value return getters self.old_field_getters = plugins.item_field_getters plugins.item_field_getters = field_getters self.lib = beets.library.Library(':memory:') self.lib.directory = b'/base' self.lib.path_formats = [(u'default', u'$artist $foo')] self.i = item(self.lib) def tearDown(self): super(PluginDestinationTest, self).tearDown() plugins.item_field_getters = self.old_field_getters def _assert_dest(self, dest): with _common.platform_posix(): the_dest = self.i.destination() self.assertEqual(the_dest, b'/base/' + dest) def test_undefined_value_not_substituted(self): self._assert_dest(b'the artist $foo') def test_plugin_value_not_substituted(self): self._tv_map = { 'foo': 'bar', } self._assert_dest(b'the artist bar') def test_plugin_value_overrides_attribute(self): self._tv_map = { 'artist': 'bar', } self._assert_dest(b'bar $foo') def test_plugin_value_sanitized(self): self._tv_map = { 'foo': 'bar/baz', } self._assert_dest(b'the artist bar_baz') class AlbumInfoTest(_common.TestCase): def setUp(self): super(AlbumInfoTest, self).setUp() self.lib = beets.library.Library(':memory:') self.i = item() self.lib.add_album((self.i,)) def test_albuminfo_reflects_metadata(self): ai = self.lib.get_album(self.i) self.assertEqual(ai.mb_albumartistid, self.i.mb_albumartistid) self.assertEqual(ai.albumartist, self.i.albumartist) self.assertEqual(ai.album, self.i.album) self.assertEqual(ai.year, self.i.year) def test_albuminfo_stores_art(self): ai = self.lib.get_album(self.i) ai.artpath = '/my/great/art' ai.store() new_ai = self.lib.get_album(self.i) self.assertEqual(new_ai.artpath, b'/my/great/art') def test_albuminfo_for_two_items_doesnt_duplicate_row(self): i2 = item(self.lib) self.lib.get_album(self.i) self.lib.get_album(i2) c = self.lib._connection().cursor() c.execute('select * from albums where album=?', (self.i.album,)) # Cursor should only return one row. self.assertNotEqual(c.fetchone(), None) self.assertEqual(c.fetchone(), None) def test_individual_tracks_have_no_albuminfo(self): i2 = item() i2.album = u'aTotallyDifferentAlbum' self.lib.add(i2) ai = self.lib.get_album(i2) self.assertEqual(ai, None) def test_get_album_by_id(self): ai = self.lib.get_album(self.i) ai = self.lib.get_album(self.i.id) self.assertNotEqual(ai, None) def test_album_items_consistent(self): ai = self.lib.get_album(self.i) for i in ai.items(): if i.id == self.i.id: break else: self.fail(u"item not found") def test_albuminfo_changes_affect_items(self): ai = self.lib.get_album(self.i) ai.album = u'myNewAlbum' ai.store() i = self.lib.items()[0] self.assertEqual(i.album, u'myNewAlbum') def test_albuminfo_change_albumartist_changes_items(self): ai = self.lib.get_album(self.i) ai.albumartist = u'myNewArtist' ai.store() i = self.lib.items()[0] self.assertEqual(i.albumartist, u'myNewArtist') self.assertNotEqual(i.artist, u'myNewArtist') def test_albuminfo_change_artist_does_not_change_items(self): ai = self.lib.get_album(self.i) ai.artist = u'myNewArtist' ai.store() i = self.lib.items()[0] self.assertNotEqual(i.artist, u'myNewArtist') def test_albuminfo_remove_removes_items(self): item_id = self.i.id self.lib.get_album(self.i).remove() c = self.lib._connection().execute( 'SELECT id FROM items WHERE id=?', (item_id,) ) self.assertEqual(c.fetchone(), None) def test_removing_last_item_removes_album(self): self.assertEqual(len(self.lib.albums()), 1) self.i.remove() self.assertEqual(len(self.lib.albums()), 0) def test_noop_albuminfo_changes_affect_items(self): i = self.lib.items()[0] i.album = u'foobar' i.store() ai = self.lib.get_album(self.i) ai.album = ai.album ai.store() i = self.lib.items()[0] self.assertEqual(i.album, ai.album) class ArtDestinationTest(_common.TestCase): def setUp(self): super(ArtDestinationTest, self).setUp() config['art_filename'] = u'artimage' config['replace'] = {u'X': u'Y'} self.lib = beets.library.Library( ':memory:', replacements=[(re.compile(u'X'), u'Y')] ) self.i = item(self.lib) self.i.path = self.i.destination() self.ai = self.lib.add_album((self.i,)) def test_art_filename_respects_setting(self): art = self.ai.art_destination('something.jpg') new_art = bytestring_path('%sartimage.jpg' % os.path.sep) self.assertTrue(new_art in art) def test_art_path_in_item_dir(self): art = self.ai.art_destination('something.jpg') track = self.i.destination() self.assertEqual(os.path.dirname(art), os.path.dirname(track)) def test_art_path_sanitized(self): config['art_filename'] = u'artXimage' art = self.ai.art_destination('something.jpg') self.assertTrue(b'artYimage' in art) class PathStringTest(_common.TestCase): def setUp(self): super(PathStringTest, self).setUp() self.lib = beets.library.Library(':memory:') self.i = item(self.lib) def test_item_path_is_bytestring(self): self.assertTrue(isinstance(self.i.path, bytes)) def test_fetched_item_path_is_bytestring(self): i = list(self.lib.items())[0] self.assertTrue(isinstance(i.path, bytes)) def test_unicode_path_becomes_bytestring(self): self.i.path = u'unicodepath' self.assertTrue(isinstance(self.i.path, bytes)) def test_unicode_in_database_becomes_bytestring(self): self.lib._connection().execute(""" update items set path=? where id=? """, (self.i.id, u'somepath')) i = list(self.lib.items())[0] self.assertTrue(isinstance(i.path, bytes)) def test_special_chars_preserved_in_database(self): path = u'b\xe1r'.encode('utf-8') self.i.path = path self.i.store() i = list(self.lib.items())[0] self.assertEqual(i.path, path) def test_special_char_path_added_to_database(self): self.i.remove() path = u'b\xe1r'.encode('utf-8') i = item() i.path = path self.lib.add(i) i = list(self.lib.items())[0] self.assertEqual(i.path, path) def test_destination_returns_bytestring(self): self.i.artist = u'b\xe1r' dest = self.i.destination() self.assertTrue(isinstance(dest, bytes)) def test_art_destination_returns_bytestring(self): self.i.artist = u'b\xe1r' alb = self.lib.add_album([self.i]) dest = alb.art_destination(u'image.jpg') self.assertTrue(isinstance(dest, bytes)) def test_artpath_stores_special_chars(self): path = b'b\xe1r' alb = self.lib.add_album([self.i]) alb.artpath = path alb.store() alb = self.lib.get_album(self.i) self.assertEqual(path, alb.artpath) def test_sanitize_path_with_special_chars(self): path = u'b\xe1r?' new_path = util.sanitize_path(path) self.assertTrue(new_path.startswith(u'b\xe1r')) def test_sanitize_path_returns_unicode(self): path = u'b\xe1r?' new_path = util.sanitize_path(path) self.assertTrue(isinstance(new_path, six.text_type)) def test_unicode_artpath_becomes_bytestring(self): alb = self.lib.add_album([self.i]) alb.artpath = u'somep\xe1th' self.assertTrue(isinstance(alb.artpath, bytes)) def test_unicode_artpath_in_database_decoded(self): alb = self.lib.add_album([self.i]) self.lib._connection().execute( "update albums set artpath=? where id=?", (u'somep\xe1th', alb.id) ) alb = self.lib.get_album(alb.id) self.assertTrue(isinstance(alb.artpath, bytes)) class MtimeTest(_common.TestCase): def setUp(self): super(MtimeTest, self).setUp() self.ipath = os.path.join(self.temp_dir, b'testfile.mp3') shutil.copy(os.path.join(_common.RSRC, b'full.mp3'), self.ipath) self.i = beets.library.Item.from_path(self.ipath) self.lib = beets.library.Library(':memory:') self.lib.add(self.i) def tearDown(self): super(MtimeTest, self).tearDown() if os.path.exists(self.ipath): os.remove(self.ipath) def _mtime(self): return int(os.path.getmtime(self.ipath)) def test_mtime_initially_up_to_date(self): self.assertGreaterEqual(self.i.mtime, self._mtime()) def test_mtime_reset_on_db_modify(self): self.i.title = u'something else' self.assertLess(self.i.mtime, self._mtime()) def test_mtime_up_to_date_after_write(self): self.i.title = u'something else' self.i.write() self.assertGreaterEqual(self.i.mtime, self._mtime()) def test_mtime_up_to_date_after_read(self): self.i.title = u'something else' self.i.read() self.assertGreaterEqual(self.i.mtime, self._mtime()) class ImportTimeTest(_common.TestCase): def setUp(self): super(ImportTimeTest, self).setUp() self.lib = beets.library.Library(':memory:') def added(self): self.track = item() self.album = self.lib.add_album((self.track,)) self.assertGreater(self.album.added, 0) self.assertGreater(self.track.added, 0) def test_atime_for_singleton(self): self.singleton = item(self.lib) self.assertGreater(self.singleton.added, 0) class TemplateTest(_common.LibTestCase): def test_year_formatted_in_template(self): self.i.year = 123 self.i.store() self.assertEqual(self.i.evaluate_template('$year'), u'0123') def test_album_flexattr_appears_in_item_template(self): self.album = self.lib.add_album([self.i]) self.album.foo = u'baz' self.album.store() self.assertEqual(self.i.evaluate_template('$foo'), u'baz') def test_album_and_item_format(self): config['format_album'] = u'foö $foo' album = beets.library.Album() album.foo = u'bar' album.tagada = u'togodo' self.assertEqual(u"{0}".format(album), u"foö bar") self.assertEqual(u"{0:$tagada}".format(album), u"togodo") self.assertEqual(six.text_type(album), u"foö bar") self.assertEqual(bytes(album), b"fo\xc3\xb6 bar") config['format_item'] = u'bar $foo' item = beets.library.Item() item.foo = u'bar' item.tagada = u'togodo' self.assertEqual(u"{0}".format(item), u"bar bar") self.assertEqual(u"{0:$tagada}".format(item), u"togodo") class UnicodePathTest(_common.LibTestCase): def test_unicode_path(self): self.i.path = os.path.join(_common.RSRC, u'unicode\u2019d.mp3'.encode('utf-8')) # If there are any problems with unicode paths, we will raise # here and fail. self.i.read() self.i.write() class WriteTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def test_write_nonexistant(self): item = self.create_item() item.path = b'/path/does/not/exist' with self.assertRaises(beets.library.ReadError): item.write() def test_no_write_permission(self): item = self.add_item_fixture() path = syspath(item.path) os.chmod(path, stat.S_IRUSR) try: self.assertRaises(beets.library.WriteError, item.write) finally: # Restore write permissions so the file can be cleaned up. os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) def test_write_with_custom_path(self): item = self.add_item_fixture() custom_path = os.path.join(self.temp_dir, b'custom.mp3') shutil.copy(syspath(item.path), syspath(custom_path)) item['artist'] = 'new artist' self.assertNotEqual(MediaFile(syspath(custom_path)).artist, 'new artist') self.assertNotEqual(MediaFile(syspath(item.path)).artist, 'new artist') item.write(custom_path) self.assertEqual(MediaFile(syspath(custom_path)).artist, 'new artist') self.assertNotEqual(MediaFile(syspath(item.path)).artist, 'new artist') def test_write_custom_tags(self): item = self.add_item_fixture(artist='old artist') item.write(tags={'artist': 'new artist'}) self.assertNotEqual(item.artist, 'new artist') self.assertEqual(MediaFile(syspath(item.path)).artist, 'new artist') def test_write_date_field(self): # Since `date` is not a MediaField, this should do nothing. item = self.add_item_fixture() clean_year = item.year item.date = u'foo' item.write() self.assertEqual(MediaFile(syspath(item.path)).year, clean_year) class ItemReadTest(unittest.TestCase): def test_unreadable_raise_read_error(self): unreadable = os.path.join(_common.RSRC, b'image-2x3.png') item = beets.library.Item() with self.assertRaises(beets.library.ReadError) as cm: item.read(unreadable) self.assertIsInstance(cm.exception.reason, beets.mediafile.UnreadableFileError) def test_nonexistent_raise_read_error(self): item = beets.library.Item() with self.assertRaises(beets.library.ReadError): item.read('/thisfiledoesnotexist') class FilesizeTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def test_filesize(self): item = self.add_item_fixture() self.assertNotEqual(item.filesize, 0) def test_nonexistent_file(self): item = beets.library.Item() self.assertEqual(item.filesize, 0) class ParseQueryTest(unittest.TestCase): def test_parse_invalid_query_string(self): with self.assertRaises(beets.dbcore.InvalidQueryError) as raised: beets.library.parse_query_string(u'foo"', None) self.assertIsInstance(raised.exception, beets.dbcore.query.ParsingError) def test_parse_bytes(self): with self.assertRaises(AssertionError): beets.library.parse_query_string(b"query", None) class LibraryFieldTypesTest(unittest.TestCase): """Test format() and parse() for library-specific field types""" def test_datetype(self): t = beets.library.DateType() # format time_format = beets.config['time_format'].as_str() time_local = time.strftime(time_format, time.localtime(123456789)) self.assertEqual(time_local, t.format(123456789)) # parse self.assertEqual(123456789.0, t.parse(time_local)) self.assertEqual(123456789.0, t.parse(u'123456789.0')) self.assertEqual(t.null, t.parse(u'not123456789.0')) self.assertEqual(t.null, t.parse(u'1973-11-29')) def test_pathtype(self): t = beets.library.PathType() # format self.assertEqual('/tmp', t.format('/tmp')) self.assertEqual(u'/tmp/\xe4lbum', t.format(u'/tmp/\u00e4lbum')) # parse self.assertEqual(np(b'/tmp'), t.parse('/tmp')) self.assertEqual(np(b'/tmp/\xc3\xa4lbum'), t.parse(u'/tmp/\u00e4lbum/')) def test_musicalkey(self): t = beets.library.MusicalKey() # parse self.assertEqual(u'C#m', t.parse(u'c#m')) self.assertEqual(u'Gm', t.parse(u'g minor')) self.assertEqual(u'Not c#m', t.parse(u'not C#m')) def test_durationtype(self): t = beets.library.DurationType() # format self.assertEqual(u'1:01', t.format(61.23)) self.assertEqual(u'60:01', t.format(3601.23)) self.assertEqual(u'0:00', t.format(None)) # parse self.assertEqual(61.0, t.parse(u'1:01')) self.assertEqual(61.23, t.parse(u'61.23')) self.assertEqual(3601.0, t.parse(u'60:01')) self.assertEqual(t.null, t.parse(u'1:00:01')) self.assertEqual(t.null, t.parse(u'not61.23')) # config format_raw_length beets.config['format_raw_length'] = True self.assertEqual(61.23, t.format(61.23)) self.assertEqual(3601.23, t.format(3601.23)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_fetchart.py0000644000076500000240000000372113025125203017754 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import os import unittest from test.helper import TestHelper from beets import util class FetchartCliTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('fetchart') self.config['fetchart']['cover_names'] = 'c\xc3\xb6ver.jpg' self.config['art_filename'] = 'mycover' self.album = self.add_album() def tearDown(self): self.unload_plugins() self.teardown_beets() def test_set_art_from_folder(self): self.touch(b'c\xc3\xb6ver.jpg', dir=self.album.path, content='IMAGE') self.run_command('fetchart') cover_path = os.path.join(self.album.path, b'mycover.jpg') self.album.load() self.assertEqual(self.album['artpath'], cover_path) with open(util.syspath(cover_path), 'r') as f: self.assertEqual(f.read(), 'IMAGE') def test_filesystem_does_not_pick_up_folder(self): os.makedirs(os.path.join(self.album.path, b'mycover.jpg')) self.run_command('fetchart') self.album.load() self.assertEqual(self.album['artpath'], None) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_player.py0000644000076500000240000000433713025125203017454 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for BPD and music playing. """ from __future__ import division, absolute_import, print_function import unittest from beetsplug import bpd class CommandParseTest(unittest.TestCase): def test_no_args(self): s = r'command' c = bpd.Command(s) self.assertEqual(c.name, u'command') self.assertEqual(c.args, []) def test_one_unquoted_arg(self): s = r'command hello' c = bpd.Command(s) self.assertEqual(c.name, u'command') self.assertEqual(c.args, [u'hello']) def test_two_unquoted_args(self): s = r'command hello there' c = bpd.Command(s) self.assertEqual(c.name, u'command') self.assertEqual(c.args, [u'hello', u'there']) def test_one_quoted_arg(self): s = r'command "hello there"' c = bpd.Command(s) self.assertEqual(c.name, u'command') self.assertEqual(c.args, [u'hello there']) def test_heterogenous_args(self): s = r'command "hello there" sir' c = bpd.Command(s) self.assertEqual(c.name, u'command') self.assertEqual(c.args, [u'hello there', u'sir']) def test_quote_in_arg(self): s = r'command "hello \" there"' c = bpd.Command(s) self.assertEqual(c.args, [u'hello " there']) def test_backslash_in_arg(self): s = r'command "hello \\ there"' c = bpd.Command(s) self.assertEqual(c.args, [u'hello \ there']) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_mpdstats.py0000644000076500000240000000570713025125203020021 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016 # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import unittest from mock import Mock, patch, call, ANY from test.helper import TestHelper from beets.library import Item from beetsplug.mpdstats import MPDStats from beets import util class MPDStatsTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('mpdstats') def tearDown(self): self.teardown_beets() self.unload_plugins() def test_update_rating(self): item = Item(title=u'title', path='', id=1) item.add(self.lib) log = Mock() mpdstats = MPDStats(self.lib, log) self.assertFalse(mpdstats.update_rating(item, True)) self.assertFalse(mpdstats.update_rating(None, True)) def test_get_item(self): item_path = util.normpath('/foo/bar.flac') item = Item(title=u'title', path=item_path, id=1) item.add(self.lib) log = Mock() mpdstats = MPDStats(self.lib, log) self.assertEqual(str(mpdstats.get_item(item_path)), str(item)) self.assertIsNone(mpdstats.get_item('/some/non-existing/path')) self.assertIn(u'item not found:', log.info.call_args[0][0]) FAKE_UNKNOWN_STATE = 'some-unknown-one' STATUSES = [{'state': FAKE_UNKNOWN_STATE}, {'state': u'pause'}, {'state': u'play', 'songid': 1, 'time': u'0:1'}, {'state': u'stop'}] EVENTS = [["player"]] * (len(STATUSES) - 1) + [KeyboardInterrupt] item_path = util.normpath('/foo/bar.flac') @patch("beetsplug.mpdstats.MPDClientWrapper", return_value=Mock(**{ "events.side_effect": EVENTS, "status.side_effect": STATUSES, "playlist.return_value": {1: item_path}})) def test_run_mpdstats(self, mpd_mock): item = Item(title=u'title', path=self.item_path, id=1) item.add(self.lib) log = Mock() try: MPDStats(self.lib, log).run() except KeyboardInterrupt: pass log.debug.assert_has_calls( [call(u'unhandled status "{0}"', ANY)]) log.info.assert_has_calls( [call(u'pause'), call(u'playing {0}', ANY), call(u'stop')]) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_logging.py0000644000076500000240000002450313120341455017610 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- """Stupid tests that ensure logging works as expected""" from __future__ import division, absolute_import, print_function import sys import threading import logging as log from six import StringIO import unittest import beets.logging as blog from beets import plugins, ui import beetsplug from test import _common from test._common import TestCase from test import helper import six class LoggingTest(TestCase): def test_logging_management(self): l1 = log.getLogger("foo123") l2 = blog.getLogger("foo123") self.assertEqual(l1, l2) self.assertEqual(l1.__class__, log.Logger) l3 = blog.getLogger("bar123") l4 = log.getLogger("bar123") self.assertEqual(l3, l4) self.assertEqual(l3.__class__, blog.BeetsLogger) self.assertIsInstance(l3, (blog.StrFormatLogger, blog.ThreadLocalLevelLogger)) l5 = l3.getChild("shalala") self.assertEqual(l5.__class__, blog.BeetsLogger) l6 = blog.getLogger() self.assertNotEqual(l1, l6) def test_str_format_logging(self): l = blog.getLogger("baz123") stream = StringIO() handler = log.StreamHandler(stream) l.addHandler(handler) l.propagate = False l.warning(u"foo {0} {bar}", "oof", bar=u"baz") handler.flush() self.assertTrue(stream.getvalue(), u"foo oof baz") class LoggingLevelTest(unittest.TestCase, helper.TestHelper): class DummyModule(object): class DummyPlugin(plugins.BeetsPlugin): def __init__(self): plugins.BeetsPlugin.__init__(self, 'dummy') self.import_stages = [self.import_stage] self.register_listener('dummy_event', self.listener) def log_all(self, name): self._log.debug(u'debug ' + name) self._log.info(u'info ' + name) self._log.warning(u'warning ' + name) def commands(self): cmd = ui.Subcommand('dummy') cmd.func = lambda _, __, ___: self.log_all('cmd') return (cmd,) def import_stage(self, session, task): self.log_all('import_stage') def listener(self): self.log_all('listener') def setUp(self): sys.modules['beetsplug.dummy'] = self.DummyModule beetsplug.dummy = self.DummyModule self.setup_beets() self.load_plugins('dummy') def tearDown(self): self.unload_plugins() self.teardown_beets() del beetsplug.dummy sys.modules.pop('beetsplug.dummy') self.DummyModule.DummyPlugin.listeners = None self.DummyModule.DummyPlugin._raw_listeners = None def test_command_level0(self): self.config['verbose'] = 0 with helper.capture_log() as logs: self.run_command('dummy') self.assertIn(u'dummy: warning cmd', logs) self.assertIn(u'dummy: info cmd', logs) self.assertNotIn(u'dummy: debug cmd', logs) def test_command_level1(self): self.config['verbose'] = 1 with helper.capture_log() as logs: self.run_command('dummy') self.assertIn(u'dummy: warning cmd', logs) self.assertIn(u'dummy: info cmd', logs) self.assertIn(u'dummy: debug cmd', logs) def test_command_level2(self): self.config['verbose'] = 2 with helper.capture_log() as logs: self.run_command('dummy') self.assertIn(u'dummy: warning cmd', logs) self.assertIn(u'dummy: info cmd', logs) self.assertIn(u'dummy: debug cmd', logs) def test_listener_level0(self): self.config['verbose'] = 0 with helper.capture_log() as logs: plugins.send('dummy_event') self.assertIn(u'dummy: warning listener', logs) self.assertNotIn(u'dummy: info listener', logs) self.assertNotIn(u'dummy: debug listener', logs) def test_listener_level1(self): self.config['verbose'] = 1 with helper.capture_log() as logs: plugins.send('dummy_event') self.assertIn(u'dummy: warning listener', logs) self.assertIn(u'dummy: info listener', logs) self.assertNotIn(u'dummy: debug listener', logs) def test_listener_level2(self): self.config['verbose'] = 2 with helper.capture_log() as logs: plugins.send('dummy_event') self.assertIn(u'dummy: warning listener', logs) self.assertIn(u'dummy: info listener', logs) self.assertIn(u'dummy: debug listener', logs) def test_import_stage_level0(self): self.config['verbose'] = 0 with helper.capture_log() as logs: importer = self.create_importer() importer.run() self.assertIn(u'dummy: warning import_stage', logs) self.assertNotIn(u'dummy: info import_stage', logs) self.assertNotIn(u'dummy: debug import_stage', logs) def test_import_stage_level1(self): self.config['verbose'] = 1 with helper.capture_log() as logs: importer = self.create_importer() importer.run() self.assertIn(u'dummy: warning import_stage', logs) self.assertIn(u'dummy: info import_stage', logs) self.assertNotIn(u'dummy: debug import_stage', logs) def test_import_stage_level2(self): self.config['verbose'] = 2 with helper.capture_log() as logs: importer = self.create_importer() importer.run() self.assertIn(u'dummy: warning import_stage', logs) self.assertIn(u'dummy: info import_stage', logs) self.assertIn(u'dummy: debug import_stage', logs) @_common.slow_test() class ConcurrentEventsTest(TestCase, helper.TestHelper): """Similar to LoggingLevelTest but lower-level and focused on multiple events interaction. Since this is a bit heavy we don't do it in LoggingLevelTest. """ class DummyPlugin(plugins.BeetsPlugin): def __init__(self, test_case): plugins.BeetsPlugin.__init__(self, 'dummy') self.register_listener('dummy_event1', self.listener1) self.register_listener('dummy_event2', self.listener2) self.lock1 = threading.Lock() self.lock2 = threading.Lock() self.test_case = test_case self.exc_info = None self.t1_step = self.t2_step = 0 def log_all(self, name): self._log.debug(u'debug ' + name) self._log.info(u'info ' + name) self._log.warning(u'warning ' + name) def listener1(self): try: self.test_case.assertEqual(self._log.level, log.INFO) self.t1_step = 1 self.lock1.acquire() self.test_case.assertEqual(self._log.level, log.INFO) self.t1_step = 2 except Exception: import sys self.exc_info = sys.exc_info() def listener2(self): try: self.test_case.assertEqual(self._log.level, log.DEBUG) self.t2_step = 1 self.lock2.acquire() self.test_case.assertEqual(self._log.level, log.DEBUG) self.t2_step = 2 except Exception: import sys self.exc_info = sys.exc_info() def setUp(self): self.setup_beets(disk=True) def tearDown(self): self.teardown_beets() def test_concurrent_events(self): dp = self.DummyPlugin(self) def check_dp_exc(): if dp.exc_info: six.reraise(dp.exc_info[1], None, dp.exc_info[2]) try: dp.lock1.acquire() dp.lock2.acquire() self.assertEqual(dp._log.level, log.NOTSET) self.config['verbose'] = 1 t1 = threading.Thread(target=dp.listeners['dummy_event1'][0]) t1.start() # blocked. t1 tested its log level while dp.t1_step != 1: check_dp_exc() self.assertTrue(t1.is_alive()) self.assertEqual(dp._log.level, log.NOTSET) self.config['verbose'] = 2 t2 = threading.Thread(target=dp.listeners['dummy_event2'][0]) t2.start() # blocked. t2 tested its log level while dp.t2_step != 1: check_dp_exc() self.assertTrue(t2.is_alive()) self.assertEqual(dp._log.level, log.NOTSET) dp.lock1.release() # dummy_event1 tests its log level + finishes while dp.t1_step != 2: check_dp_exc() t1.join(.1) self.assertFalse(t1.is_alive()) self.assertTrue(t2.is_alive()) self.assertEqual(dp._log.level, log.NOTSET) dp.lock2.release() # dummy_event2 tests its log level + finishes while dp.t2_step != 2: check_dp_exc() t2.join(.1) self.assertFalse(t2.is_alive()) except Exception: print(u"Alive threads:", threading.enumerate()) if dp.lock1.locked(): print(u"Releasing lock1 after exception in test") dp.lock1.release() if dp.lock2.locked(): print(u"Releasing lock2 after exception in test") dp.lock2.release() print(u"Alive threads:", threading.enumerate()) raise def test_root_logger_levels(self): """Root logger level should be shared between threads. """ self.config['threaded'] = True blog.getLogger('beets').set_global_level(blog.WARNING) with helper.capture_log() as logs: importer = self.create_importer() importer.run() self.assertEqual(logs, []) blog.getLogger('beets').set_global_level(blog.INFO) with helper.capture_log() as logs: importer = self.create_importer() importer.run() for l in logs: self.assertIn(u"import", l) self.assertIn(u"album", l) blog.getLogger('beets').set_global_level(blog.DEBUG) with helper.capture_log() as logs: importer = self.create_importer() importer.run() self.assertIn(u"Sending event: database_change", logs) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_filefilter.py0000644000076500000240000001777413025125203020316 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Malte Ried. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the `filefilter` plugin. """ from __future__ import division, absolute_import, print_function import os import shutil import unittest from test import _common from test.helper import capture_log from test.test_importer import ImportHelper from beets import config from beets.mediafile import MediaFile from beets.util import displayable_path, bytestring_path from beetsplug.filefilter import FileFilterPlugin class FileFilterPluginTest(unittest.TestCase, ImportHelper): def setUp(self): self.setup_beets() self.__create_import_dir(2) self._setup_import_session() config['import']['pretend'] = True def tearDown(self): self.teardown_beets() def __copy_file(self, dest_path, metadata): # Copy files resource_path = os.path.join(_common.RSRC, b'full.mp3') shutil.copy(resource_path, dest_path) medium = MediaFile(dest_path) # Set metadata for attr in metadata: setattr(medium, attr, metadata[attr]) medium.save() def __create_import_dir(self, count): self.import_dir = os.path.join(self.temp_dir, b'testsrcdir') if os.path.isdir(self.import_dir): shutil.rmtree(self.import_dir) self.artist_path = os.path.join(self.import_dir, b'artist') self.album_path = os.path.join(self.artist_path, b'album') self.misc_path = os.path.join(self.import_dir, b'misc') os.makedirs(self.album_path) os.makedirs(self.misc_path) metadata = { 'artist': 'Tag Artist', 'album': 'Tag Album', 'albumartist': None, 'mb_trackid': None, 'mb_albumid': None, 'comp': None, } self.album_paths = [] for i in range(count): metadata['track'] = i + 1 metadata['title'] = 'Tag Title Album %d' % (i + 1) track_file = bytestring_path('%02d - track.mp3' % (i + 1)) dest_path = os.path.join(self.album_path, track_file) self.__copy_file(dest_path, metadata) self.album_paths.append(dest_path) self.artist_paths = [] metadata['album'] = None for i in range(count): metadata['track'] = i + 10 metadata['title'] = 'Tag Title Artist %d' % (i + 1) track_file = bytestring_path('track_%d.mp3' % (i + 1)) dest_path = os.path.join(self.artist_path, track_file) self.__copy_file(dest_path, metadata) self.artist_paths.append(dest_path) self.misc_paths = [] for i in range(count): metadata['artist'] = 'Artist %d' % (i + 42) metadata['track'] = i + 5 metadata['title'] = 'Tag Title Misc %d' % (i + 1) track_file = bytestring_path('track_%d.mp3' % (i + 1)) dest_path = os.path.join(self.misc_path, track_file) self.__copy_file(dest_path, metadata) self.misc_paths.append(dest_path) def __run(self, expected_lines, singletons=False): self.load_plugins('filefilter') import_files = [self.import_dir] self._setup_import_session(singletons=singletons) self.importer.paths = import_files with capture_log() as logs: self.importer.run() self.unload_plugins() FileFilterPlugin.listeners = None logs = [line for line in logs if not line.startswith('Sending event:')] self.assertEqual(logs, expected_lines) def test_import_default(self): """ The default configuration should import everything. """ self.__run([ 'Album: %s' % displayable_path(self.artist_path), ' %s' % displayable_path(self.artist_paths[0]), ' %s' % displayable_path(self.artist_paths[1]), 'Album: %s' % displayable_path(self.album_path), ' %s' % displayable_path(self.album_paths[0]), ' %s' % displayable_path(self.album_paths[1]), 'Album: %s' % displayable_path(self.misc_path), ' %s' % displayable_path(self.misc_paths[0]), ' %s' % displayable_path(self.misc_paths[1]) ]) def test_import_nothing(self): config['filefilter']['path'] = 'not_there' self.__run(['No files imported from %s' % displayable_path( self.import_dir)]) # Global options def test_import_global(self): config['filefilter']['path'] = '.*track_1.*\.mp3' self.__run([ 'Album: %s' % displayable_path(self.artist_path), ' %s' % displayable_path(self.artist_paths[0]), 'Album: %s' % displayable_path(self.misc_path), ' %s' % displayable_path(self.misc_paths[0]), ]) self.__run([ 'Singleton: %s' % displayable_path(self.artist_paths[0]), 'Singleton: %s' % displayable_path(self.misc_paths[0]) ], singletons=True) # Album options def test_import_album(self): config['filefilter']['album_path'] = '.*track_1.*\.mp3' self.__run([ 'Album: %s' % displayable_path(self.artist_path), ' %s' % displayable_path(self.artist_paths[0]), 'Album: %s' % displayable_path(self.misc_path), ' %s' % displayable_path(self.misc_paths[0]), ]) self.__run([ 'Singleton: %s' % displayable_path(self.artist_paths[0]), 'Singleton: %s' % displayable_path(self.artist_paths[1]), 'Singleton: %s' % displayable_path(self.album_paths[0]), 'Singleton: %s' % displayable_path(self.album_paths[1]), 'Singleton: %s' % displayable_path(self.misc_paths[0]), 'Singleton: %s' % displayable_path(self.misc_paths[1]) ], singletons=True) # Singleton options def test_import_singleton(self): config['filefilter']['singleton_path'] = '.*track_1.*\.mp3' self.__run([ 'Singleton: %s' % displayable_path(self.artist_paths[0]), 'Singleton: %s' % displayable_path(self.misc_paths[0]) ], singletons=True) self.__run([ 'Album: %s' % displayable_path(self.artist_path), ' %s' % displayable_path(self.artist_paths[0]), ' %s' % displayable_path(self.artist_paths[1]), 'Album: %s' % displayable_path(self.album_path), ' %s' % displayable_path(self.album_paths[0]), ' %s' % displayable_path(self.album_paths[1]), 'Album: %s' % displayable_path(self.misc_path), ' %s' % displayable_path(self.misc_paths[0]), ' %s' % displayable_path(self.misc_paths[1]) ]) # Album and singleton options def test_import_both(self): config['filefilter']['album_path'] = '.*track_1.*\.mp3' config['filefilter']['singleton_path'] = '.*track_2.*\.mp3' self.__run([ 'Album: %s' % displayable_path(self.artist_path), ' %s' % displayable_path(self.artist_paths[0]), 'Album: %s' % displayable_path(self.misc_path), ' %s' % displayable_path(self.misc_paths[0]), ]) self.__run([ 'Singleton: %s' % displayable_path(self.artist_paths[1]), 'Singleton: %s' % displayable_path(self.misc_paths[1]) ], singletons=True) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_embyupdate.py0000644000076500000240000002364513025125203020322 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- from __future__ import division, absolute_import, print_function from test.helper import TestHelper from beetsplug import embyupdate import unittest import responses class EmbyUpdateTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('embyupdate') self.config['emby'] = { u'host': u'localhost', u'port': 8096, u'username': u'username', u'password': u'password' } def tearDown(self): self.teardown_beets() self.unload_plugins() def test_api_url_only_name(self): self.assertEqual( embyupdate.api_url(self.config['emby']['host'].get(), self.config['emby']['port'].get(), '/Library/Refresh'), 'http://localhost:8096/Library/Refresh?format=json' ) def test_api_url_http(self): self.assertEqual( embyupdate.api_url(u'http://localhost', self.config['emby']['port'].get(), '/Library/Refresh'), 'http://localhost:8096/Library/Refresh?format=json' ) def test_api_url_https(self): self.assertEqual( embyupdate.api_url(u'https://localhost', self.config['emby']['port'].get(), '/Library/Refresh'), 'https://localhost:8096/Library/Refresh?format=json' ) def test_password_data(self): self.assertEqual( embyupdate.password_data(self.config['emby']['username'].get(), self.config['emby']['password'].get()), { 'username': 'username', 'password': '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', 'passwordMd5': '5f4dcc3b5aa765d61d8327deb882cf99' } ) def test_create_header_no_token(self): self.assertEqual( embyupdate.create_headers('e8837bc1-ad67-520e-8cd2-f629e3155721'), { 'x-emby-authorization': ( 'MediaBrowser ' 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ) } ) def test_create_header_with_token(self): self.assertEqual( embyupdate.create_headers('e8837bc1-ad67-520e-8cd2-f629e3155721', token='abc123'), { 'x-emby-authorization': ( 'MediaBrowser ' 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ), 'x-mediabrowser-token': 'abc123' } ) @responses.activate def test_get_token(self): body = ('{"User":{"Name":"username", ' '"ServerId":"1efa5077976bfa92bc71652404f646ec",' '"Id":"2ec276a2642e54a19b612b9418a8bd3b","HasPassword":true,' '"HasConfiguredPassword":true,' '"HasConfiguredEasyPassword":false,' '"LastLoginDate":"2015-11-09T08:35:03.6357440Z",' '"LastActivityDate":"2015-11-09T08:35:03.6665060Z",' '"Configuration":{"AudioLanguagePreference":"",' '"PlayDefaultAudioTrack":true,"SubtitleLanguagePreference":"",' '"DisplayMissingEpisodes":false,' '"DisplayUnairedEpisodes":false,' '"GroupMoviesIntoBoxSets":false,' '"DisplayChannelsWithinViews":[],' '"ExcludeFoldersFromGrouping":[],"GroupedFolders":[],' '"SubtitleMode":"Default","DisplayCollectionsView":true,' '"DisplayFoldersView":false,"EnableLocalPassword":false,' '"OrderedViews":[],"IncludeTrailersInSuggestions":true,' '"EnableCinemaMode":true,"LatestItemsExcludes":[],' '"PlainFolderViews":[],"HidePlayedInLatest":true,' '"DisplayChannelsInline":false},' '"Policy":{"IsAdministrator":true,"IsHidden":false,' '"IsDisabled":false,"BlockedTags":[],' '"EnableUserPreferenceAccess":true,"AccessSchedules":[],' '"BlockUnratedItems":[],' '"EnableRemoteControlOfOtherUsers":false,' '"EnableSharedDeviceControl":true,' '"EnableLiveTvManagement":true,"EnableLiveTvAccess":true,' '"EnableMediaPlayback":true,' '"EnableAudioPlaybackTranscoding":true,' '"EnableVideoPlaybackTranscoding":true,' '"EnableContentDeletion":false,' '"EnableContentDownloading":true,"EnableSync":true,' '"EnableSyncTranscoding":true,"EnabledDevices":[],' '"EnableAllDevices":true,"EnabledChannels":[],' '"EnableAllChannels":true,"EnabledFolders":[],' '"EnableAllFolders":true,"InvalidLoginAttemptCount":0,' '"EnablePublicSharing":true}},' '"SessionInfo":{"SupportedCommands":[],' '"QueueableMediaTypes":[],"PlayableMediaTypes":[],' '"Id":"89f3b33f8b3a56af22088733ad1d76b3",' '"UserId":"2ec276a2642e54a19b612b9418a8bd3b",' '"UserName":"username","AdditionalUsers":[],' '"ApplicationVersion":"Unknown version",' '"Client":"Unknown app",' '"LastActivityDate":"2015-11-09T08:35:03.6665060Z",' '"DeviceName":"Unknown device","DeviceId":"Unknown device id",' '"SupportsRemoteControl":false,"PlayState":{"CanSeek":false,' '"IsPaused":false,"IsMuted":false,"RepeatMode":"RepeatNone"}},' '"AccessToken":"4b19180cf02748f7b95c7e8e76562fc8",' '"ServerId":"1efa5077976bfa92bc71652404f646ec"}') responses.add(responses.POST, ('http://localhost:8096' '/Users/AuthenticateByName'), body=body, status=200, content_type='application/json') headers = { 'x-emby-authorization': ( 'MediaBrowser ' 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ) } auth_data = { 'username': 'username', 'password': '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', 'passwordMd5': '5f4dcc3b5aa765d61d8327deb882cf99' } self.assertEqual( embyupdate.get_token('http://localhost', 8096, headers, auth_data), '4b19180cf02748f7b95c7e8e76562fc8') @responses.activate def test_get_user(self): body = ('[{"Name":"username",' '"ServerId":"1efa5077976bfa92bc71652404f646ec",' '"Id":"2ec276a2642e54a19b612b9418a8bd3b","HasPassword":true,' '"HasConfiguredPassword":true,' '"HasConfiguredEasyPassword":false,' '"LastLoginDate":"2015-11-09T08:35:03.6357440Z",' '"LastActivityDate":"2015-11-09T08:42:39.3693220Z",' '"Configuration":{"AudioLanguagePreference":"",' '"PlayDefaultAudioTrack":true,"SubtitleLanguagePreference":"",' '"DisplayMissingEpisodes":false,' '"DisplayUnairedEpisodes":false,' '"GroupMoviesIntoBoxSets":false,' '"DisplayChannelsWithinViews":[],' '"ExcludeFoldersFromGrouping":[],"GroupedFolders":[],' '"SubtitleMode":"Default","DisplayCollectionsView":true,' '"DisplayFoldersView":false,"EnableLocalPassword":false,' '"OrderedViews":[],"IncludeTrailersInSuggestions":true,' '"EnableCinemaMode":true,"LatestItemsExcludes":[],' '"PlainFolderViews":[],"HidePlayedInLatest":true,' '"DisplayChannelsInline":false},' '"Policy":{"IsAdministrator":true,"IsHidden":false,' '"IsDisabled":false,"BlockedTags":[],' '"EnableUserPreferenceAccess":true,"AccessSchedules":[],' '"BlockUnratedItems":[],' '"EnableRemoteControlOfOtherUsers":false,' '"EnableSharedDeviceControl":true,' '"EnableLiveTvManagement":true,"EnableLiveTvAccess":true,' '"EnableMediaPlayback":true,' '"EnableAudioPlaybackTranscoding":true,' '"EnableVideoPlaybackTranscoding":true,' '"EnableContentDeletion":false,' '"EnableContentDownloading":true,' '"EnableSync":true,"EnableSyncTranscoding":true,' '"EnabledDevices":[],"EnableAllDevices":true,' '"EnabledChannels":[],"EnableAllChannels":true,' '"EnabledFolders":[],"EnableAllFolders":true,' '"InvalidLoginAttemptCount":0,"EnablePublicSharing":true}}]') responses.add(responses.GET, 'http://localhost:8096/Users/Public', body=body, status=200, content_type='application/json') response = embyupdate.get_user('http://localhost', 8096, 'username') self.assertEqual(response[0]['Id'], '2ec276a2642e54a19b612b9418a8bd3b') self.assertEqual(response[0]['Name'], 'username') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_plugins.py0000644000076500000240000005013313031013626017636 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import os from mock import patch, Mock, ANY import shutil import itertools import unittest from beets.importer import SingletonImportTask, SentinelImportTask, \ ArchiveImportTask, action from beets import plugins, config, ui from beets.library import Item from beets.dbcore import types from beets.mediafile import MediaFile from beets.util import displayable_path, bytestring_path, syspath from test.test_importer import ImportHelper, AutotagStub from test.test_ui_importer import TerminalImportSessionSetup from test._common import RSRC from test import helper class TestHelper(helper.TestHelper): def setup_plugin_loader(self): # FIXME the mocking code is horrific, but this is the lowest and # earliest level of the plugin mechanism we can hook into. self.load_plugins() self._plugin_loader_patch = patch('beets.plugins.load_plugins') self._plugin_classes = set() load_plugins = self._plugin_loader_patch.start() def myload(names=()): plugins._classes.update(self._plugin_classes) load_plugins.side_effect = myload self.setup_beets() def teardown_plugin_loader(self): self._plugin_loader_patch.stop() self.unload_plugins() def register_plugin(self, plugin_class): self._plugin_classes.add(plugin_class) class ItemTypesTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_plugin_loader() def tearDown(self): self.teardown_plugin_loader() self.teardown_beets() def test_flex_field_type(self): class RatingPlugin(plugins.BeetsPlugin): item_types = {'rating': types.Float()} self.register_plugin(RatingPlugin) self.config['plugins'] = 'rating' item = Item(path=u'apath', artist=u'aaa') item.add(self.lib) # Do not match unset values out = self.run_with_output(u'ls', u'rating:1..3') self.assertNotIn(u'aaa', out) self.run_command(u'modify', u'rating=2', u'--yes') # Match in range out = self.run_with_output(u'ls', u'rating:1..3') self.assertIn(u'aaa', out) # Don't match out of range out = self.run_with_output(u'ls', u'rating:3..5') self.assertNotIn(u'aaa', out) class ItemWriteTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_plugin_loader() self.setup_beets() class EventListenerPlugin(plugins.BeetsPlugin): pass self.event_listener_plugin = EventListenerPlugin() self.register_plugin(EventListenerPlugin) def tearDown(self): self.teardown_plugin_loader() self.teardown_beets() def test_change_tags(self): def on_write(item=None, path=None, tags=None): if tags['artist'] == u'XXX': tags['artist'] = u'YYY' self.register_listener('write', on_write) item = self.add_item_fixture(artist=u'XXX') item.write() mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.artist, u'YYY') def register_listener(self, event, func): self.event_listener_plugin.register_listener(event, func) class ItemTypeConflictTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_plugin_loader() self.setup_beets() def tearDown(self): self.teardown_plugin_loader() self.teardown_beets() def test_mismatch(self): class EventListenerPlugin(plugins.BeetsPlugin): item_types = {'duplicate': types.INTEGER} class AdventListenerPlugin(plugins.BeetsPlugin): item_types = {'duplicate': types.FLOAT} self.event_listener_plugin = EventListenerPlugin self.advent_listener_plugin = AdventListenerPlugin self.register_plugin(EventListenerPlugin) self.register_plugin(AdventListenerPlugin) self.assertRaises(plugins.PluginConflictException, plugins.types, Item ) def test_match(self): class EventListenerPlugin(plugins.BeetsPlugin): item_types = {'duplicate': types.INTEGER} class AdventListenerPlugin(plugins.BeetsPlugin): item_types = {'duplicate': types.INTEGER} self.event_listener_plugin = EventListenerPlugin self.advent_listener_plugin = AdventListenerPlugin self.register_plugin(EventListenerPlugin) self.register_plugin(AdventListenerPlugin) self.assertNotEqual(None, plugins.types(Item)) class EventsTest(unittest.TestCase, ImportHelper, TestHelper): def setUp(self): self.setup_plugin_loader() self.setup_beets() self.__create_import_dir(2) config['import']['pretend'] = True def tearDown(self): self.teardown_plugin_loader() self.teardown_beets() def __copy_file(self, dest_path, metadata): # Copy files resource_path = os.path.join(RSRC, b'full.mp3') shutil.copy(resource_path, dest_path) medium = MediaFile(dest_path) # Set metadata for attr in metadata: setattr(medium, attr, metadata[attr]) medium.save() def __create_import_dir(self, count): self.import_dir = os.path.join(self.temp_dir, b'testsrcdir') if os.path.isdir(self.import_dir): shutil.rmtree(self.import_dir) self.album_path = os.path.join(self.import_dir, b'album') os.makedirs(self.album_path) metadata = { 'artist': u'Tag Artist', 'album': u'Tag Album', 'albumartist': None, 'mb_trackid': None, 'mb_albumid': None, 'comp': None } self.file_paths = [] for i in range(count): metadata['track'] = i + 1 metadata['title'] = u'Tag Title Album %d' % (i + 1) track_file = bytestring_path('%02d - track.mp3' % (i + 1)) dest_path = os.path.join(self.album_path, track_file) self.__copy_file(dest_path, metadata) self.file_paths.append(dest_path) def test_import_task_created(self): import_files = [self.import_dir] self._setup_import_session(singletons=False) self.importer.paths = import_files with helper.capture_log() as logs: self.importer.run() self.unload_plugins() # Exactly one event should have been imported (for the album). # Sentinels do not get emitted. self.assertEqual(logs.count(u'Sending event: import_task_created'), 1) logs = [line for line in logs if not line.startswith( u'Sending event:')] self.assertEqual(logs, [ u'Album: {0}'.format(displayable_path( os.path.join(self.import_dir, b'album'))), u' {0}'.format(displayable_path(self.file_paths[0])), u' {0}'.format(displayable_path(self.file_paths[1])), ]) def test_import_task_created_with_plugin(self): class ToSingletonPlugin(plugins.BeetsPlugin): def __init__(self): super(ToSingletonPlugin, self).__init__() self.register_listener('import_task_created', self.import_task_created_event) def import_task_created_event(self, session, task): if isinstance(task, SingletonImportTask) \ or isinstance(task, SentinelImportTask)\ or isinstance(task, ArchiveImportTask): return task new_tasks = [] for item in task.items: new_tasks.append(SingletonImportTask(task.toppath, item)) return new_tasks to_singleton_plugin = ToSingletonPlugin self.register_plugin(to_singleton_plugin) import_files = [self.import_dir] self._setup_import_session(singletons=False) self.importer.paths = import_files with helper.capture_log() as logs: self.importer.run() self.unload_plugins() # Exactly one event should have been imported (for the album). # Sentinels do not get emitted. self.assertEqual(logs.count(u'Sending event: import_task_created'), 1) logs = [line for line in logs if not line.startswith( u'Sending event:')] self.assertEqual(logs, [ u'Singleton: {0}'.format(displayable_path(self.file_paths[0])), u'Singleton: {0}'.format(displayable_path(self.file_paths[1])), ]) class HelpersTest(unittest.TestCase): def test_sanitize_choices(self): self.assertEqual( plugins.sanitize_choices([u'A', u'Z'], (u'A', u'B')), [u'A']) self.assertEqual( plugins.sanitize_choices([u'A', u'A'], (u'A')), [u'A']) self.assertEqual( plugins.sanitize_choices([u'D', u'*', u'A'], (u'A', u'B', u'C', u'D')), [u'D', u'B', u'C', u'A']) class ListenersTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_plugin_loader() def tearDown(self): self.teardown_plugin_loader() self.teardown_beets() def test_register(self): class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super(DummyPlugin, self).__init__() self.register_listener('cli_exit', self.dummy) self.register_listener('cli_exit', self.dummy) def dummy(self): pass d = DummyPlugin() self.assertEqual(DummyPlugin._raw_listeners['cli_exit'], [d.dummy]) d2 = DummyPlugin() self.assertEqual(DummyPlugin._raw_listeners['cli_exit'], [d.dummy, d2.dummy]) d.register_listener('cli_exit', d2.dummy) self.assertEqual(DummyPlugin._raw_listeners['cli_exit'], [d.dummy, d2.dummy]) @patch('beets.plugins.find_plugins') @patch('beets.plugins.inspect') def test_events_called(self, mock_inspect, mock_find_plugins): mock_inspect.getargspec.return_value = None class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super(DummyPlugin, self).__init__() self.foo = Mock(__name__='foo') self.register_listener('event_foo', self.foo) self.bar = Mock(__name__='bar') self.register_listener('event_bar', self.bar) d = DummyPlugin() mock_find_plugins.return_value = d, plugins.send('event') d.foo.assert_has_calls([]) d.bar.assert_has_calls([]) plugins.send('event_foo', var=u"tagada") d.foo.assert_called_once_with(var=u"tagada") d.bar.assert_has_calls([]) @patch('beets.plugins.find_plugins') def test_listener_params(self, mock_find_plugins): test = self class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super(DummyPlugin, self).__init__() for i in itertools.count(1): try: meth = getattr(self, 'dummy{0}'.format(i)) except AttributeError: break self.register_listener('event{0}'.format(i), meth) def dummy1(self, foo): test.assertEqual(foo, 5) def dummy2(self, foo=None): test.assertEqual(foo, 5) def dummy3(self): # argument cut off pass def dummy4(self, bar=None): # argument cut off pass def dummy5(self, bar): test.assertFalse(True) # more complex exmaples def dummy6(self, foo, bar=None): test.assertEqual(foo, 5) test.assertEqual(bar, None) def dummy7(self, foo, **kwargs): test.assertEqual(foo, 5) test.assertEqual(kwargs, {}) def dummy8(self, foo, bar, **kwargs): test.assertFalse(True) def dummy9(self, **kwargs): test.assertEqual(kwargs, {"foo": 5}) d = DummyPlugin() mock_find_plugins.return_value = d, plugins.send('event1', foo=5) plugins.send('event2', foo=5) plugins.send('event3', foo=5) plugins.send('event4', foo=5) with self.assertRaises(TypeError): plugins.send('event5', foo=5) plugins.send('event6', foo=5) plugins.send('event7', foo=5) with self.assertRaises(TypeError): plugins.send('event8', foo=5) plugins.send('event9', foo=5) class PromptChoicesTest(TerminalImportSessionSetup, unittest.TestCase, ImportHelper, TestHelper): def setUp(self): self.setup_plugin_loader() self.setup_beets() self._create_import_dir(3) self._setup_import_session() self.matcher = AutotagStub().install() # keep track of ui.input_option() calls self.input_options_patcher = patch('beets.ui.input_options', side_effect=ui.input_options) self.mock_input_options = self.input_options_patcher.start() def tearDown(self): self.input_options_patcher.stop() self.teardown_plugin_loader() self.teardown_beets() self.matcher.restore() def test_plugin_choices_in_ui_input_options_album(self): """Test the presence of plugin choices on the prompt (album).""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super(DummyPlugin, self).__init__() self.register_listener('before_choose_candidate', self.return_choices) def return_choices(self, session, task): return [ui.commands.PromptChoice('f', u'Foo', None), ui.commands.PromptChoice('r', u'baR', None)] self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', u'as Tracks', u'Group albums', u'Enter search', u'enter Id', u'aBort') + (u'Foo', u'baR') self.importer.add_choice(action.SKIP) self.importer.run() self.mock_input_options.assert_called_once_with(opts, default='a', require=ANY) def test_plugin_choices_in_ui_input_options_singleton(self): """Test the presence of plugin choices on the prompt (singleton).""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super(DummyPlugin, self).__init__() self.register_listener('before_choose_candidate', self.return_choices) def return_choices(self, session, task): return [ui.commands.PromptChoice('f', u'Foo', None), ui.commands.PromptChoice('r', u'baR', None)] self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', u'Enter search', u'enter Id', u'aBort') + (u'Foo', u'baR') config['import']['singletons'] = True self.importer.add_choice(action.SKIP) self.importer.run() self.mock_input_options.assert_called_with(opts, default='a', require=ANY) def test_choices_conflicts(self): """Test the short letter conflict solving.""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super(DummyPlugin, self).__init__() self.register_listener('before_choose_candidate', self.return_choices) def return_choices(self, session, task): return [ui.commands.PromptChoice('a', u'A foo', None), # dupe ui.commands.PromptChoice('z', u'baZ', None), # ok ui.commands.PromptChoice('z', u'Zupe', None), # dupe ui.commands.PromptChoice('z', u'Zoo', None)] # dupe self.register_plugin(DummyPlugin) # Default options + not dupe extra choices by the plugin ('baZ') opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', u'as Tracks', u'Group albums', u'Enter search', u'enter Id', u'aBort') + (u'baZ',) self.importer.add_choice(action.SKIP) self.importer.run() self.mock_input_options.assert_called_once_with(opts, default='a', require=ANY) def test_plugin_callback(self): """Test that plugin callbacks are being called upon user choice.""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super(DummyPlugin, self).__init__() self.register_listener('before_choose_candidate', self.return_choices) def return_choices(self, session, task): return [ui.commands.PromptChoice('f', u'Foo', self.foo)] def foo(self, session, task): pass self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', u'as Tracks', u'Group albums', u'Enter search', u'enter Id', u'aBort') + (u'Foo',) # DummyPlugin.foo() should be called once with patch.object(DummyPlugin, 'foo', autospec=True) as mock_foo: with helper.control_stdin('\n'.join(['f', 's'])): self.importer.run() self.assertEqual(mock_foo.call_count, 1) # input_options should be called twice, as foo() returns None self.assertEqual(self.mock_input_options.call_count, 2) self.mock_input_options.assert_called_with(opts, default='a', require=ANY) def test_plugin_callback_return(self): """Test that plugin callbacks that return a value exit the loop.""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super(DummyPlugin, self).__init__() self.register_listener('before_choose_candidate', self.return_choices) def return_choices(self, session, task): return [ui.commands.PromptChoice('f', u'Foo', self.foo)] def foo(self, session, task): return action.SKIP self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', u'as Tracks', u'Group albums', u'Enter search', u'enter Id', u'aBort') + (u'Foo',) # DummyPlugin.foo() should be called once with helper.control_stdin('f\n'): self.importer.run() # input_options should be called once, as foo() returns SKIP self.mock_input_options.assert_called_once_with(opts, default='a', require=ANY) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_importfeeds.py0000644000076500000240000000424613025125203020500 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- from __future__ import division, absolute_import, print_function import os import os.path import tempfile import shutil import unittest from beets import config from beets.library import Item, Album, Library from beetsplug.importfeeds import ImportFeedsPlugin class ImportfeedsTestTest(unittest.TestCase): def setUp(self): config.clear() config.read(user=False) self.importfeeds = ImportFeedsPlugin() self.lib = Library(':memory:') self.feeds_dir = tempfile.mkdtemp() config['importfeeds']['dir'] = self.feeds_dir def tearDown(self): shutil.rmtree(self.feeds_dir) def test_multi_format_album_playlist(self): config['importfeeds']['formats'] = 'm3u_multi' album = Album(album='album/name', id=1) item_path = os.path.join('path', 'to', 'item') item = Item(title='song', album_id=1, path=item_path) self.lib.add(album) self.lib.add(item) self.importfeeds.album_imported(self.lib, album) playlist_path = os.path.join(self.feeds_dir, os.listdir(self.feeds_dir)[0]) self.assertTrue(playlist_path.endswith('album_name.m3u')) with open(playlist_path) as playlist: self.assertIn(item_path, playlist.read()) def test_playlist_in_subdir(self): config['importfeeds']['formats'] = 'm3u' config['importfeeds']['m3u_name'] = \ os.path.join('subdir', 'imported.m3u') album = Album(album='album/name', id=1) item_path = os.path.join('path', 'to', 'item') item = Item(title='song', album_id=1, path=item_path) self.lib.add(album) self.lib.add(item) self.importfeeds.album_imported(self.lib, album) playlist = os.path.join(self.feeds_dir, config['importfeeds']['m3u_name'].get()) playlist_subdir = os.path.dirname(playlist) self.assertTrue(os.path.isdir(playlist_subdir)) self.assertTrue(os.path.isfile(playlist)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_thumbnails.py0000644000076500000240000002571313120341455020334 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Bruno Cauet # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import os.path from mock import Mock, patch, call from tempfile import mkdtemp from shutil import rmtree import unittest from test.helper import TestHelper from beets.util import bytestring_path from beetsplug.thumbnails import (ThumbnailsPlugin, NORMAL_DIR, LARGE_DIR, write_metadata_im, write_metadata_pil, PathlibURI, GioURI) class ThumbnailsTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() @patch('beetsplug.thumbnails.util') def test_write_metadata_im(self, mock_util): metadata = {"a": u"A", "b": u"B"} write_metadata_im("foo", metadata) try: command = u"convert foo -set a A -set b B foo".split(' ') mock_util.command_output.assert_called_once_with(command) except AssertionError: command = u"convert foo -set b B -set a A foo".split(' ') mock_util.command_output.assert_called_once_with(command) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') @patch('beetsplug.thumbnails.os.stat') def test_add_tags(self, mock_stat, _): plugin = ThumbnailsPlugin() plugin.write_metadata = Mock() plugin.get_uri = Mock(side_effect={b"/path/to/cover": "COVER_URI"}.__getitem__) album = Mock(artpath=b"/path/to/cover") mock_stat.return_value.st_mtime = 12345 plugin.add_tags(album, b"/path/to/thumbnail") metadata = {"Thumb::URI": "COVER_URI", "Thumb::MTime": u"12345"} plugin.write_metadata.assert_called_once_with(b"/path/to/thumbnail", metadata) mock_stat.assert_called_once_with(album.artpath) @patch('beetsplug.thumbnails.os') @patch('beetsplug.thumbnails.ArtResizer') @patch('beetsplug.thumbnails.get_im_version') @patch('beetsplug.thumbnails.get_pil_version') @patch('beetsplug.thumbnails.GioURI') def test_check_local_ok(self, mock_giouri, mock_pil, mock_im, mock_artresizer, mock_os): # test local resizing capability mock_artresizer.shared.local = False plugin = ThumbnailsPlugin() self.assertFalse(plugin._check_local_ok()) # test dirs creation mock_artresizer.shared.local = True def exists(path): if path == NORMAL_DIR: return False if path == LARGE_DIR: return True raise ValueError(u"unexpected path {0!r}".format(path)) mock_os.path.exists = exists plugin = ThumbnailsPlugin() mock_os.makedirs.assert_called_once_with(NORMAL_DIR) self.assertTrue(plugin._check_local_ok()) # test metadata writer function mock_os.path.exists = lambda _: True mock_pil.return_value = False mock_im.return_value = False with self.assertRaises(AssertionError): ThumbnailsPlugin() mock_pil.return_value = True self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_pil) mock_im.return_value = True self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im) mock_pil.return_value = False self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im) self.assertTrue(ThumbnailsPlugin()._check_local_ok()) # test URI getter function giouri_inst = mock_giouri.return_value giouri_inst.available = True self.assertEqual(ThumbnailsPlugin().get_uri, giouri_inst.uri) giouri_inst.available = False self.assertEqual(ThumbnailsPlugin().get_uri.__self__.__class__, PathlibURI) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') @patch('beetsplug.thumbnails.ArtResizer') @patch('beetsplug.thumbnails.util') @patch('beetsplug.thumbnails.os') @patch('beetsplug.thumbnails.shutil') def test_make_cover_thumbnail(self, mock_shutils, mock_os, mock_util, mock_artresizer, _): thumbnail_dir = os.path.normpath(b"/thumbnail/dir") md5_file = os.path.join(thumbnail_dir, b"md5") path_to_art = os.path.normpath(b"/path/to/art") mock_os.path.join = os.path.join # don't mock that function plugin = ThumbnailsPlugin() plugin.add_tags = Mock() album = Mock(artpath=path_to_art) mock_util.syspath.side_effect = lambda x: x plugin.thumbnail_file_name = Mock(return_value=b'md5') mock_os.path.exists.return_value = False def os_stat(target): if target == md5_file: return Mock(st_mtime=1) elif target == path_to_art: return Mock(st_mtime=2) else: raise ValueError(u"invalid target {0}".format(target)) mock_os.stat.side_effect = os_stat plugin.make_cover_thumbnail(album, 12345, thumbnail_dir) mock_os.path.exists.assert_called_once_with(md5_file) mock_os.stat.has_calls([call(md5_file), call(path_to_art)], any_order=True) resize = mock_artresizer.shared.resize resize.assert_called_once_with(12345, path_to_art, md5_file) plugin.add_tags.assert_called_once_with(album, resize.return_value) mock_shutils.move.assert_called_once_with(resize.return_value, md5_file) # now test with recent thumbnail & with force mock_os.path.exists.return_value = True plugin.force = False resize.reset_mock() def os_stat(target): if target == md5_file: return Mock(st_mtime=3) elif target == path_to_art: return Mock(st_mtime=2) else: raise ValueError(u"invalid target {0}".format(target)) mock_os.stat.side_effect = os_stat plugin.make_cover_thumbnail(album, 12345, thumbnail_dir) self.assertEqual(resize.call_count, 0) # and with force plugin.config['force'] = True plugin.make_cover_thumbnail(album, 12345, thumbnail_dir) resize.assert_called_once_with(12345, path_to_art, md5_file) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') def test_make_dolphin_cover_thumbnail(self, _): plugin = ThumbnailsPlugin() tmp = bytestring_path(mkdtemp()) album = Mock(path=tmp, artpath=os.path.join(tmp, b"cover.jpg")) plugin.make_dolphin_cover_thumbnail(album) with open(os.path.join(tmp, b".directory"), "rb") as f: self.assertEqual( f.read().splitlines(), [b"[Desktop Entry]", b"Icon=./cover.jpg"] ) # not rewritten when it already exists (yup that's a big limitation) album.artpath = b"/my/awesome/art.tiff" plugin.make_dolphin_cover_thumbnail(album) with open(os.path.join(tmp, b".directory"), "rb") as f: self.assertEqual( f.read().splitlines(), [b"[Desktop Entry]", b"Icon=./cover.jpg"] ) rmtree(tmp) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') @patch('beetsplug.thumbnails.ArtResizer') def test_process_album(self, mock_artresizer, _): get_size = mock_artresizer.shared.get_size plugin = ThumbnailsPlugin() make_cover = plugin.make_cover_thumbnail = Mock(return_value=True) make_dolphin = plugin.make_dolphin_cover_thumbnail = Mock() # no art album = Mock(artpath=None) plugin.process_album(album) self.assertEqual(get_size.call_count, 0) self.assertEqual(make_dolphin.call_count, 0) # cannot get art size album.artpath = b"/path/to/art" get_size.return_value = None plugin.process_album(album) get_size.assert_called_once_with(b"/path/to/art") self.assertEqual(make_cover.call_count, 0) # dolphin tests plugin.config['dolphin'] = False plugin.process_album(album) self.assertEqual(make_dolphin.call_count, 0) plugin.config['dolphin'] = True plugin.process_album(album) make_dolphin.assert_called_once_with(album) # small art get_size.return_value = 200, 200 plugin.process_album(album) make_cover.assert_called_once_with(album, 128, NORMAL_DIR) # big art make_cover.reset_mock() get_size.return_value = 500, 500 plugin.process_album(album) make_cover.has_calls([call(album, 128, NORMAL_DIR), call(album, 256, LARGE_DIR)], any_order=True) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') @patch('beetsplug.thumbnails.decargs') def test_invokations(self, mock_decargs, _): plugin = ThumbnailsPlugin() plugin.process_album = Mock() album = Mock() plugin.process_album.reset_mock() lib = Mock() album2 = Mock() lib.albums.return_value = [album, album2] plugin.process_query(lib, Mock(), None) lib.albums.assert_called_once_with(mock_decargs.return_value) plugin.process_album.has_calls([call(album), call(album2)], any_order=True) @patch('beetsplug.thumbnails.BaseDirectory') def test_thumbnail_file_name(self, mock_basedir): plug = ThumbnailsPlugin() plug.get_uri = Mock(return_value=u"file:///my/uri") self.assertEqual(plug.thumbnail_file_name(b'idontcare'), b"9488f5797fbe12ffb316d607dfd93d04.png") def test_uri(self): gio = GioURI() if not gio.available: self.skipTest(u"GIO library not found") self.assertEqual(gio.uri(u"/foo"), u"file:///") # silent fail self.assertEqual(gio.uri(b"/foo"), u"file:///foo") self.assertEqual(gio.uri(b"/foo!"), u"file:///foo!") self.assertEqual( gio.uri(b'/music/\xec\x8b\xb8\xec\x9d\xb4'), u'file:///music/%EC%8B%B8%EC%9D%B4') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_ihate.py0000644000076500000240000000343213025125203017245 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- """Tests for the 'ihate' plugin""" from __future__ import division, absolute_import, print_function import unittest from beets import importer from beets.library import Item from beetsplug.ihate import IHatePlugin class IHatePluginTest(unittest.TestCase): def test_hate(self): match_pattern = {} test_item = Item( genre=u'TestGenre', album=u'TestAlbum', artist=u'TestArtist') task = importer.SingletonImportTask(None, test_item) # Empty query should let it pass. self.assertFalse(IHatePlugin.do_i_hate_this(task, match_pattern)) # 1 query match. match_pattern = [u"artist:bad_artist", u"artist:TestArtist"] self.assertTrue(IHatePlugin.do_i_hate_this(task, match_pattern)) # 2 query matches, either should trigger. match_pattern = [u"album:test", u"artist:testartist"] self.assertTrue(IHatePlugin.do_i_hate_this(task, match_pattern)) # Query is blocked by AND clause. match_pattern = [u"album:notthis genre:testgenre"] self.assertFalse(IHatePlugin.do_i_hate_this(task, match_pattern)) # Both queries are blocked by AND clause with unmatched condition. match_pattern = [u"album:notthis genre:testgenre", u"artist:testartist album:notthis"] self.assertFalse(IHatePlugin.do_i_hate_this(task, match_pattern)) # Only one query should fire. match_pattern = [u"album:testalbum genre:testgenre", u"artist:testartist album:notthis"] self.assertTrue(IHatePlugin.do_i_hate_this(task, match_pattern)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_config_command.py0000644000076500000240000001133313025125203021115 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- from __future__ import division, absolute_import, print_function import os import yaml from mock import patch from tempfile import mkdtemp from shutil import rmtree import unittest from beets import ui from beets import config from test.helper import TestHelper from beets.library import Library import six class ConfigCommandTest(unittest.TestCase, TestHelper): def setUp(self): self.lib = Library(':memory:') self.temp_dir = mkdtemp() if 'EDITOR' in os.environ: del os.environ['EDITOR'] os.environ['BEETSDIR'] = self.temp_dir self.config_path = os.path.join(self.temp_dir, 'config.yaml') with open(self.config_path, 'w') as file: file.write('library: lib\n') file.write('option: value\n') file.write('password: password_value') self.cli_config_path = os.path.join(self.temp_dir, 'cli_config.yaml') with open(self.cli_config_path, 'w') as file: file.write('option: cli overwrite') config.clear() config['password'].redact = True config._materialized = False def tearDown(self): rmtree(self.temp_dir) def _run_with_yaml_output(self, *args): output = self.run_with_output(*args) return yaml.load(output) def test_show_user_config(self): output = self._run_with_yaml_output('config', '-c') self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'password_value') def test_show_user_config_with_defaults(self): output = self._run_with_yaml_output('config', '-dc') self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'password_value') self.assertEqual(output['library'], 'lib') self.assertEqual(output['import']['timid'], False) def test_show_user_config_with_cli(self): output = self._run_with_yaml_output('--config', self.cli_config_path, 'config') self.assertEqual(output['library'], 'lib') self.assertEqual(output['option'], 'cli overwrite') def test_show_redacted_user_config(self): output = self._run_with_yaml_output('config') self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'REDACTED') def test_show_redacted_user_config_with_defaults(self): output = self._run_with_yaml_output('config', '-d') self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'REDACTED') self.assertEqual(output['import']['timid'], False) def test_config_paths(self): output = self.run_with_output('config', '-p') paths = output.split('\n') self.assertEqual(len(paths), 2) self.assertEqual(paths[0], self.config_path) def test_config_paths_with_cli(self): output = self.run_with_output('--config', self.cli_config_path, 'config', '-p') paths = output.split('\n') self.assertEqual(len(paths), 3) self.assertEqual(paths[0], self.cli_config_path) def test_edit_config_with_editor_env(self): os.environ['EDITOR'] = 'myeditor' with patch('os.execlp') as execlp: self.run_command('config', '-e') execlp.assert_called_once_with( 'myeditor', 'myeditor', self.config_path) def test_edit_config_with_automatic_open(self): with patch('beets.util.open_anything') as open: open.return_value = 'please_open' with patch('os.execlp') as execlp: self.run_command('config', '-e') execlp.assert_called_once_with( 'please_open', 'please_open', self.config_path) def test_config_editor_not_found(self): with self.assertRaises(ui.UserError) as user_error: with patch('os.execlp') as execlp: execlp.side_effect = OSError('here is problem') self.run_command('config', '-e') self.assertIn('Could not edit configuration', six.text_type(user_error.exception)) self.assertIn('here is problem', six.text_type(user_error.exception)) def test_edit_invalid_config_file(self): with open(self.config_path, 'w') as file: file.write('invalid: [') config.clear() config._materialized = False os.environ['EDITOR'] = 'myeditor' with patch('os.execlp') as execlp: self.run_command('config', '-e') execlp.assert_called_once_with( 'myeditor', 'myeditor', self.config_path) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_keyfinder.py0000644000076500000240000000473213025125203020137 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function from mock import patch import unittest from test.helper import TestHelper from beets.library import Item from beets import util @patch('beets.util.command_output') class KeyFinderTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('keyfinder') def tearDown(self): self.teardown_beets() self.unload_plugins() def test_add_key(self, command_output): item = Item(path='/file') item.add(self.lib) command_output.return_value = 'dbm' self.run_command('keyfinder') item.load() self.assertEqual(item['initial_key'], 'C#m') command_output.assert_called_with( ['KeyFinder', '-f', util.syspath(item.path)]) def test_add_key_on_import(self, command_output): command_output.return_value = 'dbm' importer = self.create_importer() importer.run() item = self.lib.items().get() self.assertEqual(item['initial_key'], 'C#m') def test_force_overwrite(self, command_output): self.config['keyfinder']['overwrite'] = True item = Item(path='/file', initial_key='F') item.add(self.lib) command_output.return_value = 'C#m' self.run_command('keyfinder') item.load() self.assertEqual(item['initial_key'], 'C#m') def test_do_not_overwrite(self, command_output): item = Item(path='/file', initial_key='F') item.add(self.lib) command_output.return_value = 'dbm' self.run_command('keyfinder') item.load() self.assertEqual(item['initial_key'], 'F') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/testall.py0000755000076500000240000000242513025125203016570 0ustar asampsonstaff00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import os import re import sys import unittest pkgpath = os.path.dirname(__file__) or '.' sys.path.append(pkgpath) os.chdir(pkgpath) def suite(): s = unittest.TestSuite() # Get the suite() of every module in this directory beginning with # "test_". for fname in os.listdir(pkgpath): match = re.match(r'(test_\S+)\.py$', fname) if match: modname = match.group(1) s.addTest(__import__(modname).suite()) return s if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_zero.py0000644000076500000240000002147113032602010017127 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- """Tests for the 'zero' plugin""" from __future__ import division, absolute_import, print_function import unittest from test.helper import TestHelper, control_stdin from beets.library import Item from beetsplug.zero import ZeroPlugin from beets.mediafile import MediaFile from beets.util import syspath class ZeroPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.config['zero'] = { 'fields': [], 'keep_fields': [], 'update_database': False, } def tearDown(self): ZeroPlugin.listeners = None self.teardown_beets() self.unload_plugins() def test_no_patterns(self): self.config['zero']['fields'] = ['comments', 'month'] item = self.add_item_fixture( comments=u'test comment', title=u'Title', month=1, year=2000, ) item.write() self.load_plugins('zero') item.write() mf = MediaFile(syspath(item.path)) self.assertIsNone(mf.comments) self.assertIsNone(mf.month) self.assertEqual(mf.title, u'Title') self.assertEqual(mf.year, 2000) def test_pattern_match(self): self.config['zero']['fields'] = ['comments'] self.config['zero']['comments'] = [u'encoded by'] item = self.add_item_fixture(comments=u'encoded by encoder') item.write() self.load_plugins('zero') item.write() mf = MediaFile(syspath(item.path)) self.assertIsNone(mf.comments) def test_pattern_nomatch(self): self.config['zero']['fields'] = ['comments'] self.config['zero']['comments'] = [u'encoded by'] item = self.add_item_fixture(comments=u'recorded at place') item.write() self.load_plugins('zero') item.write() mf = MediaFile(syspath(item.path)) self.assertEqual(mf.comments, u'recorded at place') def test_do_not_change_database(self): self.config['zero']['fields'] = ['year'] item = self.add_item_fixture(year=2000) item.write() self.load_plugins('zero') item.write() self.assertEqual(item['year'], 2000) def test_change_database(self): self.config['zero']['fields'] = ['year'] self.config['zero']['update_database'] = True item = self.add_item_fixture(year=2000) item.write() self.load_plugins('zero') item.write() self.assertEqual(item['year'], 0) def test_album_art(self): self.config['zero']['fields'] = ['images'] path = self.create_mediafile_fixture(images=['jpg']) item = Item.from_path(path) self.load_plugins('zero') item.write() mf = MediaFile(syspath(path)) self.assertEqual(0, len(mf.images)) def test_auto_false(self): self.config['zero']['fields'] = ['year'] self.config['zero']['update_database'] = True self.config['zero']['auto'] = False item = self.add_item_fixture(year=2000) item.write() self.load_plugins('zero') item.write() self.assertEqual(item['year'], 2000) def test_subcommand_update_database_true(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments=u'test comment' ) item.write() item_id = item.id self.config['zero']['fields'] = ['comments'] self.config['zero']['update_database'] = True self.config['zero']['auto'] = False self.load_plugins('zero') with control_stdin('y'): self.run_command('zero') mf = MediaFile(syspath(item.path)) item = self.lib.get_item(item_id) self.assertEqual(item['year'], 2016) self.assertEqual(mf.year, 2016) self.assertEqual(mf.comments, None) self.assertEqual(item['comments'], u'') def test_subcommand_update_database_false(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments=u'test comment' ) item.write() item_id = item.id self.config['zero']['fields'] = ['comments'] self.config['zero']['update_database'] = False self.config['zero']['auto'] = False self.load_plugins('zero') with control_stdin('y'): self.run_command('zero') mf = MediaFile(syspath(item.path)) item = self.lib.get_item(item_id) self.assertEqual(item['year'], 2016) self.assertEqual(mf.year, 2016) self.assertEqual(item['comments'], u'test comment') self.assertEqual(mf.comments, None) def test_subcommand_query_include(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments=u'test comment' ) item.write() self.config['zero']['fields'] = ['comments'] self.config['zero']['update_database'] = False self.config['zero']['auto'] = False self.load_plugins('zero') self.run_command('zero', 'year: 2016') mf = MediaFile(syspath(item.path)) self.assertEqual(mf.year, 2016) self.assertEqual(mf.comments, None) def test_subcommand_query_exclude(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments=u'test comment' ) item.write() self.config['zero']['fields'] = ['comments'] self.config['zero']['update_database'] = False self.config['zero']['auto'] = False self.load_plugins('zero') self.run_command('zero', 'year: 0000') mf = MediaFile(syspath(item.path)) self.assertEqual(mf.year, 2016) self.assertEqual(mf.comments, u'test comment') def test_no_fields(self): item = self.add_item_fixture(year=2016) item.write() mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.year, 2016) item_id = item.id self.load_plugins('zero') with control_stdin('y'): self.run_command('zero') item = self.lib.get_item(item_id) self.assertEqual(item['year'], 2016) self.assertEqual(mediafile.year, 2016) def test_whitelist_and_blacklist(self): item = self.add_item_fixture(year=2016) item.write() mf = MediaFile(syspath(item.path)) self.assertEqual(mf.year, 2016) item_id = item.id self.config['zero']['fields'] = [u'year'] self.config['zero']['keep_fields'] = [u'comments'] self.load_plugins('zero') with control_stdin('y'): self.run_command('zero') item = self.lib.get_item(item_id) self.assertEqual(item['year'], 2016) self.assertEqual(mf.year, 2016) def test_keep_fields(self): item = self.add_item_fixture(year=2016, comments=u'test comment') self.config['zero']['keep_fields'] = [u'year'] self.config['zero']['fields'] = None self.config['zero']['update_database'] = True tags = { 'comments': u'test comment', 'year': 2016, } self.load_plugins('zero') z = ZeroPlugin() z.write_event(item, item.path, tags) self.assertEqual(tags['comments'], None) self.assertEqual(tags['year'], 2016) def test_keep_fields_removes_preserved_tags(self): self.config['zero']['keep_fields'] = [u'year'] self.config['zero']['fields'] = None self.config['zero']['update_database'] = True z = ZeroPlugin() self.assertNotIn('id', z.fields_to_progs) def test_fields_removes_preserved_tags(self): self.config['zero']['fields'] = [u'year id'] self.config['zero']['update_database'] = True z = ZeroPlugin() self.assertNotIn('id', z.fields_to_progs) def test_empty_query_n_response_no_changes(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments=u'test comment' ) item.write() item_id = item.id self.config['zero']['fields'] = ['comments'] self.config['zero']['update_database'] = True self.config['zero']['auto'] = False self.load_plugins('zero') with control_stdin('n'): self.run_command('zero') mf = MediaFile(syspath(item.path)) item = self.lib.get_item(item_id) self.assertEqual(item['year'], 2016) self.assertEqual(mf.year, 2016) self.assertEqual(mf.comments, u'test comment') self.assertEqual(item['comments'], u'test comment') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_play.py0000644000076500000240000001152113120341455017123 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Jesse Weinstein # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the play plugin""" from __future__ import division, absolute_import, print_function import os import unittest from mock import patch, ANY from test.helper import TestHelper, control_stdin from beets.ui import UserError from beets.util import open_anything @patch('beetsplug.play.util.interactive_open') class PlayPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('play') self.item = self.add_item(album=u'a nice älbum', title=u'aNiceTitle') self.lib.add_album([self.item]) self.config['play']['command'] = 'echo' def tearDown(self): self.teardown_beets() self.unload_plugins() def run_and_assert(self, open_mock, args=('title:aNiceTitle',), expected_cmd='echo', expected_playlist=None): self.run_command('play', *args) open_mock.assert_called_once_with(ANY, expected_cmd) expected_playlist = expected_playlist or self.item.path.decode('utf-8') exp_playlist = expected_playlist + u'\n' with open(open_mock.call_args[0][0][0], 'rb') as playlist: self.assertEqual(exp_playlist, playlist.read().decode('utf-8')) def test_basic(self, open_mock): self.run_and_assert(open_mock) def test_album_option(self, open_mock): self.run_and_assert(open_mock, [u'-a', u'nice']) def test_args_option(self, open_mock): self.run_and_assert( open_mock, [u'-A', u'foo', u'title:aNiceTitle'], u'echo foo') def test_args_option_in_middle(self, open_mock): self.config['play']['command'] = 'echo $args other' self.run_and_assert( open_mock, [u'-A', u'foo', u'title:aNiceTitle'], u'echo foo other') def test_unset_args_option_in_middle(self, open_mock): self.config['play']['command'] = 'echo $args other' self.run_and_assert( open_mock, [u'title:aNiceTitle'], u'echo other') def test_relative_to(self, open_mock): self.config['play']['command'] = 'echo' self.config['play']['relative_to'] = '/something' path = os.path.relpath(self.item.path, b'/something') playlist = path.decode('utf-8') self.run_and_assert( open_mock, expected_cmd='echo', expected_playlist=playlist) def test_use_folders(self, open_mock): self.config['play']['command'] = None self.config['play']['use_folders'] = True self.run_command('play', '-a', 'nice') open_mock.assert_called_once_with(ANY, open_anything()) with open(open_mock.call_args[0][0][0], 'rb') as f: playlist = f.read().decode('utf-8') self.assertEqual(u'{}\n'.format( os.path.dirname(self.item.path.decode('utf-8'))), playlist) def test_raw(self, open_mock): self.config['play']['raw'] = True self.run_command(u'play', u'nice') open_mock.assert_called_once_with([self.item.path], 'echo') def test_not_found(self, open_mock): self.run_command(u'play', u'not found') open_mock.assert_not_called() def test_warning_threshold(self, open_mock): self.config['play']['warning_threshold'] = 1 self.add_item(title='another NiceTitle') with control_stdin("a"): self.run_command(u'play', u'nice') open_mock.assert_not_called() def test_skip_warning_threshold_bypass(self, open_mock): self.config['play']['warning_threshold'] = 1 self.other_item = self.add_item(title='another NiceTitle') expected_playlist = u'{0}\n{1}'.format( self.item.path.decode('utf-8'), self.other_item.path.decode('utf-8')) with control_stdin("a"): self.run_and_assert( open_mock, [u'-y', u'NiceTitle'], expected_playlist=expected_playlist) def test_command_failed(self, open_mock): open_mock.side_effect = OSError(u"some reason") with self.assertRaises(UserError): self.run_command(u'play', u'title:aNiceTitle') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_hook.py0000644000076500000240000000703413025125203017115 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import os.path import tempfile import unittest from test import _common from test.helper import TestHelper from beets import config from beets import plugins def get_temporary_path(): temporary_directory = tempfile._get_default_tempdir() temporary_name = next(tempfile._get_candidate_names()) return os.path.join(temporary_directory, temporary_name) class HookTest(_common.TestCase, TestHelper): TEST_HOOK_COUNT = 5 def setUp(self): self.setup_beets() # Converter is threaded def tearDown(self): self.unload_plugins() self.teardown_beets() def _add_hook(self, event, command): hook = { 'event': event, 'command': command } hooks = config['hook']['hooks'].get(list) if 'hook' in config else [] hooks.append(hook) config['hook']['hooks'] = hooks def test_hook_no_arguments(self): temporary_paths = [ get_temporary_path() for i in range(self.TEST_HOOK_COUNT) ] for index, path in enumerate(temporary_paths): self._add_hook('test_no_argument_event_{0}'.format(index), 'touch "{0}"'.format(path)) self.load_plugins('hook') for index in range(len(temporary_paths)): plugins.send('test_no_argument_event_{0}'.format(index)) for path in temporary_paths: self.assertTrue(os.path.isfile(path)) os.remove(path) def test_hook_event_substitution(self): temporary_directory = tempfile._get_default_tempdir() event_names = ['test_event_event_{0}'.format(i) for i in range(self.TEST_HOOK_COUNT)] for event in event_names: self._add_hook(event, 'touch "{0}/{{event}}"'.format(temporary_directory)) self.load_plugins('hook') for event in event_names: plugins.send(event) for event in event_names: path = os.path.join(temporary_directory, event) self.assertTrue(os.path.isfile(path)) os.remove(path) def test_hook_argument_substitution(self): temporary_paths = [ get_temporary_path() for i in range(self.TEST_HOOK_COUNT) ] for index, path in enumerate(temporary_paths): self._add_hook('test_argument_event_{0}'.format(index), 'touch "{path}"') self.load_plugins('hook') for index, path in enumerate(temporary_paths): plugins.send('test_argument_event_{0}'.format(index), path=path) for path in temporary_paths: self.assertTrue(os.path.isfile(path)) os.remove(path) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_edit.py0000644000076500000240000005014013150552332017104 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson and Diego Moreda. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import codecs import unittest from mock import patch from test import _common from test.helper import TestHelper, control_stdin from test.test_ui_importer import TerminalImportSessionSetup from test.test_importer import ImportHelper, AutotagStub from beets.dbcore.query import TrueQuery from beets.library import Item from beetsplug.edit import EditPlugin class ModifyFileMocker(object): """Helper for modifying a file, replacing or editing its contents. Used for mocking the calls to the external editor during testing. """ def __init__(self, contents=None, replacements=None): """ `self.contents` and `self.replacements` are initialized here, in order to keep the rest of the functions of this class with the same signature as `EditPlugin.get_editor()`, making mocking easier. - `contents`: string with the contents of the file to be used for `overwrite_contents()` - `replacement`: dict with the in-place replacements to be used for `replace_contents()`, in the form {'previous string': 'new string'} TODO: check if it can be solved more elegantly with a decorator """ self.contents = contents self.replacements = replacements self.action = self.overwrite_contents if replacements: self.action = self.replace_contents # The two methods below mock the `edit` utility function in the plugin. def overwrite_contents(self, filename, log): """Modify `filename`, replacing its contents with `self.contents`. If `self.contents` is empty, the file remains unchanged. """ if self.contents: with codecs.open(filename, 'w', encoding='utf-8') as f: f.write(self.contents) def replace_contents(self, filename, log): """Modify `filename`, reading its contents and replacing the strings specified in `self.replacements`. """ with codecs.open(filename, 'r', encoding='utf-8') as f: contents = f.read() for old, new_ in self.replacements.items(): contents = contents.replace(old, new_) with codecs.open(filename, 'w', encoding='utf-8') as f: f.write(contents) class EditMixin(object): """Helper containing some common functionality used for the Edit tests.""" def assertItemFieldsModified(self, library_items, items, fields=[], # noqa allowed=['path']): """Assert that items in the library (`lib_items`) have different values on the specified `fields` (and *only* on those fields), compared to `items`. An empty `fields` list results in asserting that no modifications have been performed. `allowed` is a list of field changes that are ignored (they may or may not have changed; the assertion doesn't care). """ for lib_item, item in zip(library_items, items): diff_fields = [field for field in lib_item._fields if lib_item[field] != item[field]] self.assertEqual(set(diff_fields).difference(allowed), set(fields)) def run_mocked_interpreter(self, modify_file_args={}, stdin=[]): """Run the edit command during an import session, with mocked stdin and yaml writing. """ m = ModifyFileMocker(**modify_file_args) with patch('beetsplug.edit.edit', side_effect=m.action): with control_stdin('\n'.join(stdin)): self.importer.run() def run_mocked_command(self, modify_file_args={}, stdin=[], args=[]): """Run the edit command, with mocked stdin and yaml writing, and passing `args` to `run_command`.""" m = ModifyFileMocker(**modify_file_args) with patch('beetsplug.edit.edit', side_effect=m.action): with control_stdin('\n'.join(stdin)): self.run_command('edit', *args) @_common.slow_test() @patch('beets.library.Item.write') class EditCommandTest(unittest.TestCase, TestHelper, EditMixin): """Black box tests for `beetsplug.edit`. Command line interaction is simulated using `test.helper.control_stdin()`, and yaml editing via an external editor is simulated using `ModifyFileMocker`. """ ALBUM_COUNT = 1 TRACK_COUNT = 10 def setUp(self): self.setup_beets() self.load_plugins('edit') # Add an album, storing the original fields for comparison. self.album = self.add_album_fixture(track_count=self.TRACK_COUNT) self.album_orig = {f: self.album[f] for f in self.album._fields} self.items_orig = [{f: item[f] for f in item._fields} for item in self.album.items()] def tearDown(self): EditPlugin.listeners = None self.teardown_beets() self.unload_plugins() def assertCounts(self, mock_write, album_count=ALBUM_COUNT, track_count=TRACK_COUNT, # noqa write_call_count=TRACK_COUNT, title_starts_with=''): """Several common assertions on Album, Track and call counts.""" self.assertEqual(len(self.lib.albums()), album_count) self.assertEqual(len(self.lib.items()), track_count) self.assertEqual(mock_write.call_count, write_call_count) self.assertTrue(all(i.title.startswith(title_starts_with) for i in self.lib.items())) def test_title_edit_discard(self, mock_write): """Edit title for all items in the library, then discard changes.""" # Edit track titles. self.run_mocked_command({'replacements': {u't\u00eftle': u'modified t\u00eftle'}}, # Cancel. ['c']) self.assertCounts(mock_write, write_call_count=0, title_starts_with=u't\u00eftle') self.assertItemFieldsModified(self.album.items(), self.items_orig, []) def test_title_edit_apply(self, mock_write): """Edit title for all items in the library, then apply changes.""" # Edit track titles. self.run_mocked_command({'replacements': {u't\u00eftle': u'modified t\u00eftle'}}, # Apply changes. ['a']) self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT, title_starts_with=u'modified t\u00eftle') self.assertItemFieldsModified(self.album.items(), self.items_orig, ['title', 'mtime']) def test_single_title_edit_apply(self, mock_write): """Edit title for one item in the library, then apply changes.""" # Edit one track title. self.run_mocked_command({'replacements': {u't\u00eftle 9': u'modified t\u00eftle 9'}}, # Apply changes. ['a']) self.assertCounts(mock_write, write_call_count=1,) # No changes except on last item. self.assertItemFieldsModified(list(self.album.items())[:-1], self.items_orig[:-1], []) self.assertEqual(list(self.album.items())[-1].title, u'modified t\u00eftle 9') def test_noedit(self, mock_write): """Do not edit anything.""" # Do not edit anything. self.run_mocked_command({'contents': None}, # No stdin. []) self.assertCounts(mock_write, write_call_count=0, title_starts_with=u't\u00eftle') self.assertItemFieldsModified(self.album.items(), self.items_orig, []) def test_album_edit_apply(self, mock_write): """Edit the album field for all items in the library, apply changes. By design, the album should not be updated."" """ # Edit album. self.run_mocked_command({'replacements': {u'\u00e4lbum': u'modified \u00e4lbum'}}, # Apply changes. ['a']) self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) self.assertItemFieldsModified(self.album.items(), self.items_orig, ['album', 'mtime']) # Ensure album is *not* modified. self.album.load() self.assertEqual(self.album.album, u'\u00e4lbum') def test_single_edit_add_field(self, mock_write): """Edit the yaml file appending an extra field to the first item, then apply changes.""" # Append "foo: bar" to item with id == 2. ("id: 1" would match both # "id: 1" and "id: 10") self.run_mocked_command({'replacements': {u"id: 2": u"id: 2\nfoo: bar"}}, # Apply changes. ['a']) self.assertEqual(self.lib.items(u'id:2')[0].foo, 'bar') # Even though a flexible attribute was written (which is not directly # written to the tags), write should still be called since templates # might use it. self.assertCounts(mock_write, write_call_count=1, title_starts_with=u't\u00eftle') def test_a_album_edit_apply(self, mock_write): """Album query (-a), edit album field, apply changes.""" self.run_mocked_command({'replacements': {u'\u00e4lbum': u'modified \u00e4lbum'}}, # Apply changes. ['a'], args=['-a']) self.album.load() self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) self.assertEqual(self.album.album, u'modified \u00e4lbum') self.assertItemFieldsModified(self.album.items(), self.items_orig, ['album', 'mtime']) def test_a_albumartist_edit_apply(self, mock_write): """Album query (-a), edit albumartist field, apply changes.""" self.run_mocked_command({'replacements': {u'album artist': u'modified album artist'}}, # Apply changes. ['a'], args=['-a']) self.album.load() self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) self.assertEqual(self.album.albumartist, u'the modified album artist') self.assertItemFieldsModified(self.album.items(), self.items_orig, ['albumartist', 'mtime']) def test_malformed_yaml(self, mock_write): """Edit the yaml file incorrectly (resulting in a malformed yaml document).""" # Edit the yaml file to an invalid file. self.run_mocked_command({'contents': '!MALFORMED'}, # Edit again to fix? No. ['n']) self.assertCounts(mock_write, write_call_count=0, title_starts_with=u't\u00eftle') def test_invalid_yaml(self, mock_write): """Edit the yaml file incorrectly (resulting in a well-formed but invalid yaml document).""" # Edit the yaml file to an invalid but parseable file. self.run_mocked_command({'contents': u'wellformed: yes, but invalid'}, # No stdin. []) self.assertCounts(mock_write, write_call_count=0, title_starts_with=u't\u00eftle') @_common.slow_test() class EditDuringImporterTest(TerminalImportSessionSetup, unittest.TestCase, ImportHelper, TestHelper, EditMixin): """TODO """ IGNORED = ['added', 'album_id', 'id', 'mtime', 'path'] def setUp(self): self.setup_beets() self.load_plugins('edit') # Create some mediafiles, and store them for comparison. self._create_import_dir(3) self.items_orig = [Item.from_path(f.path) for f in self.media_files] self.matcher = AutotagStub().install() self.matcher.matching = AutotagStub.GOOD self.config['import']['timid'] = True def tearDown(self): EditPlugin.listeners = None self.unload_plugins() self.teardown_beets() self.matcher.restore() def test_edit_apply_asis(self): """Edit the album field for all items in the library, apply changes, using the original item tags. """ self._setup_import_session() # Edit track titles. self.run_mocked_interpreter({'replacements': {u'Tag Title': u'Edited Title'}}, # eDit, Apply changes. ['d', 'a']) # Check that only the 'title' field is modified. self.assertItemFieldsModified(self.lib.items(), self.items_orig, ['title'], self.IGNORED + ['albumartist', 'mb_albumartistid']) self.assertTrue(all('Edited Title' in i.title for i in self.lib.items())) # Ensure album is *not* fetched from a candidate. self.assertEqual(self.lib.albums()[0].mb_albumid, u'') def test_edit_discard_asis(self): """Edit the album field for all items in the library, discard changes, using the original item tags. """ self._setup_import_session() # Edit track titles. self.run_mocked_interpreter({'replacements': {u'Tag Title': u'Edited Title'}}, # eDit, Cancel, Use as-is. ['d', 'c', 'u']) # Check that nothing is modified, the album is imported ASIS. self.assertItemFieldsModified(self.lib.items(), self.items_orig, [], self.IGNORED + ['albumartist', 'mb_albumartistid']) self.assertTrue(all('Tag Title' in i.title for i in self.lib.items())) # Ensure album is *not* fetched from a candidate. self.assertEqual(self.lib.albums()[0].mb_albumid, u'') def test_edit_apply_candidate(self): """Edit the album field for all items in the library, apply changes, using a candidate. """ self._setup_import_session() # Edit track titles. self.run_mocked_interpreter({'replacements': {u'Applied Title': u'Edited Title'}}, # edit Candidates, 1, Apply changes. ['c', '1', 'a']) # Check that 'title' field is modified, and other fields come from # the candidate. self.assertTrue(all('Edited Title ' in i.title for i in self.lib.items())) self.assertTrue(all('match ' in i.mb_trackid for i in self.lib.items())) # Ensure album is fetched from a candidate. self.assertIn('albumid', self.lib.albums()[0].mb_albumid) def test_edit_retag_apply(self): """Import the album using a candidate, then retag and edit and apply changes. """ self._setup_import_session() self.run_mocked_interpreter({}, # 1, Apply changes. ['1', 'a']) # Retag and edit track titles. On retag, the importer will reset items # ids but not the db connections. self.importer.paths = [] self.importer.query = TrueQuery() self.run_mocked_interpreter({'replacements': {u'Applied Title': u'Edited Title'}}, # eDit, Apply changes. ['d', 'a']) # Check that 'title' field is modified, and other fields come from # the candidate. self.assertTrue(all('Edited Title ' in i.title for i in self.lib.items())) self.assertTrue(all('match ' in i.mb_trackid for i in self.lib.items())) # Ensure album is fetched from a candidate. self.assertIn('albumid', self.lib.albums()[0].mb_albumid) def test_edit_discard_candidate(self): """Edit the album field for all items in the library, discard changes, using a candidate. """ self._setup_import_session() # Edit track titles. self.run_mocked_interpreter({'replacements': {u'Applied Title': u'Edited Title'}}, # edit Candidates, 1, Apply changes. ['c', '1', 'a']) # Check that 'title' field is modified, and other fields come from # the candidate. self.assertTrue(all('Edited Title ' in i.title for i in self.lib.items())) self.assertTrue(all('match ' in i.mb_trackid for i in self.lib.items())) # Ensure album is fetched from a candidate. self.assertIn('albumid', self.lib.albums()[0].mb_albumid) def test_edit_apply_asis_singleton(self): """Edit the album field for all items in the library, apply changes, using the original item tags and singleton mode. """ self._setup_import_session(singletons=True) # Edit track titles. self.run_mocked_interpreter({'replacements': {u'Tag Title': u'Edited Title'}}, # eDit, Apply changes, aBort. ['d', 'a', 'b']) # Check that only the 'title' field is modified. self.assertItemFieldsModified(self.lib.items(), self.items_orig, ['title'], self.IGNORED + ['albumartist', 'mb_albumartistid']) self.assertTrue(all('Edited Title' in i.title for i in self.lib.items())) def test_edit_apply_candidate_singleton(self): """Edit the album field for all items in the library, apply changes, using a candidate and singleton mode. """ self._setup_import_session() # Edit track titles. self.run_mocked_interpreter({'replacements': {u'Applied Title': u'Edited Title'}}, # edit Candidates, 1, Apply changes, aBort. ['c', '1', 'a', 'b']) # Check that 'title' field is modified, and other fields come from # the candidate. self.assertTrue(all('Edited Title ' in i.title for i in self.lib.items())) self.assertTrue(all('match ' in i.mb_trackid for i in self.lib.items())) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_web.py0000644000076500000240000001271513122255064016743 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- """Tests for the 'web' plugin""" from __future__ import division, absolute_import, print_function import json import unittest import os.path from six import assertCountEqual from test import _common from beets.library import Item, Album from beetsplug import web class WebPluginTest(_common.LibTestCase): def setUp(self): super(WebPluginTest, self).setUp() # Add fixtures for track in self.lib.items(): track.remove() self.lib.add(Item(title=u'title', path='/path_1', id=1)) self.lib.add(Item(title=u'another title', path='/path_2', id=2)) self.lib.add(Album(album=u'album', id=3)) self.lib.add(Album(album=u'another album', id=4)) web.app.config['TESTING'] = True web.app.config['lib'] = self.lib web.app.config['INCLUDE_PATHS'] = False self.client = web.app.test_client() def test_config_include_paths_true(self): web.app.config['INCLUDE_PATHS'] = True response = self.client.get('/item/1') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(response.json['path'], u'/path_1') def test_config_include_paths_false(self): web.app.config['INCLUDE_PATHS'] = False response = self.client.get('/item/1') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertNotIn('path', response.json) def test_get_all_items(self): response = self.client.get('/item/') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json['items']), 2) def test_get_single_item_by_id(self): response = self.client.get('/item/1') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(response.json['id'], 1) self.assertEqual(response.json['title'], u'title') def test_get_multiple_items_by_id(self): response = self.client.get('/item/1,2') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json['items']), 2) response_titles = [item['title'] for item in response.json['items']] assertCountEqual(self, response_titles, [u'title', u'another title']) def test_get_single_item_not_found(self): response = self.client.get('/item/3') self.assertEqual(response.status_code, 404) def test_get_single_item_by_path(self): data_path = os.path.join(_common.RSRC, b'full.mp3') self.lib.add(Item.from_path(data_path)) response = self.client.get('/item/path/' + data_path.decode('utf-8')) response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(response.json['title'], u'full') def test_get_single_item_by_path_not_found_if_not_in_library(self): data_path = os.path.join(_common.RSRC, b'full.mp3') # data_path points to a valid file, but we have not added the file # to the library. response = self.client.get('/item/path/' + data_path.decode('utf-8')) self.assertEqual(response.status_code, 404) def test_get_item_empty_query(self): response = self.client.get('/item/query/') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json['items']), 2) def test_get_simple_item_query(self): response = self.client.get('/item/query/another') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json['results']), 1) self.assertEqual(response.json['results'][0]['title'], u'another title') def test_get_all_albums(self): response = self.client.get('/album/') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) response_albums = [album['album'] for album in response.json['albums']] assertCountEqual(self, response_albums, [u'album', u'another album']) def test_get_single_album_by_id(self): response = self.client.get('/album/2') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(response.json['id'], 2) self.assertEqual(response.json['album'], u'another album') def test_get_multiple_albums_by_id(self): response = self.client.get('/album/1,2') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) response_albums = [album['album'] for album in response.json['albums']] assertCountEqual(self, response_albums, [u'album', u'another album']) def test_get_album_empty_query(self): response = self.client.get('/album/query/') response.json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json['albums']), 2) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/_common.py0000644000076500000240000002460213175434666016573 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Some common functionality for beets' test cases.""" from __future__ import division, absolute_import, print_function import time import sys import os import tempfile import shutil import six import unittest from contextlib import contextmanager # Mangle the search path to include the beets sources. sys.path.insert(0, '..') import beets.library # noqa: E402 from beets import importer, logging # noqa: E402 from beets.ui import commands # noqa: E402 from beets import util # noqa: E402 import beets # noqa: E402 # Make sure the development versions of the plugins are used import beetsplug # noqa: E402 beetsplug.__path__ = [os.path.abspath( os.path.join(__file__, '..', '..', 'beetsplug') )] # Test resources path. RSRC = util.bytestring_path(os.path.join(os.path.dirname(__file__), 'rsrc')) PLUGINPATH = os.path.join(os.path.dirname(__file__), 'rsrc', 'beetsplug') # Propagate to root logger so nosetest can capture it log = logging.getLogger('beets') log.propagate = True log.setLevel(logging.DEBUG) # Dummy item creation. _item_ident = 0 # OS feature test. HAVE_SYMLINK = sys.platform != 'win32' HAVE_HARDLINK = sys.platform != 'win32' def item(lib=None): global _item_ident _item_ident += 1 i = beets.library.Item( title=u'the title', artist=u'the artist', albumartist=u'the album artist', album=u'the album', genre=u'the genre', lyricist=u'the lyricist', composer=u'the composer', arranger=u'the arranger', grouping=u'the grouping', year=1, month=2, day=3, track=4, tracktotal=5, disc=6, disctotal=7, lyrics=u'the lyrics', comments=u'the comments', bpm=8, comp=True, path='somepath{0}'.format(_item_ident), length=60.0, bitrate=128000, format='FLAC', mb_trackid='someID-1', mb_albumid='someID-2', mb_artistid='someID-3', mb_albumartistid='someID-4', album_id=None, mtime=12345, ) if lib: lib.add(i) return i _album_ident = 0 def album(lib=None): global _item_ident _item_ident += 1 i = beets.library.Album( artpath=None, albumartist=u'some album artist', albumartist_sort=u'some sort album artist', albumartist_credit=u'some album artist credit', album=u'the album', genre=u'the genre', year=2014, month=2, day=5, tracktotal=0, disctotal=1, comp=False, mb_albumid='someID-1', mb_albumartistid='someID-1' ) if lib: lib.add(i) return i # Dummy import session. def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False): cls = commands.TerminalImportSession if cli else importer.ImportSession return cls(lib, loghandler, paths, query) class Assertions(object): """A mixin with additional unit test assertions.""" def assertExists(self, path): # noqa self.assertTrue(os.path.exists(util.syspath(path)), u'file does not exist: {!r}'.format(path)) def assertNotExists(self, path): # noqa self.assertFalse(os.path.exists(util.syspath(path)), u'file exists: {!r}'.format((path))) def assert_equal_path(self, a, b): """Check that two paths are equal.""" self.assertEqual(util.normpath(a), util.normpath(b), u'paths are not equal: {!r} and {!r}'.format(a, b)) # A test harness for all beets tests. # Provides temporary, isolated configuration. class TestCase(unittest.TestCase, Assertions): """A unittest.TestCase subclass that saves and restores beets' global configuration. This allows tests to make temporary modifications that will then be automatically removed when the test completes. Also provides some additional assertion methods, a temporary directory, and a DummyIO. """ def setUp(self): # A "clean" source list including only the defaults. beets.config.sources = [] beets.config.read(user=False, defaults=True) # Direct paths to a temporary directory. Tests can also use this # temporary directory. self.temp_dir = util.bytestring_path(tempfile.mkdtemp()) beets.config['statefile'] = \ util.py3_path(os.path.join(self.temp_dir, b'state.pickle')) beets.config['library'] = \ util.py3_path(os.path.join(self.temp_dir, b'library.db')) beets.config['directory'] = \ util.py3_path(os.path.join(self.temp_dir, b'libdir')) # Set $HOME, which is used by confit's `config_dir()` to create # directories. self._old_home = os.environ.get('HOME') os.environ['HOME'] = util.py3_path(self.temp_dir) # Initialize, but don't install, a DummyIO. self.io = DummyIO() def tearDown(self): if os.path.isdir(self.temp_dir): shutil.rmtree(self.temp_dir) if self._old_home is None: del os.environ['HOME'] else: os.environ['HOME'] = self._old_home self.io.restore() beets.config.clear() beets.config._materialized = False class LibTestCase(TestCase): """A test case that includes an in-memory library object (`lib`) and an item added to the library (`i`). """ def setUp(self): super(LibTestCase, self).setUp() self.lib = beets.library.Library(':memory:') self.i = item(self.lib) def tearDown(self): self.lib._connection().close() super(LibTestCase, self).tearDown() # Mock timing. class Timecop(object): """Mocks the timing system (namely time() and sleep()) for testing. Inspired by the Ruby timecop library. """ def __init__(self): self.now = time.time() def time(self): return self.now def sleep(self, amount): self.now += amount def install(self): self.orig = { 'time': time.time, 'sleep': time.sleep, } time.time = self.time time.sleep = self.sleep def restore(self): time.time = self.orig['time'] time.sleep = self.orig['sleep'] # Mock I/O. class InputException(Exception): def __init__(self, output=None): self.output = output def __str__(self): msg = "Attempt to read with no input provided." if self.output is not None: msg += " Output: {!r}".format(self.output) return msg class DummyOut(object): encoding = 'utf-8' def __init__(self): self.buf = [] def write(self, s): self.buf.append(s) def get(self): if six.PY2: return b''.join(self.buf) else: return ''.join(self.buf) def flush(self): self.clear() def clear(self): self.buf = [] class DummyIn(object): encoding = 'utf-8' def __init__(self, out=None): self.buf = [] self.reads = 0 self.out = out def add(self, s): if six.PY2: self.buf.append(s + b'\n') else: self.buf.append(s + '\n') def readline(self): if not self.buf: if self.out: raise InputException(self.out.get()) else: raise InputException() self.reads += 1 return self.buf.pop(0) class DummyIO(object): """Mocks input and output streams for testing UI code.""" def __init__(self): self.stdout = DummyOut() self.stdin = DummyIn(self.stdout) def addinput(self, s): self.stdin.add(s) def getoutput(self): res = self.stdout.get() self.stdout.clear() return res def readcount(self): return self.stdin.reads def install(self): sys.stdin = self.stdin sys.stdout = self.stdout def restore(self): sys.stdin = sys.__stdin__ sys.stdout = sys.__stdout__ # Utility. def touch(path): open(path, 'a').close() class Bag(object): """An object that exposes a set of fields given as keyword arguments. Any field not found in the dictionary appears to be None. Used for mocking Album objects and the like. """ def __init__(self, **fields): self.fields = fields def __getattr__(self, key): return self.fields.get(key) # Convenience methods for setting up a temporary sandbox directory for tests # that need to interact with the filesystem. class TempDirMixin(object): """Text mixin for creating and deleting a temporary directory. """ def create_temp_dir(self): """Create a temporary directory and assign it into `self.temp_dir`. Call `remove_temp_dir` later to delete it. """ path = tempfile.mkdtemp() if not isinstance(path, bytes): path = path.encode('utf8') self.temp_dir = path def remove_temp_dir(self): """Delete the temporary directory created by `create_temp_dir`. """ if os.path.isdir(self.temp_dir): shutil.rmtree(self.temp_dir) # Platform mocking. @contextmanager def platform_windows(): import ntpath old_path = os.path try: os.path = ntpath yield finally: os.path = old_path @contextmanager def platform_posix(): import posixpath old_path = os.path try: os.path = posixpath yield finally: os.path = old_path @contextmanager def system_mock(name): import platform old_system = platform.system platform.system = lambda: name try: yield finally: platform.system = old_system def slow_test(unused=None): def _id(obj): return obj if 'SKIP_SLOW_TESTS' in os.environ: return unittest.skip(u'test is slow') return _id beets-1.4.6/test/test_importer.py0000644000076500000240000021014513215275541020031 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function """Tests for the general importer functionality. """ import os import re import shutil import unicodedata import sys import stat from six import StringIO from tempfile import mkstemp from zipfile import ZipFile from tarfile import TarFile from mock import patch, Mock import unittest from test import _common from beets.util import displayable_path, bytestring_path, py3_path from test.helper import TestImportSession, TestHelper, has_program, capture_log from beets import importer from beets.importer import albums_in_dir from beets.mediafile import MediaFile from beets import autotag from beets.autotag import AlbumInfo, TrackInfo, AlbumMatch from beets import config from beets import logging from beets import util class AutotagStub(object): """Stub out MusicBrainz album and track matcher and control what the autotagger returns. """ NONE = 'NONE' IDENT = 'IDENT' GOOD = 'GOOD' BAD = 'BAD' MISSING = 'MISSING' """Generate an album match for all but one track """ length = 2 matching = IDENT def install(self): self.mb_match_album = autotag.mb.match_album self.mb_match_track = autotag.mb.match_track self.mb_album_for_id = autotag.mb.album_for_id self.mb_track_for_id = autotag.mb.track_for_id autotag.mb.match_album = self.match_album autotag.mb.match_track = self.match_track autotag.mb.album_for_id = self.album_for_id autotag.mb.track_for_id = self.track_for_id return self def restore(self): autotag.mb.match_album = self.mb_match_album autotag.mb.match_track = self.mb_match_track autotag.mb.album_for_id = self.mb_album_for_id autotag.mb.track_for_id = self.mb_track_for_id def match_album(self, albumartist, album, tracks): if self.matching == self.IDENT: yield self._make_album_match(albumartist, album, tracks) elif self.matching == self.GOOD: for i in range(self.length): yield self._make_album_match(albumartist, album, tracks, i) elif self.matching == self.BAD: for i in range(self.length): yield self._make_album_match(albumartist, album, tracks, i + 1) elif self.matching == self.MISSING: yield self._make_album_match(albumartist, album, tracks, missing=1) def match_track(self, artist, title): yield TrackInfo( title=title.replace('Tag', 'Applied'), track_id=u'trackid', artist=artist.replace('Tag', 'Applied'), artist_id=u'artistid', length=1, index=0, ) def album_for_id(self, mbid): return None def track_for_id(self, mbid): return None def _make_track_match(self, artist, album, number): return TrackInfo( title=u'Applied Title %d' % number, track_id=u'match %d' % number, artist=artist, length=1, index=0, ) def _make_album_match(self, artist, album, tracks, distance=0, missing=0): if distance: id = ' ' + 'M' * distance else: id = '' if artist is None: artist = u"Various Artists" else: artist = artist.replace('Tag', 'Applied') + id album = album.replace('Tag', 'Applied') + id track_infos = [] for i in range(tracks - missing): track_infos.append(self._make_track_match(artist, album, i + 1)) return AlbumInfo( artist=artist, album=album, tracks=track_infos, va=False, album_id=u'albumid' + id, artist_id=u'artistid' + id, albumtype=u'soundtrack' ) class ImportHelper(TestHelper): """Provides tools to setup a library, a directory containing files that are to be imported and an import session. The class also provides stubs for the autotagging library and several assertions for the library. """ def setup_beets(self, disk=False): super(ImportHelper, self).setup_beets(disk) self.lib.path_formats = [ (u'default', os.path.join('$artist', '$album', '$title')), (u'singleton:true', os.path.join('singletons', '$title')), (u'comp:true', os.path.join('compilations', '$album', '$title')), ] def _create_import_dir(self, count=3): """Creates a directory with media files to import. Sets ``self.import_dir`` to the path of the directory. Also sets ``self.import_media`` to a list :class:`MediaFile` for all the files in the directory. The directory has following layout the_album/ track_1.mp3 track_2.mp3 track_3.mp3 :param count: Number of files to create """ self.import_dir = os.path.join(self.temp_dir, b'testsrcdir') if os.path.isdir(self.import_dir): shutil.rmtree(self.import_dir) album_path = os.path.join(self.import_dir, b'the_album') os.makedirs(album_path) resource_path = os.path.join(_common.RSRC, b'full.mp3') metadata = { 'artist': u'Tag Artist', 'album': u'Tag Album', 'albumartist': None, 'mb_trackid': None, 'mb_albumid': None, 'comp': None } self.media_files = [] for i in range(count): # Copy files medium_path = os.path.join( album_path, bytestring_path('track_%d.mp3' % (i + 1)) ) shutil.copy(resource_path, medium_path) medium = MediaFile(medium_path) # Set metadata metadata['track'] = i + 1 metadata['title'] = u'Tag Title %d' % (i + 1) for attr in metadata: setattr(medium, attr, metadata[attr]) medium.save() self.media_files.append(medium) self.import_media = self.media_files def _setup_import_session(self, import_dir=None, delete=False, threaded=False, copy=True, singletons=False, move=False, autotag=True, link=False, hardlink=False): config['import']['copy'] = copy config['import']['delete'] = delete config['import']['timid'] = True config['threaded'] = False config['import']['singletons'] = singletons config['import']['move'] = move config['import']['autotag'] = autotag config['import']['resume'] = False config['import']['link'] = link config['import']['hardlink'] = hardlink self.importer = TestImportSession( self.lib, loghandler=None, query=None, paths=[import_dir or self.import_dir] ) def assert_file_in_lib(self, *segments): """Join the ``segments`` and assert that this path exists in the library directory """ self.assertExists(os.path.join(self.libdir, *segments)) def assert_file_not_in_lib(self, *segments): """Join the ``segments`` and assert that this path exists in the library directory """ self.assertNotExists(os.path.join(self.libdir, *segments)) def assert_lib_dir_empty(self): self.assertEqual(len(os.listdir(self.libdir)), 0) @_common.slow_test() class NonAutotaggedImportTest(_common.TestCase, ImportHelper): def setUp(self): self.setup_beets(disk=True) self._create_import_dir(2) self._setup_import_session(autotag=False) def tearDown(self): self.teardown_beets() def test_album_created_with_track_artist(self): self.importer.run() albums = self.lib.albums() self.assertEqual(len(albums), 1) self.assertEqual(albums[0].albumartist, u'Tag Artist') def test_import_copy_arrives(self): self.importer.run() for mediafile in self.import_media: self.assert_file_in_lib( b'Tag Artist', b'Tag Album', util.bytestring_path('{0}.mp3'.format(mediafile.title))) def test_threaded_import_copy_arrives(self): config['threaded'] = True self.importer.run() for mediafile in self.import_media: self.assert_file_in_lib( b'Tag Artist', b'Tag Album', util.bytestring_path('{0}.mp3'.format(mediafile.title))) def test_import_with_move_deletes_import_files(self): config['import']['move'] = True for mediafile in self.import_media: self.assertExists(mediafile.path) self.importer.run() for mediafile in self.import_media: self.assertNotExists(mediafile.path) def test_import_with_move_prunes_directory_empty(self): config['import']['move'] = True self.assertExists(os.path.join(self.import_dir, b'the_album')) self.importer.run() self.assertNotExists(os.path.join(self.import_dir, b'the_album')) def test_import_with_move_prunes_with_extra_clutter(self): f = open(os.path.join(self.import_dir, b'the_album', b'alog.log'), 'w') f.close() config['clutter'] = ['*.log'] config['import']['move'] = True self.assertExists(os.path.join(self.import_dir, b'the_album')) self.importer.run() self.assertNotExists(os.path.join(self.import_dir, b'the_album')) def test_threaded_import_move_arrives(self): config['import']['move'] = True config['import']['threaded'] = True self.importer.run() for mediafile in self.import_media: self.assert_file_in_lib( b'Tag Artist', b'Tag Album', util.bytestring_path('{0}.mp3'.format(mediafile.title))) def test_threaded_import_move_deletes_import(self): config['import']['move'] = True config['threaded'] = True self.importer.run() for mediafile in self.import_media: self.assertNotExists(mediafile.path) def test_import_without_delete_retains_files(self): config['import']['delete'] = False self.importer.run() for mediafile in self.import_media: self.assertExists(mediafile.path) def test_import_with_delete_removes_files(self): config['import']['delete'] = True self.importer.run() for mediafile in self.import_media: self.assertNotExists(mediafile.path) def test_import_with_delete_prunes_directory_empty(self): config['import']['delete'] = True self.assertExists(os.path.join(self.import_dir, b'the_album')) self.importer.run() self.assertNotExists(os.path.join(self.import_dir, b'the_album')) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_import_link_arrives(self): config['import']['link'] = True self.importer.run() for mediafile in self.import_media: filename = os.path.join( self.libdir, b'Tag Artist', b'Tag Album', util.bytestring_path('{0}.mp3'.format(mediafile.title)) ) self.assertExists(filename) self.assertTrue(os.path.islink(filename)) self.assert_equal_path( util.bytestring_path(os.readlink(filename)), mediafile.path ) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_import_hardlink_arrives(self): config['import']['hardlink'] = True self.importer.run() for mediafile in self.import_media: filename = os.path.join( self.libdir, b'Tag Artist', b'Tag Album', util.bytestring_path('{0}.mp3'.format(mediafile.title)) ) self.assertExists(filename) s1 = os.stat(mediafile.path) s2 = os.stat(filename) self.assertTrue( (s1[stat.ST_INO], s1[stat.ST_DEV]) == (s2[stat.ST_INO], s2[stat.ST_DEV]) ) def create_archive(session): (handle, path) = mkstemp(dir=py3_path(session.temp_dir)) os.close(handle) archive = ZipFile(py3_path(path), mode='w') archive.write(os.path.join(_common.RSRC, b'full.mp3'), 'full.mp3') archive.close() path = bytestring_path(path) return path class RmTempTest(unittest.TestCase, ImportHelper, _common.Assertions): """Tests that temporarily extracted archives are properly removed after usage. """ def setUp(self): self.setup_beets() self.want_resume = False self.config['incremental'] = False self._old_home = None def tearDown(self): self.teardown_beets() def test_rm(self): zip_path = create_archive(self) archive_task = importer.ArchiveImportTask(zip_path) archive_task.extract() tmp_path = archive_task.toppath self._setup_import_session(autotag=False, import_dir=tmp_path) self.assertExists(tmp_path) archive_task.finalize(self) self.assertNotExists(tmp_path) class ImportZipTest(unittest.TestCase, ImportHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def test_import_zip(self): zip_path = create_archive(self) self.assertEqual(len(self.lib.items()), 0) self.assertEqual(len(self.lib.albums()), 0) self._setup_import_session(autotag=False, import_dir=zip_path) self.importer.run() self.assertEqual(len(self.lib.items()), 1) self.assertEqual(len(self.lib.albums()), 1) class ImportTarTest(ImportZipTest): def create_archive(self): (handle, path) = mkstemp(dir=self.temp_dir) os.close(handle) archive = TarFile(py3_path(path), mode='w') archive.add(os.path.join(_common.RSRC, b'full.mp3'), 'full.mp3') archive.close() return path @unittest.skipIf(not has_program('unrar'), u'unrar program not found') class ImportRarTest(ImportZipTest): def create_archive(self): return os.path.join(_common.RSRC, b'archive.rar') @unittest.skip('Implement me!') class ImportPasswordRarTest(ImportZipTest): def create_archive(self): return os.path.join(_common.RSRC, b'password.rar') class ImportSingletonTest(_common.TestCase, ImportHelper): """Test ``APPLY`` and ``ASIS`` choices for an import session with singletons config set to True. """ def setUp(self): self.setup_beets() self._create_import_dir(1) self._setup_import_session() config['import']['singletons'] = True self.matcher = AutotagStub().install() def tearDown(self): self.teardown_beets() self.matcher.restore() def test_apply_asis_adds_track(self): self.assertEqual(self.lib.items().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.items().get().title, u'Tag Title 1') def test_apply_asis_does_not_add_album(self): self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get(), None) def test_apply_asis_adds_singleton_path(self): self.assert_lib_dir_empty() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assert_file_in_lib(b'singletons', b'Tag Title 1.mp3') def test_apply_candidate_adds_track(self): self.assertEqual(self.lib.items().get(), None) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().title, u'Applied Title 1') def test_apply_candidate_does_not_add_album(self): self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.albums().get(), None) def test_apply_candidate_adds_singleton_path(self): self.assert_lib_dir_empty() self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assert_file_in_lib(b'singletons', b'Applied Title 1.mp3') def test_skip_does_not_add_first_track(self): self.importer.add_choice(importer.action.SKIP) self.importer.run() self.assertEqual(self.lib.items().get(), None) def test_skip_adds_other_tracks(self): self._create_import_dir(2) self.importer.add_choice(importer.action.SKIP) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(len(self.lib.items()), 1) def test_import_single_files(self): resource_path = os.path.join(_common.RSRC, b'empty.mp3') single_path = os.path.join(self.import_dir, b'track_2.mp3') shutil.copy(resource_path, single_path) import_files = [ os.path.join(self.import_dir, b'the_album'), single_path ] self._setup_import_session(singletons=False) self.importer.paths = import_files self.importer.add_choice(importer.action.ASIS) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(len(self.lib.items()), 2) self.assertEqual(len(self.lib.albums()), 2) def test_set_fields(self): genre = u"\U0001F3B7 Jazz" collection = u"To Listen" config['import']['set_fields'] = { u'collection': collection, u'genre': genre } # As-is item import. self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() for item in self.lib.items(): item.load() # TODO: Not sure this is necessary. self.assertEqual(item.genre, genre) self.assertEqual(item.collection, collection) # Remove item from library to test again with APPLY choice. item.remove() # Autotagged. self.assertEqual(self.lib.albums().get(), None) self.importer.clear_choices() self.importer.add_choice(importer.action.APPLY) self.importer.run() for item in self.lib.items(): item.load() self.assertEqual(item.genre, genre) self.assertEqual(item.collection, collection) class ImportTest(_common.TestCase, ImportHelper): """Test APPLY, ASIS and SKIP choices. """ def setUp(self): self.setup_beets() self._create_import_dir(1) self._setup_import_session() self.matcher = AutotagStub().install() self.matcher.macthin = AutotagStub.GOOD def tearDown(self): self.teardown_beets() self.matcher.restore() def test_apply_asis_adds_album(self): self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get().album, u'Tag Album') def test_apply_asis_adds_tracks(self): self.assertEqual(self.lib.items().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.items().get().title, u'Tag Title 1') def test_apply_asis_adds_album_path(self): self.assert_lib_dir_empty() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assert_file_in_lib( b'Tag Artist', b'Tag Album', b'Tag Title 1.mp3') def test_apply_candidate_adds_album(self): self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.albums().get().album, u'Applied Album') def test_apply_candidate_adds_tracks(self): self.assertEqual(self.lib.items().get(), None) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().title, u'Applied Title 1') def test_apply_candidate_adds_album_path(self): self.assert_lib_dir_empty() self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assert_file_in_lib( b'Applied Artist', b'Applied Album', b'Applied Title 1.mp3') def test_apply_from_scratch_removes_other_metadata(self): config['import']['from_scratch'] = True for mediafile in self.import_media: mediafile.genre = u'Tag Genre' mediafile.save() self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().genre, u'') def test_apply_with_move_deletes_import(self): config['import']['move'] = True import_file = os.path.join( self.import_dir, b'the_album', b'track_1.mp3') self.assertExists(import_file) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertNotExists(import_file) def test_apply_with_delete_deletes_import(self): config['import']['delete'] = True import_file = os.path.join(self.import_dir, b'the_album', b'track_1.mp3') self.assertExists(import_file) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertNotExists(import_file) def test_skip_does_not_add_track(self): self.importer.add_choice(importer.action.SKIP) self.importer.run() self.assertEqual(self.lib.items().get(), None) def test_skip_non_album_dirs(self): self.assertTrue(os.path.isdir( os.path.join(self.import_dir, b'the_album'))) self.touch(b'cruft', dir=self.import_dir) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(len(self.lib.albums()), 1) def test_unmatched_tracks_not_added(self): self._create_import_dir(2) self.matcher.matching = self.matcher.MISSING self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(len(self.lib.items()), 1) def test_empty_directory_warning(self): import_dir = os.path.join(self.temp_dir, b'empty') self.touch(b'non-audio', dir=import_dir) self._setup_import_session(import_dir=import_dir) with capture_log() as logs: self.importer.run() import_dir = displayable_path(import_dir) self.assertIn(u'No files imported from {0}'.format(import_dir), logs) def test_empty_directory_singleton_warning(self): import_dir = os.path.join(self.temp_dir, b'empty') self.touch(b'non-audio', dir=import_dir) self._setup_import_session(import_dir=import_dir, singletons=True) with capture_log() as logs: self.importer.run() import_dir = displayable_path(import_dir) self.assertIn(u'No files imported from {0}'.format(import_dir), logs) def test_asis_no_data_source(self): self.assertEqual(self.lib.items().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() with self.assertRaises(AttributeError): self.lib.items().get().data_source def test_set_fields(self): genre = u"\U0001F3B7 Jazz" collection = u"To Listen" config['import']['set_fields'] = { u'collection': collection, u'genre': genre } # As-is album import. self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() for album in self.lib.albums(): album.load() # TODO: Not sure this is necessary. self.assertEqual(album.genre, genre) self.assertEqual(album.collection, collection) # Remove album from library to test again with APPLY choice. album.remove() # Autotagged. self.assertEqual(self.lib.albums().get(), None) self.importer.clear_choices() self.importer.add_choice(importer.action.APPLY) self.importer.run() for album in self.lib.albums(): album.load() self.assertEqual(album.genre, genre) self.assertEqual(album.collection, collection) class ImportTracksTest(_common.TestCase, ImportHelper): """Test TRACKS and APPLY choice. """ def setUp(self): self.setup_beets() self._create_import_dir(1) self._setup_import_session() self.matcher = AutotagStub().install() def tearDown(self): self.teardown_beets() self.matcher.restore() def test_apply_tracks_adds_singleton_track(self): self.assertEqual(self.lib.items().get(), None) self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.TRACKS) self.importer.add_choice(importer.action.APPLY) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().title, u'Applied Title 1') self.assertEqual(self.lib.albums().get(), None) def test_apply_tracks_adds_singleton_path(self): self.assert_lib_dir_empty() self.importer.add_choice(importer.action.TRACKS) self.importer.add_choice(importer.action.APPLY) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assert_file_in_lib(b'singletons', b'Applied Title 1.mp3') class ImportCompilationTest(_common.TestCase, ImportHelper): """Test ASIS import of a folder containing tracks with different artists. """ def setUp(self): self.setup_beets() self._create_import_dir(3) self._setup_import_session() self.matcher = AutotagStub().install() def tearDown(self): self.teardown_beets() self.matcher.restore() def test_asis_homogenous_sets_albumartist(self): self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get().albumartist, u'Tag Artist') for item in self.lib.items(): self.assertEqual(item.albumartist, u'Tag Artist') def test_asis_heterogenous_sets_various_albumartist(self): self.import_media[0].artist = u'Other Artist' self.import_media[0].save() self.import_media[1].artist = u'Another Artist' self.import_media[1].save() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get().albumartist, u'Various Artists') for item in self.lib.items(): self.assertEqual(item.albumartist, u'Various Artists') def test_asis_heterogenous_sets_sompilation(self): self.import_media[0].artist = u'Other Artist' self.import_media[0].save() self.import_media[1].artist = u'Another Artist' self.import_media[1].save() self.importer.add_choice(importer.action.ASIS) self.importer.run() for item in self.lib.items(): self.assertTrue(item.comp) def test_asis_sets_majority_albumartist(self): self.import_media[0].artist = u'Other Artist' self.import_media[0].save() self.import_media[1].artist = u'Other Artist' self.import_media[1].save() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get().albumartist, u'Other Artist') for item in self.lib.items(): self.assertEqual(item.albumartist, u'Other Artist') def test_asis_albumartist_tag_sets_albumartist(self): self.import_media[0].artist = u'Other Artist' self.import_media[1].artist = u'Another Artist' for mediafile in self.import_media: mediafile.albumartist = u'Album Artist' mediafile.mb_albumartistid = u'Album Artist ID' mediafile.save() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get().albumartist, u'Album Artist') self.assertEqual(self.lib.albums().get().mb_albumartistid, u'Album Artist ID') for item in self.lib.items(): self.assertEqual(item.albumartist, u'Album Artist') self.assertEqual(item.mb_albumartistid, u'Album Artist ID') class ImportExistingTest(_common.TestCase, ImportHelper): """Test importing files that are already in the library directory. """ def setUp(self): self.setup_beets() self._create_import_dir(1) self.matcher = AutotagStub().install() self._setup_import_session() self.setup_importer = self.importer self.setup_importer.default_choice = importer.action.APPLY self._setup_import_session(import_dir=self.libdir) def tearDown(self): self.teardown_beets() self.matcher.restore() def test_does_not_duplicate_item(self): self.setup_importer.run() self.assertEqual(len((self.lib.items())), 1) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(len((self.lib.items())), 1) def test_does_not_duplicate_album(self): self.setup_importer.run() self.assertEqual(len((self.lib.albums())), 1) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(len((self.lib.albums())), 1) def test_does_not_duplicate_singleton_track(self): self.setup_importer.add_choice(importer.action.TRACKS) self.setup_importer.add_choice(importer.action.APPLY) self.setup_importer.run() self.assertEqual(len((self.lib.items())), 1) self.importer.add_choice(importer.action.TRACKS) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(len((self.lib.items())), 1) def test_asis_updates_metadata(self): self.setup_importer.run() medium = MediaFile(self.lib.items().get().path) medium.title = u'New Title' medium.save() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.items().get().title, u'New Title') def test_asis_updated_moves_file(self): self.setup_importer.run() medium = MediaFile(self.lib.items().get().path) medium.title = u'New Title' medium.save() old_path = os.path.join(b'Applied Artist', b'Applied Album', b'Applied Title 1.mp3') self.assert_file_in_lib(old_path) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assert_file_in_lib(b'Applied Artist', b'Applied Album', b'New Title.mp3') self.assert_file_not_in_lib(old_path) def test_asis_updated_without_copy_does_not_move_file(self): self.setup_importer.run() medium = MediaFile(self.lib.items().get().path) medium.title = u'New Title' medium.save() old_path = os.path.join(b'Applied Artist', b'Applied Album', b'Applied Title 1.mp3') self.assert_file_in_lib(old_path) config['import']['copy'] = False self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assert_file_not_in_lib(b'Applied Artist', b'Applied Album', b'New Title.mp3') self.assert_file_in_lib(old_path) def test_outside_file_is_copied(self): config['import']['copy'] = False self.setup_importer.run() self.assert_equal_path(self.lib.items().get().path, self.import_media[0].path) config['import']['copy'] = True self._setup_import_session() self.importer.add_choice(importer.action.APPLY) self.importer.run() new_path = os.path.join(b'Applied Artist', b'Applied Album', b'Applied Title 1.mp3') self.assert_file_in_lib(new_path) self.assert_equal_path(self.lib.items().get().path, os.path.join(self.libdir, new_path)) def test_outside_file_is_moved(self): config['import']['copy'] = False self.setup_importer.run() self.assert_equal_path(self.lib.items().get().path, self.import_media[0].path) self._setup_import_session(move=True) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertNotExists(self.import_media[0].path) class GroupAlbumsImportTest(_common.TestCase, ImportHelper): def setUp(self): self.setup_beets() self._create_import_dir(3) self.matcher = AutotagStub().install() self.matcher.matching = AutotagStub.NONE self._setup_import_session() # Split tracks into two albums and use both as-is self.importer.add_choice(importer.action.ALBUMS) self.importer.add_choice(importer.action.ASIS) self.importer.add_choice(importer.action.ASIS) def tearDown(self): self.teardown_beets() self.matcher.restore() def test_add_album_for_different_artist_and_different_album(self): self.import_media[0].artist = u"Artist B" self.import_media[0].album = u"Album B" self.import_media[0].save() self.importer.run() albums = set([album.album for album in self.lib.albums()]) self.assertEqual(albums, set(['Album B', 'Tag Album'])) def test_add_album_for_different_artist_and_same_albumartist(self): self.import_media[0].artist = u"Artist B" self.import_media[0].albumartist = u"Album Artist" self.import_media[0].save() self.import_media[1].artist = u"Artist C" self.import_media[1].albumartist = u"Album Artist" self.import_media[1].save() self.importer.run() artists = set([album.albumartist for album in self.lib.albums()]) self.assertEqual(artists, set(['Album Artist', 'Tag Artist'])) def test_add_album_for_same_artist_and_different_album(self): self.import_media[0].album = u"Album B" self.import_media[0].save() self.importer.run() albums = set([album.album for album in self.lib.albums()]) self.assertEqual(albums, set(['Album B', 'Tag Album'])) def test_add_album_for_same_album_and_different_artist(self): self.import_media[0].artist = u"Artist B" self.import_media[0].save() self.importer.run() artists = set([album.albumartist for album in self.lib.albums()]) self.assertEqual(artists, set(['Artist B', 'Tag Artist'])) def test_incremental(self): config['import']['incremental'] = True self.import_media[0].album = u"Album B" self.import_media[0].save() self.importer.run() albums = set([album.album for album in self.lib.albums()]) self.assertEqual(albums, set(['Album B', 'Tag Album'])) class GlobalGroupAlbumsImportTest(GroupAlbumsImportTest): def setUp(self): super(GlobalGroupAlbumsImportTest, self).setUp() self.importer.clear_choices() self.importer.default_choice = importer.action.ASIS config['import']['group_albums'] = True class ChooseCandidateTest(_common.TestCase, ImportHelper): def setUp(self): self.setup_beets() self._create_import_dir(1) self._setup_import_session() self.matcher = AutotagStub().install() self.matcher.matching = AutotagStub.BAD def tearDown(self): self.teardown_beets() self.matcher.restore() def test_choose_first_candidate(self): self.importer.add_choice(1) self.importer.run() self.assertEqual(self.lib.albums().get().album, u'Applied Album M') def test_choose_second_candidate(self): self.importer.add_choice(2) self.importer.run() self.assertEqual(self.lib.albums().get().album, u'Applied Album MM') class InferAlbumDataTest(_common.TestCase): def setUp(self): super(InferAlbumDataTest, self).setUp() i1 = _common.item() i2 = _common.item() i3 = _common.item() i1.title = u'first item' i2.title = u'second item' i3.title = u'third item' i1.comp = i2.comp = i3.comp = False i1.albumartist = i2.albumartist = i3.albumartist = '' i1.mb_albumartistid = i2.mb_albumartistid = i3.mb_albumartistid = '' self.items = [i1, i2, i3] self.task = importer.ImportTask(paths=['a path'], toppath='top path', items=self.items) def test_asis_homogenous_single_artist(self): self.task.set_choice(importer.action.ASIS) self.task.align_album_level_fields() self.assertFalse(self.items[0].comp) self.assertEqual(self.items[0].albumartist, self.items[2].artist) def test_asis_heterogenous_va(self): self.items[0].artist = u'another artist' self.items[1].artist = u'some other artist' self.task.set_choice(importer.action.ASIS) self.task.align_album_level_fields() self.assertTrue(self.items[0].comp) self.assertEqual(self.items[0].albumartist, u'Various Artists') def test_asis_comp_applied_to_all_items(self): self.items[0].artist = u'another artist' self.items[1].artist = u'some other artist' self.task.set_choice(importer.action.ASIS) self.task.align_album_level_fields() for item in self.items: self.assertTrue(item.comp) self.assertEqual(item.albumartist, u'Various Artists') def test_asis_majority_artist_single_artist(self): self.items[0].artist = u'another artist' self.task.set_choice(importer.action.ASIS) self.task.align_album_level_fields() self.assertFalse(self.items[0].comp) self.assertEqual(self.items[0].albumartist, self.items[2].artist) def test_asis_track_albumartist_override(self): self.items[0].artist = u'another artist' self.items[1].artist = u'some other artist' for item in self.items: item.albumartist = u'some album artist' item.mb_albumartistid = u'some album artist id' self.task.set_choice(importer.action.ASIS) self.task.align_album_level_fields() self.assertEqual(self.items[0].albumartist, u'some album artist') self.assertEqual(self.items[0].mb_albumartistid, u'some album artist id') def test_apply_gets_artist_and_id(self): self.task.set_choice(AlbumMatch(0, None, {}, set(), set())) # APPLY self.task.align_album_level_fields() self.assertEqual(self.items[0].albumartist, self.items[0].artist) self.assertEqual(self.items[0].mb_albumartistid, self.items[0].mb_artistid) def test_apply_lets_album_values_override(self): for item in self.items: item.albumartist = u'some album artist' item.mb_albumartistid = u'some album artist id' self.task.set_choice(AlbumMatch(0, None, {}, set(), set())) # APPLY self.task.align_album_level_fields() self.assertEqual(self.items[0].albumartist, u'some album artist') self.assertEqual(self.items[0].mb_albumartistid, u'some album artist id') def test_small_single_artist_album(self): self.items = [self.items[0]] self.task.items = self.items self.task.set_choice(importer.action.ASIS) self.task.align_album_level_fields() self.assertFalse(self.items[0].comp) def test_album_info(*args, **kwargs): """Create an AlbumInfo object for testing. """ track_info = TrackInfo( title=u'new title', track_id=u'trackid', index=0, ) album_info = AlbumInfo( artist=u'artist', album=u'album', tracks=[track_info], album_id=u'albumid', artist_id=u'artistid', ) return iter([album_info]) @patch('beets.autotag.mb.match_album', Mock(side_effect=test_album_info)) class ImportDuplicateAlbumTest(unittest.TestCase, TestHelper, _common.Assertions): def setUp(self): self.setup_beets() # Original album self.add_album_fixture(albumartist=u'artist', album=u'album') # Create import session self.importer = self.create_importer() config['import']['autotag'] = True def tearDown(self): self.teardown_beets() def test_remove_duplicate_album(self): item = self.lib.items().get() self.assertEqual(item.title, u't\xeftle 0') self.assertExists(item.path) self.importer.default_resolution = self.importer.Resolution.REMOVE self.importer.run() self.assertNotExists(item.path) self.assertEqual(len(self.lib.albums()), 1) self.assertEqual(len(self.lib.items()), 1) item = self.lib.items().get() self.assertEqual(item.title, u'new title') def test_no_autotag_keeps_duplicate_album(self): config['import']['autotag'] = False item = self.lib.items().get() self.assertEqual(item.title, u't\xeftle 0') self.assertExists(item.path) # Imported item has the same artist and album as the one in the # library. import_file = os.path.join(self.importer.paths[0], b'album 0', b'track 0.mp3') import_file = MediaFile(import_file) import_file.artist = item['artist'] import_file.albumartist = item['artist'] import_file.album = item['album'] import_file.title = 'new title' self.importer.default_resolution = self.importer.Resolution.REMOVE self.importer.run() self.assertExists(item.path) self.assertEqual(len(self.lib.albums()), 2) self.assertEqual(len(self.lib.items()), 2) def test_keep_duplicate_album(self): self.importer.default_resolution = self.importer.Resolution.KEEPBOTH self.importer.run() self.assertEqual(len(self.lib.albums()), 2) self.assertEqual(len(self.lib.items()), 2) def test_skip_duplicate_album(self): item = self.lib.items().get() self.assertEqual(item.title, u't\xeftle 0') self.importer.default_resolution = self.importer.Resolution.SKIP self.importer.run() self.assertEqual(len(self.lib.albums()), 1) self.assertEqual(len(self.lib.items()), 1) item = self.lib.items().get() self.assertEqual(item.title, u't\xeftle 0') def test_merge_duplicate_album(self): self.importer.default_resolution = self.importer.Resolution.MERGE self.importer.run() self.assertEqual(len(self.lib.albums()), 1) def test_twice_in_import_dir(self): self.skipTest('write me') def add_album_fixture(self, **kwargs): # TODO move this into upstream album = super(ImportDuplicateAlbumTest, self).add_album_fixture() album.update(kwargs) album.store() return album def test_track_info(*args, **kwargs): return iter([TrackInfo( artist=u'artist', title=u'title', track_id=u'new trackid', index=0,)]) @patch('beets.autotag.mb.match_track', Mock(side_effect=test_track_info)) class ImportDuplicateSingletonTest(unittest.TestCase, TestHelper, _common.Assertions): def setUp(self): self.setup_beets() # Original file in library self.add_item_fixture(artist=u'artist', title=u'title', mb_trackid='old trackid') # Import session self.importer = self.create_importer() config['import']['autotag'] = True config['import']['singletons'] = True def tearDown(self): self.teardown_beets() def test_remove_duplicate(self): item = self.lib.items().get() self.assertEqual(item.mb_trackid, u'old trackid') self.assertExists(item.path) self.importer.default_resolution = self.importer.Resolution.REMOVE self.importer.run() self.assertNotExists(item.path) self.assertEqual(len(self.lib.items()), 1) item = self.lib.items().get() self.assertEqual(item.mb_trackid, u'new trackid') def test_keep_duplicate(self): self.assertEqual(len(self.lib.items()), 1) self.importer.default_resolution = self.importer.Resolution.KEEPBOTH self.importer.run() self.assertEqual(len(self.lib.items()), 2) def test_skip_duplicate(self): item = self.lib.items().get() self.assertEqual(item.mb_trackid, u'old trackid') self.importer.default_resolution = self.importer.Resolution.SKIP self.importer.run() self.assertEqual(len(self.lib.items()), 1) item = self.lib.items().get() self.assertEqual(item.mb_trackid, u'old trackid') def test_twice_in_import_dir(self): self.skipTest('write me') def add_item_fixture(self, **kwargs): # Move this to TestHelper item = self.add_item_fixtures()[0] item.update(kwargs) item.store() return item class TagLogTest(_common.TestCase): def test_tag_log_line(self): sio = StringIO() handler = logging.StreamHandler(sio) session = _common.import_session(loghandler=handler) session.tag_log('status', 'path') self.assertIn('status path', sio.getvalue()) def test_tag_log_unicode(self): sio = StringIO() handler = logging.StreamHandler(sio) session = _common.import_session(loghandler=handler) session.tag_log('status', u'caf\xe9') # send unicode self.assertIn(u'status caf\xe9', sio.getvalue()) class ResumeImportTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() @patch('beets.plugins.send') def test_resume_album(self, plugins_send): self.importer = self.create_importer(album_count=2) self.config['import']['resume'] = True # Aborts import after one album. This also ensures that we skip # the first album in the second try. def raise_exception(event, **kwargs): if event == 'album_imported': raise importer.ImportAbort plugins_send.side_effect = raise_exception self.importer.run() self.assertEqual(len(self.lib.albums()), 1) self.assertIsNotNone(self.lib.albums(u'album:album 0').get()) self.importer.run() self.assertEqual(len(self.lib.albums()), 2) self.assertIsNotNone(self.lib.albums(u'album:album 1').get()) @patch('beets.plugins.send') def test_resume_singleton(self, plugins_send): self.importer = self.create_importer(item_count=2) self.config['import']['resume'] = True self.config['import']['singletons'] = True # Aborts import after one track. This also ensures that we skip # the first album in the second try. def raise_exception(event, **kwargs): if event == 'item_imported': raise importer.ImportAbort plugins_send.side_effect = raise_exception self.importer.run() self.assertEqual(len(self.lib.items()), 1) self.assertIsNotNone(self.lib.items(u'title:track 0').get()) self.importer.run() self.assertEqual(len(self.lib.items()), 2) self.assertIsNotNone(self.lib.items(u'title:track 1').get()) class IncrementalImportTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.config['import']['incremental'] = True def tearDown(self): self.teardown_beets() def test_incremental_album(self): importer = self.create_importer(album_count=1) importer.run() # Change album name so the original file would be imported again # if incremental was off. album = self.lib.albums().get() album['album'] = 'edited album' album.store() importer = self.create_importer(album_count=1) importer.run() self.assertEqual(len(self.lib.albums()), 2) def test_incremental_item(self): self.config['import']['singletons'] = True importer = self.create_importer(item_count=1) importer.run() # Change track name so the original file would be imported again # if incremental was off. item = self.lib.items().get() item['artist'] = 'edited artist' item.store() importer = self.create_importer(item_count=1) importer.run() self.assertEqual(len(self.lib.items()), 2) def test_invalid_state_file(self): importer = self.create_importer() with open(self.config['statefile'].as_filename(), 'wb') as f: f.write(b'000') importer.run() self.assertEqual(len(self.lib.albums()), 1) def _mkmp3(path): shutil.copyfile(os.path.join(_common.RSRC, b'min.mp3'), path) class AlbumsInDirTest(_common.TestCase): def setUp(self): super(AlbumsInDirTest, self).setUp() # create a directory structure for testing self.base = os.path.abspath(os.path.join(self.temp_dir, b'tempdir')) os.mkdir(self.base) os.mkdir(os.path.join(self.base, b'album1')) os.mkdir(os.path.join(self.base, b'album2')) os.mkdir(os.path.join(self.base, b'more')) os.mkdir(os.path.join(self.base, b'more', b'album3')) os.mkdir(os.path.join(self.base, b'more', b'album4')) _mkmp3(os.path.join(self.base, b'album1', b'album1song1.mp3')) _mkmp3(os.path.join(self.base, b'album1', b'album1song2.mp3')) _mkmp3(os.path.join(self.base, b'album2', b'album2song.mp3')) _mkmp3(os.path.join(self.base, b'more', b'album3', b'album3song.mp3')) _mkmp3(os.path.join(self.base, b'more', b'album4', b'album4song.mp3')) def test_finds_all_albums(self): albums = list(albums_in_dir(self.base)) self.assertEqual(len(albums), 4) def test_separates_contents(self): found = [] for _, album in albums_in_dir(self.base): found.append(re.search(br'album(.)song', album[0]).group(1)) self.assertTrue(b'1' in found) self.assertTrue(b'2' in found) self.assertTrue(b'3' in found) self.assertTrue(b'4' in found) def test_finds_multiple_songs(self): for _, album in albums_in_dir(self.base): n = re.search(br'album(.)song', album[0]).group(1) if n == b'1': self.assertEqual(len(album), 2) else: self.assertEqual(len(album), 1) class MultiDiscAlbumsInDirTest(_common.TestCase): def create_music(self, files=True, ascii=True): """Create some music in multiple album directories. `files` indicates whether to create the files (otherwise, only directories are made). `ascii` indicates ACII-only filenames; otherwise, we use Unicode names. """ self.base = os.path.abspath(os.path.join(self.temp_dir, b'tempdir')) os.mkdir(self.base) name = b'CAT' if ascii else util.bytestring_path(u'C\xc1T') name_alt_case = b'CAt' if ascii else util.bytestring_path(u'C\xc1t') self.dirs = [ # Nested album, multiple subdirs. # Also, false positive marker in root dir, and subtitle for disc 3. os.path.join(self.base, b'ABCD1234'), os.path.join(self.base, b'ABCD1234', b'cd 1'), os.path.join(self.base, b'ABCD1234', b'cd 3 - bonus'), # Nested album, single subdir. # Also, punctuation between marker and disc number. os.path.join(self.base, b'album'), os.path.join(self.base, b'album', b'cd _ 1'), # Flattened album, case typo. # Also, false positive marker in parent dir. os.path.join(self.base, b'artist [CD5]'), os.path.join(self.base, b'artist [CD5]', name + b' disc 1'), os.path.join(self.base, b'artist [CD5]', name_alt_case + b' disc 2'), # Single disc album, sorted between CAT discs. os.path.join(self.base, b'artist [CD5]', name + b'S'), ] self.files = [ os.path.join(self.base, b'ABCD1234', b'cd 1', b'song1.mp3'), os.path.join(self.base, b'ABCD1234', b'cd 3 - bonus', b'song2.mp3'), os.path.join(self.base, b'ABCD1234', b'cd 3 - bonus', b'song3.mp3'), os.path.join(self.base, b'album', b'cd _ 1', b'song4.mp3'), os.path.join(self.base, b'artist [CD5]', name + b' disc 1', b'song5.mp3'), os.path.join(self.base, b'artist [CD5]', name_alt_case + b' disc 2', b'song6.mp3'), os.path.join(self.base, b'artist [CD5]', name + b'S', b'song7.mp3'), ] if not ascii: self.dirs = [self._normalize_path(p) for p in self.dirs] self.files = [self._normalize_path(p) for p in self.files] for path in self.dirs: os.mkdir(util.syspath(path)) if files: for path in self.files: _mkmp3(util.syspath(path)) def _normalize_path(self, path): """Normalize a path's Unicode combining form according to the platform. """ path = path.decode('utf-8') norm_form = 'NFD' if sys.platform == 'darwin' else 'NFC' path = unicodedata.normalize(norm_form, path) return path.encode('utf-8') def test_coalesce_nested_album_multiple_subdirs(self): self.create_music() albums = list(albums_in_dir(self.base)) self.assertEqual(len(albums), 4) root, items = albums[0] self.assertEqual(root, self.dirs[0:3]) self.assertEqual(len(items), 3) def test_coalesce_nested_album_single_subdir(self): self.create_music() albums = list(albums_in_dir(self.base)) root, items = albums[1] self.assertEqual(root, self.dirs[3:5]) self.assertEqual(len(items), 1) def test_coalesce_flattened_album_case_typo(self): self.create_music() albums = list(albums_in_dir(self.base)) root, items = albums[2] self.assertEqual(root, self.dirs[6:8]) self.assertEqual(len(items), 2) def test_single_disc_album(self): self.create_music() albums = list(albums_in_dir(self.base)) root, items = albums[3] self.assertEqual(root, self.dirs[8:]) self.assertEqual(len(items), 1) def test_do_not_yield_empty_album(self): self.create_music(files=False) albums = list(albums_in_dir(self.base)) self.assertEqual(len(albums), 0) def test_single_disc_unicode(self): self.create_music(ascii=False) albums = list(albums_in_dir(self.base)) root, items = albums[3] self.assertEqual(root, self.dirs[8:]) self.assertEqual(len(items), 1) def test_coalesce_multiple_unicode(self): self.create_music(ascii=False) albums = list(albums_in_dir(self.base)) self.assertEqual(len(albums), 4) root, items = albums[0] self.assertEqual(root, self.dirs[0:3]) self.assertEqual(len(items), 3) class ReimportTest(unittest.TestCase, ImportHelper, _common.Assertions): """Test "re-imports", in which the autotagging machinery is used for music that's already in the library. This works by importing new database entries for the same files and replacing the old data with the new data. We also copy over flexible attributes and the added date. """ def setUp(self): self.setup_beets() # The existing album. album = self.add_album_fixture() album.added = 4242.0 album.foo = u'bar' # Some flexible attribute. album.store() item = album.items().get() item.baz = u'qux' item.added = 4747.0 item.store() # Set up an import pipeline with a "good" match. self.matcher = AutotagStub().install() self.matcher.matching = AutotagStub.GOOD def tearDown(self): self.teardown_beets() self.matcher.restore() def _setup_session(self, singletons=False): self._setup_import_session(self._album().path, singletons=singletons) self.importer.add_choice(importer.action.APPLY) def _album(self): return self.lib.albums().get() def _item(self): return self.lib.items().get() def test_reimported_album_gets_new_metadata(self): self._setup_session() self.assertEqual(self._album().album, u'\xe4lbum') self.importer.run() self.assertEqual(self._album().album, u'the album') def test_reimported_album_preserves_flexattr(self): self._setup_session() self.importer.run() self.assertEqual(self._album().foo, u'bar') def test_reimported_album_preserves_added(self): self._setup_session() self.importer.run() self.assertEqual(self._album().added, 4242.0) def test_reimported_album_preserves_item_flexattr(self): self._setup_session() self.importer.run() self.assertEqual(self._item().baz, u'qux') def test_reimported_album_preserves_item_added(self): self._setup_session() self.importer.run() self.assertEqual(self._item().added, 4747.0) def test_reimported_item_gets_new_metadata(self): self._setup_session(True) self.assertEqual(self._item().title, u't\xeftle 0') self.importer.run() self.assertEqual(self._item().title, u'full') def test_reimported_item_preserves_flexattr(self): self._setup_session(True) self.importer.run() self.assertEqual(self._item().baz, u'qux') def test_reimported_item_preserves_added(self): self._setup_session(True) self.importer.run() self.assertEqual(self._item().added, 4747.0) def test_reimported_item_preserves_art(self): self._setup_session() art_source = os.path.join(_common.RSRC, b'abbey.jpg') replaced_album = self._album() replaced_album.set_art(art_source) replaced_album.store() old_artpath = replaced_album.artpath self.importer.run() new_album = self._album() new_artpath = new_album.art_destination(art_source) self.assertEqual(new_album.artpath, new_artpath) self.assertExists(new_artpath) if new_artpath != old_artpath: self.assertNotExists(old_artpath) class ImportPretendTest(_common.TestCase, ImportHelper): """ Test the pretend commandline option """ def __init__(self, method_name='runTest'): super(ImportPretendTest, self).__init__(method_name) self.matcher = None def setUp(self): super(ImportPretendTest, self).setUp() self.setup_beets() self.__create_import_dir() self.__create_empty_import_dir() self._setup_import_session() config['import']['pretend'] = True self.matcher = AutotagStub().install() self.io.install() def tearDown(self): self.teardown_beets() self.matcher.restore() def __create_import_dir(self): self._create_import_dir(1) resource_path = os.path.join(_common.RSRC, b'empty.mp3') single_path = os.path.join(self.import_dir, b'track_2.mp3') shutil.copy(resource_path, single_path) self.import_paths = [ os.path.join(self.import_dir, b'the_album'), single_path ] self.import_files = [ displayable_path( os.path.join(self.import_paths[0], b'track_1.mp3')), displayable_path(single_path) ] def __create_empty_import_dir(self): path = os.path.join(self.temp_dir, b'empty') os.makedirs(path) self.empty_path = path def __run(self, import_paths, singletons=True): self._setup_import_session(singletons=singletons) self.importer.paths = import_paths with capture_log() as logs: self.importer.run() logs = [line for line in logs if not line.startswith('Sending event:')] self.assertEqual(len(self.lib.items()), 0) self.assertEqual(len(self.lib.albums()), 0) return logs def test_import_singletons_pretend(self): logs = self.__run(self.import_paths) self.assertEqual(logs, [ 'Singleton: %s' % displayable_path(self.import_files[0]), 'Singleton: %s' % displayable_path(self.import_paths[1])]) def test_import_album_pretend(self): logs = self.__run(self.import_paths, singletons=False) self.assertEqual(logs, [ 'Album: %s' % displayable_path(self.import_paths[0]), ' %s' % displayable_path(self.import_files[0]), 'Album: %s' % displayable_path(self.import_paths[1]), ' %s' % displayable_path(self.import_paths[1])]) def test_import_pretend_empty(self): logs = self.__run([self.empty_path]) self.assertEqual(logs, [u'No files imported from {0}' .format(displayable_path(self.empty_path))]) # Helpers for ImportMusicBrainzIdTest. def mocked_get_release_by_id(id_, includes=[], release_status=[], release_type=[]): """Mimic musicbrainzngs.get_release_by_id, accepting only a restricted list of MB ids (ID_RELEASE_0, ID_RELEASE_1). The returned dict differs only in the release title and artist name, so that ID_RELEASE_0 is a closer match to the items created by ImportHelper._create_import_dir().""" # Map IDs to (release title, artist), so the distances are different. releases = {ImportMusicBrainzIdTest.ID_RELEASE_0: ('VALID_RELEASE_0', 'TAG ARTIST'), ImportMusicBrainzIdTest.ID_RELEASE_1: ('VALID_RELEASE_1', 'DISTANT_MATCH')} return { 'release': { 'title': releases[id_][0], 'id': id_, 'medium-list': [{ 'track-list': [{ 'recording': { 'title': 'foo', 'id': 'bar', 'length': 59, }, 'position': 9, 'number': 'A2' }], 'position': 5, }], 'artist-credit': [{ 'artist': { 'name': releases[id_][1], 'id': 'some-id', }, }], 'release-group': { 'id': 'another-id', } } } def mocked_get_recording_by_id(id_, includes=[], release_status=[], release_type=[]): """Mimic musicbrainzngs.get_recording_by_id, accepting only a restricted list of MB ids (ID_RECORDING_0, ID_RECORDING_1). The returned dict differs only in the recording title and artist name, so that ID_RECORDING_0 is a closer match to the items created by ImportHelper._create_import_dir().""" # Map IDs to (recording title, artist), so the distances are different. releases = {ImportMusicBrainzIdTest.ID_RECORDING_0: ('VALID_RECORDING_0', 'TAG ARTIST'), ImportMusicBrainzIdTest.ID_RECORDING_1: ('VALID_RECORDING_1', 'DISTANT_MATCH')} return { 'recording': { 'title': releases[id_][0], 'id': id_, 'length': 59, 'artist-credit': [{ 'artist': { 'name': releases[id_][1], 'id': 'some-id', }, }], } } @patch('musicbrainzngs.get_recording_by_id', Mock(side_effect=mocked_get_recording_by_id)) @patch('musicbrainzngs.get_release_by_id', Mock(side_effect=mocked_get_release_by_id)) class ImportMusicBrainzIdTest(_common.TestCase, ImportHelper): """Test the --musicbrainzid argument.""" MB_RELEASE_PREFIX = 'https://musicbrainz.org/release/' MB_RECORDING_PREFIX = 'https://musicbrainz.org/recording/' ID_RELEASE_0 = '00000000-0000-0000-0000-000000000000' ID_RELEASE_1 = '11111111-1111-1111-1111-111111111111' ID_RECORDING_0 = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' ID_RECORDING_1 = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' def setUp(self): self.setup_beets() self._create_import_dir(1) def tearDown(self): self.teardown_beets() def test_one_mbid_one_album(self): self.config['import']['search_ids'] = \ [self.MB_RELEASE_PREFIX + self.ID_RELEASE_0] self._setup_import_session() self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.albums().get().album, 'VALID_RELEASE_0') def test_several_mbid_one_album(self): self.config['import']['search_ids'] = \ [self.MB_RELEASE_PREFIX + self.ID_RELEASE_0, self.MB_RELEASE_PREFIX + self.ID_RELEASE_1] self._setup_import_session() self.importer.add_choice(2) # Pick the 2nd best match (release 1). self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.albums().get().album, 'VALID_RELEASE_1') def test_one_mbid_one_singleton(self): self.config['import']['search_ids'] = \ [self.MB_RECORDING_PREFIX + self.ID_RECORDING_0] self._setup_import_session(singletons=True) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().title, 'VALID_RECORDING_0') def test_several_mbid_one_singleton(self): self.config['import']['search_ids'] = \ [self.MB_RECORDING_PREFIX + self.ID_RECORDING_0, self.MB_RECORDING_PREFIX + self.ID_RECORDING_1] self._setup_import_session(singletons=True) self.importer.add_choice(2) # Pick the 2nd best match (recording 1). self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().title, 'VALID_RECORDING_1') def test_candidates_album(self): """Test directly ImportTask.lookup_candidates().""" task = importer.ImportTask(paths=self.import_dir, toppath='top path', items=[_common.item()]) task.search_ids = [self.MB_RELEASE_PREFIX + self.ID_RELEASE_0, self.MB_RELEASE_PREFIX + self.ID_RELEASE_1, 'an invalid and discarded id'] task.lookup_candidates() self.assertEqual(set(['VALID_RELEASE_0', 'VALID_RELEASE_1']), set([c.info.album for c in task.candidates])) def test_candidates_singleton(self): """Test directly SingletonImportTask.lookup_candidates().""" task = importer.SingletonImportTask(toppath='top path', item=_common.item()) task.search_ids = [self.MB_RECORDING_PREFIX + self.ID_RECORDING_0, self.MB_RECORDING_PREFIX + self.ID_RECORDING_1, 'an invalid and discarded id'] task.lookup_candidates() self.assertEqual(set(['VALID_RECORDING_0', 'VALID_RECORDING_1']), set([c.info.title for c in task.candidates])) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/__init__.py0000644000076500000240000000017513025125202016653 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # Make python -m testall.py work. from __future__ import division, absolute_import, print_function beets-1.4.6/test/test_ui.py0000644000076500000240000014153713164763003016613 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the command-line interface. """ from __future__ import division, absolute_import, print_function import os import shutil import re import subprocess import platform from copy import deepcopy import six import unittest from mock import patch, Mock from test import _common from test.helper import capture_stdout, has_program, TestHelper, control_stdin from beets import library from beets import ui from beets.ui import commands from beets import autotag from beets.autotag.match import distance from beets.mediafile import MediaFile from beets import config from beets import plugins from beets.util.confit import ConfigError from beets import util from beets.util import syspath, MoveOperation class ListTest(unittest.TestCase): def setUp(self): self.lib = library.Library(':memory:') self.item = _common.item() self.item.path = 'xxx/yyy' self.lib.add(self.item) self.lib.add_album([self.item]) def _run_list(self, query=u'', album=False, path=False, fmt=u''): with capture_stdout() as stdout: commands.list_items(self.lib, query, album, fmt) return stdout def test_list_outputs_item(self): stdout = self._run_list() self.assertIn(u'the title', stdout.getvalue()) def test_list_unicode_query(self): self.item.title = u'na\xefve' self.item.store() self.lib._connection().commit() stdout = self._run_list([u'na\xefve']) out = stdout.getvalue() if six.PY2: out = out.decode(stdout.encoding) self.assertTrue(u'na\xefve' in out) def test_list_item_path(self): stdout = self._run_list(fmt=u'$path') self.assertEqual(stdout.getvalue().strip(), u'xxx/yyy') def test_list_album_outputs_something(self): stdout = self._run_list(album=True) self.assertGreater(len(stdout.getvalue()), 0) def test_list_album_path(self): stdout = self._run_list(album=True, fmt=u'$path') self.assertEqual(stdout.getvalue().strip(), u'xxx') def test_list_album_omits_title(self): stdout = self._run_list(album=True) self.assertNotIn(u'the title', stdout.getvalue()) def test_list_uses_track_artist(self): stdout = self._run_list() self.assertIn(u'the artist', stdout.getvalue()) self.assertNotIn(u'the album artist', stdout.getvalue()) def test_list_album_uses_album_artist(self): stdout = self._run_list(album=True) self.assertNotIn(u'the artist', stdout.getvalue()) self.assertIn(u'the album artist', stdout.getvalue()) def test_list_item_format_artist(self): stdout = self._run_list(fmt=u'$artist') self.assertIn(u'the artist', stdout.getvalue()) def test_list_item_format_multiple(self): stdout = self._run_list(fmt=u'$artist - $album - $year') self.assertEqual(u'the artist - the album - 0001', stdout.getvalue().strip()) def test_list_album_format(self): stdout = self._run_list(album=True, fmt=u'$genre') self.assertIn(u'the genre', stdout.getvalue()) self.assertNotIn(u'the album', stdout.getvalue()) class RemoveTest(_common.TestCase): def setUp(self): super(RemoveTest, self).setUp() self.io.install() self.libdir = os.path.join(self.temp_dir, b'testlibdir') os.mkdir(self.libdir) # Copy a file into the library. self.lib = library.Library(':memory:', self.libdir) item_path = os.path.join(_common.RSRC, b'full.mp3') self.i = library.Item.from_path(item_path) self.lib.add(self.i) self.i.move(operation=MoveOperation.COPY) def test_remove_items_no_delete(self): self.io.addinput('y') commands.remove_items(self.lib, u'', False, False, False) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertTrue(os.path.exists(self.i.path)) def test_remove_items_with_delete(self): self.io.addinput('y') commands.remove_items(self.lib, u'', False, True, False) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertFalse(os.path.exists(self.i.path)) def test_remove_items_with_force_no_delete(self): commands.remove_items(self.lib, u'', False, False, True) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertTrue(os.path.exists(self.i.path)) def test_remove_items_with_force_delete(self): commands.remove_items(self.lib, u'', False, True, True) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertFalse(os.path.exists(self.i.path)) class ModifyTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.album = self.add_album_fixture() [self.item] = self.album.items() def tearDown(self): self.teardown_beets() def modify_inp(self, inp, *args): with control_stdin(inp): self.run_command('modify', *args) def modify(self, *args): self.modify_inp('y', *args) # Item tests def test_modify_item(self): self.modify(u"title=newTitle") item = self.lib.items().get() self.assertEqual(item.title, u'newTitle') def test_modify_item_abort(self): item = self.lib.items().get() title = item.title self.modify_inp('n', u"title=newTitle") item = self.lib.items().get() self.assertEqual(item.title, title) def test_modify_item_no_change(self): title = u"Tracktitle" item = self.add_item_fixture(title=title) self.modify_inp('y', u"title", u"title={0}".format(title)) item = self.lib.items(title).get() self.assertEqual(item.title, title) def test_modify_write_tags(self): self.modify(u"title=newTitle") item = self.lib.items().get() item.read() self.assertEqual(item.title, u'newTitle') def test_modify_dont_write_tags(self): self.modify(u"--nowrite", u"title=newTitle") item = self.lib.items().get() item.read() self.assertNotEqual(item.title, 'newTitle') def test_move(self): self.modify(u"title=newTitle") item = self.lib.items().get() self.assertIn(b'newTitle', item.path) def test_not_move(self): self.modify(u"--nomove", u"title=newTitle") item = self.lib.items().get() self.assertNotIn(b'newTitle', item.path) def test_no_write_no_move(self): self.modify(u"--nomove", u"--nowrite", u"title=newTitle") item = self.lib.items().get() item.read() self.assertNotIn(b'newTitle', item.path) self.assertNotEqual(item.title, u'newTitle') def test_update_mtime(self): item = self.item old_mtime = item.mtime self.modify(u"title=newTitle") item.load() self.assertNotEqual(old_mtime, item.mtime) self.assertEqual(item.current_mtime(), item.mtime) def test_reset_mtime_with_no_write(self): item = self.item self.modify(u"--nowrite", u"title=newTitle") item.load() self.assertEqual(0, item.mtime) def test_selective_modify(self): title = u"Tracktitle" album = u"album" original_artist = u"composer" new_artist = u"coverArtist" for i in range(0, 10): self.add_item_fixture(title=u"{0}{1}".format(title, i), artist=original_artist, album=album) self.modify_inp('s\ny\ny\ny\nn\nn\ny\ny\ny\ny\nn', title, u"artist={0}".format(new_artist)) original_items = self.lib.items(u"artist:{0}".format(original_artist)) new_items = self.lib.items(u"artist:{0}".format(new_artist)) self.assertEqual(len(list(original_items)), 3) self.assertEqual(len(list(new_items)), 7) # Album Tests def test_modify_album(self): self.modify(u"--album", u"album=newAlbum") album = self.lib.albums().get() self.assertEqual(album.album, u'newAlbum') def test_modify_album_write_tags(self): self.modify(u"--album", u"album=newAlbum") item = self.lib.items().get() item.read() self.assertEqual(item.album, u'newAlbum') def test_modify_album_dont_write_tags(self): self.modify(u"--album", u"--nowrite", u"album=newAlbum") item = self.lib.items().get() item.read() self.assertEqual(item.album, u'the album') def test_album_move(self): self.modify(u"--album", u"album=newAlbum") item = self.lib.items().get() item.read() self.assertIn(b'newAlbum', item.path) def test_album_not_move(self): self.modify(u"--nomove", u"--album", u"album=newAlbum") item = self.lib.items().get() item.read() self.assertNotIn(b'newAlbum', item.path) # Misc def test_write_initial_key_tag(self): self.modify(u"initial_key=C#m") item = self.lib.items().get() mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.initial_key, u'C#m') def test_set_flexattr(self): self.modify(u"flexattr=testAttr") item = self.lib.items().get() self.assertEqual(item.flexattr, u'testAttr') def test_remove_flexattr(self): item = self.lib.items().get() item.flexattr = u'testAttr' item.store() self.modify(u"flexattr!") item = self.lib.items().get() self.assertNotIn(u"flexattr", item) @unittest.skip(u'not yet implemented') def test_delete_initial_key_tag(self): item = self.lib.items().get() item.initial_key = u'C#m' item.write() item.store() mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.initial_key, u'C#m') self.modify(u"initial_key!") mediafile = MediaFile(syspath(item.path)) self.assertIsNone(mediafile.initial_key) def test_arg_parsing_colon_query(self): (query, mods, dels) = commands.modify_parse_args([u"title:oldTitle", u"title=newTitle"]) self.assertEqual(query, [u"title:oldTitle"]) self.assertEqual(mods, {"title": u"newTitle"}) def test_arg_parsing_delete(self): (query, mods, dels) = commands.modify_parse_args([u"title:oldTitle", u"title!"]) self.assertEqual(query, [u"title:oldTitle"]) self.assertEqual(dels, ["title"]) def test_arg_parsing_query_with_exclaimation(self): (query, mods, dels) = commands.modify_parse_args([u"title:oldTitle!", u"title=newTitle!"]) self.assertEqual(query, [u"title:oldTitle!"]) self.assertEqual(mods, {"title": u"newTitle!"}) def test_arg_parsing_equals_in_value(self): (query, mods, dels) = commands.modify_parse_args([u"title:foo=bar", u"title=newTitle"]) self.assertEqual(query, [u"title:foo=bar"]) self.assertEqual(mods, {"title": u"newTitle"}) class WriteTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def write_cmd(self, *args): return self.run_with_output('write', *args) def test_update_mtime(self): item = self.add_item_fixture() item['title'] = u'a new title' item.store() item = self.lib.items().get() self.assertEqual(item.mtime, 0) self.write_cmd() item = self.lib.items().get() self.assertEqual(item.mtime, item.current_mtime()) def test_non_metadata_field_unchanged(self): """Changing a non-"tag" field like `bitrate` and writing should have no effect. """ # An item that starts out "clean". item = self.add_item_fixture() item.read() # ... but with a mismatched bitrate. item.bitrate = 123 item.store() output = self.write_cmd() self.assertEqual(output, '') def test_write_metadata_field(self): item = self.add_item_fixture() item.read() old_title = item.title item.title = u'new title' item.store() output = self.write_cmd() self.assertTrue(u'{0} -> new title'.format(old_title) in output) class MoveTest(_common.TestCase): def setUp(self): super(MoveTest, self).setUp() self.io.install() self.libdir = os.path.join(self.temp_dir, b'testlibdir') os.mkdir(self.libdir) self.itempath = os.path.join(self.libdir, b'srcfile') shutil.copy(os.path.join(_common.RSRC, b'full.mp3'), self.itempath) # Add a file to the library but don't copy it in yet. self.lib = library.Library(':memory:', self.libdir) self.i = library.Item.from_path(self.itempath) self.lib.add(self.i) self.album = self.lib.add_album([self.i]) # Alternate destination directory. self.otherdir = os.path.join(self.temp_dir, b'testotherdir') def _move(self, query=(), dest=None, copy=False, album=False, pretend=False, export=False): commands.move_items(self.lib, dest, query, copy, album, pretend, export=export) def test_move_item(self): self._move() self.i.load() self.assertTrue(b'testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_copy_item(self): self._move(copy=True) self.i.load() self.assertTrue(b'testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertExists(self.itempath) def test_move_album(self): self._move(album=True) self.i.load() self.assertTrue(b'testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_copy_album(self): self._move(copy=True, album=True) self.i.load() self.assertTrue(b'testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertExists(self.itempath) def test_move_item_custom_dir(self): self._move(dest=self.otherdir) self.i.load() self.assertTrue(b'testotherdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_move_album_custom_dir(self): self._move(dest=self.otherdir, album=True) self.i.load() self.assertTrue(b'testotherdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_pretend_move_item(self): self._move(dest=self.otherdir, pretend=True) self.i.load() self.assertIn(b'srcfile', self.i.path) def test_pretend_move_album(self): self._move(album=True, pretend=True) self.i.load() self.assertIn(b'srcfile', self.i.path) def test_export_item_custom_dir(self): self._move(dest=self.otherdir, export=True) self.i.load() self.assertEqual(self.i.path, self.itempath) self.assertExists(self.otherdir) def test_export_album_custom_dir(self): self._move(dest=self.otherdir, album=True, export=True) self.i.load() self.assertEqual(self.i.path, self.itempath) self.assertExists(self.otherdir) def test_pretend_export_item(self): self._move(dest=self.otherdir, pretend=True, export=True) self.i.load() self.assertIn(b'srcfile', self.i.path) self.assertNotExists(self.otherdir) class UpdateTest(_common.TestCase): def setUp(self): super(UpdateTest, self).setUp() self.io.install() self.libdir = os.path.join(self.temp_dir, b'testlibdir') # Copy a file into the library. self.lib = library.Library(':memory:', self.libdir) item_path = os.path.join(_common.RSRC, b'full.mp3') self.i = library.Item.from_path(item_path) self.lib.add(self.i) self.i.move(operation=MoveOperation.COPY) self.album = self.lib.add_album([self.i]) # Album art. artfile = os.path.join(self.temp_dir, b'testart.jpg') _common.touch(artfile) self.album.set_art(artfile) self.album.store() os.remove(artfile) def _update(self, query=(), album=False, move=False, reset_mtime=True, fields=None): self.io.addinput('y') if reset_mtime: self.i.mtime = 0 self.i.store() commands.update_items(self.lib, query, album, move, False, fields=fields) def test_delete_removes_item(self): self.assertTrue(list(self.lib.items())) os.remove(self.i.path) self._update() self.assertFalse(list(self.lib.items())) def test_delete_removes_album(self): self.assertTrue(self.lib.albums()) os.remove(self.i.path) self._update() self.assertFalse(self.lib.albums()) def test_delete_removes_album_art(self): artpath = self.album.artpath self.assertExists(artpath) os.remove(self.i.path) self._update() self.assertNotExists(artpath) def test_modified_metadata_detected(self): mf = MediaFile(syspath(self.i.path)) mf.title = u'differentTitle' mf.save() self._update() item = self.lib.items().get() self.assertEqual(item.title, u'differentTitle') def test_modified_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = u'differentTitle' mf.save() self._update(move=True) item = self.lib.items().get() self.assertTrue(b'differentTitle' in item.path) def test_modified_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = u'differentTitle' mf.save() self._update(move=False) item = self.lib.items().get() self.assertTrue(b'differentTitle' not in item.path) def test_selective_modified_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = u'differentTitle' mf.genre = u'differentGenre' mf.save() self._update(move=True, fields=['title']) item = self.lib.items().get() self.assertTrue(b'differentTitle' in item.path) self.assertNotEqual(item.genre, u'differentGenre') def test_selective_modified_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = u'differentTitle' mf.genre = u'differentGenre' mf.save() self._update(move=False, fields=['title']) item = self.lib.items().get() self.assertTrue(b'differentTitle' not in item.path) self.assertNotEqual(item.genre, u'differentGenre') def test_modified_album_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = u'differentAlbum' mf.save() self._update(move=True) item = self.lib.items().get() self.assertTrue(b'differentAlbum' in item.path) def test_modified_album_metadata_art_moved(self): artpath = self.album.artpath mf = MediaFile(syspath(self.i.path)) mf.album = u'differentAlbum' mf.save() self._update(move=True) album = self.lib.albums()[0] self.assertNotEqual(artpath, album.artpath) def test_selective_modified_album_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = u'differentAlbum' mf.genre = u'differentGenre' mf.save() self._update(move=True, fields=['album']) item = self.lib.items().get() self.assertTrue(b'differentAlbum' in item.path) self.assertNotEqual(item.genre, u'differentGenre') def test_selective_modified_album_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = u'differentAlbum' mf.genre = u'differentGenre' mf.save() self._update(move=True, fields=['genre']) item = self.lib.items().get() self.assertTrue(b'differentAlbum' not in item.path) self.assertEqual(item.genre, u'differentGenre') def test_mtime_match_skips_update(self): mf = MediaFile(syspath(self.i.path)) mf.title = u'differentTitle' mf.save() # Make in-memory mtime match on-disk mtime. self.i.mtime = os.path.getmtime(self.i.path) self.i.store() self._update(reset_mtime=False) item = self.lib.items().get() self.assertEqual(item.title, u'full') class PrintTest(_common.TestCase): def setUp(self): super(PrintTest, self).setUp() self.io.install() def test_print_without_locale(self): lang = os.environ.get('LANG') if lang: del os.environ['LANG'] try: ui.print_(u'something') except TypeError: self.fail(u'TypeError during print') finally: if lang: os.environ['LANG'] = lang def test_print_with_invalid_locale(self): old_lang = os.environ.get('LANG') os.environ['LANG'] = '' old_ctype = os.environ.get('LC_CTYPE') os.environ['LC_CTYPE'] = 'UTF-8' try: ui.print_(u'something') except ValueError: self.fail(u'ValueError during print') finally: if old_lang: os.environ['LANG'] = old_lang else: del os.environ['LANG'] if old_ctype: os.environ['LC_CTYPE'] = old_ctype else: del os.environ['LC_CTYPE'] class ImportTest(_common.TestCase): def test_quiet_timid_disallowed(self): config['import']['quiet'] = True config['import']['timid'] = True self.assertRaises(ui.UserError, commands.import_files, None, [], None) @_common.slow_test() class ConfigTest(unittest.TestCase, TestHelper, _common.Assertions): def setUp(self): self.setup_beets() # Don't use the BEETSDIR from `helper`. Instead, we point the home # directory there. Some tests will set `BEETSDIR` themselves. del os.environ['BEETSDIR'] self._old_home = os.environ.get('HOME') os.environ['HOME'] = util.py3_path(self.temp_dir) # Also set APPDATA, the Windows equivalent of setting $HOME. self._old_appdata = os.environ.get('APPDATA') os.environ['APPDATA'] = \ util.py3_path(os.path.join(self.temp_dir, b'AppData', b'Roaming')) self._orig_cwd = os.getcwd() self.test_cmd = self._make_test_cmd() commands.default_commands.append(self.test_cmd) # Default user configuration if platform.system() == 'Windows': self.user_config_dir = os.path.join( self.temp_dir, b'AppData', b'Roaming', b'beets' ) else: self.user_config_dir = os.path.join( self.temp_dir, b'.config', b'beets' ) os.makedirs(self.user_config_dir) self.user_config_path = os.path.join(self.user_config_dir, b'config.yaml') # Custom BEETSDIR self.beetsdir = os.path.join(self.temp_dir, b'beetsdir') os.makedirs(self.beetsdir) self._reset_config() def tearDown(self): commands.default_commands.pop() os.chdir(self._orig_cwd) if self._old_home is not None: os.environ['HOME'] = self._old_home if self._old_appdata is None: del os.environ['APPDATA'] else: os.environ['APPDATA'] = self._old_appdata self.teardown_beets() def _make_test_cmd(self): test_cmd = ui.Subcommand('test', help=u'test') def run(lib, options, args): test_cmd.lib = lib test_cmd.options = options test_cmd.args = args test_cmd.func = run return test_cmd def _reset_config(self): # Config should read files again on demand config.clear() config._materialized = False def write_config_file(self): return open(self.user_config_path, 'w') def test_paths_section_respected(self): with self.write_config_file() as config: config.write('paths: {x: y}') self.run_command('test', lib=None) key, template = self.test_cmd.lib.path_formats[0] self.assertEqual(key, 'x') self.assertEqual(template.original, 'y') def test_default_paths_preserved(self): default_formats = ui.get_path_formats() self._reset_config() with self.write_config_file() as config: config.write('paths: {x: y}') self.run_command('test', lib=None) key, template = self.test_cmd.lib.path_formats[0] self.assertEqual(key, 'x') self.assertEqual(template.original, 'y') self.assertEqual(self.test_cmd.lib.path_formats[1:], default_formats) def test_nonexistant_db(self): with self.write_config_file() as config: config.write('library: /xxx/yyy/not/a/real/path') with self.assertRaises(ui.UserError): self.run_command('test', lib=None) def test_user_config_file(self): with self.write_config_file() as file: file.write('anoption: value') self.run_command('test', lib=None) self.assertEqual(config['anoption'].get(), 'value') def test_replacements_parsed(self): with self.write_config_file() as config: config.write("replace: {'[xy]': z}") self.run_command('test', lib=None) replacements = self.test_cmd.lib.replacements self.assertEqual(replacements, [(re.compile(u'[xy]'), 'z')]) def test_multiple_replacements_parsed(self): with self.write_config_file() as config: config.write("replace: {'[xy]': z, foo: bar}") self.run_command('test', lib=None) replacements = self.test_cmd.lib.replacements self.assertEqual(replacements, [ (re.compile(u'[xy]'), u'z'), (re.compile(u'foo'), u'bar'), ]) def test_cli_config_option(self): config_path = os.path.join(self.temp_dir, b'config.yaml') with open(config_path, 'w') as file: file.write('anoption: value') self.run_command('--config', config_path, 'test', lib=None) self.assertEqual(config['anoption'].get(), 'value') def test_cli_config_file_overwrites_user_defaults(self): with open(self.user_config_path, 'w') as file: file.write('anoption: value') cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('anoption: cli overwrite') self.run_command('--config', cli_config_path, 'test', lib=None) self.assertEqual(config['anoption'].get(), 'cli overwrite') def test_cli_config_file_overwrites_beetsdir_defaults(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) env_config_path = os.path.join(self.beetsdir, b'config.yaml') with open(env_config_path, 'w') as file: file.write('anoption: value') cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('anoption: cli overwrite') self.run_command('--config', cli_config_path, 'test', lib=None) self.assertEqual(config['anoption'].get(), 'cli overwrite') # @unittest.skip('Difficult to implement with optparse') # def test_multiple_cli_config_files(self): # cli_config_path_1 = os.path.join(self.temp_dir, b'config.yaml') # cli_config_path_2 = os.path.join(self.temp_dir, b'config_2.yaml') # # with open(cli_config_path_1, 'w') as file: # file.write('first: value') # # with open(cli_config_path_2, 'w') as file: # file.write('second: value') # # self.run_command('--config', cli_config_path_1, # '--config', cli_config_path_2, 'test', lib=None) # self.assertEqual(config['first'].get(), 'value') # self.assertEqual(config['second'].get(), 'value') # # @unittest.skip('Difficult to implement with optparse') # def test_multiple_cli_config_overwrite(self): # cli_config_path = os.path.join(self.temp_dir, b'config.yaml') # cli_overwrite_config_path = os.path.join(self.temp_dir, # b'overwrite_config.yaml') # # with open(cli_config_path, 'w') as file: # file.write('anoption: value') # # with open(cli_overwrite_config_path, 'w') as file: # file.write('anoption: overwrite') # # self.run_command('--config', cli_config_path, # '--config', cli_overwrite_config_path, 'test') # self.assertEqual(config['anoption'].get(), 'cli overwrite') def test_cli_config_paths_resolve_relative_to_user_dir(self): cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('library: beets.db\n') file.write('statefile: state') self.run_command('--config', cli_config_path, 'test', lib=None) self.assert_equal_path( util.bytestring_path(config['library'].as_filename()), os.path.join(self.user_config_dir, b'beets.db') ) self.assert_equal_path( util.bytestring_path(config['statefile'].as_filename()), os.path.join(self.user_config_dir, b'state') ) def test_cli_config_paths_resolve_relative_to_beetsdir(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('library: beets.db\n') file.write('statefile: state') self.run_command('--config', cli_config_path, 'test', lib=None) self.assert_equal_path( util.bytestring_path(config['library'].as_filename()), os.path.join(self.beetsdir, b'beets.db') ) self.assert_equal_path( util.bytestring_path(config['statefile'].as_filename()), os.path.join(self.beetsdir, b'state') ) def test_command_line_option_relative_to_working_dir(self): os.chdir(self.temp_dir) self.run_command('--library', 'foo.db', 'test', lib=None) self.assert_equal_path(config['library'].as_filename(), os.path.join(os.getcwd(), 'foo.db')) def test_cli_config_file_loads_plugin_commands(self): cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('pluginpath: %s\n' % _common.PLUGINPATH) file.write('plugins: test') self.run_command('--config', cli_config_path, 'plugin', lib=None) self.assertTrue(plugins.find_plugins()[0].is_test_plugin) def test_beetsdir_config(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) env_config_path = os.path.join(self.beetsdir, b'config.yaml') with open(env_config_path, 'w') as file: file.write('anoption: overwrite') config.read() self.assertEqual(config['anoption'].get(), 'overwrite') def test_beetsdir_points_to_file_error(self): beetsdir = os.path.join(self.temp_dir, b'beetsfile') open(beetsdir, 'a').close() os.environ['BEETSDIR'] = util.py3_path(beetsdir) self.assertRaises(ConfigError, self.run_command, 'test') def test_beetsdir_config_does_not_load_default_user_config(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) with open(self.user_config_path, 'w') as file: file.write('anoption: value') config.read() self.assertFalse(config['anoption'].exists()) def test_default_config_paths_resolve_relative_to_beetsdir(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) config.read() self.assert_equal_path( util.bytestring_path(config['library'].as_filename()), os.path.join(self.beetsdir, b'library.db') ) self.assert_equal_path( util.bytestring_path(config['statefile'].as_filename()), os.path.join(self.beetsdir, b'state.pickle') ) def test_beetsdir_config_paths_resolve_relative_to_beetsdir(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) env_config_path = os.path.join(self.beetsdir, b'config.yaml') with open(env_config_path, 'w') as file: file.write('library: beets.db\n') file.write('statefile: state') config.read() self.assert_equal_path( util.bytestring_path(config['library'].as_filename()), os.path.join(self.beetsdir, b'beets.db') ) self.assert_equal_path( util.bytestring_path(config['statefile'].as_filename()), os.path.join(self.beetsdir, b'state') ) class ShowModelChangeTest(_common.TestCase): def setUp(self): super(ShowModelChangeTest, self).setUp() self.io.install() self.a = _common.item() self.b = _common.item() self.a.path = self.b.path def _show(self, **kwargs): change = ui.show_model_changes(self.a, self.b, **kwargs) out = self.io.getoutput() return change, out def test_identical(self): change, out = self._show() self.assertFalse(change) self.assertEqual(out, '') def test_string_fixed_field_change(self): self.b.title = 'x' change, out = self._show() self.assertTrue(change) self.assertTrue(u'title' in out) def test_int_fixed_field_change(self): self.b.track = 9 change, out = self._show() self.assertTrue(change) self.assertTrue(u'track' in out) def test_floats_close_to_identical(self): self.a.length = 1.00001 self.b.length = 1.00005 change, out = self._show() self.assertFalse(change) self.assertEqual(out, u'') def test_floats_different(self): self.a.length = 1.00001 self.b.length = 2.00001 change, out = self._show() self.assertTrue(change) self.assertTrue(u'length' in out) def test_both_values_shown(self): self.a.title = u'foo' self.b.title = u'bar' change, out = self._show() self.assertTrue(u'foo' in out) self.assertTrue(u'bar' in out) class ShowChangeTest(_common.TestCase): def setUp(self): super(ShowChangeTest, self).setUp() self.io.install() self.items = [_common.item()] self.items[0].track = 1 self.items[0].path = b'/path/to/file.mp3' self.info = autotag.AlbumInfo( u'the album', u'album id', u'the artist', u'artist id', [ autotag.TrackInfo(u'the title', u'track id', index=1) ] ) def _show_change(self, items=None, info=None, cur_artist=u'the artist', cur_album=u'the album', dist=0.1): """Return an unicode string representing the changes""" items = items or self.items info = info or self.info mapping = dict(zip(items, info.tracks)) config['ui']['color'] = False album_dist = distance(items, info, mapping) album_dist._penalties = {'album': [dist]} commands.show_change( cur_artist, cur_album, autotag.AlbumMatch(album_dist, info, mapping, set(), set()), ) # FIXME decoding shouldn't be done here return util.text_string(self.io.getoutput().lower()) def test_null_change(self): msg = self._show_change() self.assertTrue('similarity: 90' in msg) self.assertTrue('tagging:' in msg) def test_album_data_change(self): msg = self._show_change(cur_artist='another artist', cur_album='another album') self.assertTrue('correcting tags from:' in msg) def test_item_data_change(self): self.items[0].title = u'different' msg = self._show_change() self.assertTrue('different -> the title' in msg) def test_item_data_change_with_unicode(self): self.items[0].title = u'caf\xe9' msg = self._show_change() self.assertTrue(u'caf\xe9 -> the title' in msg) def test_album_data_change_with_unicode(self): msg = self._show_change(cur_artist=u'caf\xe9', cur_album=u'another album') self.assertTrue(u'correcting tags from:' in msg) def test_item_data_change_title_missing(self): self.items[0].title = u'' msg = re.sub(r' +', ' ', self._show_change()) self.assertTrue(u'file.mp3 -> the title' in msg) def test_item_data_change_title_missing_with_unicode_filename(self): self.items[0].title = u'' self.items[0].path = u'/path/to/caf\xe9.mp3'.encode('utf-8') msg = re.sub(r' +', ' ', self._show_change()) self.assertTrue(u'caf\xe9.mp3 -> the title' in msg or u'caf.mp3 ->' in msg) @patch('beets.library.Item.try_filesize', Mock(return_value=987)) class SummarizeItemsTest(_common.TestCase): def setUp(self): super(SummarizeItemsTest, self).setUp() item = library.Item() item.bitrate = 4321 item.length = 10 * 60 + 54 item.format = "F" self.item = item def test_summarize_item(self): summary = commands.summarize_items([], True) self.assertEqual(summary, u"") summary = commands.summarize_items([self.item], True) self.assertEqual(summary, u"F, 4kbps, 10:54, 987.0 B") def test_summarize_items(self): summary = commands.summarize_items([], False) self.assertEqual(summary, u"0 items") summary = commands.summarize_items([self.item], False) self.assertEqual(summary, u"1 items, F, 4kbps, 10:54, 987.0 B") i2 = deepcopy(self.item) summary = commands.summarize_items([self.item, i2], False) self.assertEqual(summary, u"2 items, F, 4kbps, 21:48, 1.9 KiB") i2.format = "G" summary = commands.summarize_items([self.item, i2], False) self.assertEqual(summary, u"2 items, F 1, G 1, 4kbps, 21:48, 1.9 KiB") summary = commands.summarize_items([self.item, i2, i2], False) self.assertEqual(summary, u"3 items, G 2, F 1, 4kbps, 32:42, 2.9 KiB") class PathFormatTest(_common.TestCase): def test_custom_paths_prepend(self): default_formats = ui.get_path_formats() config['paths'] = {u'foo': u'bar'} pf = ui.get_path_formats() key, tmpl = pf[0] self.assertEqual(key, u'foo') self.assertEqual(tmpl.original, u'bar') self.assertEqual(pf[1:], default_formats) @_common.slow_test() class PluginTest(_common.TestCase, TestHelper): def test_plugin_command_from_pluginpath(self): config['pluginpath'] = [_common.PLUGINPATH] config['plugins'] = ['test'] self.run_command('test', lib=None) @_common.slow_test() class CompletionTest(_common.TestCase, TestHelper): def test_completion(self): # Load plugin commands config['pluginpath'] = [_common.PLUGINPATH] config['plugins'] = ['test'] # Do not load any other bash completion scripts on the system. env = dict(os.environ) env['BASH_COMPLETION_DIR'] = os.devnull env['BASH_COMPLETION_COMPAT_DIR'] = os.devnull # Open a `bash` process to run the tests in. We'll pipe in bash # commands via stdin. cmd = os.environ.get('BEETS_TEST_SHELL', '/bin/bash --norc').split() if not has_program(cmd[0]): self.skipTest(u'bash not available') tester = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env) # Load bash_completion library. for path in commands.BASH_COMPLETION_PATHS: if os.path.exists(util.syspath(path)): bash_completion = path break else: self.skipTest(u'bash-completion script not found') try: with open(util.syspath(bash_completion), 'rb') as f: tester.stdin.writelines(f) except IOError: self.skipTest(u'could not read bash-completion script') # Load completion script. self.io.install() self.run_command('completion', lib=None) completion_script = self.io.getoutput().encode('utf-8') self.io.restore() tester.stdin.writelines(completion_script.splitlines(True)) # Load test suite. test_script_name = os.path.join(_common.RSRC, b'test_completion.sh') with open(test_script_name, 'rb') as test_script_file: tester.stdin.writelines(test_script_file) out, err = tester.communicate() if tester.returncode != 0 or out != b'completion tests passed\n': print(out.decode('utf-8')) self.fail(u'test/test_completion.sh did not execute properly') class CommonOptionsParserCliTest(unittest.TestCase, TestHelper): """Test CommonOptionsParser and formatting LibModel formatting on 'list' command. """ def setUp(self): self.setup_beets() self.lib = library.Library(':memory:') self.item = _common.item() self.item.path = b'xxx/yyy' self.lib.add(self.item) self.lib.add_album([self.item]) def tearDown(self): self.teardown_beets() def test_base(self): l = self.run_with_output(u'ls') self.assertEqual(l, u'the artist - the album - the title\n') l = self.run_with_output(u'ls', u'-a') self.assertEqual(l, u'the album artist - the album\n') def test_path_option(self): l = self.run_with_output(u'ls', u'-p') self.assertEqual(l, u'xxx/yyy\n') l = self.run_with_output(u'ls', u'-a', u'-p') self.assertEqual(l, u'xxx\n') def test_format_option(self): l = self.run_with_output(u'ls', u'-f', u'$artist') self.assertEqual(l, u'the artist\n') l = self.run_with_output(u'ls', u'-a', u'-f', u'$albumartist') self.assertEqual(l, u'the album artist\n') def test_format_option_unicode(self): l = self.run_with_output(b'ls', b'-f', u'caf\xe9'.encode(util.arg_encoding())) self.assertEqual(l, u'caf\xe9\n') def test_root_format_option(self): l = self.run_with_output(u'--format-item', u'$artist', u'--format-album', u'foo', u'ls') self.assertEqual(l, u'the artist\n') l = self.run_with_output(u'--format-item', u'foo', u'--format-album', u'$albumartist', u'ls', u'-a') self.assertEqual(l, u'the album artist\n') def test_help(self): l = self.run_with_output(u'help') self.assertIn(u'Usage:', l) l = self.run_with_output(u'help', u'list') self.assertIn(u'Usage:', l) with self.assertRaises(ui.UserError): self.run_command(u'help', u'this.is.not.a.real.command') def test_stats(self): l = self.run_with_output(u'stats') self.assertIn(u'Approximate total size:', l) # # Need to have more realistic library setup for this to work # l = self.run_with_output('stats', '-e') # self.assertIn('Total size:', l) def test_version(self): l = self.run_with_output(u'version') self.assertIn(u'Python version', l) self.assertIn(u'no plugins loaded', l) # # Need to have plugin loaded # l = self.run_with_output('version') # self.assertIn('plugins: ', l) class CommonOptionsParserTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def test_album_option(self): parser = ui.CommonOptionsParser() self.assertFalse(parser._album_flags) parser.add_album_option() self.assertTrue(bool(parser._album_flags)) self.assertEqual(parser.parse_args([]), ({'album': None}, [])) self.assertEqual(parser.parse_args([u'-a']), ({'album': True}, [])) self.assertEqual(parser.parse_args([u'--album']), ({'album': True}, [])) def test_path_option(self): parser = ui.CommonOptionsParser() parser.add_path_option() self.assertFalse(parser._album_flags) config['format_item'].set('$foo') self.assertEqual(parser.parse_args([]), ({'path': None}, [])) self.assertEqual(config['format_item'].as_str(), u'$foo') self.assertEqual(parser.parse_args([u'-p']), ({'path': True, 'format': u'$path'}, [])) self.assertEqual(parser.parse_args(['--path']), ({'path': True, 'format': u'$path'}, [])) self.assertEqual(config['format_item'].as_str(), u'$path') self.assertEqual(config['format_album'].as_str(), u'$path') def test_format_option(self): parser = ui.CommonOptionsParser() parser.add_format_option() self.assertFalse(parser._album_flags) config['format_item'].set('$foo') self.assertEqual(parser.parse_args([]), ({'format': None}, [])) self.assertEqual(config['format_item'].as_str(), u'$foo') self.assertEqual(parser.parse_args([u'-f', u'$bar']), ({'format': u'$bar'}, [])) self.assertEqual(parser.parse_args([u'--format', u'$baz']), ({'format': u'$baz'}, [])) self.assertEqual(config['format_item'].as_str(), u'$baz') self.assertEqual(config['format_album'].as_str(), u'$baz') def test_format_option_with_target(self): with self.assertRaises(KeyError): ui.CommonOptionsParser().add_format_option(target='thingy') parser = ui.CommonOptionsParser() parser.add_format_option(target='item') config['format_item'].set('$item') config['format_album'].set('$album') self.assertEqual(parser.parse_args([u'-f', u'$bar']), ({'format': u'$bar'}, [])) self.assertEqual(config['format_item'].as_str(), u'$bar') self.assertEqual(config['format_album'].as_str(), u'$album') def test_format_option_with_album(self): parser = ui.CommonOptionsParser() parser.add_album_option() parser.add_format_option() config['format_item'].set('$item') config['format_album'].set('$album') parser.parse_args([u'-f', u'$bar']) self.assertEqual(config['format_item'].as_str(), u'$bar') self.assertEqual(config['format_album'].as_str(), u'$album') parser.parse_args([u'-a', u'-f', u'$foo']) self.assertEqual(config['format_item'].as_str(), u'$bar') self.assertEqual(config['format_album'].as_str(), u'$foo') parser.parse_args([u'-f', u'$foo2', u'-a']) self.assertEqual(config['format_album'].as_str(), u'$foo2') def test_add_all_common_options(self): parser = ui.CommonOptionsParser() parser.add_all_common_options() self.assertEqual(parser.parse_args([]), ({'album': None, 'path': None, 'format': None}, [])) class EncodingTest(_common.TestCase): """Tests for the `terminal_encoding` config option and our `_in_encoding` and `_out_encoding` utility functions. """ def out_encoding_overridden(self): config['terminal_encoding'] = 'fake_encoding' self.assertEqual(ui._out_encoding(), 'fake_encoding') def in_encoding_overridden(self): config['terminal_encoding'] = 'fake_encoding' self.assertEqual(ui._in_encoding(), 'fake_encoding') def out_encoding_default_utf8(self): with patch('sys.stdout') as stdout: stdout.encoding = None self.assertEqual(ui._out_encoding(), 'utf-8') def in_encoding_default_utf8(self): with patch('sys.stdin') as stdin: stdin.encoding = None self.assertEqual(ui._in_encoding(), 'utf-8') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_convert.py0000644000076500000240000002351713120341455017646 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import sys import re import os.path import unittest from test import _common from test import helper from test.helper import control_stdin, capture_log from beets.mediafile import MediaFile from beets import util def shell_quote(text): if sys.version_info[0] < 3: import pipes return pipes.quote(text) else: import shlex return shlex.quote(text) class TestHelper(helper.TestHelper): def tagged_copy_cmd(self, tag): """Return a conversion command that copies files and appends `tag` to the copy. """ if re.search('[^a-zA-Z0-9]', tag): raise ValueError(u"tag '{0}' must only contain letters and digits" .format(tag)) # A Python script that copies the file and appends a tag. stub = os.path.join(_common.RSRC, b'convert_stub.py').decode('utf-8') return u"{} {} $source $dest {}".format(shell_quote(sys.executable), shell_quote(stub), tag) def assertFileTag(self, path, tag): # noqa """Assert that the path is a file and the files content ends with `tag`. """ display_tag = tag tag = tag.encode('utf-8') self.assertTrue(os.path.isfile(path), u'{0} is not a file'.format( util.displayable_path(path))) with open(path, 'rb') as f: f.seek(-len(display_tag), os.SEEK_END) self.assertEqual(f.read(), tag, u'{0} is not tagged with {1}' .format( util.displayable_path(path), display_tag)) def assertNoFileTag(self, path, tag): # noqa """Assert that the path is a file and the files content does not end with `tag`. """ display_tag = tag tag = tag.encode('utf-8') self.assertTrue(os.path.isfile(path), u'{0} is not a file'.format( util.displayable_path(path))) with open(path, 'rb') as f: f.seek(-len(tag), os.SEEK_END) self.assertNotEqual(f.read(), tag, u'{0} is unexpectedly tagged with {1}' .format( util.displayable_path(path), display_tag)) @_common.slow_test() class ImportConvertTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets(disk=True) # Converter is threaded self.importer = self.create_importer() self.load_plugins('convert') self.config['convert'] = { 'dest': os.path.join(self.temp_dir, b'convert'), 'command': self.tagged_copy_cmd('convert'), # Enforce running convert 'max_bitrate': 1, 'auto': True, 'quiet': False, } def tearDown(self): self.unload_plugins() self.teardown_beets() def test_import_converted(self): self.importer.run() item = self.lib.items().get() self.assertFileTag(item.path, 'convert') def test_import_original_on_convert_error(self): # `false` exits with non-zero code self.config['convert']['command'] = u'false' self.importer.run() item = self.lib.items().get() self.assertIsNotNone(item) self.assertTrue(os.path.isfile(item.path)) class ConvertCommand(object): """A mixin providing a utility method to run the `convert`command in tests. """ def run_convert_path(self, path, *args): """Run the `convert` command on a given path.""" # The path is currently a filesystem bytestring. Convert it to # an argument bytestring. path = path.decode(util._fsencoding()).encode(util.arg_encoding()) args = args + (b'path:' + path,) return self.run_command('convert', *args) def run_convert(self, *args): """Run the `convert` command on `self.item`.""" return self.run_convert_path(self.item.path, *args) @_common.slow_test() class ConvertCliTest(unittest.TestCase, TestHelper, ConvertCommand): def setUp(self): self.setup_beets(disk=True) # Converter is threaded self.album = self.add_album_fixture(ext='ogg') self.item = self.album.items()[0] self.load_plugins('convert') self.convert_dest = util.bytestring_path( os.path.join(self.temp_dir, b'convert_dest') ) self.config['convert'] = { 'dest': self.convert_dest, 'paths': {'default': 'converted'}, 'format': 'mp3', 'formats': { 'mp3': self.tagged_copy_cmd('mp3'), 'opus': { 'command': self.tagged_copy_cmd('opus'), 'extension': 'ops', } } } def tearDown(self): self.unload_plugins() self.teardown_beets() def test_convert(self): with control_stdin('y'): self.run_convert() converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFileTag(converted, 'mp3') def test_convert_with_auto_confirmation(self): self.run_convert('--yes') converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFileTag(converted, 'mp3') def test_reject_confirmation(self): with control_stdin('n'): self.run_convert() converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFalse(os.path.isfile(converted)) def test_convert_keep_new(self): self.assertEqual(os.path.splitext(self.item.path)[1], b'.ogg') with control_stdin('y'): self.run_convert('--keep-new') self.item.load() self.assertEqual(os.path.splitext(self.item.path)[1], b'.mp3') def test_format_option(self): with control_stdin('y'): self.run_convert('--format', 'opus') converted = os.path.join(self.convert_dest, b'converted.ops') self.assertFileTag(converted, 'opus') def test_embed_album_art(self): self.config['convert']['embed'] = True image_path = os.path.join(_common.RSRC, b'image-2x3.jpg') self.album.artpath = image_path self.album.store() with open(os.path.join(image_path), 'rb') as f: image_data = f.read() with control_stdin('y'): self.run_convert() converted = os.path.join(self.convert_dest, b'converted.mp3') mediafile = MediaFile(converted) self.assertEqual(mediafile.images[0].data, image_data) def test_skip_existing(self): converted = os.path.join(self.convert_dest, b'converted.mp3') self.touch(converted, content='XXX') self.run_convert('--yes') with open(converted, 'r') as f: self.assertEqual(f.read(), 'XXX') def test_pretend(self): self.run_convert('--pretend') converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFalse(os.path.exists(converted)) def test_empty_query(self): with capture_log('beets.convert') as logs: self.run_convert('An impossible query') self.assertEqual(logs[0], u'convert: Empty query result.') @_common.slow_test() class NeverConvertLossyFilesTest(unittest.TestCase, TestHelper, ConvertCommand): """Test the effect of the `never_convert_lossy_files` option. """ def setUp(self): self.setup_beets(disk=True) # Converter is threaded self.load_plugins('convert') self.convert_dest = os.path.join(self.temp_dir, b'convert_dest') self.config['convert'] = { 'dest': self.convert_dest, 'paths': {'default': 'converted'}, 'never_convert_lossy_files': True, 'format': 'mp3', 'formats': { 'mp3': self.tagged_copy_cmd('mp3'), } } def tearDown(self): self.unload_plugins() self.teardown_beets() def test_transcode_from_lossles(self): [item] = self.add_item_fixtures(ext='flac') with control_stdin('y'): self.run_convert_path(item.path) converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFileTag(converted, 'mp3') def test_transcode_from_lossy(self): self.config['convert']['never_convert_lossy_files'] = False [item] = self.add_item_fixtures(ext='ogg') with control_stdin('y'): self.run_convert_path(item.path) converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFileTag(converted, 'mp3') def test_transcode_from_lossy_prevented(self): [item] = self.add_item_fixtures(ext='ogg') with control_stdin('y'): self.run_convert_path(item.path) converted = os.path.join(self.convert_dest, b'converted.ogg') self.assertNoFileTag(converted, 'mp3') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_ui_init.py0000644000076500000240000000677313025125203017626 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test module for file ui/__init__.py """ from __future__ import division, absolute_import, print_function import unittest from test import _common from beets import ui class InputMethodsTest(_common.TestCase): def setUp(self): super(InputMethodsTest, self).setUp() self.io.install() def _print_helper(self, s): print(s) def _print_helper2(self, s, prefix): print(prefix, s) def test_input_select_objects(self): full_items = ['1', '2', '3', '4', '5'] # Test no self.io.addinput('n') items = ui.input_select_objects( "Prompt", full_items, self._print_helper) self.assertEqual(items, []) # Test yes self.io.addinput('y') items = ui.input_select_objects( "Prompt", full_items, self._print_helper) self.assertEqual(items, full_items) # Test selective 1 self.io.addinput('s') self.io.addinput('n') self.io.addinput('y') self.io.addinput('n') self.io.addinput('y') self.io.addinput('n') items = ui.input_select_objects( "Prompt", full_items, self._print_helper) self.assertEqual(items, ['2', '4']) # Test selective 2 self.io.addinput('s') self.io.addinput('y') self.io.addinput('y') self.io.addinput('n') self.io.addinput('y') self.io.addinput('n') items = ui.input_select_objects( "Prompt", full_items, lambda s: self._print_helper2(s, "Prefix")) self.assertEqual(items, ['1', '2', '4']) class InitTest(_common.LibTestCase): def setUp(self): super(InitTest, self).setUp() def test_human_bytes(self): tests = [ (0, '0.0 B'), (30, '30.0 B'), (pow(2, 10), '1.0 KiB'), (pow(2, 20), '1.0 MiB'), (pow(2, 30), '1.0 GiB'), (pow(2, 40), '1.0 TiB'), (pow(2, 50), '1.0 PiB'), (pow(2, 60), '1.0 EiB'), (pow(2, 70), '1.0 ZiB'), (pow(2, 80), '1.0 YiB'), (pow(2, 90), '1.0 HiB'), (pow(2, 100), 'big'), ] for i, h in tests: self.assertEqual(h, ui.human_bytes(i)) def test_human_seconds(self): tests = [ (0, '0.0 seconds'), (30, '30.0 seconds'), (60, '1.0 minutes'), (90, '1.5 minutes'), (125, '2.1 minutes'), (3600, '1.0 hours'), (86400, '1.0 days'), (604800, '1.0 weeks'), (31449600, '1.0 years'), (314496000, '1.0 decades'), ] for i, h in tests: self.assertEqual(h, ui.human_seconds(i)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_util.py0000644000076500000240000001564713025125203017143 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for base utils from the beets.util package. """ from __future__ import division, absolute_import, print_function import sys import re import os import subprocess import unittest from mock import patch, Mock from test import _common from beets import util import six class UtilTest(unittest.TestCase): def test_open_anything(self): with _common.system_mock('Windows'): self.assertEqual(util.open_anything(), 'start') with _common.system_mock('Darwin'): self.assertEqual(util.open_anything(), 'open') with _common.system_mock('Tagada'): self.assertEqual(util.open_anything(), 'xdg-open') @patch('os.execlp') @patch('beets.util.open_anything') def test_interactive_open(self, mock_open, mock_execlp): mock_open.return_value = u'tagada' util.interactive_open(['foo'], util.open_anything()) mock_execlp.assert_called_once_with(u'tagada', u'tagada', u'foo') mock_execlp.reset_mock() util.interactive_open(['foo'], u'bar') mock_execlp.assert_called_once_with(u'bar', u'bar', u'foo') def test_sanitize_unix_replaces_leading_dot(self): with _common.platform_posix(): p = util.sanitize_path(u'one/.two/three') self.assertFalse(u'.' in p) def test_sanitize_windows_replaces_trailing_dot(self): with _common.platform_windows(): p = util.sanitize_path(u'one/two./three') self.assertFalse(u'.' in p) def test_sanitize_windows_replaces_illegal_chars(self): with _common.platform_windows(): p = util.sanitize_path(u':*?"<>|') self.assertFalse(u':' in p) self.assertFalse(u'*' in p) self.assertFalse(u'?' in p) self.assertFalse(u'"' in p) self.assertFalse(u'<' in p) self.assertFalse(u'>' in p) self.assertFalse(u'|' in p) def test_sanitize_windows_replaces_trailing_space(self): with _common.platform_windows(): p = util.sanitize_path(u'one/two /three') self.assertFalse(u' ' in p) def test_sanitize_path_works_on_empty_string(self): with _common.platform_posix(): p = util.sanitize_path(u'') self.assertEqual(p, u'') def test_sanitize_with_custom_replace_overrides_built_in_sub(self): with _common.platform_posix(): p = util.sanitize_path(u'a/.?/b', [ (re.compile(r'foo'), u'bar'), ]) self.assertEqual(p, u'a/.?/b') def test_sanitize_with_custom_replace_adds_replacements(self): with _common.platform_posix(): p = util.sanitize_path(u'foo/bar', [ (re.compile(r'foo'), u'bar'), ]) self.assertEqual(p, u'bar/bar') @unittest.skip(u'unimplemented: #359') def test_sanitize_empty_component(self): with _common.platform_posix(): p = util.sanitize_path(u'foo//bar', [ (re.compile(r'^$'), u'_'), ]) self.assertEqual(p, u'foo/_/bar') @unittest.skipIf(six.PY2, 'surrogateescape error handler not available' 'on Python 2') def test_convert_command_args_keeps_undecodeable_bytes(self): arg = b'\x82' # non-ascii bytes cmd_args = util.convert_command_args([arg]) self.assertEqual(cmd_args[0], arg.decode(util.arg_encoding(), 'surrogateescape')) @patch('beets.util.subprocess.Popen') def test_command_output(self, mock_popen): def popen_fail(*args, **kwargs): m = Mock(returncode=1) m.communicate.return_value = u'foo', u'bar' return m mock_popen.side_effect = popen_fail with self.assertRaises(subprocess.CalledProcessError) as exc_context: util.command_output(['taga', '\xc3\xa9']) self.assertEqual(exc_context.exception.returncode, 1) self.assertEqual(exc_context.exception.cmd, 'taga \xc3\xa9') class PathConversionTest(_common.TestCase): def test_syspath_windows_format(self): with _common.platform_windows(): path = os.path.join(u'a', u'b', u'c') outpath = util.syspath(path) self.assertTrue(isinstance(outpath, six.text_type)) self.assertTrue(outpath.startswith(u'\\\\?\\')) def test_syspath_windows_format_unc_path(self): # The \\?\ prefix on Windows behaves differently with UNC # (network share) paths. path = '\\\\server\\share\\file.mp3' with _common.platform_windows(): outpath = util.syspath(path) self.assertTrue(isinstance(outpath, six.text_type)) self.assertEqual(outpath, u'\\\\?\\UNC\\server\\share\\file.mp3') def test_syspath_posix_unchanged(self): with _common.platform_posix(): path = os.path.join(u'a', u'b', u'c') outpath = util.syspath(path) self.assertEqual(path, outpath) def _windows_bytestring_path(self, path): old_gfse = sys.getfilesystemencoding sys.getfilesystemencoding = lambda: 'mbcs' try: with _common.platform_windows(): return util.bytestring_path(path) finally: sys.getfilesystemencoding = old_gfse def test_bytestring_path_windows_encodes_utf8(self): path = u'caf\xe9' outpath = self._windows_bytestring_path(path) self.assertEqual(path, outpath.decode('utf-8')) def test_bytesting_path_windows_removes_magic_prefix(self): path = u'\\\\?\\C:\\caf\xe9' outpath = self._windows_bytestring_path(path) self.assertEqual(outpath, u'C:\\caf\xe9'.encode('utf-8')) class PathTruncationTest(_common.TestCase): def test_truncate_bytestring(self): with _common.platform_posix(): p = util.truncate_path(b'abcde/fgh', 4) self.assertEqual(p, b'abcd/fgh') def test_truncate_unicode(self): with _common.platform_posix(): p = util.truncate_path(u'abcde/fgh', 4) self.assertEqual(p, u'abcd/fgh') def test_truncate_preserves_extension(self): with _common.platform_posix(): p = util.truncate_path(u'abcde/fgh.ext', 5) self.assertEqual(p, u'abcde/f.ext') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_sort.py0000644000076500000240000004764713025125203017162 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Various tests for querying the library database. """ from __future__ import division, absolute_import, print_function import unittest from test import _common import beets.library from beets import dbcore from beets import config # A test case class providing a library with some dummy data and some # assertions involving that data. class DummyDataTestCase(_common.TestCase): def setUp(self): super(DummyDataTestCase, self).setUp() self.lib = beets.library.Library(':memory:') albums = [_common.album() for _ in range(3)] albums[0].album = u"Album A" albums[0].genre = u"Rock" albums[0].year = 2001 albums[0].flex1 = u"Flex1-1" albums[0].flex2 = u"Flex2-A" albums[0].albumartist = u"Foo" albums[0].albumartist_sort = None albums[1].album = u"Album B" albums[1].genre = u"Rock" albums[1].year = 2001 albums[1].flex1 = u"Flex1-2" albums[1].flex2 = u"Flex2-A" albums[1].albumartist = u"Bar" albums[1].albumartist_sort = None albums[2].album = u"Album C" albums[2].genre = u"Jazz" albums[2].year = 2005 albums[2].flex1 = u"Flex1-1" albums[2].flex2 = u"Flex2-B" albums[2].albumartist = u"Baz" albums[2].albumartist_sort = None for album in albums: self.lib.add(album) items = [_common.item() for _ in range(4)] items[0].title = u'Foo bar' items[0].artist = u'One' items[0].album = u'Baz' items[0].year = 2001 items[0].comp = True items[0].flex1 = u"Flex1-0" items[0].flex2 = u"Flex2-A" items[0].album_id = albums[0].id items[0].artist_sort = None items[0].path = "/path0.mp3" items[0].track = 1 items[1].title = u'Baz qux' items[1].artist = u'Two' items[1].album = u'Baz' items[1].year = 2002 items[1].comp = True items[1].flex1 = u"Flex1-1" items[1].flex2 = u"Flex2-A" items[1].album_id = albums[0].id items[1].artist_sort = None items[1].path = "/patH1.mp3" items[1].track = 2 items[2].title = u'Beets 4 eva' items[2].artist = u'Three' items[2].album = u'Foo' items[2].year = 2003 items[2].comp = False items[2].flex1 = u"Flex1-2" items[2].flex2 = u"Flex1-B" items[2].album_id = albums[1].id items[2].artist_sort = None items[2].path = "/paTH2.mp3" items[2].track = 3 items[3].title = u'Beets 4 eva' items[3].artist = u'Three' items[3].album = u'Foo2' items[3].year = 2004 items[3].comp = False items[3].flex1 = u"Flex1-2" items[3].flex2 = u"Flex1-C" items[3].album_id = albums[2].id items[3].artist_sort = None items[3].path = "/PATH3.mp3" items[3].track = 4 for item in items: self.lib.add(item) class SortFixedFieldTest(DummyDataTestCase): def test_sort_asc(self): q = u'' sort = dbcore.query.FixedFieldSort(u"year", True) results = self.lib.items(q, sort) self.assertLessEqual(results[0]['year'], results[1]['year']) self.assertEqual(results[0]['year'], 2001) # same thing with query string q = u'year+' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_desc(self): q = u'' sort = dbcore.query.FixedFieldSort(u"year", False) results = self.lib.items(q, sort) self.assertGreaterEqual(results[0]['year'], results[1]['year']) self.assertEqual(results[0]['year'], 2004) # same thing with query string q = u'year-' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_two_field_asc(self): q = u'' s1 = dbcore.query.FixedFieldSort(u"album", True) s2 = dbcore.query.FixedFieldSort(u"year", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.items(q, sort) self.assertLessEqual(results[0]['album'], results[1]['album']) self.assertLessEqual(results[1]['album'], results[2]['album']) self.assertEqual(results[0]['album'], u'Baz') self.assertEqual(results[1]['album'], u'Baz') self.assertLessEqual(results[0]['year'], results[1]['year']) # same thing with query string q = u'album+ year+' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_path_field(self): q = u'' sort = dbcore.query.FixedFieldSort('path', True) results = self.lib.items(q, sort) self.assertEqual(results[0]['path'], b'/path0.mp3') self.assertEqual(results[1]['path'], b'/patH1.mp3') self.assertEqual(results[2]['path'], b'/paTH2.mp3') self.assertEqual(results[3]['path'], b'/PATH3.mp3') class SortFlexFieldTest(DummyDataTestCase): def test_sort_asc(self): q = u'' sort = dbcore.query.SlowFieldSort(u"flex1", True) results = self.lib.items(q, sort) self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) self.assertEqual(results[0]['flex1'], u'Flex1-0') # same thing with query string q = u'flex1+' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_desc(self): q = u'' sort = dbcore.query.SlowFieldSort(u"flex1", False) results = self.lib.items(q, sort) self.assertGreaterEqual(results[0]['flex1'], results[1]['flex1']) self.assertGreaterEqual(results[1]['flex1'], results[2]['flex1']) self.assertGreaterEqual(results[2]['flex1'], results[3]['flex1']) self.assertEqual(results[0]['flex1'], u'Flex1-2') # same thing with query string q = u'flex1-' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_two_field(self): q = u'' s1 = dbcore.query.SlowFieldSort(u"flex2", False) s2 = dbcore.query.SlowFieldSort(u"flex1", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.items(q, sort) self.assertGreaterEqual(results[0]['flex2'], results[1]['flex2']) self.assertGreaterEqual(results[1]['flex2'], results[2]['flex2']) self.assertEqual(results[0]['flex2'], u'Flex2-A') self.assertEqual(results[1]['flex2'], u'Flex2-A') self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) # same thing with query string q = u'flex2- flex1+' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) class SortAlbumFixedFieldTest(DummyDataTestCase): def test_sort_asc(self): q = u'' sort = dbcore.query.FixedFieldSort(u"year", True) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['year'], results[1]['year']) self.assertEqual(results[0]['year'], 2001) # same thing with query string q = u'year+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_desc(self): q = u'' sort = dbcore.query.FixedFieldSort(u"year", False) results = self.lib.albums(q, sort) self.assertGreaterEqual(results[0]['year'], results[1]['year']) self.assertEqual(results[0]['year'], 2005) # same thing with query string q = u'year-' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_two_field_asc(self): q = u'' s1 = dbcore.query.FixedFieldSort(u"genre", True) s2 = dbcore.query.FixedFieldSort(u"album", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['genre'], results[1]['genre']) self.assertLessEqual(results[1]['genre'], results[2]['genre']) self.assertEqual(results[1]['genre'], u'Rock') self.assertEqual(results[2]['genre'], u'Rock') self.assertLessEqual(results[1]['album'], results[2]['album']) # same thing with query string q = u'genre+ album+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) class SortAlbumFlexFieldTest(DummyDataTestCase): def test_sort_asc(self): q = u'' sort = dbcore.query.SlowFieldSort(u"flex1", True) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) self.assertLessEqual(results[1]['flex1'], results[2]['flex1']) # same thing with query string q = u'flex1+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_desc(self): q = u'' sort = dbcore.query.SlowFieldSort(u"flex1", False) results = self.lib.albums(q, sort) self.assertGreaterEqual(results[0]['flex1'], results[1]['flex1']) self.assertGreaterEqual(results[1]['flex1'], results[2]['flex1']) # same thing with query string q = u'flex1-' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_two_field_asc(self): q = u'' s1 = dbcore.query.SlowFieldSort(u"flex2", True) s2 = dbcore.query.SlowFieldSort(u"flex1", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['flex2'], results[1]['flex2']) self.assertLessEqual(results[1]['flex2'], results[2]['flex2']) self.assertEqual(results[0]['flex2'], u'Flex2-A') self.assertEqual(results[1]['flex2'], u'Flex2-A') self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) # same thing with query string q = u'flex2+ flex1+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) class SortAlbumComputedFieldTest(DummyDataTestCase): def test_sort_asc(self): q = u'' sort = dbcore.query.SlowFieldSort(u"path", True) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['path'], results[1]['path']) self.assertLessEqual(results[1]['path'], results[2]['path']) # same thing with query string q = u'path+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_desc(self): q = u'' sort = dbcore.query.SlowFieldSort(u"path", False) results = self.lib.albums(q, sort) self.assertGreaterEqual(results[0]['path'], results[1]['path']) self.assertGreaterEqual(results[1]['path'], results[2]['path']) # same thing with query string q = u'path-' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) class SortCombinedFieldTest(DummyDataTestCase): def test_computed_first(self): q = u'' s1 = dbcore.query.SlowFieldSort(u"path", True) s2 = dbcore.query.FixedFieldSort(u"year", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['path'], results[1]['path']) self.assertLessEqual(results[1]['path'], results[2]['path']) q = u'path+ year+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_computed_second(self): q = u'' s1 = dbcore.query.FixedFieldSort(u"year", True) s2 = dbcore.query.SlowFieldSort(u"path", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['year'], results[1]['year']) self.assertLessEqual(results[1]['year'], results[2]['year']) self.assertLessEqual(results[0]['path'], results[1]['path']) q = u'year+ path+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) class ConfigSortTest(DummyDataTestCase): def test_default_sort_item(self): results = list(self.lib.items()) self.assertLess(results[0].artist, results[1].artist) def test_config_opposite_sort_item(self): config['sort_item'] = 'artist-' results = list(self.lib.items()) self.assertGreater(results[0].artist, results[1].artist) def test_default_sort_album(self): results = list(self.lib.albums()) self.assertLess(results[0].albumartist, results[1].albumartist) def test_config_opposite_sort_album(self): config['sort_album'] = 'albumartist-' results = list(self.lib.albums()) self.assertGreater(results[0].albumartist, results[1].albumartist) class CaseSensitivityTest(DummyDataTestCase, _common.TestCase): """If case_insensitive is false, lower-case values should be placed after all upper-case values. E.g., `Foo Qux bar` """ def setUp(self): super(CaseSensitivityTest, self).setUp() album = _common.album() album.album = u"album" album.genre = u"alternative" album.year = u"2001" album.flex1 = u"flex1" album.flex2 = u"flex2-A" album.albumartist = u"bar" album.albumartist_sort = None self.lib.add(album) item = _common.item() item.title = u'another' item.artist = u'lowercase' item.album = u'album' item.year = 2001 item.comp = True item.flex1 = u"flex1" item.flex2 = u"flex2-A" item.album_id = album.id item.artist_sort = None item.track = 10 self.lib.add(item) self.new_album = album self.new_item = item def tearDown(self): self.new_item.remove(delete=True) self.new_album.remove(delete=True) super(CaseSensitivityTest, self).tearDown() def test_smart_artist_case_insensitive(self): config['sort_case_insensitive'] = True q = u'artist+' results = list(self.lib.items(q)) self.assertEqual(results[0].artist, u'lowercase') self.assertEqual(results[1].artist, u'One') def test_smart_artist_case_sensitive(self): config['sort_case_insensitive'] = False q = u'artist+' results = list(self.lib.items(q)) self.assertEqual(results[0].artist, u'One') self.assertEqual(results[-1].artist, u'lowercase') def test_fixed_field_case_insensitive(self): config['sort_case_insensitive'] = True q = u'album+' results = list(self.lib.albums(q)) self.assertEqual(results[0].album, u'album') self.assertEqual(results[1].album, u'Album A') def test_fixed_field_case_sensitive(self): config['sort_case_insensitive'] = False q = u'album+' results = list(self.lib.albums(q)) self.assertEqual(results[0].album, u'Album A') self.assertEqual(results[-1].album, u'album') def test_flex_field_case_insensitive(self): config['sort_case_insensitive'] = True q = u'flex1+' results = list(self.lib.items(q)) self.assertEqual(results[0].flex1, u'flex1') self.assertEqual(results[1].flex1, u'Flex1-0') def test_flex_field_case_sensitive(self): config['sort_case_insensitive'] = False q = u'flex1+' results = list(self.lib.items(q)) self.assertEqual(results[0].flex1, u'Flex1-0') self.assertEqual(results[-1].flex1, u'flex1') def test_case_sensitive_only_affects_text(self): config['sort_case_insensitive'] = True q = u'track+' results = list(self.lib.items(q)) # If the numerical values were sorted as strings, # then ['1', '10', '2'] would be valid. print([r.track for r in results]) self.assertEqual(results[0].track, 1) self.assertEqual(results[1].track, 2) self.assertEqual(results[-1].track, 10) class NonExistingFieldTest(DummyDataTestCase): """Test sorting by non-existing fields""" def test_non_existing_fields_not_fail(self): qs = [u'foo+', u'foo-', u'--', u'-+', u'+-', u'++', u'-foo-', u'-foo+', u'---'] q0 = u'foo+' results0 = list(self.lib.items(q0)) for q1 in qs: results1 = list(self.lib.items(q1)) for r1, r2 in zip(results0, results1): self.assertEqual(r1.id, r2.id) def test_combined_non_existing_field_asc(self): all_results = list(self.lib.items(u'id+')) q = u'foo+ id+' results = list(self.lib.items(q)) self.assertEqual(len(all_results), len(results)) for r1, r2 in zip(all_results, results): self.assertEqual(r1.id, r2.id) def test_combined_non_existing_field_desc(self): all_results = list(self.lib.items(u'id+')) q = u'foo- id+' results = list(self.lib.items(q)) self.assertEqual(len(all_results), len(results)) for r1, r2 in zip(all_results, results): self.assertEqual(r1.id, r2.id) def test_field_present_in_some_items(self): """Test ordering by a field not present on all items.""" # append 'foo' to two to items (1,2) items = self.lib.items(u'id+') ids = [i.id for i in items] items[1].foo = u'bar1' items[2].foo = u'bar2' items[1].store() items[2].store() results_asc = list(self.lib.items(u'foo+ id+')) self.assertEqual([i.id for i in results_asc], # items without field first [ids[0], ids[3], ids[1], ids[2]]) results_desc = list(self.lib.items(u'foo- id+')) self.assertEqual([i.id for i in results_desc], # items without field last [ids[2], ids[1], ids[0], ids[3]]) def test_negation_interaction(self): """Test the handling of negation and sorting together. If a string ends with a sorting suffix, it takes precedence over the NotQuery parsing. """ query, sort = beets.library.parse_query_string(u'-bar+', beets.library.Item) self.assertEqual(len(query.subqueries), 1) self.assertTrue(isinstance(query.subqueries[0], dbcore.query.TrueQuery)) self.assertTrue(isinstance(sort, dbcore.query.SlowFieldSort)) self.assertEqual(sort.field, u'-bar') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_plexupdate.py0000644000076500000240000001155413025125203020332 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- from __future__ import division, absolute_import, print_function from test.helper import TestHelper from beetsplug.plexupdate import get_music_section, update_plex import unittest import responses class PlexUpdateTest(unittest.TestCase, TestHelper): def add_response_get_music_section(self, section_name='Music'): """Create response for mocking the get_music_section function. """ escaped_section_name = section_name.replace('"', '\\"') body = ( '' '' '' '' '' '' '' '' '' '' '' '') status = 200 content_type = 'text/xml;charset=utf-8' responses.add(responses.GET, 'http://localhost:32400/library/sections', body=body, status=status, content_type=content_type) def add_response_update_plex(self): """Create response for mocking the update_plex function. """ body = '' status = 200 content_type = 'text/html' responses.add(responses.GET, 'http://localhost:32400/library/sections/2/refresh', body=body, status=status, content_type=content_type) def setUp(self): self.setup_beets() self.load_plugins('plexupdate') self.config['plex'] = { u'host': u'localhost', u'port': 32400} def tearDown(self): self.teardown_beets() self.unload_plugins() @responses.activate def test_get_music_section(self): # Adding response. self.add_response_get_music_section() # Test if section key is "2" out of the mocking data. self.assertEqual(get_music_section( self.config['plex']['host'], self.config['plex']['port'], self.config['plex']['token'], self.config['plex']['library_name'].get()), '2') @responses.activate def test_get_named_music_section(self): # Adding response. self.add_response_get_music_section('My Music Library') self.assertEqual(get_music_section( self.config['plex']['host'], self.config['plex']['port'], self.config['plex']['token'], 'My Music Library'), '2') @responses.activate def test_update_plex(self): # Adding responses. self.add_response_get_music_section() self.add_response_update_plex() # Testing status code of the mocking request. self.assertEqual(update_plex( self.config['plex']['host'], self.config['plex']['port'], self.config['plex']['token'], self.config['plex']['library_name'].get()).status_code, 200) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_ipfs.py0000644000076500000240000000642413122501062017117 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function from mock import patch, Mock from beets import library from beets.util import bytestring_path, _fsencoding from beetsplug.ipfs import IPFSPlugin import unittest import os from test import _common from test.helper import TestHelper @patch('beets.util.command_output', Mock()) class IPFSPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('ipfs') self.lib = library.Library(":memory:") def tearDown(self): self.unload_plugins() self.teardown_beets() def test_stored_hashes(self): test_album = self.mk_test_album() ipfs = IPFSPlugin() added_albums = ipfs.ipfs_added_albums(self.lib, self.lib.path) added_album = added_albums.get_album(1) self.assertEqual(added_album.ipfs, test_album.ipfs) found = False want_item = test_album.items()[2] for check_item in added_album.items(): try: if check_item.ipfs: ipfs_item = os.path.basename(want_item.path).decode( _fsencoding(), ) want_path = '/ipfs/{0}/{1}'.format(test_album.ipfs, ipfs_item) want_path = bytestring_path(want_path) self.assertEqual(check_item.path, want_path) self.assertEqual(check_item.ipfs, want_item.ipfs) self.assertEqual(check_item.title, want_item.title) found = True except AttributeError: pass self.assertTrue(found) def mk_test_album(self): items = [_common.item() for _ in range(3)] items[0].title = 'foo bar' items[0].artist = '1one' items[0].album = 'baz' items[0].year = 2001 items[0].comp = True items[1].title = 'baz qux' items[1].artist = '2two' items[1].album = 'baz' items[1].year = 2002 items[1].comp = True items[2].title = 'beets 4 eva' items[2].artist = '3three' items[2].album = 'foo' items[2].year = 2003 items[2].comp = False items[2].ipfs = 'QmfM9ic5LJj7V6ecozFx1MkSoaaiq3PXfhJoFvyqzpLXSk' for item in items: self.lib.add(item) album = self.lib.add_album(items) album.ipfs = "QmfM9ic5LJj7V6ecozFx1MkSoaaiq3PXfhJoFvyqzpLXSf" album.store() return album def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_autotag.py0000644000076500000240000010273413025125203017624 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for autotagging functionality. """ from __future__ import division, absolute_import, print_function import re import copy import unittest from test import _common from beets import autotag from beets.autotag import match from beets.autotag.hooks import Distance, string_dist from beets.library import Item from beets.util import plurality from beets.autotag import AlbumInfo, TrackInfo from beets import config class PluralityTest(_common.TestCase): def test_plurality_consensus(self): objs = [1, 1, 1, 1] obj, freq = plurality(objs) self.assertEqual(obj, 1) self.assertEqual(freq, 4) def test_plurality_near_consensus(self): objs = [1, 1, 2, 1] obj, freq = plurality(objs) self.assertEqual(obj, 1) self.assertEqual(freq, 3) def test_plurality_conflict(self): objs = [1, 1, 2, 2, 3] obj, freq = plurality(objs) self.assertTrue(obj in (1, 2)) self.assertEqual(freq, 2) def test_plurality_empty_sequence_raises_error(self): with self.assertRaises(ValueError): plurality([]) def test_current_metadata_finds_pluralities(self): items = [Item(artist='The Beetles', album='The White Album'), Item(artist='The Beatles', album='The White Album'), Item(artist='The Beatles', album='Teh White Album')] likelies, consensus = match.current_metadata(items) self.assertEqual(likelies['artist'], 'The Beatles') self.assertEqual(likelies['album'], 'The White Album') self.assertFalse(consensus['artist']) def test_current_metadata_artist_consensus(self): items = [Item(artist='The Beatles', album='The White Album'), Item(artist='The Beatles', album='The White Album'), Item(artist='The Beatles', album='Teh White Album')] likelies, consensus = match.current_metadata(items) self.assertEqual(likelies['artist'], 'The Beatles') self.assertEqual(likelies['album'], 'The White Album') self.assertTrue(consensus['artist']) def test_albumartist_consensus(self): items = [Item(artist='tartist1', album='album', albumartist='aartist'), Item(artist='tartist2', album='album', albumartist='aartist'), Item(artist='tartist3', album='album', albumartist='aartist')] likelies, consensus = match.current_metadata(items) self.assertEqual(likelies['artist'], 'aartist') self.assertFalse(consensus['artist']) def test_current_metadata_likelies(self): fields = ['artist', 'album', 'albumartist', 'year', 'disctotal', 'mb_albumid', 'label', 'catalognum', 'country', 'media', 'albumdisambig'] items = [Item(**dict((f, '%s_%s' % (f, i or 1)) for f in fields)) for i in range(5)] likelies, _ = match.current_metadata(items) for f in fields: self.assertEqual(likelies[f], '%s_1' % f) def _make_item(title, track, artist=u'some artist'): return Item(title=title, track=track, artist=artist, album=u'some album', length=1, mb_trackid='', mb_albumid='', mb_artistid='') def _make_trackinfo(): return [ TrackInfo(u'one', None, u'some artist', length=1, index=1), TrackInfo(u'two', None, u'some artist', length=1, index=2), TrackInfo(u'three', None, u'some artist', length=1, index=3), ] def _clear_weights(): """Hack around the lazy descriptor used to cache weights for Distance calculations. """ Distance.__dict__['_weights'].computed = False class DistanceTest(_common.TestCase): def tearDown(self): super(DistanceTest, self).tearDown() _clear_weights() def test_add(self): dist = Distance() dist.add('add', 1.0) self.assertEqual(dist._penalties, {'add': [1.0]}) def test_add_equality(self): dist = Distance() dist.add_equality('equality', 'ghi', ['abc', 'def', 'ghi']) self.assertEqual(dist._penalties['equality'], [0.0]) dist.add_equality('equality', 'xyz', ['abc', 'def', 'ghi']) self.assertEqual(dist._penalties['equality'], [0.0, 1.0]) dist.add_equality('equality', 'abc', re.compile(r'ABC', re.I)) self.assertEqual(dist._penalties['equality'], [0.0, 1.0, 0.0]) def test_add_expr(self): dist = Distance() dist.add_expr('expr', True) self.assertEqual(dist._penalties['expr'], [1.0]) dist.add_expr('expr', False) self.assertEqual(dist._penalties['expr'], [1.0, 0.0]) def test_add_number(self): dist = Distance() # Add a full penalty for each number of difference between two numbers. dist.add_number('number', 1, 1) self.assertEqual(dist._penalties['number'], [0.0]) dist.add_number('number', 1, 2) self.assertEqual(dist._penalties['number'], [0.0, 1.0]) dist.add_number('number', 2, 1) self.assertEqual(dist._penalties['number'], [0.0, 1.0, 1.0]) dist.add_number('number', -1, 2) self.assertEqual(dist._penalties['number'], [0.0, 1.0, 1.0, 1.0, 1.0, 1.0]) def test_add_priority(self): dist = Distance() dist.add_priority('priority', 'abc', 'abc') self.assertEqual(dist._penalties['priority'], [0.0]) dist.add_priority('priority', 'def', ['abc', 'def']) self.assertEqual(dist._penalties['priority'], [0.0, 0.5]) dist.add_priority('priority', 'gh', ['ab', 'cd', 'ef', re.compile('GH', re.I)]) self.assertEqual(dist._penalties['priority'], [0.0, 0.5, 0.75]) dist.add_priority('priority', 'xyz', ['abc', 'def']) self.assertEqual(dist._penalties['priority'], [0.0, 0.5, 0.75, 1.0]) def test_add_ratio(self): dist = Distance() dist.add_ratio('ratio', 25, 100) self.assertEqual(dist._penalties['ratio'], [0.25]) dist.add_ratio('ratio', 10, 5) self.assertEqual(dist._penalties['ratio'], [0.25, 1.0]) dist.add_ratio('ratio', -5, 5) self.assertEqual(dist._penalties['ratio'], [0.25, 1.0, 0.0]) dist.add_ratio('ratio', 5, 0) self.assertEqual(dist._penalties['ratio'], [0.25, 1.0, 0.0, 0.0]) def test_add_string(self): dist = Distance() sdist = string_dist(u'abc', u'bcd') dist.add_string('string', u'abc', u'bcd') self.assertEqual(dist._penalties['string'], [sdist]) self.assertNotEqual(dist._penalties['string'], [0]) def test_add_string_none(self): dist = Distance() dist.add_string('string', None, 'string') self.assertEqual(dist._penalties['string'], [1]) def test_add_string_both_none(self): dist = Distance() dist.add_string('string', None, None) self.assertEqual(dist._penalties['string'], [0]) def test_distance(self): config['match']['distance_weights']['album'] = 2.0 config['match']['distance_weights']['medium'] = 1.0 _clear_weights() dist = Distance() dist.add('album', 0.5) dist.add('media', 0.25) dist.add('media', 0.75) self.assertEqual(dist.distance, 0.5) # __getitem__() self.assertEqual(dist['album'], 0.25) self.assertEqual(dist['media'], 0.25) def test_max_distance(self): config['match']['distance_weights']['album'] = 3.0 config['match']['distance_weights']['medium'] = 1.0 _clear_weights() dist = Distance() dist.add('album', 0.5) dist.add('medium', 0.0) dist.add('medium', 0.0) self.assertEqual(dist.max_distance, 5.0) def test_operators(self): config['match']['distance_weights']['source'] = 1.0 config['match']['distance_weights']['album'] = 2.0 config['match']['distance_weights']['medium'] = 1.0 _clear_weights() dist = Distance() dist.add('source', 0.0) dist.add('album', 0.5) dist.add('medium', 0.25) dist.add('medium', 0.75) self.assertEqual(len(dist), 2) self.assertEqual(list(dist), [('album', 0.2), ('medium', 0.2)]) self.assertTrue(dist == 0.4) self.assertTrue(dist < 1.0) self.assertTrue(dist > 0.0) self.assertEqual(dist - 0.4, 0.0) self.assertEqual(0.4 - dist, 0.0) self.assertEqual(float(dist), 0.4) def test_raw_distance(self): config['match']['distance_weights']['album'] = 3.0 config['match']['distance_weights']['medium'] = 1.0 _clear_weights() dist = Distance() dist.add('album', 0.5) dist.add('medium', 0.25) dist.add('medium', 0.5) self.assertEqual(dist.raw_distance, 2.25) def test_items(self): config['match']['distance_weights']['album'] = 4.0 config['match']['distance_weights']['medium'] = 2.0 _clear_weights() dist = Distance() dist.add('album', 0.1875) dist.add('medium', 0.75) self.assertEqual(dist.items(), [('medium', 0.25), ('album', 0.125)]) # Sort by key if distance is equal. dist = Distance() dist.add('album', 0.375) dist.add('medium', 0.75) self.assertEqual(dist.items(), [('album', 0.25), ('medium', 0.25)]) def test_update(self): dist1 = Distance() dist1.add('album', 0.5) dist1.add('media', 1.0) dist2 = Distance() dist2.add('album', 0.75) dist2.add('album', 0.25) dist2.add('media', 0.05) dist1.update(dist2) self.assertEqual(dist1._penalties, {'album': [0.5, 0.75, 0.25], 'media': [1.0, 0.05]}) class TrackDistanceTest(_common.TestCase): def test_identical_tracks(self): item = _make_item(u'one', 1) info = _make_trackinfo()[0] dist = match.track_distance(item, info, incl_artist=True) self.assertEqual(dist, 0.0) def test_different_title(self): item = _make_item(u'foo', 1) info = _make_trackinfo()[0] dist = match.track_distance(item, info, incl_artist=True) self.assertNotEqual(dist, 0.0) def test_different_artist(self): item = _make_item(u'one', 1) item.artist = u'foo' info = _make_trackinfo()[0] dist = match.track_distance(item, info, incl_artist=True) self.assertNotEqual(dist, 0.0) def test_various_artists_tolerated(self): item = _make_item(u'one', 1) item.artist = u'Various Artists' info = _make_trackinfo()[0] dist = match.track_distance(item, info, incl_artist=True) self.assertEqual(dist, 0.0) class AlbumDistanceTest(_common.TestCase): def _mapping(self, items, info): out = {} for i, t in zip(items, info.tracks): out[i] = t return out def _dist(self, items, info): return match.distance(items, info, self._mapping(items, info)) def test_identical_albums(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist=u'some artist', album=u'some album', tracks=_make_trackinfo(), va=False, album_id=None, artist_id=None, ) self.assertEqual(self._dist(items, info), 0) def test_incomplete_album(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist=u'some artist', album=u'some album', tracks=_make_trackinfo(), va=False, album_id=None, artist_id=None, ) dist = self._dist(items, info) self.assertNotEqual(dist, 0) # Make sure the distance is not too great self.assertTrue(dist < 0.2) def test_global_artists_differ(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist=u'someone else', album=u'some album', tracks=_make_trackinfo(), va=False, album_id=None, artist_id=None, ) self.assertNotEqual(self._dist(items, info), 0) def test_comp_track_artists_match(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist=u'should be ignored', album=u'some album', tracks=_make_trackinfo(), va=True, album_id=None, artist_id=None, ) self.assertEqual(self._dist(items, info), 0) def test_comp_no_track_artists(self): # Some VA releases don't have track artists (incomplete metadata). items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist=u'should be ignored', album=u'some album', tracks=_make_trackinfo(), va=True, album_id=None, artist_id=None, ) info.tracks[0].artist = None info.tracks[1].artist = None info.tracks[2].artist = None self.assertEqual(self._dist(items, info), 0) def test_comp_track_artists_do_not_match(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2, u'someone else')) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist=u'some artist', album=u'some album', tracks=_make_trackinfo(), va=True, album_id=None, artist_id=None, ) self.assertNotEqual(self._dist(items, info), 0) def test_tracks_out_of_order(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'three', 2)) items.append(_make_item(u'two', 3)) info = AlbumInfo( artist=u'some artist', album=u'some album', tracks=_make_trackinfo(), va=False, album_id=None, artist_id=None, ) dist = self._dist(items, info) self.assertTrue(0 < dist < 0.2) def test_two_medium_release(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist=u'some artist', album=u'some album', tracks=_make_trackinfo(), va=False, album_id=None, artist_id=None, ) info.tracks[0].medium_index = 1 info.tracks[1].medium_index = 2 info.tracks[2].medium_index = 1 dist = self._dist(items, info) self.assertEqual(dist, 0) def test_per_medium_track_numbers(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 1)) info = AlbumInfo( artist=u'some artist', album=u'some album', tracks=_make_trackinfo(), va=False, album_id=None, artist_id=None, ) info.tracks[0].medium_index = 1 info.tracks[1].medium_index = 2 info.tracks[2].medium_index = 1 dist = self._dist(items, info) self.assertEqual(dist, 0) class AssignmentTest(unittest.TestCase): def item(self, title, track): return Item( title=title, track=track, mb_trackid='', mb_albumid='', mb_artistid='', ) def test_reorder_when_track_numbers_incorrect(self): items = [] items.append(self.item(u'one', 1)) items.append(self.item(u'three', 2)) items.append(self.item(u'two', 3)) trackinfo = [] trackinfo.append(TrackInfo(u'one', None)) trackinfo.append(TrackInfo(u'two', None)) trackinfo.append(TrackInfo(u'three', None)) mapping, extra_items, extra_tracks = \ match.assign_items(items, trackinfo) self.assertEqual(extra_items, []) self.assertEqual(extra_tracks, []) self.assertEqual(mapping, { items[0]: trackinfo[0], items[1]: trackinfo[2], items[2]: trackinfo[1], }) def test_order_works_with_invalid_track_numbers(self): items = [] items.append(self.item(u'one', 1)) items.append(self.item(u'three', 1)) items.append(self.item(u'two', 1)) trackinfo = [] trackinfo.append(TrackInfo(u'one', None)) trackinfo.append(TrackInfo(u'two', None)) trackinfo.append(TrackInfo(u'three', None)) mapping, extra_items, extra_tracks = \ match.assign_items(items, trackinfo) self.assertEqual(extra_items, []) self.assertEqual(extra_tracks, []) self.assertEqual(mapping, { items[0]: trackinfo[0], items[1]: trackinfo[2], items[2]: trackinfo[1], }) def test_order_works_with_missing_tracks(self): items = [] items.append(self.item(u'one', 1)) items.append(self.item(u'three', 3)) trackinfo = [] trackinfo.append(TrackInfo(u'one', None)) trackinfo.append(TrackInfo(u'two', None)) trackinfo.append(TrackInfo(u'three', None)) mapping, extra_items, extra_tracks = \ match.assign_items(items, trackinfo) self.assertEqual(extra_items, []) self.assertEqual(extra_tracks, [trackinfo[1]]) self.assertEqual(mapping, { items[0]: trackinfo[0], items[1]: trackinfo[2], }) def test_order_works_with_extra_tracks(self): items = [] items.append(self.item(u'one', 1)) items.append(self.item(u'two', 2)) items.append(self.item(u'three', 3)) trackinfo = [] trackinfo.append(TrackInfo(u'one', None)) trackinfo.append(TrackInfo(u'three', None)) mapping, extra_items, extra_tracks = \ match.assign_items(items, trackinfo) self.assertEqual(extra_items, [items[1]]) self.assertEqual(extra_tracks, []) self.assertEqual(mapping, { items[0]: trackinfo[0], items[2]: trackinfo[1], }) def test_order_works_when_track_names_are_entirely_wrong(self): # A real-world test case contributed by a user. def item(i, length): return Item( artist=u'ben harper', album=u'burn to shine', title=u'ben harper - Burn to Shine {0}'.format(i), track=i, length=length, mb_trackid='', mb_albumid='', mb_artistid='', ) items = [] items.append(item(1, 241.37243007106997)) items.append(item(2, 342.27781704375036)) items.append(item(3, 245.95070222338137)) items.append(item(4, 472.87662515485437)) items.append(item(5, 279.1759535763187)) items.append(item(6, 270.33333768012)) items.append(item(7, 247.83435613222923)) items.append(item(8, 216.54504531525072)) items.append(item(9, 225.72775379800484)) items.append(item(10, 317.7643606963552)) items.append(item(11, 243.57001238834192)) items.append(item(12, 186.45916150485752)) def info(index, title, length): return TrackInfo(title, None, length=length, index=index) trackinfo = [] trackinfo.append(info(1, u'Alone', 238.893)) trackinfo.append(info(2, u'The Woman in You', 341.44)) trackinfo.append(info(3, u'Less', 245.59999999999999)) trackinfo.append(info(4, u'Two Hands of a Prayer', 470.49299999999999)) trackinfo.append(info(5, u'Please Bleed', 277.86599999999999)) trackinfo.append(info(6, u'Suzie Blue', 269.30599999999998)) trackinfo.append(info(7, u'Steal My Kisses', 245.36000000000001)) trackinfo.append(info(8, u'Burn to Shine', 214.90600000000001)) trackinfo.append(info(9, u'Show Me a Little Shame', 224.0929999999999)) trackinfo.append(info(10, u'Forgiven', 317.19999999999999)) trackinfo.append(info(11, u'Beloved One', 243.733)) trackinfo.append(info(12, u'In the Lord\'s Arms', 186.13300000000001)) mapping, extra_items, extra_tracks = \ match.assign_items(items, trackinfo) self.assertEqual(extra_items, []) self.assertEqual(extra_tracks, []) for item, info in mapping.items(): self.assertEqual(items.index(item), trackinfo.index(info)) class ApplyTestUtil(object): def _apply(self, info=None, per_disc_numbering=False): info = info or self.info mapping = {} for i, t in zip(self.items, info.tracks): mapping[i] = t config['per_disc_numbering'] = per_disc_numbering autotag.apply_metadata(info, mapping) class ApplyTest(_common.TestCase, ApplyTestUtil): def setUp(self): super(ApplyTest, self).setUp() self.items = [] self.items.append(Item({})) self.items.append(Item({})) trackinfo = [] trackinfo.append(TrackInfo( u'oneNew', u'dfa939ec-118c-4d0f-84a0-60f3d1e6522c', medium=1, medium_index=1, medium_total=1, index=1, artist_credit='trackArtistCredit', artist_sort='trackArtistSort', )) trackinfo.append(TrackInfo( u'twoNew', u'40130ed1-a27c-42fd-a328-1ebefb6caef4', medium=2, medium_index=1, index=2, medium_total=1, )) self.info = AlbumInfo( tracks=trackinfo, artist=u'artistNew', album=u'albumNew', album_id='7edb51cb-77d6-4416-a23c-3a8c2994a2c7', artist_id='a6623d39-2d8e-4f70-8242-0a9553b91e50', artist_credit=u'albumArtistCredit', artist_sort=u'albumArtistSort', albumtype=u'album', va=False, mediums=2, ) def test_titles_applied(self): self._apply() self.assertEqual(self.items[0].title, 'oneNew') self.assertEqual(self.items[1].title, 'twoNew') def test_album_and_artist_applied_to_all(self): self._apply() self.assertEqual(self.items[0].album, 'albumNew') self.assertEqual(self.items[1].album, 'albumNew') self.assertEqual(self.items[0].artist, 'artistNew') self.assertEqual(self.items[1].artist, 'artistNew') def test_track_index_applied(self): self._apply() self.assertEqual(self.items[0].track, 1) self.assertEqual(self.items[1].track, 2) def test_track_total_applied(self): self._apply() self.assertEqual(self.items[0].tracktotal, 2) self.assertEqual(self.items[1].tracktotal, 2) def test_disc_index_applied(self): self._apply() self.assertEqual(self.items[0].disc, 1) self.assertEqual(self.items[1].disc, 2) def test_disc_total_applied(self): self._apply() self.assertEqual(self.items[0].disctotal, 2) self.assertEqual(self.items[1].disctotal, 2) def test_per_disc_numbering(self): self._apply(per_disc_numbering=True) self.assertEqual(self.items[0].track, 1) self.assertEqual(self.items[1].track, 1) def test_per_disc_numbering_track_total(self): self._apply(per_disc_numbering=True) self.assertEqual(self.items[0].tracktotal, 1) self.assertEqual(self.items[1].tracktotal, 1) def test_mb_trackid_applied(self): self._apply() self.assertEqual(self.items[0].mb_trackid, 'dfa939ec-118c-4d0f-84a0-60f3d1e6522c') self.assertEqual(self.items[1].mb_trackid, '40130ed1-a27c-42fd-a328-1ebefb6caef4') def test_mb_albumid_and_artistid_applied(self): self._apply() for item in self.items: self.assertEqual(item.mb_albumid, '7edb51cb-77d6-4416-a23c-3a8c2994a2c7') self.assertEqual(item.mb_artistid, 'a6623d39-2d8e-4f70-8242-0a9553b91e50') def test_albumtype_applied(self): self._apply() self.assertEqual(self.items[0].albumtype, 'album') self.assertEqual(self.items[1].albumtype, 'album') def test_album_artist_overrides_empty_track_artist(self): my_info = copy.deepcopy(self.info) self._apply(info=my_info) self.assertEqual(self.items[0].artist, 'artistNew') self.assertEqual(self.items[1].artist, 'artistNew') def test_album_artist_overriden_by_nonempty_track_artist(self): my_info = copy.deepcopy(self.info) my_info.tracks[0].artist = 'artist1!' my_info.tracks[1].artist = 'artist2!' self._apply(info=my_info) self.assertEqual(self.items[0].artist, 'artist1!') self.assertEqual(self.items[1].artist, 'artist2!') def test_artist_credit_applied(self): self._apply() self.assertEqual(self.items[0].albumartist_credit, 'albumArtistCredit') self.assertEqual(self.items[0].artist_credit, 'trackArtistCredit') self.assertEqual(self.items[1].albumartist_credit, 'albumArtistCredit') self.assertEqual(self.items[1].artist_credit, 'albumArtistCredit') def test_artist_sort_applied(self): self._apply() self.assertEqual(self.items[0].albumartist_sort, 'albumArtistSort') self.assertEqual(self.items[0].artist_sort, 'trackArtistSort') self.assertEqual(self.items[1].albumartist_sort, 'albumArtistSort') self.assertEqual(self.items[1].artist_sort, 'albumArtistSort') def test_full_date_applied(self): my_info = copy.deepcopy(self.info) my_info.year = 2013 my_info.month = 12 my_info.day = 18 self._apply(info=my_info) self.assertEqual(self.items[0].year, 2013) self.assertEqual(self.items[0].month, 12) self.assertEqual(self.items[0].day, 18) def test_date_only_zeros_month_and_day(self): self.items = [] self.items.append(Item(year=1, month=2, day=3)) self.items.append(Item(year=4, month=5, day=6)) my_info = copy.deepcopy(self.info) my_info.year = 2013 self._apply(info=my_info) self.assertEqual(self.items[0].year, 2013) self.assertEqual(self.items[0].month, 0) self.assertEqual(self.items[0].day, 0) def test_missing_date_applies_nothing(self): self.items = [] self.items.append(Item(year=1, month=2, day=3)) self.items.append(Item(year=4, month=5, day=6)) self._apply() self.assertEqual(self.items[0].year, 1) self.assertEqual(self.items[0].month, 2) self.assertEqual(self.items[0].day, 3) def test_data_source_applied(self): my_info = copy.deepcopy(self.info) my_info.data_source = 'MusicBrainz' self._apply(info=my_info) self.assertEqual(self.items[0].data_source, 'MusicBrainz') class ApplyCompilationTest(_common.TestCase, ApplyTestUtil): def setUp(self): super(ApplyCompilationTest, self).setUp() self.items = [] self.items.append(Item({})) self.items.append(Item({})) trackinfo = [] trackinfo.append(TrackInfo( u'oneNew', u'dfa939ec-118c-4d0f-84a0-60f3d1e6522c', u'artistOneNew', u'a05686fc-9db2-4c23-b99e-77f5db3e5282', index=1, )) trackinfo.append(TrackInfo( u'twoNew', u'40130ed1-a27c-42fd-a328-1ebefb6caef4', u'artistTwoNew', u'80b3cf5e-18fe-4c59-98c7-e5bb87210710', index=2, )) self.info = AlbumInfo( tracks=trackinfo, artist=u'variousNew', album=u'albumNew', album_id='3b69ea40-39b8-487f-8818-04b6eff8c21a', artist_id='89ad4ac3-39f7-470e-963a-56509c546377', albumtype=u'compilation', ) def test_album_and_track_artists_separate(self): self._apply() self.assertEqual(self.items[0].artist, 'artistOneNew') self.assertEqual(self.items[1].artist, 'artistTwoNew') self.assertEqual(self.items[0].albumartist, 'variousNew') self.assertEqual(self.items[1].albumartist, 'variousNew') def test_mb_albumartistid_applied(self): self._apply() self.assertEqual(self.items[0].mb_albumartistid, '89ad4ac3-39f7-470e-963a-56509c546377') self.assertEqual(self.items[1].mb_albumartistid, '89ad4ac3-39f7-470e-963a-56509c546377') self.assertEqual(self.items[0].mb_artistid, 'a05686fc-9db2-4c23-b99e-77f5db3e5282') self.assertEqual(self.items[1].mb_artistid, '80b3cf5e-18fe-4c59-98c7-e5bb87210710') def test_va_flag_cleared_does_not_set_comp(self): self._apply() self.assertFalse(self.items[0].comp) self.assertFalse(self.items[1].comp) def test_va_flag_sets_comp(self): va_info = copy.deepcopy(self.info) va_info.va = True self._apply(info=va_info) self.assertTrue(self.items[0].comp) self.assertTrue(self.items[1].comp) class StringDistanceTest(unittest.TestCase): def test_equal_strings(self): dist = string_dist(u'Some String', u'Some String') self.assertEqual(dist, 0.0) def test_different_strings(self): dist = string_dist(u'Some String', u'Totally Different') self.assertNotEqual(dist, 0.0) def test_punctuation_ignored(self): dist = string_dist(u'Some String', u'Some.String!') self.assertEqual(dist, 0.0) def test_case_ignored(self): dist = string_dist(u'Some String', u'sOME sTring') self.assertEqual(dist, 0.0) def test_leading_the_has_lower_weight(self): dist1 = string_dist(u'XXX Band Name', u'Band Name') dist2 = string_dist(u'The Band Name', u'Band Name') self.assertTrue(dist2 < dist1) def test_parens_have_lower_weight(self): dist1 = string_dist(u'One .Two.', u'One') dist2 = string_dist(u'One (Two)', u'One') self.assertTrue(dist2 < dist1) def test_brackets_have_lower_weight(self): dist1 = string_dist(u'One .Two.', u'One') dist2 = string_dist(u'One [Two]', u'One') self.assertTrue(dist2 < dist1) def test_ep_label_has_zero_weight(self): dist = string_dist(u'My Song (EP)', u'My Song') self.assertEqual(dist, 0.0) def test_featured_has_lower_weight(self): dist1 = string_dist(u'My Song blah Someone', u'My Song') dist2 = string_dist(u'My Song feat Someone', u'My Song') self.assertTrue(dist2 < dist1) def test_postfix_the(self): dist = string_dist(u'The Song Title', u'Song Title, The') self.assertEqual(dist, 0.0) def test_postfix_a(self): dist = string_dist(u'A Song Title', u'Song Title, A') self.assertEqual(dist, 0.0) def test_postfix_an(self): dist = string_dist(u'An Album Title', u'Album Title, An') self.assertEqual(dist, 0.0) def test_empty_strings(self): dist = string_dist(u'', u'') self.assertEqual(dist, 0.0) def test_solo_pattern(self): # Just make sure these don't crash. string_dist(u'The ', u'') string_dist(u'(EP)', u'(EP)') string_dist(u', An', u'') def test_heuristic_does_not_harm_distance(self): dist = string_dist(u'Untitled', u'[Untitled]') self.assertEqual(dist, 0.0) def test_ampersand_expansion(self): dist = string_dist(u'And', u'&') self.assertEqual(dist, 0.0) def test_accented_characters(self): dist = string_dist(u'\xe9\xe1\xf1', u'ean') self.assertEqual(dist, 0.0) class EnumTest(_common.TestCase): """ Test Enum Subclasses defined in beets.util.enumeration """ def test_ordered_enum(self): OrderedEnumClass = match.OrderedEnum('OrderedEnumTest', ['a', 'b', 'c']) # noqa self.assertLess(OrderedEnumClass.a, OrderedEnumClass.b) self.assertLess(OrderedEnumClass.a, OrderedEnumClass.c) self.assertLess(OrderedEnumClass.b, OrderedEnumClass.c) self.assertGreater(OrderedEnumClass.b, OrderedEnumClass.a) self.assertGreater(OrderedEnumClass.c, OrderedEnumClass.a) self.assertGreater(OrderedEnumClass.c, OrderedEnumClass.b) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_acousticbrainz.py0000644000076500000240000000735713025125203021205 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Nathan Dwek. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'acousticbrainz' plugin. """ from __future__ import division, absolute_import, print_function import json import os.path import unittest from test._common import RSRC from beetsplug.acousticbrainz import AcousticPlugin, ABSCHEME class MapDataToSchemeTest(unittest.TestCase): def test_basic(self): ab = AcousticPlugin() data = {'key 1': 'value 1', 'key 2': 'value 2'} scheme = {'key 1': 'attribute 1', 'key 2': 'attribute 2'} mapping = set(ab._map_data_to_scheme(data, scheme)) self.assertEqual(mapping, {('attribute 1', 'value 1'), ('attribute 2', 'value 2')}) def test_recurse(self): ab = AcousticPlugin() data = { 'key': 'value', 'group': { 'subkey': 'subvalue', 'subgroup': { 'subsubkey': 'subsubvalue' } } } scheme = { 'key': 'attribute 1', 'group': { 'subkey': 'attribute 2', 'subgroup': { 'subsubkey': 'attribute 3' } } } mapping = set(ab._map_data_to_scheme(data, scheme)) self.assertEqual(mapping, {('attribute 1', 'value'), ('attribute 2', 'subvalue'), ('attribute 3', 'subsubvalue')}) def test_composite(self): ab = AcousticPlugin() data = {'key 1': 'part 1', 'key 2': 'part 2'} scheme = {'key 1': ('attribute', 0), 'key 2': ('attribute', 1)} mapping = set(ab._map_data_to_scheme(data, scheme)) self.assertEqual(mapping, {('attribute', 'part 1 part 2')}) def test_realistic(self): ab = AcousticPlugin() data_path = os.path.join(RSRC, b'acousticbrainz/data.json') with open(data_path) as res: data = json.load(res) mapping = set(ab._map_data_to_scheme(data, ABSCHEME)) expected = { ('chords_key', 'A'), ('average_loudness', 0.815025985241), ('mood_acoustic', 0.415711194277), ('chords_changes_rate', 0.0445116683841), ('tonal', 0.874250173569), ('mood_sad', 0.299694597721), ('bpm', 162.532119751), ('gender', 'female'), ('initial_key', 'A minor'), ('chords_number_rate', 0.00194468453992), ('mood_relaxed', 0.123632438481), ('chords_scale', 'minor'), ('voice_instrumental', 'instrumental'), ('key_strength', 0.636936545372), ('genre_rosamerica', 'roc'), ('mood_party', 0.234383180737), ('mood_aggressive', 0.0779221653938), ('danceable', 0.143928021193), ('rhythm', 'VienneseWaltz'), ('mood_electronic', 0.339881360531), ('mood_happy', 0.0894767045975) } self.assertEqual(mapping, expected) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_embedart.py0000644000076500000240000002501013175430622017744 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import os.path import shutil from mock import patch, MagicMock import tempfile import unittest from test import _common from test.helper import TestHelper from beets.mediafile import MediaFile from beets import config, logging, ui from beets.util import syspath, displayable_path from beets.util.artresizer import ArtResizer from beets import art def require_artresizer_compare(test): def wrapper(*args, **kwargs): if not ArtResizer.shared.can_compare: raise unittest.SkipTest("compare not available") else: return test(*args, **kwargs) wrapper.__name__ = test.__name__ return wrapper class EmbedartCliTest(_common.TestCase, TestHelper): small_artpath = os.path.join(_common.RSRC, b'image-2x3.jpg') abbey_artpath = os.path.join(_common.RSRC, b'abbey.jpg') abbey_similarpath = os.path.join(_common.RSRC, b'abbey-similar.jpg') abbey_differentpath = os.path.join(_common.RSRC, b'abbey-different.jpg') def setUp(self): super(EmbedartCliTest, self).setUp() self.io.install() self.setup_beets() # Converter is threaded self.load_plugins('embedart') def _setup_data(self, artpath=None): if not artpath: artpath = self.small_artpath with open(syspath(artpath), 'rb') as f: self.image_data = f.read() def tearDown(self): self.unload_plugins() self.teardown_beets() def test_embed_art_from_file_with_yes_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput('y') self.run_command('embedart', '-f', self.small_artpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data) def test_embed_art_from_file_with_no_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput('n') self.run_command('embedart', '-f', self.small_artpath) mediafile = MediaFile(syspath(item.path)) # make sure that images array is empty (nothing embedded) self.assertEqual(len(mediafile.images), 0) def test_embed_art_from_file(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.run_command('embedart', '-y', '-f', self.small_artpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data) def test_embed_art_from_album(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] album.artpath = self.small_artpath album.store() self.run_command('embedart', '-y') mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data) def test_embed_art_remove_art_file(self): self._setup_data() album = self.add_album_fixture() logging.getLogger('beets.embedart').setLevel(logging.DEBUG) handle, tmp_path = tempfile.mkstemp() os.write(handle, self.image_data) os.close(handle) album.artpath = tmp_path album.store() config['embedart']['remove_art_file'] = True self.run_command('embedart', '-y') if os.path.isfile(tmp_path): os.remove(tmp_path) self.fail(u'Artwork file {0} was not deleted'.format(tmp_path)) def test_art_file_missing(self): self.add_album_fixture() logging.getLogger('beets.embedart').setLevel(logging.DEBUG) with self.assertRaises(ui.UserError): self.run_command('embedart', '-y', '-f', '/doesnotexist') def test_embed_non_image_file(self): album = self.add_album_fixture() logging.getLogger('beets.embedart').setLevel(logging.DEBUG) handle, tmp_path = tempfile.mkstemp() os.write(handle, b'I am not an image.') os.close(handle) try: self.run_command('embedart', '-y', '-f', tmp_path) finally: os.remove(tmp_path) mediafile = MediaFile(syspath(album.items()[0].path)) self.assertFalse(mediafile.images) # No image added. @require_artresizer_compare def test_reject_different_art(self): self._setup_data(self.abbey_artpath) album = self.add_album_fixture() item = album.items()[0] self.run_command('embedart', '-y', '-f', self.abbey_artpath) config['embedart']['compare_threshold'] = 20 self.run_command('embedart', '-y', '-f', self.abbey_differentpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data, u'Image written is not {0}'.format( displayable_path(self.abbey_artpath))) @require_artresizer_compare def test_accept_similar_art(self): self._setup_data(self.abbey_similarpath) album = self.add_album_fixture() item = album.items()[0] self.run_command('embedart', '-y', '-f', self.abbey_artpath) config['embedart']['compare_threshold'] = 20 self.run_command('embedart', '-y', '-f', self.abbey_similarpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data, u'Image written is not {0}'.format( displayable_path(self.abbey_similarpath))) def test_non_ascii_album_path(self): resource_path = os.path.join(_common.RSRC, b'image.mp3') album = self.add_album_fixture() trackpath = album.items()[0].path albumpath = album.path shutil.copy(syspath(resource_path), syspath(trackpath)) self.run_command('extractart', '-n', 'extracted') self.assertExists(os.path.join(albumpath, b'extracted.png')) def test_extracted_extension(self): resource_path = os.path.join(_common.RSRC, b'image-jpeg.mp3') album = self.add_album_fixture() trackpath = album.items()[0].path albumpath = album.path shutil.copy(syspath(resource_path), syspath(trackpath)) self.run_command('extractart', '-n', 'extracted') self.assertExists(os.path.join(albumpath, b'extracted.jpg')) def test_clear_art_with_yes_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput('y') self.run_command('embedart', '-f', self.small_artpath) self.io.addinput('y') self.run_command('clearart') mediafile = MediaFile(syspath(item.path)) self.assertEqual(len(mediafile.images), 0) def test_clear_art_with_no_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput('y') self.run_command('embedart', '-f', self.small_artpath) self.io.addinput('n') self.run_command('clearart') mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data) @patch('beets.art.subprocess') @patch('beets.art.extract') class ArtSimilarityTest(unittest.TestCase): def setUp(self): self.item = _common.item() self.log = logging.getLogger('beets.embedart') def _similarity(self, threshold): return art.check_art_similarity(self.log, self.item, b'path', threshold) def _popen(self, status=0, stdout="", stderr=""): """Create a mock `Popen` object.""" popen = MagicMock(returncode=status) popen.communicate.return_value = stdout, stderr return popen def _mock_popens(self, mock_extract, mock_subprocess, compare_status=0, compare_stdout="", compare_stderr="", convert_status=0): mock_extract.return_value = b'extracted_path' mock_subprocess.Popen.side_effect = [ # The `convert` call. self._popen(convert_status), # The `compare` call. self._popen(compare_status, compare_stdout, compare_stderr), ] def test_compare_success_similar(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 0, "10", "err") self.assertTrue(self._similarity(20)) def test_compare_success_different(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 0, "10", "err") self.assertFalse(self._similarity(5)) def test_compare_status1_similar(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 1, "out", "10") self.assertTrue(self._similarity(20)) def test_compare_status1_different(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 1, "out", "10") self.assertFalse(self._similarity(5)) def test_compare_failed(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 2, "out", "10") self.assertIsNone(self._similarity(20)) def test_compare_parsing_error(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 0, "foo", "bar") self.assertIsNone(self._similarity(20)) def test_compare_parsing_error_and_failure(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 1, "foo", "bar") self.assertIsNone(self._similarity(20)) def test_convert_failure(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, convert_status=1) self.assertIsNone(self._similarity(20)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_dbcore.py0000644000076500000240000004763613122501062017426 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the DBCore database abstraction. """ from __future__ import division, absolute_import, print_function import os import shutil import sqlite3 import unittest from six import assertRaisesRegex from test import _common from beets import dbcore from tempfile import mkstemp import six # Fixture: concrete database and model classes. For migration tests, we # have multiple models with different numbers of fields. class TestSort(dbcore.query.FieldSort): pass class TestModel1(dbcore.Model): _table = 'test' _flex_table = 'testflex' _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, } _types = { 'some_float_field': dbcore.types.FLOAT, } _sorts = { 'some_sort': TestSort, } @classmethod def _getters(cls): return {} def _template_funcs(self): return {} class TestDatabase1(dbcore.Database): _models = (TestModel1,) pass class TestModel2(TestModel1): _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, 'field_two': dbcore.types.INTEGER, } class TestDatabase2(dbcore.Database): _models = (TestModel2,) pass class TestModel3(TestModel1): _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, 'field_two': dbcore.types.INTEGER, 'field_three': dbcore.types.INTEGER, } class TestDatabase3(dbcore.Database): _models = (TestModel3,) pass class TestModel4(TestModel1): _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, 'field_two': dbcore.types.INTEGER, 'field_three': dbcore.types.INTEGER, 'field_four': dbcore.types.INTEGER, } class TestDatabase4(dbcore.Database): _models = (TestModel4,) pass class AnotherTestModel(TestModel1): _table = 'another' _flex_table = 'anotherflex' _fields = { 'id': dbcore.types.PRIMARY_ID, 'foo': dbcore.types.INTEGER, } class TestDatabaseTwoModels(dbcore.Database): _models = (TestModel2, AnotherTestModel) pass class TestModelWithGetters(dbcore.Model): @classmethod def _getters(cls): return {'aComputedField': (lambda s: 'thing')} def _template_funcs(self): return {} @_common.slow_test() class MigrationTest(unittest.TestCase): """Tests the ability to change the database schema between versions. """ @classmethod def setUpClass(cls): handle, cls.orig_libfile = mkstemp('orig_db') os.close(handle) # Set up a database with the two-field schema. old_lib = TestDatabase2(cls.orig_libfile) # Add an item to the old library. old_lib._connection().execute( 'insert into test (field_one, field_two) values (4, 2)' ) old_lib._connection().commit() del old_lib @classmethod def tearDownClass(cls): os.remove(cls.orig_libfile) def setUp(self): handle, self.libfile = mkstemp('db') os.close(handle) shutil.copyfile(self.orig_libfile, self.libfile) def tearDown(self): os.remove(self.libfile) def test_open_with_same_fields_leaves_untouched(self): new_lib = TestDatabase2(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() self.assertEqual(len(row.keys()), len(TestModel2._fields)) def test_open_with_new_field_adds_column(self): new_lib = TestDatabase3(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() self.assertEqual(len(row.keys()), len(TestModel3._fields)) def test_open_with_fewer_fields_leaves_untouched(self): new_lib = TestDatabase1(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() self.assertEqual(len(row.keys()), len(TestModel2._fields)) def test_open_with_multiple_new_fields(self): new_lib = TestDatabase4(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() self.assertEqual(len(row.keys()), len(TestModel4._fields)) def test_extra_model_adds_table(self): new_lib = TestDatabaseTwoModels(self.libfile) try: new_lib._connection().execute("select * from another") except sqlite3.OperationalError: self.fail("select failed") class ModelTest(unittest.TestCase): def setUp(self): self.db = TestDatabase1(':memory:') def tearDown(self): self.db._connection().close() def test_add_model(self): model = TestModel1() model.add(self.db) rows = self.db._connection().execute('select * from test').fetchall() self.assertEqual(len(rows), 1) def test_store_fixed_field(self): model = TestModel1() model.add(self.db) model.field_one = 123 model.store() row = self.db._connection().execute('select * from test').fetchone() self.assertEqual(row['field_one'], 123) def test_retrieve_by_id(self): model = TestModel1() model.add(self.db) other_model = self.db._get(TestModel1, model.id) self.assertEqual(model.id, other_model.id) def test_store_and_retrieve_flexattr(self): model = TestModel1() model.add(self.db) model.foo = 'bar' model.store() other_model = self.db._get(TestModel1, model.id) self.assertEqual(other_model.foo, 'bar') def test_delete_flexattr(self): model = TestModel1() model['foo'] = 'bar' self.assertTrue('foo' in model) del model['foo'] self.assertFalse('foo' in model) def test_delete_flexattr_via_dot(self): model = TestModel1() model['foo'] = 'bar' self.assertTrue('foo' in model) del model.foo self.assertFalse('foo' in model) def test_delete_flexattr_persists(self): model = TestModel1() model.add(self.db) model.foo = 'bar' model.store() model = self.db._get(TestModel1, model.id) del model['foo'] model.store() model = self.db._get(TestModel1, model.id) self.assertFalse('foo' in model) def test_delete_non_existent_attribute(self): model = TestModel1() with self.assertRaises(KeyError): del model['foo'] def test_delete_fixed_attribute(self): model = TestModel1() with self.assertRaises(KeyError): del model['field_one'] def test_null_value_normalization_by_type(self): model = TestModel1() model.field_one = None self.assertEqual(model.field_one, 0) def test_null_value_stays_none_for_untyped_field(self): model = TestModel1() model.foo = None self.assertEqual(model.foo, None) def test_normalization_for_typed_flex_fields(self): model = TestModel1() model.some_float_field = None self.assertEqual(model.some_float_field, 0.0) def test_load_deleted_flex_field(self): model1 = TestModel1() model1['flex_field'] = True model1.add(self.db) model2 = self.db._get(TestModel1, model1.id) self.assertIn('flex_field', model2) del model1['flex_field'] model1.store() model2.load() self.assertNotIn('flex_field', model2) def test_check_db_fails(self): with assertRaisesRegex(self, ValueError, 'no database'): dbcore.Model()._check_db() with assertRaisesRegex(self, ValueError, 'no id'): TestModel1(self.db)._check_db() dbcore.Model(self.db)._check_db(need_id=False) def test_missing_field(self): with self.assertRaises(AttributeError): TestModel1(self.db).nonExistingKey def test_computed_field(self): model = TestModelWithGetters() self.assertEqual(model.aComputedField, 'thing') with assertRaisesRegex(self, KeyError, u'computed field .+ deleted'): del model.aComputedField def test_items(self): model = TestModel1(self.db) model.id = 5 self.assertEqual({('id', 5), ('field_one', 0)}, set(model.items())) def test_delete_internal_field(self): model = dbcore.Model() del model._db with self.assertRaises(AttributeError): model._db def test_parse_nonstring(self): with assertRaisesRegex(self, TypeError, u"must be a string"): dbcore.Model._parse(None, 42) class FormatTest(unittest.TestCase): def test_format_fixed_field(self): model = TestModel1() model.field_one = u'caf\xe9' value = model.formatted().get('field_one') self.assertEqual(value, u'caf\xe9') def test_format_flex_field(self): model = TestModel1() model.other_field = u'caf\xe9' value = model.formatted().get('other_field') self.assertEqual(value, u'caf\xe9') def test_format_flex_field_bytes(self): model = TestModel1() model.other_field = u'caf\xe9'.encode('utf-8') value = model.formatted().get('other_field') self.assertTrue(isinstance(value, six.text_type)) self.assertEqual(value, u'caf\xe9') def test_format_unset_field(self): model = TestModel1() value = model.formatted().get('other_field') self.assertEqual(value, u'') def test_format_typed_flex_field(self): model = TestModel1() model.some_float_field = 3.14159265358979 value = model.formatted().get('some_float_field') self.assertEqual(value, u'3.1') class FormattedMappingTest(unittest.TestCase): def test_keys_equal_model_keys(self): model = TestModel1() formatted = model.formatted() self.assertEqual(set(model.keys(True)), set(formatted.keys())) def test_get_unset_field(self): model = TestModel1() formatted = model.formatted() with self.assertRaises(KeyError): formatted['other_field'] def test_get_method_with_default(self): model = TestModel1() formatted = model.formatted() self.assertEqual(formatted.get('other_field'), u'') def test_get_method_with_specified_default(self): model = TestModel1() formatted = model.formatted() self.assertEqual(formatted.get('other_field', 'default'), 'default') class ParseTest(unittest.TestCase): def test_parse_fixed_field(self): value = TestModel1._parse('field_one', u'2') self.assertIsInstance(value, int) self.assertEqual(value, 2) def test_parse_flex_field(self): value = TestModel1._parse('some_float_field', u'2') self.assertIsInstance(value, float) self.assertEqual(value, 2.0) def test_parse_untyped_field(self): value = TestModel1._parse('field_nine', u'2') self.assertEqual(value, u'2') class QueryParseTest(unittest.TestCase): def pqp(self, part): return dbcore.queryparse.parse_query_part( part, {'year': dbcore.query.NumericQuery}, {':': dbcore.query.RegexpQuery}, )[:-1] # remove the negate flag def test_one_basic_term(self): q = 'test' r = (None, 'test', dbcore.query.SubstringQuery) self.assertEqual(self.pqp(q), r) def test_one_keyed_term(self): q = 'test:val' r = ('test', 'val', dbcore.query.SubstringQuery) self.assertEqual(self.pqp(q), r) def test_colon_at_end(self): q = 'test:' r = ('test', '', dbcore.query.SubstringQuery) self.assertEqual(self.pqp(q), r) def test_one_basic_regexp(self): q = r':regexp' r = (None, 'regexp', dbcore.query.RegexpQuery) self.assertEqual(self.pqp(q), r) def test_keyed_regexp(self): q = r'test::regexp' r = ('test', 'regexp', dbcore.query.RegexpQuery) self.assertEqual(self.pqp(q), r) def test_escaped_colon(self): q = r'test\:val' r = (None, 'test:val', dbcore.query.SubstringQuery) self.assertEqual(self.pqp(q), r) def test_escaped_colon_in_regexp(self): q = r':test\:regexp' r = (None, 'test:regexp', dbcore.query.RegexpQuery) self.assertEqual(self.pqp(q), r) def test_single_year(self): q = 'year:1999' r = ('year', '1999', dbcore.query.NumericQuery) self.assertEqual(self.pqp(q), r) def test_multiple_years(self): q = 'year:1999..2010' r = ('year', '1999..2010', dbcore.query.NumericQuery) self.assertEqual(self.pqp(q), r) def test_empty_query_part(self): q = '' r = (None, '', dbcore.query.SubstringQuery) self.assertEqual(self.pqp(q), r) class QueryFromStringsTest(unittest.TestCase): def qfs(self, strings): return dbcore.queryparse.query_from_strings( dbcore.query.AndQuery, TestModel1, {':': dbcore.query.RegexpQuery}, strings, ) def test_zero_parts(self): q = self.qfs([]) self.assertIsInstance(q, dbcore.query.AndQuery) self.assertEqual(len(q.subqueries), 1) self.assertIsInstance(q.subqueries[0], dbcore.query.TrueQuery) def test_two_parts(self): q = self.qfs(['foo', 'bar:baz']) self.assertIsInstance(q, dbcore.query.AndQuery) self.assertEqual(len(q.subqueries), 2) self.assertIsInstance(q.subqueries[0], dbcore.query.AnyFieldQuery) self.assertIsInstance(q.subqueries[1], dbcore.query.SubstringQuery) def test_parse_fixed_type_query(self): q = self.qfs(['field_one:2..3']) self.assertIsInstance(q.subqueries[0], dbcore.query.NumericQuery) def test_parse_flex_type_query(self): q = self.qfs(['some_float_field:2..3']) self.assertIsInstance(q.subqueries[0], dbcore.query.NumericQuery) def test_empty_query_part(self): q = self.qfs(['']) self.assertIsInstance(q.subqueries[0], dbcore.query.TrueQuery) class SortFromStringsTest(unittest.TestCase): def sfs(self, strings): return dbcore.queryparse.sort_from_strings( TestModel1, strings, ) def test_zero_parts(self): s = self.sfs([]) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(s, dbcore.query.NullSort()) def test_one_parts(self): s = self.sfs(['field+']) self.assertIsInstance(s, dbcore.query.Sort) def test_two_parts(self): s = self.sfs(['field+', 'another_field-']) self.assertIsInstance(s, dbcore.query.MultipleSort) self.assertEqual(len(s.sorts), 2) def test_fixed_field_sort(self): s = self.sfs(['field_one+']) self.assertIsInstance(s, dbcore.query.FixedFieldSort) self.assertEqual(s, dbcore.query.FixedFieldSort('field_one')) def test_flex_field_sort(self): s = self.sfs(['flex_field+']) self.assertIsInstance(s, dbcore.query.SlowFieldSort) self.assertEqual(s, dbcore.query.SlowFieldSort('flex_field')) def test_special_sort(self): s = self.sfs(['some_sort+']) self.assertIsInstance(s, TestSort) class ParseSortedQueryTest(unittest.TestCase): def psq(self, parts): return dbcore.parse_sorted_query( TestModel1, parts.split(), ) def test_and_query(self): q, s = self.psq('foo bar') self.assertIsInstance(q, dbcore.query.AndQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 2) def test_or_query(self): q, s = self.psq('foo , bar') self.assertIsInstance(q, dbcore.query.OrQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 2) def test_no_space_before_comma_or_query(self): q, s = self.psq('foo, bar') self.assertIsInstance(q, dbcore.query.OrQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 2) def test_no_spaces_or_query(self): q, s = self.psq('foo,bar') self.assertIsInstance(q, dbcore.query.AndQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 1) def test_trailing_comma_or_query(self): q, s = self.psq('foo , bar ,') self.assertIsInstance(q, dbcore.query.OrQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 3) def test_leading_comma_or_query(self): q, s = self.psq(', foo , bar') self.assertIsInstance(q, dbcore.query.OrQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 3) def test_only_direction(self): q, s = self.psq('-') self.assertIsInstance(q, dbcore.query.AndQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 1) class ResultsIteratorTest(unittest.TestCase): def setUp(self): self.db = TestDatabase1(':memory:') model = TestModel1() model['foo'] = 'baz' model.add(self.db) model = TestModel1() model['foo'] = 'bar' model.add(self.db) def tearDown(self): self.db._connection().close() def test_iterate_once(self): objs = self.db._fetch(TestModel1) self.assertEqual(len(list(objs)), 2) def test_iterate_twice(self): objs = self.db._fetch(TestModel1) list(objs) self.assertEqual(len(list(objs)), 2) def test_concurrent_iterators(self): results = self.db._fetch(TestModel1) it1 = iter(results) it2 = iter(results) next(it1) list(it2) self.assertEqual(len(list(it1)), 1) def test_slow_query(self): q = dbcore.query.SubstringQuery('foo', 'ba', False) objs = self.db._fetch(TestModel1, q) self.assertEqual(len(list(objs)), 2) def test_slow_query_negative(self): q = dbcore.query.SubstringQuery('foo', 'qux', False) objs = self.db._fetch(TestModel1, q) self.assertEqual(len(list(objs)), 0) def test_iterate_slow_sort(self): s = dbcore.query.SlowFieldSort('foo') res = self.db._fetch(TestModel1, sort=s) objs = list(res) self.assertEqual(objs[0].foo, 'bar') self.assertEqual(objs[1].foo, 'baz') def test_unsorted_subscript(self): objs = self.db._fetch(TestModel1) self.assertEqual(objs[0].foo, 'baz') self.assertEqual(objs[1].foo, 'bar') def test_slow_sort_subscript(self): s = dbcore.query.SlowFieldSort('foo') objs = self.db._fetch(TestModel1, sort=s) self.assertEqual(objs[0].foo, 'bar') self.assertEqual(objs[1].foo, 'baz') def test_length(self): objs = self.db._fetch(TestModel1) self.assertEqual(len(objs), 2) def test_out_of_range(self): objs = self.db._fetch(TestModel1) with self.assertRaises(IndexError): objs[100] def test_no_results(self): self.assertIsNone(self.db._fetch( TestModel1, dbcore.query.FalseQuery()).get()) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_metasync.py0000644000076500000240000001072113025125203017775 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Tom Jaspers. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import os import platform import time from datetime import datetime from beets.library import Item from beets.util import py3_path import unittest from test import _common from test.helper import TestHelper def _parsetime(s): return time.mktime(datetime.strptime(s, '%Y-%m-%d %H:%M:%S').timetuple()) def _is_windows(): return platform.system() == "Windows" class MetaSyncTest(_common.TestCase, TestHelper): itunes_library_unix = os.path.join(_common.RSRC, b'itunes_library_unix.xml') itunes_library_windows = os.path.join(_common.RSRC, b'itunes_library_windows.xml') def setUp(self): self.setup_beets() self.load_plugins('metasync') self.config['metasync']['source'] = 'itunes' if _is_windows(): self.config['metasync']['itunes']['library'] = \ py3_path(self.itunes_library_windows) else: self.config['metasync']['itunes']['library'] = \ py3_path(self.itunes_library_unix) self._set_up_data() def _set_up_data(self): items = [_common.item() for _ in range(2)] items[0].title = 'Tessellate' items[0].artist = 'alt-J' items[0].albumartist = 'alt-J' items[0].album = 'An Awesome Wave' items[0].itunes_rating = 60 items[1].title = 'Breezeblocks' items[1].artist = 'alt-J' items[1].albumartist = 'alt-J' items[1].album = 'An Awesome Wave' if _is_windows(): items[0].path = \ u'G:\\Music\\Alt-J\\An Awesome Wave\\03 Tessellate.mp3' items[1].path = \ u'G:\\Music\\Alt-J\\An Awesome Wave\\04 Breezeblocks.mp3' else: items[0].path = u'/Music/Alt-J/An Awesome Wave/03 Tessellate.mp3' items[1].path = u'/Music/Alt-J/An Awesome Wave/04 Breezeblocks.mp3' for item in items: self.lib.add(item) def tearDown(self): self.unload_plugins() self.teardown_beets() def test_load_item_types(self): # This test also verifies that the MetaSources have loaded correctly self.assertIn('amarok_score', Item._types) self.assertIn('itunes_rating', Item._types) def test_pretend_sync_from_itunes(self): out = self.run_with_output('metasync', '-p') self.assertIn('itunes_rating: 60 -> 80', out) self.assertIn('itunes_rating: 100', out) self.assertIn('itunes_playcount: 31', out) self.assertIn('itunes_skipcount: 3', out) self.assertIn('itunes_lastplayed: 2015-05-04 12:20:51', out) self.assertIn('itunes_lastskipped: 2015-02-05 15:41:04', out) self.assertEqual(self.lib.items()[0].itunes_rating, 60) def test_sync_from_itunes(self): self.run_command('metasync') self.assertEqual(self.lib.items()[0].itunes_rating, 80) self.assertEqual(self.lib.items()[0].itunes_playcount, 0) self.assertEqual(self.lib.items()[0].itunes_skipcount, 3) self.assertFalse(hasattr(self.lib.items()[0], 'itunes_lastplayed')) self.assertEqual(self.lib.items()[0].itunes_lastskipped, _parsetime('2015-02-05 15:41:04')) self.assertEqual(self.lib.items()[1].itunes_rating, 100) self.assertEqual(self.lib.items()[1].itunes_playcount, 31) self.assertEqual(self.lib.items()[1].itunes_skipcount, 0) self.assertEqual(self.lib.items()[1].itunes_lastplayed, _parsetime('2015-05-04 12:20:51')) self.assertFalse(hasattr(self.lib.items()[1], 'itunes_lastskipped')) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_files.py0000644000076500000240000005230613175434666017307 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test file manipulation functionality of Item. """ from __future__ import division, absolute_import, print_function import shutil import os import stat from os.path import join import unittest from test import _common from test._common import item, touch import beets.library from beets import util from beets.util import MoveOperation class MoveTest(_common.TestCase): def setUp(self): super(MoveTest, self).setUp() # make a temporary file self.path = join(self.temp_dir, b'temp.mp3') shutil.copy(join(_common.RSRC, b'full.mp3'), self.path) # add it to a temporary library self.lib = beets.library.Library(':memory:') self.i = beets.library.Item.from_path(self.path) self.lib.add(self.i) # set up the destination self.libdir = join(self.temp_dir, b'testlibdir') os.mkdir(self.libdir) self.lib.directory = self.libdir self.lib.path_formats = [('default', join('$artist', '$album', '$title'))] self.i.artist = 'one' self.i.album = 'two' self.i.title = 'three' self.dest = join(self.libdir, b'one', b'two', b'three.mp3') self.otherdir = join(self.temp_dir, b'testotherdir') def test_move_arrives(self): self.i.move() self.assertExists(self.dest) def test_move_to_custom_dir(self): self.i.move(basedir=self.otherdir) self.assertExists(join(self.otherdir, b'one', b'two', b'three.mp3')) def test_move_departs(self): self.i.move() self.assertNotExists(self.path) def test_move_in_lib_prunes_empty_dir(self): self.i.move() old_path = self.i.path self.assertExists(old_path) self.i.artist = u'newArtist' self.i.move() self.assertNotExists(old_path) self.assertNotExists(os.path.dirname(old_path)) def test_copy_arrives(self): self.i.move(operation=MoveOperation.COPY) self.assertExists(self.dest) def test_copy_does_not_depart(self): self.i.move(operation=MoveOperation.COPY) self.assertExists(self.path) def test_move_changes_path(self): self.i.move() self.assertEqual(self.i.path, util.normpath(self.dest)) def test_copy_already_at_destination(self): self.i.move() old_path = self.i.path self.i.move(operation=MoveOperation.COPY) self.assertEqual(self.i.path, old_path) def test_move_already_at_destination(self): self.i.move() old_path = self.i.path self.i.move() self.assertEqual(self.i.path, old_path) def test_read_only_file_copied_writable(self): # Make the source file read-only. os.chmod(self.path, 0o444) try: self.i.move(operation=MoveOperation.COPY) self.assertTrue(os.access(self.i.path, os.W_OK)) finally: # Make everything writable so it can be cleaned up. os.chmod(self.path, 0o777) os.chmod(self.i.path, 0o777) def test_move_avoids_collision_with_existing_file(self): # Make a conflicting file at the destination. dest = self.i.destination() os.makedirs(os.path.dirname(dest)) touch(dest) self.i.move() self.assertNotEqual(self.i.path, dest) self.assertEqual(os.path.dirname(self.i.path), os.path.dirname(dest)) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_arrives(self): self.i.move(operation=MoveOperation.LINK) self.assertExists(self.dest) self.assertTrue(os.path.islink(self.dest)) self.assertEqual(os.readlink(self.dest), self.path) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_does_not_depart(self): self.i.move(operation=MoveOperation.LINK) self.assertExists(self.path) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_changes_path(self): self.i.move(operation=MoveOperation.LINK) self.assertEqual(self.i.path, util.normpath(self.dest)) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_hardlink_arrives(self): self.i.move(operation=MoveOperation.HARDLINK) self.assertExists(self.dest) s1 = os.stat(self.path) s2 = os.stat(self.dest) self.assertTrue( (s1[stat.ST_INO], s1[stat.ST_DEV]) == (s2[stat.ST_INO], s2[stat.ST_DEV]) ) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_hardlink_does_not_depart(self): self.i.move(operation=MoveOperation.HARDLINK) self.assertExists(self.path) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_hardlink_changes_path(self): self.i.move(operation=MoveOperation.HARDLINK) self.assertEqual(self.i.path, util.normpath(self.dest)) class HelperTest(_common.TestCase): def test_ancestry_works_on_file(self): p = '/a/b/c' a = ['/', '/a', '/a/b'] self.assertEqual(util.ancestry(p), a) def test_ancestry_works_on_dir(self): p = '/a/b/c/' a = ['/', '/a', '/a/b', '/a/b/c'] self.assertEqual(util.ancestry(p), a) def test_ancestry_works_on_relative(self): p = 'a/b/c' a = ['a', 'a/b'] self.assertEqual(util.ancestry(p), a) def test_components_works_on_file(self): p = '/a/b/c' a = ['/', 'a', 'b', 'c'] self.assertEqual(util.components(p), a) def test_components_works_on_dir(self): p = '/a/b/c/' a = ['/', 'a', 'b', 'c'] self.assertEqual(util.components(p), a) def test_components_works_on_relative(self): p = 'a/b/c' a = ['a', 'b', 'c'] self.assertEqual(util.components(p), a) class AlbumFileTest(_common.TestCase): def setUp(self): super(AlbumFileTest, self).setUp() # Make library and item. self.lib = beets.library.Library(':memory:') self.lib.path_formats = \ [('default', join('$albumartist', '$album', '$title'))] self.libdir = os.path.join(self.temp_dir, b'testlibdir') self.lib.directory = self.libdir self.i = item(self.lib) # Make a file for the item. self.i.path = self.i.destination() util.mkdirall(self.i.path) touch(self.i.path) # Make an album. self.ai = self.lib.add_album((self.i,)) # Alternate destination dir. self.otherdir = os.path.join(self.temp_dir, b'testotherdir') def test_albuminfo_move_changes_paths(self): self.ai.album = u'newAlbumName' self.ai.move() self.ai.store() self.i.load() self.assertTrue(b'newAlbumName' in self.i.path) def test_albuminfo_move_moves_file(self): oldpath = self.i.path self.ai.album = u'newAlbumName' self.ai.move() self.ai.store() self.i.load() self.assertFalse(os.path.exists(oldpath)) self.assertTrue(os.path.exists(self.i.path)) def test_albuminfo_move_copies_file(self): oldpath = self.i.path self.ai.album = u'newAlbumName' self.ai.move(operation=MoveOperation.COPY) self.ai.store() self.i.load() self.assertTrue(os.path.exists(oldpath)) self.assertTrue(os.path.exists(self.i.path)) def test_albuminfo_move_to_custom_dir(self): self.ai.move(basedir=self.otherdir) self.i.load() self.ai.store() self.assertTrue(b'testotherdir' in self.i.path) class ArtFileTest(_common.TestCase): def setUp(self): super(ArtFileTest, self).setUp() # Make library and item. self.lib = beets.library.Library(':memory:') self.libdir = os.path.join(self.temp_dir, b'testlibdir') self.lib.directory = self.libdir self.i = item(self.lib) self.i.path = self.i.destination() # Make a music file. util.mkdirall(self.i.path) touch(self.i.path) # Make an album. self.ai = self.lib.add_album((self.i,)) # Make an art file too. self.art = self.lib.get_album(self.i).art_destination('something.jpg') touch(self.art) self.ai.artpath = self.art self.ai.store() # Alternate destination dir. self.otherdir = os.path.join(self.temp_dir, b'testotherdir') def test_art_deleted_when_items_deleted(self): self.assertTrue(os.path.exists(self.art)) self.ai.remove(True) self.assertFalse(os.path.exists(self.art)) def test_art_moves_with_album(self): self.assertTrue(os.path.exists(self.art)) oldpath = self.i.path self.ai.album = u'newAlbum' self.ai.move() self.i.load() self.assertNotEqual(self.i.path, oldpath) self.assertFalse(os.path.exists(self.art)) newart = self.lib.get_album(self.i).art_destination(self.art) self.assertTrue(os.path.exists(newart)) def test_art_moves_with_album_to_custom_dir(self): # Move the album to another directory. self.ai.move(basedir=self.otherdir) self.ai.store() self.i.load() # Art should be in new directory. self.assertNotExists(self.art) newart = self.lib.get_album(self.i).artpath self.assertExists(newart) self.assertTrue(b'testotherdir' in newart) def test_setart_copies_image(self): os.remove(self.art) newart = os.path.join(self.libdir, b'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = u'someArtist' ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) self.assertEqual(ai.artpath, None) ai.set_art(newart) self.assertTrue(os.path.exists(ai.artpath)) def test_setart_to_existing_art_works(self): os.remove(self.art) # Original art. newart = os.path.join(self.libdir, b'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = u'someArtist' ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) ai.set_art(newart) # Set the art again. ai.set_art(ai.artpath) self.assertTrue(os.path.exists(ai.artpath)) def test_setart_to_existing_but_unset_art_works(self): newart = os.path.join(self.libdir, b'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = u'someArtist' ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) # Copy the art to the destination. artdest = ai.art_destination(newart) shutil.copy(newart, artdest) # Set the art again. ai.set_art(artdest) self.assertTrue(os.path.exists(ai.artpath)) def test_setart_to_conflicting_file_gets_new_path(self): newart = os.path.join(self.libdir, b'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = u'someArtist' ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) # Make a file at the destination. artdest = ai.art_destination(newart) touch(artdest) # Set the art. ai.set_art(newart) self.assertNotEqual(artdest, ai.artpath) self.assertEqual(os.path.dirname(artdest), os.path.dirname(ai.artpath)) def test_setart_sets_permissions(self): os.remove(self.art) newart = os.path.join(self.libdir, b'newart.jpg') touch(newart) os.chmod(newart, 0o400) # read-only try: i2 = item() i2.path = self.i.path i2.artist = u'someArtist' ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) ai.set_art(newart) mode = stat.S_IMODE(os.stat(ai.artpath).st_mode) self.assertTrue(mode & stat.S_IRGRP) self.assertTrue(os.access(ai.artpath, os.W_OK)) finally: # Make everything writable so it can be cleaned up. os.chmod(newart, 0o777) os.chmod(ai.artpath, 0o777) def test_move_last_file_moves_albumart(self): oldartpath = self.lib.albums()[0].artpath self.assertExists(oldartpath) self.ai.album = u'different_album' self.ai.store() self.ai.items()[0].move() artpath = self.lib.albums()[0].artpath self.assertTrue(b'different_album' in artpath) self.assertExists(artpath) self.assertNotExists(oldartpath) def test_move_not_last_file_does_not_move_albumart(self): i2 = item() i2.albumid = self.ai.id self.lib.add(i2) oldartpath = self.lib.albums()[0].artpath self.assertExists(oldartpath) self.i.album = u'different_album' self.i.album_id = None # detach from album self.i.move() artpath = self.lib.albums()[0].artpath self.assertFalse(b'different_album' in artpath) self.assertEqual(artpath, oldartpath) self.assertExists(oldartpath) class RemoveTest(_common.TestCase): def setUp(self): super(RemoveTest, self).setUp() # Make library and item. self.lib = beets.library.Library(':memory:') self.libdir = os.path.join(self.temp_dir, b'testlibdir') self.lib.directory = self.libdir self.i = item(self.lib) self.i.path = self.i.destination() # Make a music file. util.mkdirall(self.i.path) touch(self.i.path) # Make an album with the item. self.ai = self.lib.add_album((self.i,)) def test_removing_last_item_prunes_empty_dir(self): parent = os.path.dirname(self.i.path) self.assertExists(parent) self.i.remove(True) self.assertNotExists(parent) def test_removing_last_item_preserves_nonempty_dir(self): parent = os.path.dirname(self.i.path) touch(os.path.join(parent, b'dummy.txt')) self.i.remove(True) self.assertExists(parent) def test_removing_last_item_prunes_dir_with_blacklisted_file(self): parent = os.path.dirname(self.i.path) touch(os.path.join(parent, b'.DS_Store')) self.i.remove(True) self.assertNotExists(parent) def test_removing_without_delete_leaves_file(self): path = self.i.path self.i.remove(False) self.assertExists(path) def test_removing_last_item_preserves_library_dir(self): self.i.remove(True) self.assertExists(self.libdir) def test_removing_item_outside_of_library_deletes_nothing(self): self.lib.directory = os.path.join(self.temp_dir, b'xxx') parent = os.path.dirname(self.i.path) self.i.remove(True) self.assertExists(parent) def test_removing_last_item_in_album_with_albumart_prunes_dir(self): artfile = os.path.join(self.temp_dir, b'testart.jpg') touch(artfile) self.ai.set_art(artfile) self.ai.store() parent = os.path.dirname(self.i.path) self.i.remove(True) self.assertNotExists(parent) # Tests that we can "delete" nonexistent files. class SoftRemoveTest(_common.TestCase): def setUp(self): super(SoftRemoveTest, self).setUp() self.path = os.path.join(self.temp_dir, b'testfile') touch(self.path) def test_soft_remove_deletes_file(self): util.remove(self.path, True) self.assertNotExists(self.path) def test_soft_remove_silent_on_no_file(self): try: util.remove(self.path + b'XXX', True) except OSError: self.fail(u'OSError when removing path') class SafeMoveCopyTest(_common.TestCase): def setUp(self): super(SafeMoveCopyTest, self).setUp() self.path = os.path.join(self.temp_dir, b'testfile') touch(self.path) self.otherpath = os.path.join(self.temp_dir, b'testfile2') touch(self.otherpath) self.dest = self.path + b'.dest' def test_successful_move(self): util.move(self.path, self.dest) self.assertExists(self.dest) self.assertNotExists(self.path) def test_successful_copy(self): util.copy(self.path, self.dest) self.assertExists(self.dest) self.assertExists(self.path) def test_unsuccessful_move(self): with self.assertRaises(util.FilesystemError): util.move(self.path, self.otherpath) def test_unsuccessful_copy(self): with self.assertRaises(util.FilesystemError): util.copy(self.path, self.otherpath) def test_self_move(self): util.move(self.path, self.path) self.assertExists(self.path) def test_self_copy(self): util.copy(self.path, self.path) self.assertExists(self.path) class PruneTest(_common.TestCase): def setUp(self): super(PruneTest, self).setUp() self.base = os.path.join(self.temp_dir, b'testdir') os.mkdir(self.base) self.sub = os.path.join(self.base, b'subdir') os.mkdir(self.sub) def test_prune_existent_directory(self): util.prune_dirs(self.sub, self.base) self.assertExists(self.base) self.assertNotExists(self.sub) def test_prune_nonexistent_directory(self): util.prune_dirs(os.path.join(self.sub, b'another'), self.base) self.assertExists(self.base) self.assertNotExists(self.sub) class WalkTest(_common.TestCase): def setUp(self): super(WalkTest, self).setUp() self.base = os.path.join(self.temp_dir, b'testdir') os.mkdir(self.base) touch(os.path.join(self.base, b'y')) touch(os.path.join(self.base, b'x')) os.mkdir(os.path.join(self.base, b'd')) touch(os.path.join(self.base, b'd', b'z')) def test_sorted_files(self): res = list(util.sorted_walk(self.base)) self.assertEqual(len(res), 2) self.assertEqual(res[0], (self.base, [b'd'], [b'x', b'y'])) self.assertEqual(res[1], (os.path.join(self.base, b'd'), [], [b'z'])) def test_ignore_file(self): res = list(util.sorted_walk(self.base, (b'x',))) self.assertEqual(len(res), 2) self.assertEqual(res[0], (self.base, [b'd'], [b'y'])) self.assertEqual(res[1], (os.path.join(self.base, b'd'), [], [b'z'])) def test_ignore_directory(self): res = list(util.sorted_walk(self.base, (b'd',))) self.assertEqual(len(res), 1) self.assertEqual(res[0], (self.base, [], [b'x', b'y'])) def test_ignore_everything(self): res = list(util.sorted_walk(self.base, (b'*',))) self.assertEqual(len(res), 1) self.assertEqual(res[0], (self.base, [], [])) class UniquePathTest(_common.TestCase): def setUp(self): super(UniquePathTest, self).setUp() self.base = os.path.join(self.temp_dir, b'testdir') os.mkdir(self.base) touch(os.path.join(self.base, b'x.mp3')) touch(os.path.join(self.base, b'x.1.mp3')) touch(os.path.join(self.base, b'x.2.mp3')) touch(os.path.join(self.base, b'y.mp3')) def test_new_file_unchanged(self): path = util.unique_path(os.path.join(self.base, b'z.mp3')) self.assertEqual(path, os.path.join(self.base, b'z.mp3')) def test_conflicting_file_appends_1(self): path = util.unique_path(os.path.join(self.base, b'y.mp3')) self.assertEqual(path, os.path.join(self.base, b'y.1.mp3')) def test_conflicting_file_appends_higher_number(self): path = util.unique_path(os.path.join(self.base, b'x.mp3')) self.assertEqual(path, os.path.join(self.base, b'x.3.mp3')) def test_conflicting_file_with_number_increases_number(self): path = util.unique_path(os.path.join(self.base, b'x.1.mp3')) self.assertEqual(path, os.path.join(self.base, b'x.3.mp3')) class MkDirAllTest(_common.TestCase): def test_parent_exists(self): path = os.path.join(self.temp_dir, b'foo', b'bar', b'baz', b'qux.mp3') util.mkdirall(path) self.assertTrue(os.path.isdir( os.path.join(self.temp_dir, b'foo', b'bar', b'baz') )) def test_child_does_not_exist(self): path = os.path.join(self.temp_dir, b'foo', b'bar', b'baz', b'qux.mp3') util.mkdirall(path) self.assertTrue(not os.path.exists( os.path.join(self.temp_dir, b'foo', b'bar', b'baz', b'qux.mp3') )) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/rsrc/0000755000076500000240000000000013216774613015532 5ustar asampsonstaff00000000000000beets-1.4.6/test/rsrc/lyrics/0000755000076500000240000000000013216774613017037 5ustar asampsonstaff00000000000000beets-1.4.6/test/rsrc/lyrics/examplecom/0000755000076500000240000000000013216774613021171 5ustar asampsonstaff00000000000000beets-1.4.6/test/rsrc/lyrics/examplecom/beetssong.txt0000644000076500000240000007035613025125203023715 0ustar asampsonstaff00000000000000 John Doe - beets song Lyrics

beets song Lyrics



John Doe beets song lyrics
Lyrics search for Artist - Song:

Back to the: Music Lyrics > John Doe lyrics > beets song lyrics

John Doe
beets song lyrics

Ringtones left icon Send "beets song" Ringtone to your Cell Ringtones right icon

Beets is the media library management system for obsessive-compulsive music geeks.
The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It then provides a bouquet of tools for manipulating and accessing your music.
Here's an example of beets' brainy tag corrector doing its thing: Because beets is designed as a library, it can do almost anything you can imagine for your music collection. Via plugins, beets becomes a panacea
Ringtones left icon Send "beets song" Ringtone to your Cell Ringtones right icon

Share beets song lyrics

  RATE THIS SONG!

Add to Favorites Lyrics Email to a Friend John Doe - beets song Lyrics
Rating:

Use the following form to post your meaning of this song, rate it, or submit comments about this song.


0
Name:
Comment:
Type maps backwards (spam prevention):


There are no comments for this song yet.
People who viewed John Doe lyrics have also visited
1. Luther Vandross % Janet Jackson lyrics
2. Baby lyrics
3. Edie Brickell & The New Bohemians lyrics
4. Al Tariq lyrics
5. Defari feat Xzibit The Alkaholiks Phil Da Agony lyrics
6. Ferradini Marco lyrics
7. Orkest Klein lyrics
8. Missey Elliot f/ Ciara lyrics
9. MC Ren f/ RBX, Snoop Dogg lyrics
10. DJ Paul f/ Crunchy Black, Lord Infamous lyrics
11. Figurines lyrics
12. Asher Roth f/ Miguel lyrics
13. Barton Fink lyrics
14. Chloe lyrics
15. Stack Bundles lyrics


Put this scroller on your page!

ToneFuse Music

beets-1.4.6/test/rsrc/lyrics/absolutelyricscom/0000755000076500000240000000000013216774613022602 5ustar asampsonstaff00000000000000beets-1.4.6/test/rsrc/lyrics/absolutelyricscom/ladymadonna.txt0000644000076500000240000004331112747535664025645 0ustar asampsonstaff00000000000000 Lady Madonna Lyrics :: The Beatles - Absolute Lyrics

Lady Madonna - The Beatles

The Beatles - Lady Madonna

Lady Madonna, children at your feet.
Wonder how you manage to make ends meet.
Who finds the money? When you pay the rent?
Did you think that money was heaven sent?
Friday night arrives without a suitcase.
Sunday morning creep in like a nun.
Monday's child has learned to tie his bootlace.
See how they run.
Lady Madonna, baby at your breast.
Wonder how you manage to feed the rest.
See how they run.
Lady Madonna, lying on the bed,
Listen to the music playing in your head.
Tuesday afternoon is never ending.
Wednesday morning papers didn't come.
Thursday night you stockings needed mending.
See how they run.
Lady Madonna, children at your feet.
Wonder how you manage to make ends meet.

view 9,779 times, correct by Diesel

comments

beets-1.4.6/test/rsrc/discc.ogg0000644000076500000240000002116213025125202017275 0ustar asampsonstaff00000000000000OggS=[vorbisDOggS=^@2vorbisXiph.Org libVorbis I 20050304ALBUM=the albumARTIST=the artistBPM=6COMMENT=the comments COMPILATION=1COMPOSER=the composer DATE=2001DISC=4DISCC=5GENRE=the genreGROUPING=the groupingLYRICS=the lyrics TITLE=full TRACKNUMBER=2 TRACKTOTAL=3 YEAR=2001vorbisBCVcT)FRJs1FbJBHsS9לk SP)RRic)RKI%t:'c[I֘kA RL)ĔRBS)ŔRJB%t:SJ(AsctJ$dLBH)JSNBH5R)sRRjA B АU@ P2((#9cI pIɱ$K,KDQU}6UUu]u]u 4d@H d Y F(BCVb(9&|sf9h*tp"In*s9's8srf1h&sf)h&sAks9qFsAj6s9j.s"Im.s9s9sspN8sZnBs>{sB8s9s9sАUA6q HEiȤݣ$h r GR TI) 4d!RH!RH!R!b) *2,2,2밳:0C KMXckZiJ)RJ) YdAF!R!r)BCVU}SvteׅYn]8u}amXeY9~Xet]_XmVY_}xu]n̺ mc}Yf_w:㩪+ , Ƴ, *±,j°ڶ1ܾn,pk1Qu|_x tu]yf]utG8~@!+8$dY(Y(躢hiiiZgi),iiZfhk)˦jʲi캲mlۢiʲil,ۮ꺤Yyijgjʲiyjzh(j,[g試'j*˦ڲilmmim-jۮ,ۺ/iijg癦il+[(扦j,ʖ癪'ꉞk*˦jڪil-*ˮmʲnljjʲl˾ʪ)˦ڲi-۲˲iʲim.˲mlhm-*۲-,ۺʮo-p0l+ۺo2}DӔeS5mTUYvee}4m[UU[6MնeY}Yma4M6UUMմmYmaeveٷe[uוu_}e溲˲p p0 a1R9Q9 dA!9!92 RZ !Rk8ؠ)8@!+TX癢jڲcI'H牢im['牢ih뺺.뺲+릪ʮ,ª+˲m°ʲl۶oܺ‘.1 G @6pR4XhJ 0!B!RJ!0`B(4dE'C)RJ)RJ)RJ)RJ)RJ)RH)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJRJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)R pz0 HQJ)Ɯ1cI()b9RRi-9!RmsRZ13礤[9RRkVk5ZZ5לs͹k9לs1לs9s9s48ذ:IX`!+Ts9R9"s9!T9tBs9!9B!s: BBA!J(!B!:!B!B!RJ!B P`@ #R΄AA AQ3 BL9љbNj3S9tjA^2  (B1A U DDH.\]BBX@ 88'ST ""8>*,.02468:<@OggS@='02310276;:?CCBl% B8FkF#zJI`76BU5: K.[/I ~JJ@!T zwP{@ۂ_,5@4> k@!TU40Mt+Wу>vyPU|GאOӱpB&>4PU'@$O}%M;: >&* @ta)t (4Ore1M0BU 1>Y(sY&-RJgMkVOr2IUU@EO5nE~IӴJq)dU)$ @Or28Mj Y"eddcw d :Or`ۛTU:]\\{QFH4T05yk2XaEP + &E^ #&!ir}Ωaku)lY & Hyk2MP*jT1{Á1^_Y~vtZ}.95\1)_#yR NyB1,r(4ptmp sRL/g9?0M),Zu8 4X huҋ~>S$=cr7Ì0ʫb*߷QY4#Oű = P{dX]59  f)6֔A`f^!-VFļV ]tGNy49aM5Xd\%`8'\57M?/'x,ׯ}YnmcfFU)0޾ڝP[+T2\S G.WX;UFhkoF/u c:78 0d7\v}iEM M3"~b#Ah'R&y,]~MG-9#8뼁#s/y78Q1%8iup;_I5?&ҵdun{!:υzU.25Bɖ+q0Q*/CU&^ܱ-59q#} &68Zлt3WܙohmP-Y?{LX8&M f%ONɔͯbPwWI;w(*hRdPLK?7IB|_O&arvQ$kװdlLc/["R#lc,VݝHbz{8T78Q1JQ3=Hi M6Rm|f Z*(Nb&u@-jI&&ho\[Jx2 ^C7^:YGcFO}ݺ<~2P"Sމ!G&SIu8Q(BRHYB,-ox3CTX| ѳ^^^)5=%Gpb{fy](>(:;ubj:kGG:OggSD=59 % . :>]?TxLsԘMPX+q!^#a|S.㵕Q%(snl]N-{~w80BbqX9v)l}żbId=!t,W79 Rfo<ӗUe׫p0[(w"u8ބ2h"hr)=|P"r:I"q蹨R""u޽ ڇ.Asψnn7'e~ u0B c@2 8>h_6zyuY|$ԕŅS n'E0!>~v3ayz9Mk 15W7#eiw81hbFhzmp4<* œ"N|oSjlxMqIߖ'SΗWKw:&U0Tn;bb Pe 8P<0P8ӹj xz}]beets-1.4.6/test/rsrc/empty.wma0000644000076500000240000005607013025125202017364 0ustar asampsonstaff000000000000000&ufblܫG SehQ>ޱtʛ  _. Sebӫ Se4ů[wHgDL#DIANEpT3&ufbl"@^Pܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104$WM/BeatsPerMinute6ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8a5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8a1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8aTRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8aWindows Media Audio V8a6&ufbl2K ] @ 6ӂ}m ;J$$$ ?'qU11%4` -I$IPc ԠIRUI10U5RIIjR` $Ę@JTBTI1,E4 ҒI:bf1&C` QR@H@1V` H :P@KI^o'@*@7C@ L $ %--i!THHEPjH@&B4JJ"0ҕ Sƶ|ALU2k?//߿,B(q1B~-Kl!+tH8 Hh-ߥ)B -P_PVCQn"v0{eIBVi~P6A \@A KTpk,RѤ vFPB(^o㧏yZUP(\KYFP}oM?_Rm>  p?,*}Kx)~yKoұ~(Κ\ysXߕ?-[}BSJ8lj9>q_ͧE'~ķH_%iM.]KV+s~_`;t㎟ߛc[O[t-A ?|T>Br-[>t~'AЄ%lE(?0i )޷Cn@4qR#)Ok(ZvϿ:`022{Qx6>T%[_y{7폷e)_y 즚|NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN @%,V,XU2 dDZb`D ZĒXpR/I &`c XH1$)1JI,2"MK A2B`*!&JADՀ$i E&(h+D!$``:h&F꤈l Fa؈ECv[0@00&)EdEBMCQ 3P 4R EB( JKBPPB0@[M/ۨJR`TOTQcM-ZZh n-:\kaS~O~p; yHZ ̡q!Ƹ8风G/ nkHH_pZݔSğߥmxi_;t8nO8}~itS[``pDsBP_ P=Bc'(cy'opc^vkL_KWQƷW ۟V}F =_SB4,(~o*Ǭl-vǾqNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ] @_mVb&6 H7#@&RI($L&@(ӣ" h&gQ0c "` 2QyJB%,D&`ȦS 2@0i jH*J 1 (i $ Ja0BJI%$X HD XB-#@!JoV ]G@c-TN I1QBJ L$i@J- .QB@~HDB%4ST(@JIJءXDS+e!/~BÊ#rQN}Jjۿ۟҇ϑ@vx-4>~m-Sm%J-U_q)4&)ZVnH~?V?'(O@}ȻgxۭVh y5aNڜ!m+(KϒHCzVB?YHC4Ombߴ5O>S*P|kv%/α8ͭS\tKq|B7KWOy#}_=Zo(ozCָ#$֖Oyr[xiQ[!^t{~lKupeQNP%θ NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN @-T߽Gé=4C $DΠ&I:MO`Ĵ6 FuaTAc A@a@-2E@i0,P I$h:I-/B(,ᴤcRL (HJMDi@IQ6 KLPLJRH4@YP 5" $ iJH`$fb`liC`)翝n6 5@L h$Ħ`+:HQE dQAE-|4h B(BA@){kW 8cCeEx o)1/t%cGv5oK(^NNNNNNNNNNNNNNNNNNNNNNNNNNNN2 @baED%iBLA2d 5fv  Kd @ :&*!!b"f@.%Z JR a,CP! %3U RDI%Q$HA@0(XVP 0,"*@0RKLTHU% (؈ þLUkI ͬnTbe.Ts!Dư Z1`  Ja$ k%Ԝ~5|m4;jΗM)$iH>R"“Y E @[)( %552JQ 4!(?Ї&*>vkO,)BRBi i`JROED(?K||PpXD`>'N}o?O+T- &e?GR OpUSo-(+\h}O〟rr7AFP">e][,in)V7[~E[GQo|CVaQiG]>U%eo@To)[[|'%>V _`J+{tԄ@x6ZmOͭ%k.kZJ|+SoDqc ßy2橮+Vֲ[/YO|iTOӔSՄinݔqۇnri6߇mlv{q/[i\kYB?4ҵiBpI?~n56ʺ펥Q/^iRZ`%|[u=|#߻~R>J[-qUo5ݞΗ`+[߾umN;}QoͻNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ]s  @AP{ges@ Z5J ĂƄ' H5 &*N䂂T`b J BRM D/HI*%t."A2iT"DH [Q% H-RL @hD pB6@"e KPI ,DB`D@ =یAUÕdʆb e@DHiBI)n,$JDX @BL4%)&KJ%lH!mىva4I/ nVЊ}HC-8PͥƄЌQEK? k :-)X>Z[KyOQKzRE8?h2A+tVOTքmߢ;zXŠRE->E鷈}BP;~%7\=_k[sဩ#=u4e."Pr}43pw/+{nq-)\|x%ּ/;֋e9Cޭ?nuÔ۰?~L/C`.7ϐkgW{~Gb/\JpԭV[EOMpe6[nk=R"?_ {->te`/7{pNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN  @O3 -9X᫣ZA):ՒT%A$d!LAD IJaIJl)% BHL Q%0&%0I HXRQ I4RDCe IiP&S5$P* e! A;DTMPIh"Pf2 XVy@$`4s0WX[$HbgD$Ul*$RDBQBhA4?& a/QAD"-x\R*IXR툤RA|_ r qjJGJhԿ(daGHmBB/MZ}VܷBh~IZ)(M M [ύ A8(Z[TNX n?/ݹPiM?) v*B/ݽhXG愾 Ihwz/7Ǡۿvq[[7q([ 0Th~G%Z-i&l>[o<87͢V׀?G"Ze$7pq>q g`?W2г8=7oŀEc汣ͺ}q  Vq Xߞ Zh'j 2 NNNNNNNNNNN  @1 E.wa(, B utL(fcD`.%I-H$HIS&$RI,)ZR6Ċ`BԔ)Am)HF(C&MH u(T$tH )A((Q5S'ɁAQI3q`$QDaS57$-%F,&o2hho`2I4aI a S蔬IJj!4  $-B  Jj(FOPV/ɷP_'ȡij.$:)G[H(Z-:RBl[nAJx%(+@|)+]hZK%/e?Z vPnV+|h񅺀[X i-U~$~T~c?iSPCN2;`>R-"۩ts&ܶ)Z[3X%q/MOV9E4/gR(l|[.FU)j_ܷhvPWQouX ӑ.Pd~-6vPt~u'[-mp.KOMxA')E6/)?#nv_r?\N}x*?U p^-H}XnIɞ(e`NNNNNNNNNNNNNNNNNNNNNN @%,HA~!f%֝z Xd $uՀP-P  1 J !5! %LI 3 DCja$ `T$  0  Y(LaP2-;*j@ ɖU2PvT@؀&bhh I *Z[- ĕ6I6]XDYC S PxPP MRHTڈ hJĤP쾠l-?B)لIoˠM (&2>-%+eb?㧊J(/߭/]|r) FR%`zY4')-"xInAZZAl>}Cn[-􂴰?R.7_?+e 8hU>G>_걖gVGT`'ߖSOS沅?e9A|!IndwCP@Jn!,-g__ [\cq`*-2mR8K揚['3O~|"Qqg_~OTV6 (B{ y?J[+{_-V7ܜ6>Jk+O/ŀ/ il,~,Fx())Ace.Z"NNNNNNNNNNNNNNNNNNNNNNNNN ]- I@_I44{`RALTĨɀRK@*L)H-` Lfgb*T 1 D)0IEC%0"0e@v%v+"T$9J*TdDJBh%au5)(5!" l"P[ %M! I(8AIh5jBN   [T4oaLlNκ0f$3)TłHv4B*0DJ)(Ja"C@(`)!4iJA| ot,_JI|RSCҁA)|)RB_Jn[in(n:V>|6SL)ED Vz4?|Gnsϩ[GJRNR] _Ʒ~J4>|u (#)oEoV2*4 :RR$~ے^kq;ZvKKOT[.|h~~qQX߯nUq,͡mSNSQW (?*ǡo=ߺ2`nNZ[g\HJߚ}V2ж_-߭" žv +rF=m!5ÀCm:?oǀNJUׅ-;c)G)۳ڪm4x +v.*?V=EJ|b~tNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNw@5Y J{l*'X 42 I l(C8 AtMY$R3(IKNɨA: ,LLAi(&BA20PU( LaI@$B+2 %(*U$&I5RD%%!BP!'R % 8S!AAAfXPFFP":˚9ʁ&H*b iB@HBH H@4Uv@j0@@%hPI(ثI$DGª?[&7d4%nފ* `*_ж8ihqin lZ->E(㦈HJx[SJ$q->Oo}KKE/mO>4SMJeHN]F ?5sz_oi B c}pP+e#)ܴ: Mc[iFRGXO7Ɨ[ ZBmo5?'oOp?6lzkR們Y[\Dt&e.Akt"x/.?\I-[P}Io>LH/U\5`<xB~vǂo݀%tN!_{eZak=HN@b#sgu"Hڛ]&;b&#`,nh$իI"%2C̘ p5; I&jhh@I-50 HJ" EMHKe)(4ՉTH"dVDK B@NAfUdRNdPB`ȄĈI %&\K"ڰ7 TlXKHcR%Q1 !&bZL@Y2HJ*! P XPL>J"" 4ER( >44lВĔR iK?  B2KRh[JV(~PP~}oD(Rx|%ic\ XGRFD[v[R-BQGij_~O)k}?9M5Qo~zG< mзotQMv(IZZH0(}~ ,XvyJGi?<h_6yFkk~$;/`{w~U\[\5 -, | _Ζ-(Zݑ- ~b?;uEaSKKK3H;}pC[罺ܵ斖>4>t?*xKO:~wo·eKυ+_ZNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN@*|W!eB( +lID5"2AVTH10dACDKphê[$N ڀI*BI" FD[ (a PH/$2PAI(A@! [Q`L @PDU I єI $A2CZL2 I2bRZ4nuB`l)"[@&!bL8`PH1NR:K@ Th%LE, JP]eZ|v"q>?TZv84~ /֟۷|\/ X*(J!Ko B4?\A\?ȠҔ[A߭OR+ Tб~T[_tEGBm}n~x [|$V5#Şݶ -Q\ ZZK5n'Վe)[չoG|P\ۨ?дnݞqдQ>^j"9j:~ ?6ݿ$'Ƿ[hk=4W<ӈT[=Uݹ18$Zq _ zاͭҊkVnQy_ pL'~te/݊2NNNNNNNNNNNNNNN ]@tǀ +W7 ;!@$I*bl.dDP"Ka$!0XȐX I" RQ)&D J V `R(EILVx1TAL A&BebDP@T$dB!"V0:5Du"J$CH@f$LImELG`,h,fI!fj2 B ÒP@t*Z)4&Z&PV)JjPcB >& ԗd-Е)~V BP?r5J~SQl|VT#;4-@'C괥!&h~T}~oRƚ8ҋzݸ_,S[~ֲ8쥨ͿK('+)OZt4%# AVoygX6즜"T5qKJ?߿̿[+$~k_)qe rt/ۥootnXÞhp>tht^n0B+?F(ߚ.8xMcyO@NNNNNNNNNNNNNNNNNNNNNNNNNNNNNN1@*ѳ.cՍ gm ʳ53($2A!0!6ȆEI  SwT$,DTKԊ0 )@! hM@ӄ pu "%5@HH˂BPmsI )HAЈuR@:%AJp H%"MdaD`$a[F64\᭹cPeI$-iI (:BABBP/#dЄh CJh*,@|A)M)B"M.Y\KA# 4%VCOޕ[E+(["޵Eq[(۲ xܕ_i}Z|?%z?hM N~ic{Rjn??5+m c-$t`.HZV-!."e%kB ˍ/+nJ)}nqt% „;n7 ƕpM (C4& <`R\CJmK҇+%?|EKv R O@)"r7Jh|M۸lj-P!!h`*rG%12|jh}oZ⦗Ͽvy}x꜠gPYFXIYGxҶGM ?i0K$[qkMÍ/>5r-Վzݾm (X6 7c0o_ΗK2߀,ZƊ([F{-ح1E=$tm>{zE9ۥrt\"x߾,@}luݔqFQL[ZHNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN]/@ q~&mk[x`.?km+x (}->_|*V=+/٥_ωV['tє?3O瀏YOԿ|_k rxAv_H-,i R /eO iKM4ۍ(A AKB-|$#Vʊj?hJRVݲK% k@PRA+iўɥq)V} (~~c=ȢX'_>j+O 8 (e/QCҵZfw,)}E5\4/ yGBOo~Ye$ICI?(RaLɦ̡IS9ȓ4)ɚ Nr!(RyBxJDP999e‡=0L%3!(S&RaJI(S'0' 3L)̧ d Pg02PLBPɔg>i'Bee I8RPȁNd NLIL(Rg S% &JLICBPJhPBϟ%%% JaBr$He$B!?"~s3L))(I Rg(g03sȁB4ffLLOI9C2|%P )%2PJ (pC93330hsC ,.)=! NRg &rPIfg0'a3Rfe B!3)P@J8Pɠ|(PNPJB&Pae%32~dBag2|s@0S33' ”&D'0M (P9C3œ)")!2!(RP9d!†g(Re'JB!HS))L)8XDe0 )2S')2B2fs9C93% )2rPL?)2 'L'sdsJIL)2!3) RLaILaIɲR&sH L̔L̡)(D39%'" 2S%% 礡IL3?(S0РRg̔2P)ɔ@DM033&™'J)œ(2eNP)g%J2!IȄY&t&S$,醅'% IC% 8@P!9BRP$'3% dp&NfaIg2eBO)С"9&i)"(fg?C'™9@̡C8D2pfs9$I2s rP<“9))"xL̡.IRRr  JH|g0e LI4L,"8r))3I !3@$L"BBIBRBae2S2O (M aNfe% r)%$)()(d"y2†'L\Re'(pfS fP)(hdCfRdBe ’)4(g0d)P(RrR!B’$(JPIB)9)(r'a%Ô̔32P)3ϟ(y2pÜ)I<99)œ(S3)S&D&D3I)9ID@!339a dfa!s LB NL<)8g?PLJP4(JBa&PL@ fM L38P&RSg=De ɔI30NJJHD)9Y@2P)(L9zM0 ,Ϥ(X&xD4 J)9@e'I J2|(pNP!’PM JfdC'30!aÙ8D8D)(D)R N)>rL@Ls0aIBLBhD $IϜ) PŸ 9Rg33338S3'333$C'%'IP@dC338D4(Y3% 2D(JJBP&fffp pe J2P@%(OP̓J)̤Id3 fg3LLɦ2t D32S&C9L(D% I9C%'(rPe&S'BRs:)Xe&re!JaLILg9CC'2S dyL<2hfp3It(d ̔'C &RM% s)2(< 4L̡`A)%8r!)2|Brdd$ B Jyy(dBLK32P)%f)))(RLBe&y˜e&)2))(sza@! 0 BIB" XAP2yL33 Re BL))8PLda2P% C3d'IPRP0Jg(y2!<2p$B2fJfP̔,)"&fsCB̑0PNIa)Re3S dy(RIr8S39Rr‡É&P JfPL"Bϙ&DD8e&% H)I pg PfPÔ9ILIB)&zBO!))(Y2Ȅfr(L2 Rg Ȅ2M <2P̖P,2 fPsPeJ˅8RP̤P(fa,CS 3(s)(JfRfK0%)™2s&PI LC@JaNIB|aСIS!LD2RpaIɦI<,(sȆJ}Y2JP2D)24)(S' ="3̑ D"(dФ2…&e I9(p CY@e9IdJ39I@#0L̜ 3g(Rf"asCP,94fNRP)3(O32RJa҄% zdPLfs9)BdBNz"= !'(Pt(XpJ@ @Ɛ gœ(Y%BP% L(r2J9C9Ig(RPI@?@Ng)')hdgM!)RPBe'L4@BzL9aL̡2p%33BPIB)™4CaO9C Nd<>dffJ3LNaЙ9M!šHICɦHJdfg$B:Rg)29By)C)YРDIx !NC$C9= IJ%(r(e&PNg9Is9C3d2% dIIINfK33339B2Xr…2rd@ÔL4(S2s s3339!(Re 9IL)3:9 =>e$)3C38P8XD $NPС̤C9 ɔ?С"s&P Nɔ) t38D2RfS%32D2S9I)B!C)3Pe$IOBLr!C9y4g30LΘy )g3) h4̔"C"I)&g3)(RP9H3Ô42Pa&JI,"CCfJaId2I ffa†LHpJRB3)$!(fRe3392e$@…'% J!HR:aNe LI3  :4 P@S8SPpPJI2RPfxЧ'(P 8DaI8PJB~fa !BS% ')YpS0̤fe LhS9&D)!Jd)@))L)Lt(rfJ%%% LINPr9RaC9̝ I)Xf fsP)C(J|8RD%(yxn@AG]s~Ý\RqsyFs$;sA <9?AT݀]n@;09F1t0O@c@^vTFhR5O L('4RKeu U|RR.؊! DRJBR)!'&J(Z @,T3DV*3AĨS'l*S["(+9BPP%1"!ANw#)"I R.P!S"*rI(BS  $D) JS S]r#r'xBS  r!CcT;#O o}M&wҊ?'xvpbeets-1.4.6/test/rsrc/empty.ape0000644000076500000240000003215413025125202017342 0ustar asampsonstaff00000000000000MAC 430' 0o|qDDP('+6Dgzn`CC-]XUڽΈ̹Ra%0'lO[&(H)"i%6aMJO1F@D8qlpE ]$ wvy0\v7I ̇m7.uM$#x|Sgb;<|:}|{H?k"{w "vt`OM".+."q=f; F&8e*3+GW3rKn>+{ &6u_zmeS)K+2k 8ݑc3L퍎vWܸёYຓ@1"kr}qIu [#wfC?sb [XA =\ yvJeׂf$0y}: ح[rz>Qx( ~ 95QK&)9ja`A8ז ";bR3 DW v)ϳS̲A}ꂇT֋DcXHwWE>':ЁȜqqoie4># -&}#}F5p㏱*"(taœ")B -,AEexjچuRHn_n(g'v9H^%!?"X@6כRy=ς8/3VM»8u!rP@ qSz~jwxTvT.'>Қ?`XT: A?1\@dF|ɶD6 X4,MR#&eEvS+vWsJ˞x̜{[>pQ t) `mnX^x-Ǯ=捴|trβ!ɰv ^T Nk19#i[@Sɑ&v_.oUtxLڽH/mfɋ.y 8KQEp:7'Nn 8lho=}9P}V{snsXiVӔEIH=҃sK{cyOgP\3yy8#;t2[m y0Qv{D3@ hlXqK!!ֹڇ%5P]|#i2 EL 7X)E[[Z$ѾƺŖKWÜeY#,ބ.e<[؆,I#ҷ5B 6)MP12] B'i"-,XQ3_@Y5ΌDdm P Ue*z =,c-gC6a1uc,a$zܤ VR堓$}~9EZ1H.1.j{N]aL.7 DQl89ggvDm^żDUV.OXEVſ>qzQ}(#MoN,-~bc97 W_ 'sNYO j]feu6݃3N+1g`j;Vn6pf_OBdkpuq\HMkbY2}Pe'qCG[J7P~O k3/A ]%h@ ozWl0ur[KŸ}ng@)E /׿A썚Fz ӓ͸B@[ˠ%!~n8oH#7=G q`KaHPX{z|\+|+Nn{W*2us1\$JE3۠(Ql \V 6춆R-]SM}.Z+)Bw{]׼B􎠣[~uk*[RȃK^W.yx^S`X T9 } ,f%czCd/oXfg/Lb zI9z,ik߸m2H:șU36V-!S)Tt ߊq4zM=03mc(f4E9Ji#$ր.fOۚaFaoFV68qc:u[P_eff$;R=!MƗS(bLIoW>铳ӶS{I=C.'?^HutqYn1o2z.:L?mtx) L\󬍩Kap\!)P#?#X58*l&#p(mL3BAV$\^G4yް"QsaR%VwPRou͑?è| j FZ UwLetޡ\]nf y*e6.PZ !i{Shgeyy& 6#F(;`;SX~N>^j߽#䬭V%ކd8tLF:n_ xrr V;!H٥IA_\dYtI˾z!%,"{( iůIemnqɅwX!qm~".5088A&kypg!O 5/#FvѾAaI$IM$V]t \^*BX/e`{h L5V4hE@Eh2H1C|gg߮ :O B90XwTl}$4]lFެ0eSb+T#A陡:^hQUfB\m\MrY^`AEQZ̪8#:V:gJoC0~}t4$i@b8epڵE+byb.v0H9('%qN){b?,-n xk ?UTʰ4vlP{%݋vuݦBnCjI4CLJXk?fCjp߿hq50RHU*-m:|5;Q&PrnLU5yI,OY*)XLN5 8LV6W 9Q R#+ KXg.n22KؗT݄F0XC,S_-, 3"mwrkBRrK.yV:; * 0kV!B Ԋ쿴祽Dbht+ǵ!dt'UNaRV3Jq(<xm%cw8Ҟ gVlz/Zφ{ꇕG@vpذUML-wm<[ iA8\0"k87ʞ?? Z"B'ry/ G)ʕHjd/EL48GV^Lr1P=g(F݇VY4dSLZ@,چqsUwOB؍i$B(ܻ/Sd3{YA S'X*=eB3;n1 rrG۶ͮT <ᗉysWC=Ijyg!=m\2}bD!b}W=Xj:ׅ*hA81 #$cWf'F]iJoYd md B~!=9- Ե yMMflpu敹6]k6%{둞: ųŤ`!T̺p=ygx(qZn@Eaec6hoU[u"}a䝓>:)߻^]Q#yC9ŅGDZj&⥎A=䲽umq>jJF!Z{^f! o:K\tJ8X@ j 4;07:M!3Vdܱ MppT]?lrV/b ֆ68.'[$<iﮂ授4 ͮ1܏.~pd>?վw8PLb\8Qmf\e+ͮ ?d)d󎍥WIr$YB8v@ؠ06f%.+1_ʣ^ <p[Gpw ٴEq6۪˴כ_-/k qNKLDu c_DM!wsV -U؟Aqg@Ukwp~SE9tݲdut}*&YezSE=|ç ¦ ;<_oL܍|'Y=2yn}VRtO A0g:K{]I}/jҏah_t I$5u&.q2^wdMMO1-)zuX<]I " ,/g |>u9V/5k-![& F {qwh y3]P\5NDٞocASVm>[}kE:"T ?W 'a3QjuxgnrjMQyϝhVW&E)ȫJ.E| 0\=e7sZ$ 5ޫw?\."<$)pRw6;n\+@iRxã7.V~傦Bmk4@%?-f$1Fb㕲PeCS r:J(qf9䈶L iNS()%wyyS($("-H2f>V$xbsh%uIm+'Nd럈?p˗8}pb謏̈Pī(KL.g kHa+x31+O̅ar7& vdVO6!3B4,F|sķw@*B`_]q>/ è"NiK&=.h-J;Rv;YDpT>Jm؄3aS J+`=*1o05Y1k2e:Ŏ5Ir W11( X=~`5;B,d5B KZO>S.T %*źoO4yJ8"B`G YS# UI 1ny)~Nj7Zg\|E 0nSōUP Rf=òUE!_?~ +ΰ[!W;#%tqe*ʎL Y9WTd +vs~V!@ݠ;WlUf6Gy %rLb@-,y MxR 伝o^dNfm%1[K;.B,:^&>/!3|֫SޗyTF?0E22>jQu?x~$[ӫ8{w!?Ia)!@ఽ@ jTz\+Uê߼TB+.%'Z oMO8b(g.EcGZBWx50'##9]!@&ݢFLF*ߕa6`1\jܦ#5~Z ;s؟o95 :!$`mJҨC+H;q !LOb5O~ 6DokCZ@ȶDtx 4 \_7tm9&DtܕhI >kFLqWPBX`#A#Xn$u1Y u|E)xH.-\t<7dƮ(T%(͂%ꖨǨ-7=aߒֱ@?T}pp(RW\T@ ;)BhR9߮㫴?}_l &Yod*ʿdD ]ha >yTMp2e q8/ߟj ǫɿϠk Y m].}`4ʐ1Sy;,ɯt r!M1:/i)XaX 4R(xIfQ3s/5',,Dzu[rwni T9Xs \ؠ=1 "co/ㅅ k?'-HWk uk1 0f-Д5gcq;Z5\Y5*[x RY b_(%La h5(yn>D-/H{uef/TCIǷR+FlĻU8fBa<9 UvnɆ`g K1ND߷ L? :YS3o 2b~g (/y'@I%JZ3 llY= ^!uaeA(34ܵSa^rrmgG`+n]8Ôī_` Hpn].2D{D|> ~mWxI#gz;*~ zi96@<ƺI&o /ޅt,=?vsܥ @R)e~3chiI˝99|F{C2V1SYe@ 9_,  9%b#2`O\f+e^Ϧ_~ag;i :3mm {w!dP-Sg0[{efO s@_!umhM}!+&6YH CD{_Ч0T7m`)C/4/a u}XcF SGÀbzEsڂXs ّ:d%NfV掶CXI +{A:3\4&0]-kM$8j]at /h& (ތj MΜ+Ϲ8,'CY0neC"ūB1N>R.p1)LӅWRWZl݌ &+U&ګu~X"c"l@u0' SXZ\%trn5*\Qz=@%|.TO"ظdGE"sXY!x}MAdL\ͽleQMY^))*}q  0epwv IW,2_FZl:%,,!9yӔ)[~CZD,M|N_J[1hn-^rs̫ymLBPyk#Q{xg[ Ha wwG՟^;bWnjkv}I*shC #9!uk{jk+Q@ڷ]+mFnWR; @aN#4HWJU?^g=vy*g^M)\ZW$4>dLN[>S3* G_xV#B)eTH N@k%GI,eU]zQ.,-"~}JW}TE> MB#D?\dO=!btph'rPX.vlKRW  =!'.|nXNkb aWIvȇ'HT'@if"rٕz:F{)Mk-!M.hq'fz0@YmVgwT x!G!'Gt g֏2K.ֵmޓkR1Uc& hFRnY =  '#UdVir N^1|QZ=O7Zҍ}wC[fB> R*( ?Όk;Lf*nSSysfiT*2ي&;+ȕ YScY4Fڙs(M4qȬ4EXu(fJ8vh dznE"p z3Ӣzr|RĄ @ 7#`'o׫bߗw.L7e@K)um.W[MFvR6"mvbfQYGuJd~2[qfBI+q ѠYl;4p2!fKCIY%>#޾?ޯy,bAY ,+ %*?XRĖ  =aH+-zvcF1ސV?B'ȋo$R[ dt# 4 KY$q:4s#M= gdLt2f;^c]L[;SD~"i]1N^ w/jO)MFW+Mȝ6_+io̊% ) $'\W'56?'p&ݙqRİɯ =9X< =}awLNgfV XuQNkJ$&gȩsЪȄ\?M?XCe+0Yli4,b`=@DW-oUIT;fʷj Ւ:tх s5K[$?#D ?x́-5 d_eS9$, RJͳ = 6!'a?`I]Kief;i#R /;:FS}Ls W=hc 4\j" E A8. `& r 句?򇬿-B}VATQ>Zv_WSQW3P ;N~U~xJn,!h,Ԛ dj-i DH\R  5A J @EV"keRw)Ьtz.E!KnvzW[u7NJ("5@aL=#Š@R䉕twiUGQlwE#GPN#_S̞?WcNB%n_Xa0ejR 5`!Ii̗31[-y?nK6▪{yN~;sU/ͯkMl0>P`P8XLT,aLqYu>yrvJKu|1Ef0 rl즽ե6\on-dfYi *'m9!D##98R e -A &"1Y2 iR+E'  NOJ(aؙd%y3;%frg,XPK1S&!Ib]aIW=˻K=\ilIus?W;e8gUYCcIˮ)MVX`L3b3E$TVRLe@w</wMr:jdZu@WWG=A .yX<,WQ`f[:+gdr2v02+#`PH"(M4D6ϋ2Jݲn0^gZ"ClVԪEy~a5TWdjja#TDL@l\ҶĀRKq@= vYq'j%#+ўl2Ř$h^ ёPI/^S4?̛3ѱ1WO\+u :!HN /s?@>;)*_jdJ$ǙYa L ʃ 8٫c} QmQXTQ7(Bm2 KR @= q6 HlAl|Tm(-r meȧ9YcwoŎawV)dK&Θxھ ]\)q]LBUReN~~WߞR;'Z\x,KMR }@ ;A6/ǘ9sIB Eu;#XVD8TuGtF[.+0lQAj$?~(@MNV' -6pP\Т/?B%Z*Jta.fԒp-s.9?K=K^gϬ3 :--: `@R %` 763 64"()芢C$3X-,t9Xƻ4eSUf?YjM %\ȸm `dND V€W5k3SH ~X);ZԟQ{- JIxeW+{Lo&I1ID`5h6 A1@RK6H$D~^?uEeƠQ : R yj6`&_@;vDC0u",gC #6|5n7Οgrn47]+ނaCO]:HOX0>he,(Tn54uZ<gZs.Mzffvtb[6TzjY: D*q8R =@ 5Ak6a(at$;^Z,Hں H(Dz3EЮe3;&Q9ou%n Cd=+_TX%8< bHG 01C Q53{sɖ~|A4͏˱S:#rm%di?2c_tיNe [|LL,%8&Y7l:1R @A}?ͨVln48T,EYN57C5Tlɂn{ Ӏ=guYeP~U (iq &pH@)HpB%7)M>-;ck9r/ą b]CA$0LYpodV{8tQ 1#moHtYerRDL(R = Ә]4zbf|0 C>HIV{װ2l)M1 R3Te芊 U,1$ ` \u"桅?m8T),K_^UQ3H>2%~#~:t(s8ע 2F `wk(RK@w6`ii3m EM_nLDWcI3 G!)\yE:p&CmMZ7| &ٞL*("bL$"]!*Q S¬eFg/4&g 蔿J1 T1K;n\Ěc(0N,ss)% S*DsubF䉜\(ćVZaB ,`vJ @ҷgs{<&Nr=yƾv8X;GӷʮQJ c1P&4R }@ } i85ad?m?2ZfyQ)%[+lgV#^*ۿ/!rT}'.U惒mQ\V`}fQNאL^Cw `Vq%K yC=Z$ԋ:t^\Ū*鋃 ,m' 2F R u@>ɂhLR)\S&7M"އk: a!%-R I ;IX6a p@,%w*LS3209TĞs)ʦEtT΋SVf{nF:B۶'bZ(H ̔ju[kie aez<Z2UE3-Pp;1T*Iv:5զWbKFmK^#.RJ U?t<(b+=5jjB5t{Jb" $/7ۭ_f˝{{9|8>0 `eKkI?róm)2<ϕs9!YybM **:mU[ݙ:daC (9 ?RN!&':8c3sA'4{2Kc>ȅXe43B3noJSc$4FWU"M3ž*j(Cp rݫ d<ͱD8L. Y={?]T.s]um9ڝ *氛_^_:V[4a /*B R m ٔ !cD󁑔@*ⲟK4#p[kUIG$7)g$lˡ?wg3vAܪ@3F$r@ٲODd9ƒJ7"5טWBMs[Ae[CPА )˴rSR6*O3z1ˎ^Y;gN6azճ$ mctt3^?wG/>}6WMH(B1#F#Ύ6?LV@HnR2dg[z݈=),Ľ>elh꩚8iAI F<$ p„(r4IR - 7Ƀ`!h( L t?ÓVs5BU)8&H?p ۫ u/5am>;3nY#KC-RziH$ e8fB-vtyfD =BszIba\T]_??;{~uڲcIUP%C+08W--R ] { ӤrF,'=AC'mP[Tہ&cn5-~g_3iZb4 gtH,2!ipM4 4̌ח˙HՖYZPɕ'#Y&!JG#,5k,Fϲ_˖( NFf\R Y !hrnLAME3.97R @oِ`hR] t4beets-1.4.6/test/rsrc/archive.rar0000644000076500000240000000446513025125202017650 0ustar asampsonstaff00000000000000Rar!ϐs lt * xXD3full.mpcXȍ<쐒(&$6Bk ]]R7hT6UsMt7j7E uu`u X5RFۄw3w7yyI{Aj.ލ3jT}۬AYXXU{)!Yn`./٧CWiq'b;}i5 o˶`:LM׻^f%j=EUx/ܔw;IU*GG~]eҊ-V1d)I^pEu~ 2̌} SQf'n\hI2iڜjQF):#x Iw{ 3+ /0[-ˇD!-!,O"яMDjI g}䏋()0;LJ׵qxD Tyq8]X;˳܈dlóZI2bM2ܴ`x,,dܖY,{qy{^}z786KLzWΜ2A&5E37 jNpP7]&T&H?Pz^uH&L8'3tq_~^P"AD]~=r+sS* LG= Osfqhgyզ3D\ηrZG+_7a\, of/Zrt4|Q $}aOyT[J8B}řLJrP <~TzگEb&aD -R^؈y|z[21臻X,j&@x80;ވle%B 콧ɭk;Zܿ>㛻^[?sS:L϶<\l@B5 }HppPR%3Hi6;7y$ۓSٺnڅA ju;B$^|BjdQ=Zas03Ŀs$XWXa :SC!7)zQSŧ1ŖWs";+W솝hnI_Wӎd/YE3\zl6o/yND9΋-ؗ͹j6wmEWG(>d Tȴ*E*}nRɟ+HmIpVA ӑ[}zq'? DaDW9`X(ȾlP"$dqSr8@XSDʈW* @QK9%3oG^@ "tŊWYin B&m֫ID渆`gG%ѪAlb@^aZvǩ)+N*5Jf@]*t&fnT։_zl]jJKsib}Ȭt5됙! g=f8}֓d4} !oZ~er{OdAJy8oWj[.ԬL1sk׻TCͫj<).KY5t)*Kcd{l P%_A[,vp Ovӗ} }t gά\YvXsLKH%$&b< O^a9&/hR}J^|Dk-mCq.OG\&^iTeGC3V筝Ҕ@,#iiBظ5sv!EpD(l!iSdW򙺓.]2:[qtƨBRwk+g_4J+'CU'|e.\B!1AQ"aq 2#BRbr$03ScCT?;h$0EA)5ޞ\IZa?DkM`RŚ'>5-.#6A-yD F#S6xPc^<`6ql˖XCj]al{!T&sҹyn^? {?臜=cj\ВPң֓3t 4,>ut? &BqgqNTl-쐀}a&Izct$ZrE˺f$GRByB*wFq BܟTH9Z _x ?vG.)[6ARa3EEڇPDGqtp7ǮJO?8iE|kju(;:Vɂxc1bxqvs|lKDUsZǢ6x34Hcf7X~p(%]Kq#dg7>3jlkلVE_M A)~D&`AYBzȺ]) -T {e PJyƲMCgHY^w8nƌ$)xηwQi 8k^ '\RKPĻ78{!_?4v8rn[6i=vPop#8 =g AϜ*7cAD8v74s9 DE ^x9}K{_(m>o8REHH1@IJ/_Jt$bee}ưf&xKg'藟$JN>jW.Vdׇ@ɘ=aݺ/ LQٌ_*K)Q,(57A?YAc`9On6^wsVJ0bHq7 d3}-!̄VIGP>Ys2;@8d9Fô砚O(6_,!6cH5*億o#}7L!@bSS(I#8(uE啗@/;J50yKF;ٜa|&䂝m=Ç@H"Rvd6{tO8nE/,FzpZ+iی.xu/t‘t5qh{zx>8򅂐aF߲ Jv%IK)G ;8h֕H=S.HcZp=c/, pq E,>朠9qhLhF|=`Y`KBn, 0MT#Oz[}JkjvƵS+ ƻ~bc1Ma@=7y-Dr) MEXȻb&*Í nsE'e3AR@xEXn,`QPǔ B(di4h ;GhũOZWLY$әwZblLyCBՋc nQ^N/ ky-M#P,xLoF6'X_lt_pqaϞ@cH9 v#nw@Si[N.d +1jǜ!}UOa`@2r}n lL&ghuUM!S[!IJJT}t5R缾eجQp0yaA"$"Qv~_xOӬ$b?նGCSC C#`{[]dLP4^AEĸ|!9J-SNqD+"Ǐv72EQ $\8(*6GrՋ#%JiJlă}0tkn=BJT[:q/#J}̆L1}UABRP.m']ҕ&W6Ud c]޶ XiIHmk-lpP*WV'3,ɴMo=1c_MYKN(RyECil8m"bҸ -+x$|g342 UW](7H=S٬z{oʑb Reld];z='/~u6 v'vݱuW,ؑ>Z -4=&7U!VJhЫ=[Mdp+-gW!#0伶Zﴵq–oZy8J {g A]# rY+V *TAs΂,K9', ov&MW\$Д. hؒ"p&̲=Xŝmkm* 8b@C$!v&pl֏qUsฉmuUNFdp#1ס g5K]h` 9G!xU$OYZKR( wj/B-SuiqIRW}*qB E%!^4C6eR;#gn&AY*ѡV@uwfNMj, Xa6+WXd>?U:s7Lg0mImV£:JKHȼ+E ڦb-?gU M8Yi D&nGPx(vZu+r-R7!O{Q,Ԍķߎch,lC=?j, {kYQ}Ǣ&~0{#q9 iJ:X-$N.[^X~?ct)`t)XD![]63?yG?4 {-q+=%D^C'!1AQaq ?0F%' k^EA_,CGVuu<㐇<`ZS[*a9 97+] 5 D3$$^ 12!n_9PFg ) :"̉aH@zȱu@h]B02JM q (>lIeC4A71|}4م[19YdBȚ!-rp҉ -]( @ U7*b$/}~#hWOZ";>:r4r&DAHd&z3 "*(HC0~玌.IQv3)ښ7å&1 4+InyOQǮF900JAh%kXĆ@V4>FPX`J!U"X:ga,Q+ZdBjp#ʦ&9f%Ҕ#Β;sљAʆLDʢT}8qjlM[CZmP @Y6$MxQEL$HW9$"@J1f#rG/F@^Ǯ2p"6d өO_hZf8фYeρ{dYz$dۅ>yVaAg(f%$irztYF91L$lxfȋ.n!8%1*oN22N/DH%tdόUXJ)rd)d$ UkUY9A|_#W"FyN}pLRF9Hky)s:3LA>31n1@yBkJ,:y ĵK'W1"(m gM?8ey/[TWMYQ=d 5!7 $UkT`| o)/0, q02jGSWD;y.}WY1:@uVňY]&~J~_0^UI&)H?1j=qOX!@՚ÚsznEv5>7IBO|` K%> Cѱˠ*,,+1 Ma0ixJԢ@[J J3DN"DLH`\g,ӾTU dn(dzwBN{R&\0 #_de EA/_ZrYJ#vT'p"@$ ˼ !Bw^)DDd2<#`LG "0L$IUeЉH% * bE"H,sF*jQ !#ygV)h@*@ڣ~pg'"f yqppԥΧ%́6̳ٔl5*j̤^y\ Z5NQ2YA=Q|("ĽcDĕp1ܓv+7es'-!KJK0Q[Fn/!$VĘl@7o! J(%'n]G@y7P k{ju0֊$ iqȅ%PKVa e>CHСVஊbUX yp X?xّ$ %ѿYȒK@˚Oa1yv LG%lKde= OrQD@comA-SL) $GD&N HG -|=H2kb8( H.y$hĉ\w -?~6'G] L|`{2 m e`Ŵ=pf r8Mq^`B^>#?_i&z&UwD6-YPGBmT"#2'x )62f'dk?0HD "n|`?et>Az2>>)LN_s;vJb$V#1(ܙ1\䵴>`O"{m^T++_G8+"G̸Z%Ru4~x%y|49> ~ tb倌/NڒD&?䢓L{׼29:R861d X/XGk /omT%+QI9O^3azw U}~pmnsd|f=ڻz#"Մ.r2^g<X3W#%#ujG>S4_!Vle{xs}uzb4GB󚔓eè{΂ قߩk(hoOTbHO>@?PRsPQYDlwĀf]V@75 -Z^󉗅 )Š-_S淎z?*z0` NC  8[}zzlVZ \ n"9@t=D y@0biy{g*1y$z!(o1ybjJNNrcƐꆞ:d(Mϭ6wcE2PpAHx (s"w e(G]D״4|xa%_&$付>O9"-a'NVT .b|ؤ oG}\vߏ+@ x:NO1\d&z'ÁГ::]o )!r6%uFayӓ{{B`(;'9\ٚ&=?3'M'dyoka2CQ@\ayc3q=W$#ד;!Eя_ W ~A 32}rdȨ0<܍1ku$ z_П1Ԟ`fB(: Q#7z\~8 &$Gd<]OMWy'xT:>lE-dH&+)S'َ\_$FWb bZo0ϵPB:F؈b}SDBD- ! z Ӄx(qÚ@,w$w._Mg'X ᪿ1^CYk9|韕~O߯.!1A"Q 2aq3BR?2 H]}!CDs$!7\zeΠSyi\j͒n/m QK<<۸KbVkOľ(}HV褺1}$Fn] sBvW IvLY}<o*b#EV'"T68ɲ8MU ~GƎ;r^ u-ȟzNJJՔ<mHd$G,\ro7rF)ܸ?{ɲC"VK("+:7%rFr/ .R3RtDH;D$E6KjG$G&Y7 OtWH ȸohF7R~~Dc!lOk&rQDczd~ !U!Q%jty6ć>-̪neZO1:t V1tfD#d!"_B< O%!1A Q2"?B1*EK.~uݍ<^5g,Tʉ&&!IindzIֆlx[bKT5cG(bMGEbX}cNDbEq%cȢ?"Bjz)xU=W xdiE1DiL[?!~?g"DG)h44Vˡ2Qt=VcZO<_ Gcbeets-1.4.6/test/rsrc/image.mp30000644000076500000240000002432413025125203017221 0ustar asampsonstaff00000000000000ID3SAPIC3image/pngalbum coverPNG  IHDR6I pHYs  tIME  JtEXtCommentCreated with GIMPWIDATc?9х'IENDB`APIC image/jpegthe artistJFIFHHCC }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ??PInfo(!w  &&,,,33999@@FFFLLSSSYY```fflllssyyy9LAME3.97 -@$|B@!w_^P1@T5"LTBhNsy}@3;B2!?}C'Bbʀ,= 7cE`M.2 :Jw f}w[?ҍW:F B5纡^*+R:kB߿ۮ[&8Ub0'QX`he2yYR!) @} 86`v"6FӔ=؊v?VaVӯSˌIleM[#@~H'zU0fZc{Τd ti1+>bWnjkv}I*shC #9!uk{jk+Q@ڷ]+mFnWR; @aN#4HWJU?^g=vy*g^M)\ZW$4>dLN[>S3* G_xV#B)eTH N@k%GI,eU]zQ.,-"~}JW}TE> MB#D?\dO=!btph'rPX.vlKRW  =!'.|nXNkb aWIvȇ'HT'@if"rٕz:F{)Mk-!M.hq'fz0@YmVgwT x!G!'Gt g֏2K.ֵmޓkR1Uc& hFRnY =  '#UdVir N^1|QZ=O7Zҍ}wC[fB> R*( ?Όk;Lf*nSSysfiT*2ي&;+ȕ YScY4Fڙs(M4qȬ4EXu(fJ8vh dznE"p z3Ӣzr|RĄ @ 7#`'o׫bߗw.L7e@K)um.W[MFvR6"mvbfQYGuJd~2[qfBI+q ѠYl;4p2!fKCIY%>#޾?ޯy,bAY ,+ %*?XRĖ  =aH+-zvcF1ސV?B'ȋo$R[ dt# 4 KY$q:4s#M= gdLt2f;^c]L[;SD~"i]1N^ w/jO)MFW+Mȝ6_+io̊% ) $'\W'56?'p&ݙqRİɯ =9X< =}awLNgfV XuQNkJ$&gȩsЪȄ\?M?XCe+0Yli4,b`=@DW-oUIT;fʷj Ւ:tх s5K[$?#D ?x́-5 d_eS9$, RJͳ = 6!'a?`I]Kief;i#R /;:FS}Ls W=hc 4\j" E A8. `& r 句?򇬿-B}VATQ>Zv_WSQW3P ;N~U~xJn,!h,Ԛ dj-i DH\R  5A J @EV"keRw)Ьtz.E!KnvzW[u7NJ("5@aL=#Š@R䉕twiUGQlwE#GPN#_S̞?WcNB%n_Xa0ejR 5`!Ii̗31[-y?nK6▪{yN~;sU/ͯkMl0>P`P8XLT,aLqYu>yrvJKu|1Ef0 rl즽ե6\on-dfYi *'m9!D##98R e -A &"1Y2 iR+E'  NOJ(aؙd%y3;%frg,XPK1S&!Ib]aIW=˻K=\ilIus?W;e8gUYCcIˮ)MVX`L3b3E$TVRLe@w</wMr:jdZu@WWG=A .yX<,WQ`f[:+gdr2v02+#`PH"(M4D6ϋ2Jݲn0^gZ"ClVԪEy~a5TWdjja#TDL@l\ҶĀRKq@= vYq'j%#+ўl2Ř$h^ ёPI/^S4?̛3ѱ1WO\+u :!HN /s?@>;)*_jdJ$ǙYa L ʃ 8٫c} QmQXTQ7(Bm2 KR @= q6 HlAl|Tm(-r meȧ9YcwoŎawV)dK&Θxھ ]\)q]LBUReN~~WߞR;'Z\x,KMR }@ ;A6/ǘ9sIB Eu;#XVD8TuGtF[.+0lQAj$?~(@MNV' -6pP\Т/?B%Z*Jta.fԒp-s.9?K=K^gϬ3 :--: `@R %` 763 64"()芢C$3X-,t9Xƻ4eSUf?YjM %\ȸm `dND V€W5k3SH ~X);ZԟQ{- JIxeW+{Lo&I1ID`5h6 A1@RK6H$D~^?uEeƠQ : R yj6`&_@;vDC0u",gC #6|5n7Οgrn47]+ނaCO]:HOX0>he,(Tn54uZ<gZs.Mzffvtb[6TzjY: D*q8R =@ 5Ak6a(at$;^Z,Hں H(Dz3EЮe3;&Q9ou%n Cd=+_TX%8< bHG 01C Q53{sɖ~|A4͏˱S:#rm%di?2c_tיNe [|LL,%8&Y7l:1R @A}?ͨVln48T,EYN57C5Tlɂn{ Ӏ=guYeP~U (iq &pH@)HpB%7)M>-;ck9r/ą b]CA$0LYpodV{8tQ 1#moHtYerRDL(R = Ә]4zbf|0 C>HIV{װ2l)M1 R3Te芊 U,1$ ` \u"桅?m8T),K_^UQ3H>2%~#~:t(s8ע 2F `wk(RK@w6`ii3m EM_nLDWcI3 G!)\yE:p&CmMZ7| &ٞL*("bL$"]!*Q S¬eFg/4&g 蔿J1 T1K;n\Ěc(0N,ss)% S*DsubF䉜\(ćVZaB ,`vJ @ҷgs{<&Nr=yƾv8X;GӷʮQJ c1P&4R }@ } i85ad?m?2ZfyQ)%[+lgV#^*ۿ/!rT}'.U惒mQ\V`}fQNאL^Cw `Vq%K yC=Z$ԋ:t^\Ū*鋃 ,m' 2F R u@>ɂhLR)\S&7M"އk: a!%-R I ;IX6a p@,%w*LS3209TĞs)ʦEtT΋SVf{nF:B۶'bZ(H ̔ju[kie aez<Z2UE3-Pp;1T*Iv:5զWbKFmK^#.RJ U?t<(b+=5jjB5t{Jb" $/7ۭ_f˝{{9|8>0 `eKkI?róm)2<ϕs9!YybM **:mU[ݙ:daC (9 ?RN!&':8c3sA'4{2Kc>ȅXe43B3noJSc$4FWU"M3ž*j(Cp rݫ d<ͱD8L. Y={?]T.s]um9ڝ *氛_^_:V[4a /*B R m ٔ !cD󁑔@*ⲟK4#p[kUIG$7)g$lˡ?wg3vAܪ@3F$r@ٲODd9ƒJ7"5טWBMs[Ae[CPА )˴rSR6*O3z1ˎ^Y;gN6azճ$ mctt3^?wG/>}6WMH(B1#F#Ύ6?LV@HnR2dg[z݈=),Ľ>elh꩚8iAI F<$ p„(r4IR - 7Ƀ`!h( L t?ÓVs5BU)8&H?p ۫ u/5am>;3nY#KC-RziH$ e8fB-vtyfD =BszIba\T]_??;{~uڲcIUP%C+08W--R ] { ӤrF,'=AC'mP[Tہ&cn5-~g_3iZb4 gtH,2!ipM4 4̌ח˙HՖYZPɕ'#Y&!JG#,5k,Fϲ_˖( NFf\R Y !hrnLAME3.97R @oِ`hR] t4beets-1.4.6/test/rsrc/pure.wma0000644000076500000240000005620013025125203017175 0ustar asampsonstaff000000000000000&ufblBܫG SehQ>ޱtʛ  _. Sebӫ Se4ů[wHgDL#DIANEpT3&ufbljthe titlethe artistthe comments@^Pܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104$WM/BeatsPerMinute6ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8a5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8a1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8aTRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8aWindows Media Audio V8a6&ufbl2K ] @ 6ӂ}m ;J$$$ ?'qU11%4` -I$IPc ԠIRUI10U5RIIjR` $Ę@JTBTI1,E4 ҒI:bf1&C` QR@H@1V` H :P@KI^o'@*@7C@ L $ %--i!THHEPjH@&B4JJ"0ҕ Sƶ|ALU2k?//߿,B(q1B~-Kl!+tH8 Hh-ߥ)B -P_PVCQn"v0{eIBVi~P6A \@A KTpk,RѤ vFPB(^o㧏yZUP(\KYFP}oM?_Rm>  p?,*}Kx)~yKoұ~(Κ\ysXߕ?-[}BSJ8lj9>q_ͧE'~ķH_%iM.]KV+s~_`;t㎟ߛc[O[t-A ?|T>Br-[>t~'AЄ%lE(?0i )޷Cn@4qR#)Ok(ZvϿ:`022{Qx6>T%[_y{7폷e)_y 즚|NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN @%,V,XU2 dDZb`D ZĒXpR/I &`c XH1$)1JI,2"MK A2B`*!&JADՀ$i E&(h+D!$``:h&F꤈l Fa؈ECv[0@00&)EdEBMCQ 3P 4R EB( JKBPPB0@[M/ۨJR`TOTQcM-ZZh n-:\kaS~O~p; yHZ ̡q!Ƹ8风G/ nkHH_pZݔSğߥmxi_;t8nO8}~itS[``pDsBP_ P=Bc'(cy'opc^vkL_KWQƷW ۟V}F =_SB4,(~o*Ǭl-vǾqNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ] @_mVb&6 H7#@&RI($L&@(ӣ" h&gQ0c "` 2QyJB%,D&`ȦS 2@0i jH*J 1 (i $ Ja0BJI%$X HD XB-#@!JoV ]G@c-TN I1QBJ L$i@J- .QB@~HDB%4ST(@JIJءXDS+e!/~BÊ#rQN}Jjۿ۟҇ϑ@vx-4>~m-Sm%J-U_q)4&)ZVnH~?V?'(O@}ȻgxۭVh y5aNڜ!m+(KϒHCzVB?YHC4Ombߴ5O>S*P|kv%/α8ͭS\tKq|B7KWOy#}_=Zo(ozCָ#$֖Oyr[xiQ[!^t{~lKupeQNP%θ NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN @-T߽Gé=4C $DΠ&I:MO`Ĵ6 FuaTAc A@a@-2E@i0,P I$h:I-/B(,ᴤcRL (HJMDi@IQ6 KLPLJRH4@YP 5" $ iJH`$fb`liC`)翝n6 5@L h$Ħ`+:HQE dQAE-|4h B(BA@){kW 8cCeEx o)1/t%cGv5oK(^NNNNNNNNNNNNNNNNNNNNNNNNNNNN2 @baED%iBLA2d 5fv  Kd @ :&*!!b"f@.%Z JR a,CP! %3U RDI%Q$HA@0(XVP 0,"*@0RKLTHU% (؈ þLUkI ͬnTbe.Ts!Dư Z1`  Ja$ k%Ԝ~5|m4;jΗM)$iH>R"“Y E @[)( %552JQ 4!(?Ї&*>vkO,)BRBi i`JROED(?K||PpXD`>'N}o?O+T- &e?GR OpUSo-(+\h}O〟rr7AFP">e][,in)V7[~E[GQo|CVaQiG]>U%eo@To)[[|'%>V _`J+{tԄ@x6ZmOͭ%k.kZJ|+SoDqc ßy2橮+Vֲ[/YO|iTOӔSՄinݔqۇnri6߇mlv{q/[i\kYB?4ҵiBpI?~n56ʺ펥Q/^iRZ`%|[u=|#߻~R>J[-qUo5ݞΗ`+[߾umN;}QoͻNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ]s  @AP{ges@ Z5J ĂƄ' H5 &*N䂂T`b J BRM D/HI*%t."A2iT"DH [Q% H-RL @hD pB6@"e KPI ,DB`D@ =یAUÕdʆb e@DHiBI)n,$JDX @BL4%)&KJ%lH!mىva4I/ nVЊ}HC-8PͥƄЌQEK? k :-)X>Z[KyOQKzRE8?h2A+tVOTքmߢ;zXŠRE->E鷈}BP;~%7\=_k[sဩ#=u4e."Pr}43pw/+{nq-)\|x%ּ/;֋e9Cޭ?nuÔ۰?~L/C`.7ϐkgW{~Gb/\JpԭV[EOMpe6[nk=R"?_ {->te`/7{pNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN  @O3 -9X᫣ZA):ՒT%A$d!LAD IJaIJl)% BHL Q%0&%0I HXRQ I4RDCe IiP&S5$P* e! A;DTMPIh"Pf2 XVy@$`4s0WX[$HbgD$Ul*$RDBQBhA4?& a/QAD"-x\R*IXR툤RA|_ r qjJGJhԿ(daGHmBB/MZ}VܷBh~IZ)(M M [ύ A8(Z[TNX n?/ݹPiM?) v*B/ݽhXG愾 Ihwz/7Ǡۿvq[[7q([ 0Th~G%Z-i&l>[o<87͢V׀?G"Ze$7pq>q g`?W2г8=7oŀEc汣ͺ}q  Vq Xߞ Zh'j 2 NNNNNNNNNNN  @1 E.wa(, B utL(fcD`.%I-H$HIS&$RI,)ZR6Ċ`BԔ)Am)HF(C&MH u(T$tH )A((Q5S'ɁAQI3q`$QDaS57$-%F,&o2hho`2I4aI a S蔬IJj!4  $-B  Jj(FOPV/ɷP_'ȡij.$:)G[H(Z-:RBl[nAJx%(+@|)+]hZK%/e?Z vPnV+|h񅺀[X i-U~$~T~c?iSPCN2;`>R-"۩ts&ܶ)Z[3X%q/MOV9E4/gR(l|[.FU)j_ܷhvPWQouX ӑ.Pd~-6vPt~u'[-mp.KOMxA')E6/)?#nv_r?\N}x*?U p^-H}XnIɞ(e`NNNNNNNNNNNNNNNNNNNNNN @%,HA~!f%֝z Xd $uՀP-P  1 J !5! %LI 3 DCja$ `T$  0  Y(LaP2-;*j@ ɖU2PvT@؀&bhh I *Z[- ĕ6I6]XDYC S PxPP MRHTڈ hJĤP쾠l-?B)لIoˠM (&2>-%+eb?㧊J(/߭/]|r) FR%`zY4')-"xInAZZAl>}Cn[-􂴰?R.7_?+e 8hU>G>_걖gVGT`'ߖSOS沅?e9A|!IndwCP@Jn!,-g__ [\cq`*-2mR8K揚['3O~|"Qqg_~OTV6 (B{ y?J[+{_-V7ܜ6>Jk+O/ŀ/ il,~,Fx())Ace.Z"NNNNNNNNNNNNNNNNNNNNNNNNN ]- I@_I44{`RALTĨɀRK@*L)H-` Lfgb*T 1 D)0IEC%0"0e@v%v+"T$9J*TdDJBh%au5)(5!" l"P[ %M! I(8AIh5jBN   [T4oaLlNκ0f$3)TłHv4B*0DJ)(Ja"C@(`)!4iJA| ot,_JI|RSCҁA)|)RB_Jn[in(n:V>|6SL)ED Vz4?|Gnsϩ[GJRNR] _Ʒ~J4>|u (#)oEoV2*4 :RR$~ے^kq;ZvKKOT[.|h~~qQX߯nUq,͡mSNSQW (?*ǡo=ߺ2`nNZ[g\HJߚ}V2ж_-߭" žv +rF=m!5ÀCm:?oǀNJUׅ-;c)G)۳ڪm4x +v.*?V=EJ|b~tNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNw@5Y J{l*'X 42 I l(C8 AtMY$R3(IKNɨA: ,LLAi(&BA20PU( LaI@$B+2 %(*U$&I5RD%%!BP!'R % 8S!AAAfXPFFP":˚9ʁ&H*b iB@HBH H@4Uv@j0@@%hPI(ثI$DGª?[&7d4%nފ* `*_ж8ihqin lZ->E(㦈HJx[SJ$q->Oo}KKE/mO>4SMJeHN]F ?5sz_oi B c}pP+e#)ܴ: Mc[iFRGXO7Ɨ[ ZBmo5?'oOp?6lzkR們Y[\Dt&e.Akt"x/.?\I-[P}Io>LH/U\5`<xB~vǂo݀%tN!_{eZak=HN@b#sgu"Hڛ]&;b&#`,nh$իI"%2C̘ p5; I&jhh@I-50 HJ" EMHKe)(4ՉTH"dVDK B@NAfUdRNdPB`ȄĈI %&\K"ڰ7 TlXKHcR%Q1 !&bZL@Y2HJ*! P XPL>J"" 4ER( >44lВĔR iK?  B2KRh[JV(~PP~}oD(Rx|%ic\ XGRFD[v[R-BQGij_~O)k}?9M5Qo~zG< mзotQMv(IZZH0(}~ ,XvyJGi?<h_6yFkk~$;/`{w~U\[\5 -, | _Ζ-(Zݑ- ~b?;uEaSKKK3H;}pC[罺ܵ斖>4>t?*xKO:~wo·eKυ+_ZNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN@*|W!eB( +lID5"2AVTH10dACDKphê[$N ڀI*BI" FD[ (a PH/$2PAI(A@! [Q`L @PDU I єI $A2CZL2 I2bRZ4nuB`l)"[@&!bL8`PH1NR:K@ Th%LE, JP]eZ|v"q>?TZv84~ /֟۷|\/ X*(J!Ko B4?\A\?ȠҔ[A߭OR+ Tб~T[_tEGBm}n~x [|$V5#Şݶ -Q\ ZZK5n'Վe)[չoG|P\ۨ?дnݞqдQ>^j"9j:~ ?6ݿ$'Ƿ[hk=4W<ӈT[=Uݹ18$Zq _ zاͭҊkVnQy_ pL'~te/݊2NNNNNNNNNNNNNNN ]@tǀ +W7 ;!@$I*bl.dDP"Ka$!0XȐX I" RQ)&D J V `R(EILVx1TAL A&BebDP@T$dB!"V0:5Du"J$CH@f$LImELG`,h,fI!fj2 B ÒP@t*Z)4&Z&PV)JjPcB >& ԗd-Е)~V BP?r5J~SQl|VT#;4-@'C괥!&h~T}~oRƚ8ҋzݸ_,S[~ֲ8쥨ͿK('+)OZt4%# AVoygX6즜"T5qKJ?߿̿[+$~k_)qe rt/ۥootnXÞhp>tht^n0B+?F(ߚ.8xMcyO@NNNNNNNNNNNNNNNNNNNNNNNNNNNNNN1@*ѳ.cՍ gm ʳ53($2A!0!6ȆEI  SwT$,DTKԊ0 )@! hM@ӄ pu "%5@HH˂BPmsI )HAЈuR@:%AJp H%"MdaD`$a[F64\᭹cPeI$-iI (:BABBP/#dЄh CJh*,@|A)M)B"M.Y\KA# 4%VCOޕ[E+(["޵Eq[(۲ xܕ_i}Z|?%z?hM N~ic{Rjn??5+m c-$t`.HZV-!."e%kB ˍ/+nJ)}nqt% „;n7 ƕpM (C4& <`R\CJmK҇+%?|EKv R O@)"r7Jh|M۸lj-P!!h`*rG%12|jh}oZ⦗Ͽvy}x꜠gPYFXIYGxҶGM ?i0K$[qkMÍ/>5r-Վzݾm (X6 7c0o_ΗK2߀,ZƊ([F{-ح1E=$tm>{zE9ۥrt\"x߾,@}luݔqFQL[ZHNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN]/@ q~&mk[x`.?km+x (}->_|*V=+/٥_ωV['tє?3O瀏YOԿ|_k rxAv_H-,i R /eO iKM4ۍ(A AKB-|$#Vʊj?hJRVݲK% k@PRA+iўɥq)V} (~~c=ȢX'_>j+O 8 (e/QCҵZfw,)}E5\4/ yGBOo~Y{sB8s9s9sАUA6q HEiȤݣ$h r GR TI) 4d!RH!RH!R!b) *2,2,2밳:0C KMXckZiJ)RJ) YdAF!R!r)BCVU}SvteׅYn]8u}amXeY9~Xet]_XmVY_}xu]n̺ mc}Yf_w:㩪+ , Ƴ, *±,j°ڶ1ܾn,pk1Qu|_x tu]yf]utG8~@!+8$dY(Y(躢hiiiZgi),iiZfhk)˦jʲi캲mlۢiʲil,ۮ꺤Yyijgjʲiyjzh(j,[g試'j*˦ڲilmmim-jۮ,ۺ/iijg癦il+[(扦j,ʖ癪'ꉞk*˦jڪil-*ˮmʲnljjʲl˾ʪ)˦ڲi-۲˲iʲim.˲mlhm-*۲-,ۺʮo-p0l+ۺo2}DӔeS5mTUYvee}4m[UU[6MնeY}Yma4M6UUMմmYmaeveٷe[uוu_}e溲˲p p0 a1R9Q9 dA!9!92 RZ !Rk8ؠ)8@!+TX癢jڲcI'H牢im['牢ih뺺.뺲+릪ʮ,ª+˲m°ʲl۶oܺ‘.1 G @6pR4XhJ 0!B!RJ!0`B(4dE'C)RJ)RJ)RJ)RJ)RJ)RH)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJRJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)R pz0 HQJ)Ɯ1cI()b9RRi-9!RmsRZ13礤[9RRkVk5ZZ5לs͹k9לs1לs9s9s48ذ:IX`!+Ts9R9"s9!T9tBs9!9B!s: BBA!J(!B!:!B!B!RJ!B P`@ #R΄AA AQ3 BL9љbNj3S9tjA^2  (B1A U DDH.\]BBX@ 88'ST ""8>*,.02468:<@OggS@='02310276;:?CCBl% B8FkF#zJI`76BU5: K.[/I ~JJ@!T zwP{@ۂ_,5@4> k@!TU40Mt+Wу>vyPU|GאOӱpB&>4PU'@$O}%M;: >&* @ta)t (4Ore1M0BU 1>Y(sY&-RJgMkVOr2IUU@EO5nE~IӴJq)dU)$ @Or28Mj Y"eddcw d :Or`ۛTU:]\\{QFH4T05yk2XaEP + &E^ #&!ir}Ωaku)lY & Hyk2MP*jT1{Á1^_Y~vtZ}.95\1)_#yR NyB1,r(4ptmp sRL/g9?0M),Zu8 4X huҋ~>S$=cr7Ì0ʫb*߷QY4#Oű = P{dX]59  f)6֔A`f^!-VFļV ]tGNy49aM5Xd\%`8'\57M?/'x,ׯ}YnmcfFU)0޾ڝP[+T2\S G.WX;UFhkoF/u c:78 0d7\v}iEM M3"~b#Ah'R&y,]~MG-9#8뼁#s/y78Q1%8iup;_I5?&ҵdun{!:υzU.25Bɖ+q0Q*/CU&^ܱ-59q#} &68Zлt3WܙohmP-Y?{LX8&M f%ONɔͯbPwWI;w(*hRdPLK?7IB|_O&arvQ$kװdlLc/["R#lc,VݝHbz{8T78Q1JQ3=Hi M6Rm|f Z*(Nb&u@-jI&&ho\[Jx2 ^C7^:YGcFO}ݺ<~2P"Sމ!G&SIu8Q(BRHYB,-ox3CTX| ѳ^^^)5=%Gpb{fy](>(:;ubj:kGG:OggSD=59 % . :>]?TxLsԘMPX+q!^#a|S.㵕Q%(snl]N-{~w80BbqX9v)l}żbId=!t,W79 Rfo<ӗUe׫p0[(w"u8ބ2h"hr)=|P"r:I"q蹨R""u޽ ڇ.Asψnn7'e~ u0B c@2 8>h_6zyuY|$ԕŅS n'E0!>~v3ayz9Mk 15W7#eiw81hbFhzmp4<* œ"N|oSjlxMqIߖ'SΗWKw:&U0Tn;bb Pe 8P<0P8ӹj xz}]beets-1.4.6/test/rsrc/full.mpc0000644000076500000240000000560513025125203017162 0ustar asampsonstaff00000000000000MP+'\@7s Df3?<>>s?EDD>@_@ @;8o E|_DȗDDDD""_|""ED""""""P 8hэm۶ӵ]K۶m^U6^j/{Ummkg{K[,Hmѥ&li۶sm"Ҕ(lu~傽gUӷUUo54i@NjT=SҩND&|/]|_8yAWDCx/]@by1!?A_O?>v`oyCWp @AWЗ /~ v>p2."?/AWu EDD@w@? t "|E|ȗ 䀈|n?," | ;mS( 9g|ΟKDD"mvk۶ۦm۴vm۶mmۦ]k6 8xhhopиpo"" """""/""HDDPDD7@ntݍp@m۶kmu.]۶]nۮk6mvq*N%+cEU5J X=Ws!r W8yA?^]/{@]? t 8:8p4tt 0? 0?? ]}__|q0+SaзK}>@P|{<0_Pt/_䋋'D/ "ct|/_ / _D{twh@ÁO/@/8x//,z@_*|=.Ls8 p AWPgw|ۧ{_>o?G@>H|_dm`g/aL8Nu@B]H>pEƈYK&gx'a+P0l2nNEdL5.‘ < }`nDcbA{@9`FSHgDJף]燿<& 6?:1MGЯaLs!jN,H_{Hs3.!0\}7_j"h Ep-cYǍf\3 "Uk%̰m}e:4鑜}˄%VdZ f Fa+#)}쾘`<=Y ;ґmom6s ]F͟doSxl.̻TqwkO׊_X8<u/d7J8/)˯'mda%fu7\uQ*n:skgh^A{_%Ibm.%?&9iˆ>_I/80\rsU `F*# ߈UukbSa$|w6V2$ gKB5_h )FWʥU ]VEf5*6s\v E 6Ͻqp ' Ya[J5 (z-/)Dᣙo\b2z]7gC6r'#"V feuXb>>. ڛ Kq? FHx8Tp*ߟG~6``ya%zA>$ІҨn+5Mts[6GyD͢c{~I7XEdOo"IaCԖn(>4vaG (5|1c?Ha;DKCB QcFK<^|j3@01yqJt sιBe͆OD(n2G3~\9MɺKw=xT$Ѩ[Eo@G|IAdşa$y]u; \!It@+ N"a1N΂e%[L>~Fn]:nE c~qseϭCO8Yl*~4׋[< GWOϲcl5X7"fFl J}sa:#5M7݂8:5Y Nw)@X7J@YLzhcXAzW6ߊ >5:?W L/puGNS)Xra+_Bh+9:1~M\IӖi<(k Uuzc_툥iPI|.ΌK8@#v[;m=ݍP59Uԩo&PD(`Ia61i\E(,يE] R)FV%_,2+L;UA$?75b_ԉ+L?{yoְP &G|jC@$y$.3q a!kpTjUGwCqaVK<}P'T7G(/T}ED kMEy-e%F1;+\y'|u֫?UJ m1QGayꍙ*QKgr&y[d3y5rzfBBeG `V-QJ([= ԶaZ)nc eel#37aY1Jx>R3&X%ay`i9xXu;[c_Q|TԗozeuS=n1hg+K^Ӂe_'^9ux̴I1n6H: ba$1~Zz$}Zw 5[F5EH3HxkkwPUmϲ~G %[>iZ oa$*kPK炰|dn:Z ;7QR}e aam?lI(q聟k6UlYuOtn66aFL3.LlaKi szSF⬩[a6ҡ7k6ðGD*4l%OLo#9c[oHSx'#SUu@%aTiMk;iԩfy1z #a`rwqEMcCDP糞V`\ԉ25CRS mf%u6g( +4pΗpoQ%;Xbě VI׶_TOU'ԯ&aVɜq )Ey8EDn pPf_!+vs%Da'gbiY>SEHFjf%0:N> S$.7SM)/DP'aN>=)"Gӱ&pד%=Ô@J߲4i)V[VOhix<\"e}*LoŷU,O/)C0}"_ bVc@9X([+d_a,~:@HϔxCNPVc5iL9uO,#IOChu+}=C7\+*^Ƅ¾n9{u.Cmאg1fa{i"I`rS Aa+#dƷĂ]qaV8rř>%ۍuTMv$^(Or' ]MA7PsN[T"UʢYB?bTeTl ΀ek0sT?Ha+Up)@o>i+Un{(BkOO$H<Ay / S 1Lt1q]l=i™mh0!J̀u{c?,ĵ,ΦQwY&myax IUG>LdGN$%/M@هt?ls֛&"aog3G[W%aPzᾒF}ECUeo{i׆IJFp+/ %na fi[}+D,d+"`OEga,{t\EpҀ>S,Ck{ }}w_35͵NPt;8e#eNXn]6):Fz2!@kGےRjfT\VW"ًDPbCa{ዧL3r%CuutA̰ڕHoppʏKtXLK(?/81"15G]l=7ўr=N_X5ܮV*R^7`T'yQXq M ԯHPN@OggS'oJ@0}>ڤpxZ=BF>-&Az":>'Kq\F:!N rh }'Myz5G4˳nQhgQsT*NȐޝX)v)ʎU,QN }H_22,'\Iefky5Xbeets-1.4.6/test/rsrc/empty.wv0000644000076500000240000003105013025125202017223 0ustar asampsonstaff00000000000000wvpk 2DD!RIFFXWAVEfmt DXdataXBWWGVHC et@ ɦf1/%k^\E7h JL9 u)q-8np#8ܹC#yT1= @Nc$lrc U $OJFQ &Qd4/ z @jQ g(l 43J~ñfИ1%hfTNc8HLaD R+9QT~DÌ2:QIPvj atX!HF )15qKP|MI=J`ձ+DXC=27TJ3)cD,"Q'#zPD6RY;ЎB4']'T D3@֜X J|pS Db2|! xgp暽=^jc'ɸe`&РDP+ƎXÜ% jYjXjl:d[>4& > ĉNЃh奀,d Nq<^@b>gӊQ:X-`FA\~fsIŒ N'vnBP.A92ic"Y 萌4FXa!P"cb(A6fȈ@q*.`[kjcY+xjXB$\R+D RfD6T,"l,R’WPxS="=i t 7T3A0`dÐQ0$2H;-b5` GN4COPM! YpJD  mؼ,x 1.0 MʕDԜz")6 P,hF\ҝt?F6xAkFXjM P#X`dI4=( 9 0Xt`h A#GO 4%Nv%N ZD3-97 .!iA X;#Q&܄쓭:LtnT hmPI(K Ђ-ZGOd7P@gY=(p%l!HLJ|fdA8>@k0'-?c͑ , f Υ dÒj)2q ej0hAP멮=a2`P\wP0UʼP!g X~l8H,f:L;Թ$vCH@x?V S0#":X3 VCE4:bqL$`<?L5/ie8{`hbXs`+CT2R(j*xyX #Mbp8E5c@#+QRHH>@@4Q%RHpS*-h H 569; LT>$F5hYda2CyG0AA>A#:`@=c.Tx1 ;dX% >FEN@H̝ `@X% Xb X ܙ AfS(1ٌ gfYDx aPJ4@"hB2/ ȉ|   džm*46͗Ǟ x ԴGp#x/b[ 4@@ -$TF9ng xXꌍšZ l a#*hTyA#qYOpHdJNbZziʧeʫХ2֙`|ҕ%I'( F*T֬H+ZA & K^ n( Ik`8#t2%aitAAAUfe"8NT Fc=pJCʰ .A<&&Y:M1yfK,($ < k߄Zk$A͒?AK@A3%f6 HS)k g;D{8@$p с$VM<-" S; G4Mg=AsMzMEe. 0zbei;¡d D L0FX9 Rw) 1& yWrem6M,ȴΡWŲrLdz㐲y1B6o' ʋQ*K"04E1qA@A0K`%(5'\Pg&R@3 Nu4FxA kF(;ĘKJ4G8 DH ccͰ2b=];pPepj@ RH)LusɼhB60fI)V2HL`p| %3JXTxļ ڔ3ɠ@"GȤ j6M (^kF1yfgLd:*%=XMA l*EJ@qD#PZ#DGXi /7KɐtA bS@t\P9n ѡk1V(Tr18Dt%i2E&(sϰʭ>4F D/P ̵#EX2y̕Dd%3LOx {p XPAj"ԩKN4u @X6'Rp T鐘 ZXܠ K`F&D!CI GtF6u9@2Fر@bdA&Nu$c1S,Wsr7@Gp4<M5E`\Xdأ$1D5p,O:6☡'*/"3P^%e"h"uCi eF=ʸH!K_uH$ͥZ30HqZ6I  \'VrePNf%$R-sp8`=0_4hcET؋AP_/1-D52/R@!G'xb́<%18l8pzp *1qP  9AʕD(P A HW"%"GىxihdN?T@ldA\a 0i4ѱ.B>b3sx8I 8\ w; !F :xSSJjO”!Ȑ2Py0WX$@7Rj;|#eT@j*/aaD lǺ8Z5SYxx4RIl"kFh"DCR{4CRdxf=R"T f2Cmpdaa#qL65zAR 09s} ddbAWn3bG1/tAq4h;#rk.9.(pXb7ոk9:X|rtġ@`KѮq +{%rC%\reaF(K' K =D#B-œ𢂦B.qHd9䐌.ʅ0pfa!{XTt0k!BJnW6 WHN IATF&ˡ4B T6]iq#l HO@чQFcw2H@-: a#BR#@ɐCgbhb,`aaC*b \B(n@S a2Rh&F̒C2eހPx*%cXd`F oRzͤOt(GcP7HDȞOLʈb(D kI DSb2FdC/RH<BD8`5@G`ijvKL> r|n06" 5Q%PaSO aYb!yn,\jdK!AQJ37c-T)'S( dsL]q4ʀ1t45z|p5/ UBcB;@雀U$/!T S!TfĢ{ptN@GbeFaY‚0N=z2`٧0I $H%Q_ |+PtM5A7"e(P"tΕ fh[c-0+)P,TV8\:2,U`}@NJad"TtC ׀UO\3Љ2'8: a!%, ^(t@hА/q9Őkw`qX`>O y@T$šI#qfd(xqɉLL6 S{ƚB8ь)`)0R̭AN5K5C5թҩ jȜ%:A:B8$%\8J*IM&%C hscQ#\B)4Z|I򝖘`  `9iP0bL$)RpBL7Yz$ aB;ǃ8!h) 7%Kf?\Aϵn,7X \M =o IKzEq 9HEH P^ΈȠh, 0HybR!5 q h4a\9AVMYC9H0n!0Y z4I3*V , b8D#RH'4<9r<ƩM!&IX8""31S 7W'dOG g10QbN5c n"&逥Ie-5_ѓ%jg' FA @(IyGeD FI7oI\Ɖͽ( ;4+BEX0yFs1RPaPjYplh,7$#SP| {\uP6Pa40 (B$:9I4P4yah`Z2QܐƸ (dOQt͕DeC>H*50ƠPB;@S8l^ pxJ."GQ͛"&gdҼIn0EEÃ/l#,0aZA 9 XIi;` Ѕ a^ 13@-2Qfyu(g02lͰ|f,Mi48;H4`&D X`"ͩ`4"/ = 8 m S+3k")KseJWс1AYd& :v Jij*If):@K5reو Z!&JS'`6+ 6tB\`ZetZG/M*B=J`RH Ƹ9r@XayH-Ђ72}I=Mi\DuXZpN# Ӝ(Kp$4AF0g 8a[:*F5Vw"dF$Rjp#b/<3BR`&C+Đ4bBY q32ci@MiDgʚ~HS!)7E8ARJhr:Ħ#>ǚBW&}0t> ,ȳO%莴c(eS$(@FOh %tD41uġI0Fć%,@D&4fTæ!ށC$l̴`5%RbTs}u P8AqLnJ($y@C;njt M4@ < *qY h\jhxm= 'jȈ u `8 tlyqpXH1 pG T9x$(T $FkD-2 Ps5QDsGB4x xpU8 PCai!06@ +Ð@ 0? T#:'BX58KO&lQ+` FX %V`T*Ra(R$@6!5\9Nk p` 6|FMyZ4˩Y QrW3{&@聆Mdv;b ZLlN+6h9@-ۃ+췂Dh4<:j5 \I\}T "re.hh`3.f#Y&ΏDTkHL@ 0 hNm@`gj2P!=XLCrg|dH =0Щ2؀ c'ua4 @ EAĚ=34.և; O#20JE&hC;-!n#z^u P^jfVDsHDl4(4թ&Gĉ5=- & <  A㬄EgẌEQ<#-3Hlc0O;j0/BiKzHJ&h26`'ZFH3W<(Րce@g^G<"ġ%ZQꔇE;hdG,!،1 9OmHDcExΡ]4!$jL ,Y0AfM$ 4Z$ ĥa#aNu (i:y\r/E60$4>>DF`z6% RGs|]0hu(Z.0d¤3R9t &"y ZH&5JE=YK`^tt?%y8]Y49 熜>ȱaN~$ahl  DYEpn"DA6=X9^v*8  t'5Wg8ŃG\(%BoaT aP i1,sɺ (X84Cicp$<&+\"@.i855Wa2 [B0˭Sr,Oh9@h ',A@≃pN'YM(,^](@M(@Z XiBxe*W3Os"Y OhE!e8POY0i`6I@#53d]SʡR.:?bfGRڹ0W̕pCD8JaTGsLЂ8\ckhB"),Ё0Dkb([>B}݀ Øq+ h4tK E8\BDKQx(#6e "Kcpi 6$8A 2 SOڃJt=D%iMpF?S#%B_~&` ʉtPCPoO` ZgdB,HТ{s8Gb(RD*kxyq+5Ԛc2U\1,Rd,Da aPU^3dKԸKJ(BY'8Tجr*G@F  M*̕q<0z ѐal4 :ĩ2)82NMI8isC0*`š`2nB5 f(cpՁ"Ҵe8 {ꃌ[DДrI1j6@l[g:ƊP0WG#h< !q!Pgc*0HG H$O7d%#2! h 5&|6p H5 (N$p *FBh%uL80L,PR67CD #{}\fUlM`=`A0CARK#p h5tjM*y1Y?@X0ϑ)M\($SMqYE1 !#-SE,2ihPqEBR) h %07U<C2(P4*\:`鐗eRAoj/ . =XM<HX :K ekd5Gcl LtJ:YC.  &iT1(zC H$%7kcj #TxDpL @tbl^ /0@Ր!h𨺣Rip 0ư*22 iM!Q fƑHHYɐCe1 6!>5E0aL iA,` >0t 7W%l*9*a{2l̙qB ȸ,<"re\!/pQPVpؒ!N)VdT..AKdqI8㠦TON~j#&؝Nc$RC@IA 7&5WFĉaHc !+PJ>P,Aτ.UJ"14q3RR1J 9np 9 Dl<O>qVх +-'<v3DH t h^fOjRy,)UQ?g)DP\L!28Q pe `Nq&Dc,b¡ѡTQkR "(H82d:bb,d h<\ReZ ihq')i^U" Q \ [YN%a [NXcU fS%3C8 hցjx &TBc:\B>eX$kOaxXa` Rc}m(Q@4g(a=P T)el%O$Act@TcQA<=N)I~*N:#(2lP 'XLi G  Y 13@_$JhZG@&Bd8=S"b`8;,5դaeVG'Ě,th` ApajUN=z57=8CCh٦8DnS"<(d}h?`! a/{'I)0}ƍ$gX!VPDW?EafĘ&vK,LNzP"!2R<Nj8`J%M L`qLB@gH3d%>pl/C Y^ JB9pL$eAƤ$",zɑM u$ ϝ xL6ƠBjBr2DAъXJ̋*F#/XpDhi6tȠnZ4C<@JC>*TJױ>؃dGsV@ 4Ec 6=Z A 5MAT`ZS 6P  :wC!lG^@~z$4^tj2e"OkAʃnDM(SX^ , -  Zk c;e $GT.桨 Sh qFOH,/h3o :K#@ki b $3L5D<5 k'KN 3dTrJ̎qa (%ѡ-K0beets-1.4.6/test/rsrc/full.mp30000644000076500000240000003102413025125203017074 0ustar asampsonstaff00000000000000ID34TIT2fullTPE1 the artistTRCK2/3TALB the albumTPOS4/5TDRC2001TCON the genreTXXX<MusicBrainz Artist Id7cf0ea9d-86b9-4dad-ba9e-2355a64899eaCOMMengthe commentsTPE2the album artistCOMMhengiTunNORM 80000000 00000000 00000000 00000000 00000224 00000000 00000008 00000000 000003AC 00000000TCMP1UFID;http://musicbrainz.org8b882575-08a5-4452-a7a7-cbb8a1531f9eTBPM6COMMengiTunPGAP0TCOMthe composerTENCiTunes v7.6.2TXXX;MusicBrainz Album Id9e873859-8aa4-4790-b985-5a953e8ef628TPUB the labelCOMMengiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000TIT1the groupingUSLTengthe lyrics` F=ɑA#F0C _ϣ)a2z`X" z)B"$) A ?vbB 4㰲+¤rR%;RQ[7UW6dTUT!ub8 x?.=L2!Զ#9#"5fE$ȉ,1Hez1!Ŗ)((,c)X,qET9N@t?` =m#tܯINi{ޞYgSdE/'̸g W&%irDu6.tܑmtP&,DuhbDN`Z{m/ÞjGH<&ClJP1I93ZHF;r/viÐ1 ܉2 y?z\+dDΪ1ckTIwb %MɬH#tRʹdꓳ1r ϳһl֕ lΆmCUc,$(t4BKZSQRŭ݌>l1f:Vm&DGB;?R+9}+ϳ+UbPS;1%; Rwt9BRw5ls>ʸ#uFlɕћԪFJȪyEus`" ?#ta2ft*3άVwVl̸c@@o2Q;> ?s:=v:VB/l-Z$ۙ5O'*MD' .zSW!a溡Z!3#NǞ#!cH5VA``g;RWTUսQeTľB%Og9F FTj9"(cJb#!ǐx3 E`, =ɯ#4vcqL8aPq2$48u?ݔ5RlogGЌi7ՎKw"1_ܧ8TDg;YF%P \RF S3: QB(́/ߟobnYz(uJ:IdZJ'J FfNe+ 8*&*q2 0 AAD0Q!2AaQE`6 AEt0/׿]s 仪D9FzӦ[7t+M29*FED2r9ҷI|Uzsp+ J8gRf$c1!Yz:d#*%P?{ɦ5\R삊Tv{$A Rki!ʁ(|rp<+)h05"dbBVAǣ4F:>y)ݐQ+7cfT[WhriO&׊aҶѺkLW0?pp1uzs8~9^;ar:hZRo|AwXo**>@0WuJFYYr\j*"H~mg*:+"ofi#E )JWAC,$aq)p1"PH(ѡ`8bo5 ɽH"3z׻r/Ȣ ʮd3їyNq{"Zu#eR se-ESip(0b3syNErӊyz.1,o5+9Euvfv}ȖND}l+3UTCo>%OyS!Xi;\8#)Ń:cŕt(P?f`x 9 ٤Gtb$vftgdkU-ƤVfR>ӭK5(i5; ъwi*"*.*1cxRH@#b@-FW?+kL*ks*gJ4`EviF"$z9Xa˔9pPXT]c`qy` .AMGt#%Yt㙬ꙉywکҥSkYO<6<&T눢!wBE2 J4\VK1`6q1@#W̫:*\ȪuoH(έb+JB{1jj7tT2+9#<ȩ* ayQ13&=B:``둂 Zp[JQ`A " k?dRɿWDJ2oRUdf3o5Kkʄybt3S5 , 1 rJ0#(@X$,84xD*νey5v}*KU2gRtK"oELʌE:2+ْ}H8fc )Bads!XPrx,($0<*5 4l`AH"|M[V۲NIbk3ߢՑZ~jg*ؔssBrQG )10dw<)]($aE8pjhF=宛k*"f5*YC^bꧻLղwcU<;0=Q֫:&q+T'TUgeR B32)D|]Mɺ+Y[֦0)b5EQEa!\hA!ذBg (C &P8|DV&Q`Aǣ4^,fd[Һ VLs ws)Pz^t252]ko泥]S2gsƙٞDka9#"%F DL* (B" XŽAb>Y//J$Y?RVT}ފrQKjZmu}vzttz%TS1#ƽj=ҪQ!0cH, Ê,w 810 jbA "}s@:XȮM*Ccr#DQz)Nb-LQl0׳ )k ;,5ܣ;" 8ЦA$qƁ\MEAB@( *-4їpRVlUK&u+<ϙtLv3ԳI{Y *R<=LUaqL (sD4P !8`A" w"ϒMwu]fFHSRKwqLOO3r1Gԭmͻ;=S8y2l#0Hp % _zZ2> ]C\ȄVGGuy(rJ[BUݟaj[ՙHLRyC%q]Qb->A!Y`1\X@` y?`6A"#ʭ&ʪ˝}V1ֳFnn:[mjsF!b)*Lcj*0p>$av!X4DH5ĨU(p@1@7R!#~ܦf5fb1ގS"iPvJ֮d1qq20:+T6abEl?2DD\M"B  c4\6 `.A"~T"ǞshDmYddSn:3c%5HQUJ:G4Q(tC\{+gO쮕Ԉ!O%f0@>q (4Q!0XeQCg?-I"*ḐYInjH~VkgT̎RK[F"Ψps<CA r$ 0RWb^Aߛ->Ş/&@z)-^𼎽me VE6$4CdP$BAţE ",8SW9 `5kZTC^5bTz-=iCB)auo,8c ""@”AD*<:* x:@DEEH8:.ͭGHNޱW%zDoe_?)leyX)ΜzR̈"]˕L'dxtS 3j ~ds`>A H" #c0&VZBrdpFEyFm *ξmYʊV}L+>j=,-*UcB= QSP"YH  ~(`JE*!bVs `#B(` n?mFb&@M[`beets-1.4.6/test/rsrc/oldape.ape0000644000076500000240000003320313025125203017445 0ustar asampsonstaff00000000000000MAC P430' 0o|qDDP('+6Dgzn`CC-]XUڽΈ̹Ra%0'lO[&(H)"i%6aMJO1F@D8qlpE ]$ wvy0\v7I ̇m7.uM$#x|Sgb;<|:}|{H?k"{w "vt`OM".+."q=f; F&8e*3+GW3rKn>+{ &6u_zmeS)K+2k 8ݑc3L퍎vWܸёYຓ@1"kr}qIu [#wfC?sb [XA =\ yvJeׂf$0y}: ح[rz>Qx( ~ 95QK&)9ja`A8ז ";bR3 DW v)ϳS̲A}ꂇT֋DcXHwWE>':ЁȜqqoie4># -&}#}F5p㏱*"(taœ")B -,AEexjچuRHn_n(g'v9H^%!?"X@6כRy=ς8/3VM»8u!rP@ qSz~jwxTvT.'>Қ?`XT: A?1\@dF|ɶD6 X4,MR#&eEvS+vWsJ˞x̜{[>pQ t) `mnX^x-Ǯ=捴|trβ!ɰv ^T Nk19#i[@Sɑ&v_.oUtxLڽH/mfɋ.y 8KQEp:7'Nn 8lho=}9P}V{snsXiVӔEIH=҃sK{cyOgP\3yy8#;t2[m y0Qv{D3@ hlXqK!!ֹڇ%5P]|#i2 EL 7X)E[[Z$ѾƺŖKWÜeY#,ބ.e<[؆,I#ҷ5B 6)MP12] B'i"-,XQ3_@Y5ΌDdm P Ue*z =,c-gC6a1uc,a$zܤ VR堓$}~9EZ1H.1.j{N]aL.7 DQl89ggvDm^żDUV.OXEVſ>qzQ}(#MoN,-~bc97 W_ 'sNYO j]feu6݃3N+1g`j;Vn6pf_OBdkpuq\HMkbY2}Pe'qCG[J7P~O k3/A ]%h@ ozWl0ur[KŸ}ng@)E /׿A썚Fz ӓ͸B@[ˠ%!~n8oH#7=G q`KaHPX{z|\+|+Nn{W*2us1\$JE3۠(Ql \V 6춆R-]SM}.Z+)Bw{]׼B􎠣[~uk*[RȃK^W.yx^S`X T9 } ,f%czCd/oXfg/Lb zI9z,ik߸m2H:șU36V-!S)Tt ߊq4zM=03mc(f4E9Ji#$ր.fOۚaFaoFV68qc:u[P_eff$;R=!MƗS(bLIoW>铳ӶS{I=C.'?^HutqYn1o2z.:L?mtx) L\󬍩Kap\!)P#?#X58*l&#p(mL3BAV$\^G4yް"QsaR%VwPRou͑?è| j FZ UwLetޡ\]nf y*e6.PZ !i{Shgeyy& 6#F(;`;SX~N>^j߽#䬭V%ކd8tLF:n_ xrr V;!H٥IA_\dYtI˾z!%,"{( iůIemnqɅwX!qm~".5088A&kypg!O 5/#FvѾAaI$IM$V]t \^*BX/e`{h L5V4hE@Eh2H1C|gg߮ :O B90XwTl}$4]lFެ0eSb+T#A陡:^hQUfB\m\MrY^`AEQZ̪8#:V:gJoC0~}t4$i@b8epڵE+byb.v0H9('%qN){b?,-n xk ?UTʰ4vlP{%݋vuݦBnCjI4CLJXk?fCjp߿hq50RHU*-m:|5;Q&PrnLU5yI,OY*)XLN5 8LV6W 9Q R#+ KXg.n22KؗT݄F0XC,S_-, 3"mwrkBRrK.yV:; * 0kV!B Ԋ쿴祽Dbht+ǵ!dt'UNaRV3Jq(<xm%cw8Ҟ gVlz/Zφ{ꇕG@vpذUML-wm<[ iA8\0"k87ʞ?? Z"B'ry/ G)ʕHjd/EL48GV^Lr1P=g(F݇VY4dSLZ@,چqsUwOB؍i$B(ܻ/Sd3{YA S'X*=eB3;n1 rrG۶ͮT <ᗉysWC=Ijyg!=m\2}bD!b}W=Xj:ׅ*hA81 #$cWf'F]iJoYd md B~!=9- Ե yMMflpu敹6]k6%{둞: ųŤ`!T̺p=ygx(qZn@Eaec6hoU[u"}a䝓>:)߻^]Q#yC9ŅGDZj&⥎A=䲽umq>jJF!Z{^f! o:K\tJ8X@ j 4;07:M!3Vdܱ MppT]?lrV/b ֆ68.'[$<iﮂ授4 ͮ1܏.~pd>?վw8PLb\8Qmf\e+ͮ ?d)d󎍥WIr$YB8v@ؠ06f%.+1_ʣ^ <p[Gpw ٴEq6۪˴כ_-/k qNKLDu c_DM!wsV -U؟Aqg@Ukwp~SE9tݲdut}*&YezSE=|ç ¦ ;<_oL܍|'Y=2yn}VRtO A0g:K{]I}/jҏah_t I$5u&.q2^wdMMO1-)zuX<]I " ,/g |>u9V/5k-![& F {qwh y3]P\5NDٞocASVm>[}kE:"T ?W 'a3QjuxgnrjMQyϝhVW&E)ȫJ.E| 0\=e7sZ$ 5ޫw?\."<$)pRw6;n\+@iRxã7.V~傦Bmk4@%?-f$1Fb㕲PeCS r:J(qf9䈶L iNS()%wyyS($("-H2f>V$xbsh%uIm+'Nd럈?p˗8}pb謏̈Pī(KL.g kHa+x31+O̅ar7& vdVO6!3B4,F|sķw@*B`_]q>/ è"NiK&=.h-J;Rv;YDpT>Jm؄3aS J+`=*1o05Y1k2e:Ŏ5Ir W11( X=~`5;B,d5B KZO>S.T %*źoO4yJ8"B`G YS# UI 1ny)~Nj7Zg\|E 0nSōUP Rf=òUE!_?~ +ΰ[!W;#%tqe*ʎL Y9WTd +vs~V!@ݠ;WlUf6Gy %rLb@-,y MxR 伝o^dNfm%1[K;.B,:^&>/!3|֫SޗyTF?0E22>jQu?x~$[ӫ8{w!?Ia)!@ఽ@ jTz\+Uê߼TB+.%'Z oMO8b(g.EcGZBWx50'##9]!@&ݢFLF*ߕa6`1\jܦ#5~Z ;s؟o95 :!$`mJҨC+H;q !LOb5O~ 6DokCZ@ȶDtx 4 \_7tm9&DtܕhI >kFLqWPBX`#A#Xn$u1Y u|E)xH.-\t<7dƮ(T%(͂%ꖨǨ-7=aߒֱ@?T}pp(RW\T@ ;)BhR9߮㫴?}_l &Yod*ʿdD ]ha >yTMp2e q8/ߟj ǫɿϠk Y m].}`4ʐ1Sy;,ɯt r!M1:/i)XaX 4R(xIfQ3s/5',,Dzu[rwni T9Xs \ؠ=1 "co/ㅅ k?'-HWk uk1 0f-Д5gcq;Z5\Y5*[x RY b_(%La h5(yn>D-/H{uef/TCIǷR+FlĻU8fBa<9 UvnɆ`g K1ND߷ L? :YS3o 2b~g (/y'@I%JZ3 llY= ^!uaeA(34ܵSa^rrmgG`+n]8Ôī_` Hpn].2D{D|> ~mWxI#gz;*~ zi96@<ƺI&o /ޅt,=?vsܥ @R)e~3chiI˝99|F{C2V1SYe@ 9_,  9%b#2`O\f+e^Ϧ_~ag;i :3mm {w!dP-Sg0[{efO s@_!umhM}!+&6YH CD{_Ч0T7m`)C/4/a u}XcF SGÀbzEsڂXs ّ:d%NfV掶CXI +{A:3\4&0]-kM$8j]at /h& (ތj MΜ+Ϲ8,'CY0neC"ūB1N>R.p1)LӅWRWZl݌ &+U&ګu~X"c"l@u0' SXZ\%trn5*\Qz=@%|.TO"ظdGE"sXY!x}MAdL\ͽleQMY^))*}q  0epwv IW,2_FZl:%,,!9yӔ)[~CZD,M|N_J[1hn-^rs̫ymLBPyk#Q{xg[ Ha wwG՟^;{sB8s9s9sАUA6q HEiȤݣ$h r GR TI) 4d!RH!RH!R!b) *2,2,2밳:0C KMXckZiJ)RJ) YdAF!R!r)BCVU}SvteׅYn]8u}amXeY9~Xet]_XmVY_}xu]n̺ mc}Yf_w:㩪+ , Ƴ, *±,j°ڶ1ܾn,pk1Qu|_x tu]yf]utG8~@!+8$dY(Y(躢hiiiZgi),iiZfhk)˦jʲi캲mlۢiʲil,ۮ꺤Yyijgjʲiyjzh(j,[g試'j*˦ڲilmmim-jۮ,ۺ/iijg癦il+[(扦j,ʖ癪'ꉞk*˦jڪil-*ˮmʲnljjʲl˾ʪ)˦ڲi-۲˲iʲim.˲mlhm-*۲-,ۺʮo-p0l+ۺo2}DӔeS5mTUYvee}4m[UU[6MնeY}Yma4M6UUMմmYmaeveٷe[uוu_}e溲˲p p0 a1R9Q9 dA!9!92 RZ !Rk8ؠ)8@!+TX癢jڲcI'H牢im['牢ih뺺.뺲+릪ʮ,ª+˲m°ʲl۶oܺ‘.1 G @6pR4XhJ 0!B!RJ!0`B(4dE'C)RJ)RJ)RJ)RJ)RJ)RH)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJRJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)R pz0 HQJ)Ɯ1cI()b9RRi-9!RmsRZ13礤[9RRkVk5ZZ5לs͹k9לs1לs9s9s48ذ:IX`!+Ts9R9"s9!T9tBs9!9B!s: BBA!J(!B!:!B!B!RJ!B P`@ #R΄AA AQ3 BL9љbNj3S9tjA^2  (B1A U DDH.\]BBX@ 88'ST ""8>*,.02468:<@OggS@='02310276;:?CCBl% B8FkF#zJI`76BU5: K.[/I ~JJ@!T zwP{@ۂ_,5@4> k@!TU40Mt+Wу>vyPU|GאOӱpB&>4PU'@$O}%M;: >&* @ta)t (4Ore1M0BU 1>Y(sY&-RJgMkVOr2IUU@EO5nE~IӴJq)dU)$ @Or28Mj Y"eddcw d :Or`ۛTU:]\\{QFH4T05yk2XaEP + &E^ #&!ir}Ωaku)lY & Hyk2MP*jT1{Á1^_Y~vtZ}.95\1)_#yR NyB1,r(4ptmp sRL/g9?0M),Zu8 4X huҋ~>S$=cr7Ì0ʫb*߷QY4#Oű = P{dX]59  f)6֔A`f^!-VFļV ]tGNy49aM5Xd\%`8'\57M?/'x,ׯ}YnmcfFU)0޾ڝP[+T2\S G.WX;UFhkoF/u c:78 0d7\v}iEM M3"~b#Ah'R&y,]~MG-9#8뼁#s/y78Q1%8iup;_I5?&ҵdun{!:υzU.25Bɖ+q0Q*/CU&^ܱ-59q#} &68Zлt3WܙohmP-Y?{LX8&M f%ONɔͯbPwWI;w(*hRdPLK?7IB|_O&arvQ$kװdlLc/["R#lc,VݝHbz{8T78Q1JQ3=Hi M6Rm|f Z*(Nb&u@-jI&&ho\[Jx2 ^C7^:YGcFO}ݺ<~2P"Sމ!G&SIu8Q(BRHYB,-ox3CTX| ѳ^^^)5=%Gpb{fy](>(:;ubj:kGG:OggSD=59 % . :>]?TxLsԘMPX+q!^#a|S.㵕Q%(snl]N-{~w80BbqX9v)l}żbId=!t,W79 Rfo<ӗUe׫p0[(w"u8ބ2h"hr)=|P"r:I"q蹨R""u޽ ڇ.Asψnn7'e~ u0B c@2 8>h_6zyuY|$ԕŅS n'E0!>~v3ayz9Mk 15W7#eiw81hbFhzmp4<* œ"N|oSjlxMqIߖ'SΗWKw:&U0Tn;bb Pe 8P<0P8ӹj xz}]beets-1.4.6/test/rsrc/image-jpeg.mp30000644000076500000240000002432413025125203020144 0ustar asampsonstaff00000000000000ID3STRCK0/0TPOS0/0TDRC0000APICimage/jpegJFIFHHCC }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ??TBPM0TCMP0TDOR0000UFIDhttp://musicbrainz.orgUSLTXXXPInfo(!w  &&,,,33999@@FFFLLSSSYY```fflllssyyy9LAME3.97 -@$|B@!w_^P1@T5"LTBhNsy}@3;B2!?}C'Bbʀ,= 7cE`M.2 :Jw f}w[?ҍW:F B5纡^*+R:kB߿ۮ[&8Ub0'QX`he2yYR!) @} 86`v"6FӔ=؊v?VaVӯSˌIleM[#@~H'zU0fZc{Τd ti1+>bWnjkv}I*shC #9!uk{jk+Q@ڷ]+mFnWR; @aN#4HWJU?^g=vy*g^M)\ZW$4>dLN[>S3* G_xV#B)eTH N@k%GI,eU]zQ.,-"~}JW}TE> MB#D?\dO=!btph'rPX.vlKRW  =!'.|nXNkb aWIvȇ'HT'@if"rٕz:F{)Mk-!M.hq'fz0@YmVgwT x!G!'Gt g֏2K.ֵmޓkR1Uc& hFRnY =  '#UdVir N^1|QZ=O7Zҍ}wC[fB> R*( ?Όk;Lf*nSSysfiT*2ي&;+ȕ YScY4Fڙs(M4qȬ4EXu(fJ8vh dznE"p z3Ӣzr|RĄ @ 7#`'o׫bߗw.L7e@K)um.W[MFvR6"mvbfQYGuJd~2[qfBI+q ѠYl;4p2!fKCIY%>#޾?ޯy,bAY ,+ %*?XRĖ  =aH+-zvcF1ސV?B'ȋo$R[ dt# 4 KY$q:4s#M= gdLt2f;^c]L[;SD~"i]1N^ w/jO)MFW+Mȝ6_+io̊% ) $'\W'56?'p&ݙqRİɯ =9X< =}awLNgfV XuQNkJ$&gȩsЪȄ\?M?XCe+0Yli4,b`=@DW-oUIT;fʷj Ւ:tх s5K[$?#D ?x́-5 d_eS9$, RJͳ = 6!'a?`I]Kief;i#R /;:FS}Ls W=hc 4\j" E A8. `& r 句?򇬿-B}VATQ>Zv_WSQW3P ;N~U~xJn,!h,Ԛ dj-i DH\R  5A J @EV"keRw)Ьtz.E!KnvzW[u7NJ("5@aL=#Š@R䉕twiUGQlwE#GPN#_S̞?WcNB%n_Xa0ejR 5`!Ii̗31[-y?nK6▪{yN~;sU/ͯkMl0>P`P8XLT,aLqYu>yrvJKu|1Ef0 rl즽ե6\on-dfYi *'m9!D##98R e -A &"1Y2 iR+E'  NOJ(aؙd%y3;%frg,XPK1S&!Ib]aIW=˻K=\ilIus?W;e8gUYCcIˮ)MVX`L3b3E$TVRLe@w</wMr:jdZu@WWG=A .yX<,WQ`f[:+gdr2v02+#`PH"(M4D6ϋ2Jݲn0^gZ"ClVԪEy~a5TWdjja#TDL@l\ҶĀRKq@= vYq'j%#+ўl2Ř$h^ ёPI/^S4?̛3ѱ1WO\+u :!HN /s?@>;)*_jdJ$ǙYa L ʃ 8٫c} QmQXTQ7(Bm2 KR @= q6 HlAl|Tm(-r meȧ9YcwoŎawV)dK&Θxھ ]\)q]LBUReN~~WߞR;'Z\x,KMR }@ ;A6/ǘ9sIB Eu;#XVD8TuGtF[.+0lQAj$?~(@MNV' -6pP\Т/?B%Z*Jta.fԒp-s.9?K=K^gϬ3 :--: `@R %` 763 64"()芢C$3X-,t9Xƻ4eSUf?YjM %\ȸm `dND V€W5k3SH ~X);ZԟQ{- JIxeW+{Lo&I1ID`5h6 A1@RK6H$D~^?uEeƠQ : R yj6`&_@;vDC0u",gC #6|5n7Οgrn47]+ނaCO]:HOX0>he,(Tn54uZ<gZs.Mzffvtb[6TzjY: D*q8R =@ 5Ak6a(at$;^Z,Hں H(Dz3EЮe3;&Q9ou%n Cd=+_TX%8< bHG 01C Q53{sɖ~|A4͏˱S:#rm%di?2c_tיNe [|LL,%8&Y7l:1R @A}?ͨVln48T,EYN57C5Tlɂn{ Ӏ=guYeP~U (iq &pH@)HpB%7)M>-;ck9r/ą b]CA$0LYpodV{8tQ 1#moHtYerRDL(R = Ә]4zbf|0 C>HIV{װ2l)M1 R3Te芊 U,1$ ` \u"桅?m8T),K_^UQ3H>2%~#~:t(s8ע 2F `wk(RK@w6`ii3m EM_nLDWcI3 G!)\yE:p&CmMZ7| &ٞL*("bL$"]!*Q S¬eFg/4&g 蔿J1 T1K;n\Ěc(0N,ss)% S*DsubF䉜\(ćVZaB ,`vJ @ҷgs{<&Nr=yƾv8X;GӷʮQJ c1P&4R }@ } i85ad?m?2ZfyQ)%[+lgV#^*ۿ/!rT}'.U惒mQ\V`}fQNאL^Cw `Vq%K yC=Z$ԋ:t^\Ū*鋃 ,m' 2F R u@>ɂhLR)\S&7M"އk: a!%-R I ;IX6a p@,%w*LS3209TĞs)ʦEtT΋SVf{nF:B۶'bZ(H ̔ju[kie aez<Z2UE3-Pp;1T*Iv:5զWbKFmK^#.RJ U?t<(b+=5jjB5t{Jb" $/7ۭ_f˝{{9|8>0 `eKkI?róm)2<ϕs9!YybM **:mU[ݙ:daC (9 ?RN!&':8c3sA'4{2Kc>ȅXe43B3noJSc$4FWU"M3ž*j(Cp rݫ d<ͱD8L. Y={?]T.s]um9ڝ *氛_^_:V[4a /*B R m ٔ !cD󁑔@*ⲟK4#p[kUIG$7)g$lˡ?wg3vAܪ@3F$r@ٲODd9ƒJ7"5טWBMs[Ae[CPА )˴rSR6*O3z1ˎ^Y;gN6azճ$ mctt3^?wG/>}6WMH(B1#F#Ύ6?LV@HnR2dg[z݈=),Ľ>elh꩚8iAI F<$ p„(r4IR - 7Ƀ`!h( L t?ÓVs5BU)8&H?p ۫ u/5am>;3nY#KC-RziH$ e8fB-vtyfD =BszIba\T]_??;{~uڲcIUP%C+08W--R ] { ӤrF,'=AC'mP[Tہ&cn5-~g_3iZb4 gtH,2!ipM4 4̌ח˙HՖYZPɕ'#Y&!JG#,5k,Fϲ_˖( NFf\R Y !hrnLAME3.97R @oِ`hR] t4beets-1.4.6/test/rsrc/unparseable.m4a0000644000076500000240000001334613025125203020424 0ustar asampsonstaff00000000000000 ftypM4A M4A mp42isom moovlmvhdL_LD@trak\tkhdL_L@mdia mdhdL_LDU"hdlrsounminfsmhd$dinfdref url stblgstsdWmp4aD3esds"@xstts.(stscstsz.22""'-+1/+&0''&%)(-,*).)(*%)-4&6.*,$,3/'& stco udta meta"hdlrmdirappl+ilst#daydataOct 3, 1995 4freefree(mdat\11;<4`Ife^P0#ܷbĻVO(*Jq8q-@>Ť[-3!rtg(q]bPEـ Dّ>JUl3A$)i)A#+:3`XaQ;"  f{q wZĠ3zf#)Nq'" kr֣ g' )y*,εx" r BXVF7nhϦN|z%e0Ue B_`9oJDz0)1F8>7t#U4#Xޙ`xTaG~fHv<`? &63l:G 莵NR#Aש9OSԦ"#P dp2~s'P=&!PG<0NE_'1:\ ,l qԺ<>&ÞJ3}]dQ8`F8I+:a;0>m>;Mۏ /gp>bSciL z:~H5M3'A+&f (p0!d:%C9B+]3ZcJr`Ea,])\Biw,y@úD'ħ 0q=bmBAwH! DS 85 !`4^^:$9υgs`M%ZZt^UY(o"09#Nѷ#̬zW;t A-1dyߔ3^" "Fw,rjs%sfep=`G @5?`MYB^;C`B iRi60 9v(GYJ=olW3 B3QhL$/b<~8ΚSn*jXe7#"(@ Ad*`'ሬco>Va &%`F x}<fP[2qX.'Ip" *ؚ'Rz[oӬaXS2p'2!Zx$Vn%j(V`FF@c}}C2>w1s5{!X[/?Gx2"%X aԞrLa\Ri"?p\ @\beets-1.4.6/test/rsrc/min.m4a0000644000076500000240000001334613025125203016706 0ustar asampsonstaff00000000000000 ftypM4A M4A mp42isom moovlmvhdL_LD@trak\tkhdL_L@mdia mdhdL_LDU"hdlrsounminfsmhd$dinfdref url stblgstsdWmp4aD3esds"@xstts.(stscstsz.22""'-+1/+&0''&%)(-,*).)(*%)-4&6.*,$,3/'& stco udta meta"hdlrmdirapplilstnamdatamincpildatapgapdatatmpodata6too.dataiTunes v7.6.2, QuickTime 7.4.5----meancom.apple.iTunesnameiTunSMPBdata 00000000 00000840 0000037C 000000000000AC44 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000----meancom.apple.iTunesnameiTunNORMjdata 00000000 00000000 00000000 00000000 00000228 00000000 00000008 00000000 00000187 00000000\freefree(mdat\11;<4`Ife^P0#ܷbĻVO(*Jq8q-@>Ť[-3!rtg(q]bPEـ Dّ>JUl3A$)i)A#+:3`XaQ;"  f{q wZĠ3zf#)Nq'" kr֣ g' )y*,εx" r BXVF7nhϦN|z%e0Ue B_`9oJDz0)1F8>7t#U4#Xޙ`xTaG~fHv<`? &63l:G 莵NR#Aש9OSԦ"#P dp2~s'P=&!PG<0NE_'1:\ ,l qԺ<>&ÞJ3}]dQ8`F8I+:a;0>m>;Mۏ /gp>bSciL z:~H5M3'A+&f (p0!d:%C9B+]3ZcJr`Ea,])\Biw,y@úD'ħ 0q=bmBAwH! DS 85 !`4^^:$9υgs`M%ZZt^UY(o"09#Nѷ#̬zW;t A-1dyߔ3^" "Fw,rjs%sfep=`G @5?`MYB^;C`B iRi60 9v(GYJ=olW3 B3QhL$/b<~8ΚSn*jXe7#"(@ Ad*`'ሬco>Va &%`F x}<fP[2qX.'Ip" *ؚ'Rz[oӬaXS2p'2!Zx$Vn%j(V`FF@c}}C2>w1s5{!X[/?Gx2"%X aԞrLa\Ri"?p\ @\beets-1.4.6/test/rsrc/empty.alac.m4a0000644000076500000240000001534413025125202020157 0ustar asampsonstaff00000000000000 ftypM4A M4A mp42isom moovlmvhdDD@trak\tkhdD@mdia mdhdDDU"hdlrsoun@minfsmhd$dinfdref url stblXstsdHalacD$alac( UFD stts  D(stsc@stsz #9I9Rstco udta |meta"hdlrmdirapplilst Ffreefree mdat 0-q](+?+F?ø8;a׮8фD ;;@t#0yF ]7xvGv 'Pu:Gpy~ï]}qq^7o@ݽGp{8R0v~uî0p]Gq{{2޼]8~qw.oI#wGp8" xGpaprAp*Gvzd#HÇz u޽,:nq6\ H=9@?b aG;.0a+{]n^Hs`s}@~Ck u@&[0 `Z/lb bw sgl@'ݼG@vb=v>xn@AG]s~Ý\RqsyFs$;sA <9?AT݀]n@;09F1t0O@c@^vTFhR5O L('4RKeu U|RR.؊! DRJBR)!'&J(Z @,T3DV*3AĨS'l*S["(+9BPP%1"!ANw#)"I R.P!S"*rI(BS  $D) JS S]r#r'xBS  r!CcT;#O o}M&wҊ?'xvpbeets-1.4.6/test/rsrc/abbey-similar.jpg0000644000076500000240000024333713025125202020746 0ustar asampsonstaff00000000000000JFIFHHC    C   " q9jaOȋ)bgkO.4āhKHǖ.THp߉+Hr8g<` Rt3.)_% !vb, E p&f1}qNkIJ1cқ+$0©J&٩Z5{.=~l F#t u'r&UT|!ziOᨎρ"\0#zu:m }T /H[pO$xiyie2atH]4dV<֝#ËDMˈsb|gF|% pd7-4GU)y>d[%2?;w#cJH2{qDc"}_!in[8)|& Aɾ U!G4΋mp1WG]Oܪlz%}2/bSo/ k|*p q[@]~6sƖAޚVYR(r# @u֜&|ZpVfteŒRlSRWUCG|=W0*F,vSuh PiEuyi O&C5>O)_( ^)q#[1-D TD{)JE`HwG/♪D83Lj7H2t3HA O RXu{3+A39kO8aIz0㳞lzn:`̶z{N5 [:U)ն5vDE@<J-Hsm!!O,"\ Qs$%{HѾdiY6\Abl?ªn5 5[qKc:*b0pvXzY xҧϖ1JU;Θ`1Ek8YRJ yIb=~eOn>\Υ&ƚi{D"<1(.ZR$-A]"Ѻ@-1)ت1jҢƷ4?J0R3x>+L2.lpw6H#qN7 J^qHIo4?πzp|낷%$WL7ƄR!#-eMaX \zW`Rs+m$TJ;-]QݟW01֮&IoPۚVeIvda;ڤChY0?| O$r yjql/.>.B/mӤ|֠МY4uoVsRd/>@%vmm%j+oѪN53Z2Ab6?bH.%uԤiu9EA.MQuMDxc|(_Vg?DK#* {?oF=8*|^W\gtO`PoTPZQZ!Mk{Q5wa>բ?%xX%':&1-?^L& MdVI{Y*5Xi5|0klW- R_YqnE.vK_߭@R(!vA}a4b U?\ޔ3f&e:r\_HY<ʫ/0tV/`sd9&dʆ :0迟n7;)"`n(n @ZP*KiKmKl;G&Xx.NF.'2ljbrrm+;)f0#liH-z S VivWQw+ƪh\]l հɦ AP 1{P;fi#S-Xf)WmV3U4C\U>z5E?d /Zy{ٱ`D&GU|QKVKqMT,Eֶ]&UTI [,:=] el<:ԧBnJlv Y.ϭ1"H]l˛,.Vg7DMElJK6xi#Ʀqd8xʠjv%CNRw;Q0S_p@(Zr-*mv'|Bji.yj_c80̘ȧSI\3n[#”k]&/2ZU5>֙9g47} -2c)l9=XyRjkEjLPsլ]Ƕ Wo58j9Z&н+г5t BCku2r[7 fMl9mH6 y{0ui,8{82s]"JLI&˩c&`΄u #̐C^ygAMI,5|ogk D{닆IQ_P02ʽp] EPe-r^,L@G6Wvs Ui36^@!ӓ^@cV2wQ<\PgVѾ. r7[kLJ#2R%"F昅paGksD?.dokԈYMuA;wkkQ>HaZ,q !e 'A^By2UսX\%=R>zݸW5Ly12;8NbT#)+xc^rUI{/eZ=Whqxdm?W2}q^\ug9$P.thoQ|cK!nO3G=W4.? ‹S%HsSt_hn woZZ?ˣձ5?T5*4&,oTkCΊRٝs,|ЦE<^S‡1Ս w }ڸGϠ sP6AB*kgI#W]X>V)ovIOYF`ζ4 ^ETZUxwUezyo;A`|tΈWU5)Śٶ%9*'"gt[T/:̟>KWy&fz]5u*`,4Ϗe$ 0ʿS`iݢ͓V/̵ٙ|/*%IW~7B6'|w x◤ [E'+4Kgފ)vgXQG H}j"V>ུX˼O@>h"ǔ꾜4G',ΚrIi˳}o?#eWDŽXGJZ!پ̈ ,fb:=rZ#V'1ͅ0 N."/P^4+/Ͳd{rZN7@[x?.=f3i仺.qO~'Zx;'ItbٌVSWNFVwD+9\[֫)ĵn}4tӄ?׫s@b1ܩ-]qb/)AJ[HCIg-3:1&@0¹\/= ė+dWMO=Z- HB2 d`W #YWCHa!LD3 [5=2!9NMllܠ]ʧkБqo JbgbFGzC94k;Y3(YKj:e=SˆljN_6GO w&$5 s\C !6gy0 3SQgN &3;g{[$6ǎ"fzc'ʅŕ+2"ރ `d {]ItY{I򪋸m.X ru58_]sk>WjXsiLOGG%_NS|ƭ֬Tw {,h٘7EVnќ7SVwψOL'>=T]JܫJѹ6=Wj]EA#=뮛v[bXKk Vc-H=qZ@mϼ2Ҙ]Zw H+F[׮DSPycڱ=Efi fhlHoCUU9jסnչOO?sNj%ukKptu {P!陑Th RFŠTtt8ٻG_P@3UĪ_)N"z;-V`U:,[~PɅƶec\jͧ#\ .2w fٿq;&L-UF^ A/ t@ +q$h +^p# =Jޱb0ҸOkj칠G4J۲c$AO0{Bd7ߴ4HBd e{ II <A ,2bA4@4S+c#H*d7r@a`UCh NKI@HHc&.~V1wJ``,p寱V <5QL{ݠoMZ*Ptv5ӗGBL$4cmUmۛ(׶ԧ[+\fDV~ߕӹ*|o-wn'.lW7.yr=6|]nqtnfl;z؄J~nU銖3WgЁ]Kآ "Mgʏ^ի.QupyYu p+U4>owc7uyU=7KZ,EreݖU5X˚ 9;**&]`f0\„5kԮVk;J2wqԘK[pZf:!"12AQ #a03B$Rq4@CS5Tbr% (cA#xI%~P~7ҧOkCS zăPp#Ojwg@*\B"mm9VO!¾{l s[99AlfF|>ņko2ݛ 6Oclg VuaBT+5ө8]9 4}geB^=9K5P*hbf%i[Pt $d9BSޕs K1e+\jՀbQaLu׫6Vzc LJ[|HHfC!U$0D!qFe:ݷcxɩ]6>ei[gY.6m*v66)ɹV?F*!.-FU_JUJ:~qJ[>"TYo A*Gs߹۽@q..1y sqW}O)2Lֽtl$%]j+vՖ\dQ7&I9\R K`"uTõz >`H[i[S6Bzm+4mX(x ʑގƔj7HqX@%q~+4>sfD'G:IG+rrCvITAM$~Q wZeA1R${+dʐVwF+>F=|C&ޣ m#ΰ6l 0G(6 bpN+ΌyQ3yjD#4Gry4 k&&xYaxc]7#ZLb2mQffLdT1]D;;x#u.1+e5-jގ4ԇ,kWc|ޟ*mՊBӚ#޷VV1,yU$wrcdc1#fhg-+cFUN,-mކc8$bk# (ϵ2a{}c*W[5T3J#2DUXm<:uw]eTEPBeeL>Ŗ0&MqH}h8bO v .PH؜kFUr~_OPΪ= ҆#ϙr6 WRB1K5ڤW(eʍDPOe߱ۏY7$i! g<12a`ڈ$ε~&Qޡ tE:}bjaq@;fAGjnc)*()I{&1+ z]n|Ec jMu2憙93wzʂTݡLb`tIĭƝv[{SQܮQ$Hpe?l7t<u rrshR !L޺slbO;xF1Nslw?>ARHg4zPHԽeήڄϥ KcpV+ԍn"qӉamM\my6w}P>Ҳ{ҕՑP}jli;o ;>9C(2s7@@Fafw@:c}, a5@l*P EL(] pDdom2mT/s#U^$S6/F]#ifѽ7zۑr}>L261v}\|2i|FzG ڒb Հ$S A_:."!QtUA >3EkCS^WƖS`ꤐKn-SM1Iǣ R  E ͏8F3hFu WS&T*lRJiD>BH29exIJ@@>'a 3.C5q"sfdU&]$zUUiIySjQ4`ڏѲI͏z>4Yȭƅ4#&q~tRN`Ttϩ_VjGv%eɊBjcؐ_@çRڲMηT>-81HWM",F :%*[O4i'u1a Y`u` kB Qnuv8ɩ$-F? 6 4CS:H m8{ڊjSQr6"M)I&e SͳR;(*[^I e ~N7?:m N%i#(rFN8LZhxT7j@K*^+UBl&ZTcD¹_ۼvLj63[YA"5yCCS;wOܓ߽vN뚷}#b|NqQM;}2Wrt\$+jP¸ف|faC|ҍz9h%sX[ 5Q^2:IW ޔ}1O!=z<QxVɫ`+h KFlaק-DEgᐤ(ɨmPڠ!"s6/hW2Oz]wGk W*\ƞZܮp(1ЖLcU,|5=]ǽI4v9-cqq#28Pzq?vdյJQحI#={Z$m*I.!_MK+=>h/zcArԒĕ)c8[$.5\]ٮ-䲕ePwxJ|f-z=c81Fd]-YdT\99nt(o58?3.2@%W!FLQã^E'RTy˕pȄa"@R af|b`xb6v[cojbE1di66-\JҬ%-j~+v5h`qq,Fݱ\.Sdm6IS=PWHyL趫Q;)-pd,Ayn)nYc!JԹW-β %[\L |YݠIyjR4 )65(ű,7ÿwөh9F(g[VZѭ䮟F=ZF3f"siܡ 9.QĊc<7_sNĮjosY>海~B{%GoMӸF=)yRT12sꜚ\I{θdlj%M$"FAQBf?Q{C Tf .ݱ'D>]\Ϣ'vm6g$'.ne4/&N,t۷~g2[@QD l{ ?*(mgW{Kգ$s9-*ԿDxq(mxb{y]t%ƪL`&^4` *CI6onwN1Ws_ Ryk4;Q9ơcɼ bCZ4`Z]\TI.4񻦸FطDE+s1\NK9i\V#˫]{cдsTj5_K#r 3ej8( M'bdVj[9K2Bc9ol` b478l zak^G I&?=i?'jYK1`$m$[FU֣QzM%:A TrF&[tw}4|EM9MBَeޟ+`=)VW\5$((5Mܨt_2桖;~MŧR}8ހ΍i<mkI+uw$*;$j:F[ L3ȅuYrã~#3_xtV"dW|%U +Dn/U`v֪u UZާv*S̓oPȲLBB1 aE=Sk4ޗZ FfRPR5#WܨJHC<Ȣ|LQKi&bIt +|MngS~ SE_2(sE@r!zj&;!<3-EoY-3%qX#$[i8 G 6-|ķ[k؋nj4TCb:rqmn.{IJk*l^^bІ;>^Q2(d<޵vFrhKCg{q/(H~(`b>"`KGԊڕHaU6mG[]O G4Rj2$3ZJЎTSgA5nIBe>\jڸ.P7?>uRݪ4_8I\2p6Y#syon5BKF xA'j3cݞ%۾}&ٯˑf[xI /oZR J.\Fhߓu oRmڥ=a [[w֊Es#δm΅gnZ~;Ć /QtԄ2oe(*Z9`<77.$L 7HC4mk0SGqIny5$.:M#xX(\hGsmX1+O\[hUQPէjL[Uܨ;6BoyHM+֪h>_޺Mi26s3V?TL(ԞhFed `\#k4:h|Cz b[Vj:>D'ap:ac4iԄLJi(O$E?knl!!UΔP9I=ڻ x%Y Ӿ;lVls)Hw&h}"4>78吊ɪ'$X[DݬyX6j[sKmTk'Ϊ~N~1ሔ4NL}9փ\B:U+ojFJi4Oگn5\7A&/b_FLhj=Tuo!փ g&0)|#kn]8)wi$#\ߗ5jTg+W1j +o&ST;o<sIg# +uoFy ߗZyWiRkKtݩpNyg=(OڸRG!R\R1@T렧Z4tSzڃ6Ҝw+ O:Tv:5q3.&.Y*;fܡ5ռáǿ>RQJZpQHlMrk%uhҡmZKZ\f-oG3ڳY_v$*/N*3]6}xC6\gh$:f ܐT0hJiMJ  qG:j^$"Eo[ԁ$%@uAM{bؐ6˳}7Y@Q5oJ|ƭszy"!@Rבscۥa WoZـf6U5d씆\dk׽09zTG#QQS BmcdFmpދv"ݔQ\f]cօFhZҽ[4;W"5cQzKnrmM\z=7".ll] ̺rMO;ֶ; "2&81ֹF t1F!I7G&Pb:Yk=4?zն'?ַ'lX]5bs=Ff +~r3[ǡNe8tl֏5m75pO_ioL(xI mʋH^WXF)THp~=YQߪi4WMi_ހ8m8URM۴6/-1UZv,S`ɦB+asεF%LMCs3~ձmn|Րnyx5فXSD_f>369ds"Y)4ޔ{o[c[{VGw1GR1 eڶ|6ڸLs|`PhV7Gjpt>J}h% 릢нcN+&q4ur݆gɫyJTCgnH4+Gq];OJWλwmheK GtJe0[|{<|P}rGޏX2 `Ffi.cLkmǼ|T:hWe{u55]aNk&CeavV>(؍GSFwoN79x9*`5tp6նm=})S˥s剮dbkP[Xvvl ڊ/r Wr>ndK T ˴z`=dەFm*Vܼfnry]k'QMs7`}WMgG_g֏+0y#d8P"76WcP_nyrIo# Ґݼx贷ybYX>†5VH7Z wSxjG͞$$U j]]>dyV[J/s X%Ģ_Jasڵ{ֱVP58+h>IkP&|HR1ڲ6:H䁴>dhYHd1H4ݏjmqez|F;o<-̺SzY…zN8O[Uσ<t C7I]#^5} 8_kS|vѭ}Ncr^ep 2ռ4+,g++iڣ>N!Y ):dT6Fw H[~[Ѩ27ich&xL"o,㳀*#26q<͑xɐoĭ-]|J0!QӖ8?qZs5$Ym:c;aiլS]9\:(s_J캱M=ZȨ,!o^J1 kѨ)ӿzzc1ɮ56Isl$@5q47K 9\2PtNcCC+Fv 'clZ&ٖcπMݥ0>ޕ5Pjk]KGІPr>|G BHarNH/sv[~8ϧoS5 ^vn.T*\d sLA ˣހDɜ9ޱHqމ_t?z? |r П]ZuPէ>z`Ehoz݈z=m:uObRV#-'"'RP "s @œntUEj),k _.-$c_{QAp7DQ.~"56ehx'vvݦ[Сs: 'RlZm|W[x%-<6ڝh@:hӻ܊V޴oS1K+j=.~W+vP2kGFuW{ l9+IA{0%$큃Wp 6t R͝_pKOK'?ĤO!P)tLHsi9- w4 զs*u`\Kv#ZI=ntG[Ѳ^s+,݈p#ڵWQH  X^"wC\3R#\$aA*R$+Ee 7d63~V aNkԻ4bB=g.}Dl<3Sn_2!5Y{+[>w_4HsS#A_bNQvRk5Y-\^ML](lPYt}S˗yfљ 'Nu ƳekMM̦_.˝[1a")2fC*CZV.۰&FrW53!iQEsBO2Ts^=5[KcY)]L֥D77å QZ~JYɣU'%žmCC&3wNq^7VQz'f|'l,j#Cq2Të.,Sz61eq+ ƙQ| ˜lOH2F&h4;rS4Ȟ bMe TFD=dfwrZܺ!F'r;| ViwZb,̍Vfmͬfu3t;Ȃ%Bro[rjXI!d̝O//a14/fuW|-eX+=a+{f#l8ZokgéJBnLK+ ;EyN1t+NjߧySRe33FxH/1̤YEgCAUQ$JG>e(k0E$qHڌEJ 35I-LGC^X^O;CҨܣVbƇ Bʦ.!GRkhލE\Z}'S'35+LjD y7OHQJ(+G$Cw j;СD㦿E>qY.x_9q|A S~6q؃#t}$Xۖ/УXE.~COT !(p!ϒE<8!Tgk,p'tr?{ +6CwEisq בy8 ^qC7NܞȱF&{ ת dvh^=)*"^hwRz,h?KD7O{ƹ!I^zjv;OOTWF'L4PZRJqs>O)M˱]rݝN1T?A"8c jܽ~jʕGG\FC5~܍jiԆ,d>XS3}.yS I榬橯"4Po#|؍"oˤ" Y(lnliNgʰc rW#|L)jU2 _O)|iU$U*\9~m&2YFr.B3<*1U圳E]#ThRZr)|f+s|U7R]Ť&}&8dž[Jy,,,94+q _äfJQM×yv7g}dvIKϨ~e$w-: )D~LQ\\v;r,2F̼5ז/J#/? $>Y2^'A7ב9D,kɽfl2$WSj7sGbXt.u鋛Q됧D3)no(n3-^7zʼnϬ(V)!(U"PwFk=9|?$.WEԤ,P逅O?5N=Q Z*֛ri3b4䦂JKfcK;q͚Ԋ=mMDYwR/Se>}ѳ!I)~Oo E(i=a?~'lsC6bi2z=d;^_A{_SL@k{ݘx/0}@HAxwB8XeFzt /|M*^HcQ+CՄl}>W+1R; `Y=l8W4oݮu-ĥϞ;ÉGH7)N=xffu{S Ir|K`WPUL5K+ԪRRG3&0$=X{zg>g? VTfTTW9exX67=]ίRmT\yUf^Ң;duWR#Z'%yLy c00اfQPDiMzV3PQ^F]v*%n `¼(]`Aˊ$*#)gT**DE$xI6׈87Oy1dy%&#C-J]#,Cďz0\!)SIG 6BL:1sI) S:zt}zj\wp 8lA&g^R]L 3r(ş,i2c@Xڹ[[g(b8W0A9gbtJc9[D]E -Xnk S2?*2sRbsMJ: 2K,6j9޼hj+AU )^7ϘGQ S7 2{me̴ 5 8$p@lU55CQEo\J'·E:kt; mHQ̒g.&nC=q{JGhϫofQr{k, c,yq"lPd4ڢCbUrcYeo>%)Sqm&,ogh(J-OoB\k RKݏ${vLVg&||Jjc1/g/و PE-YZTSFdՕ @}k /D K5Efe7ɳ QzE_)Q$Oy>'`6>9YOyC:y":Y#70(4*'a W cZg N}9+2e*8R,n2*|x %ɱqCx`Txcd|%"֝ bH"kb*x.@ØQpw0ȩJ)vܕn6}vz9wE }3)c0mY3/]EYq E(}RSC/3fj^<ߪ J1,ꩌ;N*Q usfS;n"d3W.*Uli0 wܪ7 cpޯԸmu+l0ժqB"3(tyytq.`=bJwW*> XvИ7솱b/\vKS!2m.(@=h5j`?sQ9 KFbu0<\]~MWU̿vҲ*0 qR6J[ cpj6bƓoQ),M4ʾk~e-}f&[s)R C򛢪adZ`{k>j`L@31( c}#솶m2Z7ʴ52֥6aN ZOtp:[R;VP~>_3EECgkfqA8ymӐ) M]@?lLDvG5 ³-Fspg>G s;SDz̪`I7!32!i> oLn̺yl[aQ;87`~ߧ>[|l9"arʶѸT|Fʼʓ:# ^4'ImrwcW"]5ۊ\AP_i7O\J\ w2*ՖQL}.QDaVM/ >8DCǥ Qes+lǭg Bŷ)njl3W/Tv&Q o0͢p[:-O2RUesW8KwR8\[ AR~P8$=O)*1Y &PXzF?F3)t7QM3~ \xL.V9x3~P΢c`pa^&BIf[/"@AK7GaYer& xs1ŽÅaG4m X {K!ɶ'ш`xtf-CpLa*AH}N -HYQ>%k0%:Ky+qȵ._'*$;kΗĭlm;zTn D=Fnfe7{P5fhxQs"X+‚ C2{],( hhdl/8?BfK9('S.&6 ]}@'izy0dn[8su,秗6Phk,` Z.# R:/Z&aL+wȵ)~UlgXWiKThԶ+?E0>A<!]d/:|=('R~:JUwyhcf F{q "Q1S>!KLk")u4YFYF/J uq ٙq*L!H%L>uPy "5_Gsc) *"Qc% gd֯sv 􎔼2n얮5p@=ģV .sጎHM-;=HD\Gs 'rɄ Lo0zJ{LÊnZ{O~]x0zWц\ۃ 90 j2^bF٧l䜸nnAM+ʠ GЅxeR ?67eܻ|e=:w/{d+EEԣsTKT,*~+ ? K SbU&8ڢ E)TihV`Y\S0+;0g*L(nㇴ sg%/"L9-|4J3DǼ0~C^ր3 /_{A[/Ю`ioYN+`Gsf-ʰ/6m Vki(i_Y@1H>Թ3+H\>D\;QpNP=-v83̬+aҬŢ?xl1i.Hdr&`Pnϴ PSvT,k HVMT$WnD=b~',tojW9xnX%/! nc*lZ`ϧ쀪*mo31k}w/4/HBxG6{ju8ԣt(= &=_"ʼW'dq#ˏzxA/"K̤ŰDLq n X5L Ju(ӓP[ &m Ux^YcwE6}O Gc̬?pBdYY 7EcOO4ULgBka3\n:D+j|Kx!^M[4RhWEY;!ys b|WR@H>X`-A* 5ܩV3vF)^FT@_WڊZJ&#l\Dx]T+lLXEDKYoٸ115Y5Y6fx)8 aUQ Ne~r2a|QI8X\B861}U]# Us=xA!'vn˦;h.\7d߳rd_8^+ 0%@G$A1G%JRP.)gc\i*~ԦʚSSWlUH!~BZ= z/063v̓ _=̵)QoƷ"AÆ >`a9b$PcؠH<;-ʧs~ЧWxϙΟ%AeĬvn}W8A43*P(M@I!b",3>"h=/ 7a⫘ i$LZx&5c ܞogqTM3-!:>3ڽ'oKRd2i.ܬ)p.V3o#kMp.?̦6 о=S+EqS ůij6R5<კ* pw6Ubr}`{ۂgZUQQ0 |y|l}=Acu v oQ%z}>a&JJQ([&*de산PdG Oeߙ># 8IFU&J@t~ iQ!%2V3N%G<Rţ{?Np+sUPL] b1r QAA=]~7q2|DnN@΋oF3OWk$c5,Wz Yx6T|rN eL[ZVkqJRw cG5g+ck T¾0:3*KRvܼsMK‚*(-4 J*T$S,˛J9cLJZ u9b]kҠ:H@v* >IF\JK<d0 \=~<>]qA3 GD|l?iH'P 8 j+嗦Xiz[+^Yps#,G1 v1[YY208S=nށHb3*sy2Y<ʖ>T5."(.n>ȔnVYBu4$ 3(U<1,!W4 ;^ cÈVU@ö~ z ΡB)DTۏx]NaPZzbֽ0 , 7UP[D t0KWc%e!bPE?fBwp^#@z cF2Jv)r(]~ ʍ <Ʒ&E1*P8 \: a-h` "RbZ}W3P^pG*.s5 ##R,j%iMEd7)\La8t=0Vw211S8.tYRs u.ԢU2\&1]Ttze!3L*Vd.($G8ݨxy2J˸>0oAJ bkT'SUT7.2b8FQQ-euL> a$]5^{:W8A,12Q. <-D3g_ϣB ~]35sgV\Eh[\!rjq2Zջ,="|UDuLF,Ӏw4tc63+.E: J^"a= We49f B*2ʖ,EC'4w ::= %=N\yFx3_rdNmb??~8M߼XC&\f44#bпzQEL9Iп(v;\ˉf,˒lr&6w2uoKxܧ]xg\ʀUɿ3'𖩡91~9g^@EOE3329Y9%*r=QlvJzK"(\Q~9c "ņLpcxaC}U[5wSZmt35p22+t:֢ Y_[nFeю;4QDn߿m0#ؐ9![*ܐtSa>n˶׼S:!%`߬Dh?0 P8G w++{j 3R%sxElM UX  dY^NbgXݕ0p^n.ӫ5{4g^S@jn#x'UyNU,]*j!ܥV37Ou8zN.?+[d~y96ϴf&]7 .$1}.| KBX%*0\?y†%oɌ j Pm3%s*OS9Xoe8:>]IaC}LoҢӞgGEWZᘌN7l%jd*qOsw`[F6s.}^}؅qzGVe}lDpj6J2fY]x%J/ S"sNA:BQW#ao)`>L!,U.!2wpel GLoUkZҭlccUPh~=(BP\bfw e  W Q=oa̳ׄW3n7P˛}j*C2}s, *e2*b{f[ 72z,LJ!}t ٽ~:( xz`29?dpaɸb^ `,6U@2TmX5#G_ًM_V$jqy;Y*c aC\}t///X>'Lr}0 mG$G$²yeYOvzcy{ sq?BǢ-w~ | vm22ct^ };luԠeqV{4=mu x?]&rLEuV]/0A~6XRKL)8m@y1Y2Yh=RSW/?J+wʴw4b6dEe j W„t^>\T!vD.!0e0B56EMN1`~h9Q9ΉeCr{Mnn! &SR%YŨo ݇B(RCO!۝dc#9M]<տ14eSla)y<ÎC la3 zR0_K~.zc ><~!MLأ{ SrhviVZ2% k^B,m2NY^ߙ,mu5*9W0pgÆ)n~,KO+):g703K8%*+ߏfc[ۡ7oLs  MVKaTU,R|!r̫(&.DY9%dxj[WCΥX8eۧq"2wV&GR >-ȃaqa衹>d5( lgy#L@F(Њeο~f See nbkes4{]`a|W Ijmf^BG^YGQ)-cuꉫ>\_)`N}J D׉0EQ,]BsG,ܩNY/ vjWc/CNa;_&b_V5l0SN21ߘs.s -^":}G\\6@>o jE2ʥQYOuh+;EE"sF;{qkq/[1 c>7pt?tKHhb8)7fc\٧ Qn (p'mLk k}oQN6 )T+=G2%p[5-ix#e7-qf K>[HT=ܦ~LS!+=ol76;e7A;R-7:QE>= qb˜Be8lw0:DS.ѓ rDƷ#B(q{@M/U1gl機s 7kq#N#Ȃqe\|1PbQ6LAX^}&U.[a4+7:/S9d;YWO[b.q%^¦4ľ8 s7SM˖lٸ6¯yc9Զؘ ~/]NOx-;䳓P<**UsSWDn%@h0FQLL@ZX<]f^ޖau"4AyqP\Asj+We1NJڼ`:|p;i7i"'G\E&AE{-XdmVZP*"":,WF3eM-W0,nه\Vb@f9zpMj21B.:+-KǘAlj1~IY=0{Xh70&ace35%1b(58ܫq ,8=TQK8Lqj$qI\6uwm^)S=kwS闸bP'eѠFkNƎI ^͂q{Qi}>yBg|E] ֈ]>73rVG.<镏 4S5_-{L巸Y$aa 8lkqi(-Ѱu3,//:7W_W(T3 gSĦ/՝)hvn?-c LN+CDmp.&#ZUSџܺ:w!O4:R~ u'kh9ǩKui^[}L#^qZV3фՉ<5Y=ىmeZl76RJ"^(Әm2Ly j;Y 7A% O)8,*Qf\d6|*"a"ŷVi).+m_?f^T7w"/qjjbhLu1~.Hz=ֺ]U?̹b.rp4BMI%L.q(5i*/ScdQ/C oܗ.z_8&G' q.W "m.\_XZqV Tڰg ncUYfK8.1Q{eT*YV eV#yw@Qq{T͛k s39YP:`W1QDCB^d…]E TGĖW [,;0 Ti f=bPkr̺ _Fq)=-iOw PEAy⢼V,;z50i0`3S~G<CMֈ)te4ZYpRh&~s zn,l~#$o*dpe/~e| gl/ 0љdQ5SW'.\>eSesGpK7* Jx1*2 [9ftRˊdhf3!Czh, w3P Ug:&PQӢh?U<&l7%VN,. |ܶz&iNqZsx=G`"Ɣ#>%Wu8%A)Z^x1ȦyA]j炖^ZT7~R<| #?Pv_u PTQ| kH\so:ͪXE(Uţ *Rj\JnŬЋze{xr.fW\rxMԹMlW<95⫈a:LgQUQJc :߁gcbTsInbs83N*bKg{arH[hu}V0HG|bv54U؜j>C69J+ 6IÈ.m&>,ZB.5؋nui;p~' Cjg^f& !du7YU[ʱR|s41Di"嗿(IxbܜZ[64`V^kS1fWQuE&l(ܖ+7[S@M Cl#AVw3S!uqR|6\d xuu(= |qp0-WϨZ zXE5ºCRWG0 @WzZWs)~ŰqE#": 1L.1NV;L*4Q5v {AW&Pr :bJ4޿lܴ\ oD(L>m:aQnLtVNl\Í~%[]IbX)F1|{ <|SuZ=ǢĢ6;9^q@qnZc08g3s.ɏa^"U|^*Ƭq^z /0R5ҙL'U5?te0vq.cg ;d[:% sDћ:`r_<J|7+> `;&s^xx!s"qQ3OEzcwsX\*ctqMӧ.=ĭp!PN1p-mzkA"B,LjHQʪUqsgy8vgv!1q>Φ 5d*7TÒe& \Z"@vq7\1-Og`$pAf, rBz&0UMF⹝yaEı2i!hk'瘳6PTڼͨb'4Բ0<>>=뉖>b;gp>bYS&BC 6dS C$ ht]$gU=zer@f_mߋ/GT>#D%vWU.nỪ f tE5AW&gz!yQ߯hZh89={b9%Z_ X򀳢_rpkJ[m7 )Bc>yv92;N~JD̾ J*TyqQ4- >&UXf7p ~2fpR߈Yqo"rzHvVaruL? rlkcyrs091ʙ`Bbzz-zU_pNs+ 3(i6u.  ǎas=GFlL$kzc)DfcVPcxOyEzfh58?HX8zXG2F C,h5Z?n#(5 x2 ,%{ Q]h̵0ж#M1 ?X+OGWPSaJ%F)-Y*!Жm{(֡$L)J;] 5̺Ug_A:W,cYU~[D X ɖc/]f#"AG1 @m|1\F(t^5p1U AjV(BJ*ү/n>y2^J#]2p kᄾ;V̬ZK.1okV' '\ 0 XXyknZOUhw3\vqO$j̲RylAȕ&܇1pЂƯ.AϸrCœ LGZoDG>?q v>X*S9't9+kٛ 3-7#t)d,UW] :bm}L̴HBP@ԢFqxJ8aE^YBM~ʴ!] 3˓@ WҦSL ` _0Ԧ 6Ⱥ*pDL פzNB=@Lcv-!x0{if\eo?tIo2O::ٻzR?2¶xy)BEc:i1i}Bu"ECPV1P: [cbj0oUAn3h߃if._IG̸Jt.zfsM 6&~Qb!,,[#]_qx J =+芬v; ǃzY:hwa^ڭcq-Dy@mKq|f:Jɉ),eG:&s#lUTc L,zNjh"~`dK YߩdA K7s-CEH/w(z5}yA-fd,o!@4"IR#h[EKcs5?jJ&&yQ*RKa.is¢ST:Oȑ\gxNM14v-TGRߟ"n}G:6&gxeA1@_*L i?3[)9خ"-D窲s>v;@ѲgX\3zq=/Y*$kPEwƝܚ.ofk&rJhZb2\hS?40!8]sM" &+,zJ 1QASV%KNay}/3Cppf`i[&1O[0\+U~-FLr+n7mYy.5 GO~Z_d2#p7q`xOɘ[k@@uFnE5Cz3L n-G⣻^߃0K55HaJ,/_,~p3p}2S~<//-0w[sd,"ԕ/8i2kLM ֥Fr莵.~J{N)| @f] cP&\8ARz;fڏq&])Ҳ ˻> %ou,0@\ Y[*RɆXVg!w2tm]侣g \ kSrW\}ԫY9FA5pQԬFBW ;)1|K\g(c En7OI} D-IL\m`"3?勦|A6=~>`8`I/lXviaqa>K|KtRn.[॥u`՗W$լm:Y ߜ⌟v'IT,͒>#ڮ[Ɍ4  qDJl&T3'0.Wo̳f *![uB.E&y<1B gU*(rL;Uخ!=Ǖ6=> qP ެ>SVP U>uYZZ ̃[$}01K#-zm"JmHѬ@H`i *e;"r[j o-sGcY Ў@w\]S^J)2L|^90i01i&]W4(/mmAG zLz255}R<2iWٲKZ|LC#Wu/F k2+G91+(Yox4|טTnU1F8J U0 fQ6 K,a:C爗>IB%̓urUx *5uG`9iWVVaFIK+oưۦT}A  7sohI$S*Q4Q8jWO&^؛A=(^FUq` JU<+(hQKA9T,Xs97,epұ,䔜<|lG^x_{aRyLvԫ@XZDaAT9qeT#fau,M|P$A׊; $` # L£1lEMa.тZ EzKUosϞc>H$.C9fePX@̄e_BܪW9FSA(LWJ _m 6 DGd^"-x`:pYvs=lj|-oPK2+,.;T!FD q,d )65%M1@ܚj)aJ5#.ɿDCr-#FmSv2Lzᑘ'r>gFF:/Eo AEm(|HFyI~^X;Axs7U3Id]0 }剣U3cba ڼ6PpL 5j ,8JDM'?1nBJ4?32}FGN׈Ѥ=dmS\J\@<̯ ꥠBa^Ks4Wc:(+Bsx}2IA# {sT bLU6.GG4pJFU E+H87-*h_2M̈씔n'zF4DsֳJȲvL. 8 uPML!Fzg2̶s: }b?\b"EqyW"ҍ[e,kK&։($V L5FW:cE@f!H>]\%?g%Q`~aӑXI䊡s-`bҀ q[mJ#Nb]5ōCH2EX/7vSx.B@JwP "ɢ%QsXL ׹Iz3^f+/䖨5+ĴB #蕎js0SaEm// -4yFW_1@QB3,GJzu)@`qNgd]c] pʠ6#`ܘZU\ uuJ.="6: C/ Z w!O]'@+Ø  pq(}4Ə5$*Qc4co/.!$ʋ(@+PdTVt˘50hsX6MbE-ex,k,20aHGc|#P2jq7H+KTs7UryZlgUFGlSvVԶt{:ձ){К- C0jkZ^1Ms,xc@&d,wxm %"=a䢘 )j\q}B(g=.4N5KA/q4i.ELN(BtDaCqJǛr)c0};Œo1SKfY, ЉnurS56AM}KwۆT0cĸ} hrX`J `STmޟ6PkxKU\? phӐT!g#G^QWlCJõM(=|Da1feϛגזJ|Ûl^L_6wY5S0,KJD ~fڠbf?0 ߉^qZd0 `W1spEFo0\Ny54Vib]BgN~n7wb2xw=?hүXF0*y8 Z0٘|~Rlz_A7w\N@1HY^mLj9接H-TXaExAsT(=Flz8s̥P?m='%;R&6 xpna@ (?`3Y%rq.5ɾ`)ZU=pHӪ Lv.9dK,lXxe6D,bpKD'ێvu:^fYv#]:Z,? g>ʻ E52q r+1lnd*.r,܎2_a(h!qsRGc/Ex&o l=X kљ F1̾+MW0TxLJ &ёudI49:Z64$ ñ%ZFU0zK?Rtg2#ĪLvO)AFɭ [yM! 2pAr6Cl _z%":4_%3&M3] !8f‚>BpbB68Y<1;[KD7}UtB>uv~n!"pM1G>ߨ+0f({ l\ʐɢ`yR,W2R4\~vO(094`q-3=F ?2bCθa] c '!sooƇʖS{%oZ+gpS{q_3+-̬&-A.1x#%287*?8Vv\ ?0]:)BDl $MIȃy|Ed r^0mkP"QoW& 䆠46%{1+nWUbTo8W11Һft=OIJUDKcBtܨ!ⶪ_k5q,V>3!CTK?i=::w{혞.ԬdRU<$,+_u/ 9V"2DAD &Ռ:]/_E<ׂQU苉f8_>a>Щ;R 3ʁˈ;;5oO$hHA8;5tD 79Kes ~Oq?UFqɝNb_W0 ƻk7ue]V\x-my\(¯$py8V(C x`SsNsb\ODbn] 8IDeS ٳӰ`%xp_+-&)uJgC)|xj15S +ÙP4^j6gQvkBzb9Vfk%K@e$ŷe<כ%A' k9;%/a,ewgaq[j" 17AR`MK0梴rR u>\$:M/P8)7G.%_2s4ѬLE(Vxb3+p4Op>FOJB6^Ɵ3~&)mɌd/휻Y~̫}3/߄`p LሢXc x+0[G9n&w}^. Tp>!KCŜеbEZ=U&&% K~S*jNj |/u^E(aa}aR*>y Zh %kbSܣf%D-"81nC -O'P@ ~X/.ê9_xق:y_gOP2߈YR]@h|N xLtu2r5} }ԧǝS[*~ȅesSExg(U[6`Rh#ZnT3=<vB )'q N5^˵)kTrBjyE963dvAAXdEOH¾Y[b`:ng-iOTwIeZy6̨<6|[72-&y>2ZRkmVJ~7 #wg\߰h.cYdH}7_UEs@G F[2JHB٠;?O+o¾c$uO766Q~!!䙏%nmV@i!OL1 pt<P΢Nq(ofL1y: uW7`<<ڕ*=D|D=@~oK> 8Q32%g)X{K-zN(?R1O&Z~Ì f3^Y7 SY|GgFFo82= |)ƷMUuvq2R׌a8Cd Y,VtPט<81=s``", ~2pdzD|>'eϱ…83Нi\m`x^scce_U%_BNܤC: Ð APOJ~rW3C* f?b7Fu6PpS.V`/eZ ֻ49ry*)ǂY3p/?+/*Vn>aj]w0Sw6[g*P ?+e$|3|=cyW`csʠs+* Id›] ^&-.uXQCE xHKuXd1@90lӃfly>(Y8<&s<Įbmk Cw:zN<aKv`^z!K@pu\D܃K;HW>֦d^Yh?Kb'/J~eJ~~%0<6ۊ9K Ng>yC_*Qh&<8'd_T1CRMg$i~@mBer>L=XtZ)c8X8s8[GpWx7?"(! yJS*KB5<U2]L@Z7vT73᯽q-<f7YIJڀlAY5./?])%e@ h#ASЎ`׳̪ZiLryCާG'ҪZD^*P*ޠ7yCcCGt?ZX)G[0kCCrH`zġB*?)q޹((H0.Py*Y73=Jcd P=J2#rdpHx 81`ֆqǸ֎dkvpudm&V >6up*8?@TZhP}Zf+R0"U/~nޗ+gʰY29]eV9z0ZIsɫglC]`K@#]rڨ^uwY:Jg ݥp%r a۩u̾ GVy[QW/So 3%9bY=Xی "v!0e@4e M5gqN1 q7[uRӎn^>`k"Ѽ?w#0߬ ;l]w @a->LJg`;y@*9*}Fh'5Jfwf^bGl`o?'bL[s`8RN9`-iܪq }0/>Ak|ĉ&!1AQaq?/Xr9#o}-Nof9"Co+h0*`7;+rD') wL<܇j-n" s r(Fv|ol :Pэ)]uee,.( MٚrcbM T1yV3\(P l@ ު[3,dk>05N ɣ  1Զb_2ҞS;AbNē߼Wy;LT0lRLJHNS~9i^1H"'"VbbMl˃hQU(e߅6} ל87iB;R6Kx„x޲eN`Nnv0"}.n)ֈhGh۱o$D@;|]X8|!w ;Hˁ0|[/XZOX ͽ1Y]/ُ,յ^ \{ʡ}䛜1zmvhѸ WF/A*p:p/;8!/m 8I64=4J9}O d&(<t9st 7R0˅><>Ac-uA;ʦϳS6Iw{qſX=1yոqйB|M8WT>dt;p1h"rBX:fFKMy,m" #$98-cII(fh| hmZ,IÛJ=ǜcK Ԏ }pc08psUkl2[uFU.#9)X|i,@j!hĤi βH)Xfmx-vitQ $<A_$W "7Z06 *mitM Rhܫm{w/3@SC@Hؤ7V_5/MD>{|el0 {󄨎_参nkM`HZQyF pѨGl3րoX#A >n }بiE-FINU͸r\,S~G x<ˬ>&! (T 2CQ]xܴ6>qǚ#hDRKAVH~1_8B{qEݖW':uN=b'JFvȼU0]˳*M8:hZ3aj ڶb jQSJ>1UhѺMt[:hRhN \hI|d'ulL|5<{s"*<<5! 4٦r 'Q"=*fΓ$MzNUP)q!Γky 4;<&#VRD&#Ix^vsD%T:O$V1yY.D2 KJpI^2Kef_'njP..D1Ka,,CN&KD#J8U\WWNn}z4h"bdj K$@K4'<IHƚ-""O>Ȧq6(\j#P4u*M.W" `gPeڻ]Cu o K֗K ]@r"Eۺ;FM 9 ղ\2qQe\;57MH!Mx+[t ^*l&#*v]b~(<{,R *HA&Py~rB J^]!0PaE!h=XƞZG /.=KSG65b]ArZq~q .,taүG`A{Gb\cS:0@ w 8hd5GmpQMBXT4fl%rtӫrNqrtrbS "*lU+m;DA@]W'FVCxYE;@]AP!+4!΍$9ZpVy .8GyT2" 6b:S:(H9ĄW~6QRjλp < ؠ#<%ˏmOx'4ת S%T ׸j`R,A +FP;E9)*:%&4``i簕S0ҮS*6iEcS#XÎ2]UThk?˸p9)ubJTd*?&:X<5*}/h8U2&Hm\n8֮6EbL$P!RX(=2 TPXN ČEsxDP*| 5.S۳"vi8bKLׁӓ% lI0JiJ:4epK3*JV"qx֕/knGxT)sО..4~1|+@wA1*|[P47;Cd+U)zB)C۟xBy'8De 󖿝lAH'M a0C tQ&ӊlb Pv_xz 6c|RL*xbsG#U.qqdfŦ,V(.~ jW)p B :7|0T2(A5%Cv9HIROu) ?Hox85 1|u4#|0W^v1NiSK`LZ8_Q*b/!m{ !a<֯z)ے*cnD $4 \zW^D[;z6S\,֛^̒v lLBl@z>͵%4TrF cƟtc#M#_]b+9 |8ۊ;Vf&'H:2U"榫l xQ)P}-pW"c"lr887З h+(XSx*1 #a|M=G91N`3~0ԠR) L%:T,t~<9FhF= 6iArfEc|fV&0o ^2:7 &mUMW1` DGxf䊢,z3D v1GjPy#H[^2Ê=D& ;3Kl`h*@B&4~׮(%"E(Fhtw1rC%r'XiTT9/xd|>گ7"T4 p)]dr=^p$:2ߧ3Sgh|⚞:9q.MدuClRͳdqHhxל IʌFw=*>>W YvEjlӘ@Tס2v(]xߩԀ: q K /(lZXh՞ 'mS_+P?)|>-lN xx5 C i oM;/B'9[t|a'D4TM*sdׯ?#Q,.3kӜ0sMl=dM۬HZbi5.ݢb9[]N$t8uV/+8~UΣCѰSP""_;ÝboNzūq7bL)MM ӌ޽c X~k1^퀎O,/} y~q2熢P:OR׌KC EO e2dayNsVw)]wѠTu5'W~iJyH|ٲsN˻}ajB jhmbGB)uaݦa* 6qыXfյYcG\C y|I+ѕn*mt_xn5.:hO X4پyA`x=ԊqPA6eQ>Gz0 N{dƈdd&ԞZ=훒yߊQ9q CB:¼P34w?ܒ)g݄^} v Y0Z9Ng!:y Q^H!h 猱Z N`Ya ib#H׬q (Vܝb9!𸤁-<઻pUUXlEFw%ebEx\ DU:QGc[&^TQ]Itt #U7-,ݖbQe|T"idw4,zu9ZAHÄ%FzǫRY<^'x&5R&8 ߶S?Y -4bxl6VA3WN778"(p$,VI[ap Z }6*sXq\f=qFˑ y#D Vw"yskw^.:@Ϝp;p5㼈D C) PVk{ /3]%|8aO Ya|1Z `lY"y 5)ӪH>Au3uu~LEuk42 4XÔ1`eLJhjnj3c;/h1&2 _;GE`zэO7`:}R[XhY+|г|kΟCq@hƃp.#mBBtFZYx1z!WF4xc.8@]8o}`6l|g E@d ӫq7"vZ&ّAZ]]ήN]D&ۃ}f5{K*MS}dHC(DK)c6l˗DAXkiǍH{SX;erǩt='8Gìb`++@*ȰÊyظ*|X( E8y`8 ½%@َ՘ pKǂDU Z 4-"4#gUV,LMe'$N 0SكI~1L;.fA(Ng4Cˊ#|51F&+AFrL@,zXף[1 hhÂ+]hG]aZ!ǎly)Ӡd9%嚓Рhǁ 'r<𝳈E1iL&Њ9(|1ZN=FCja^Gx(lQpew]6 xL6$J]ٜ1=<J T=?xD䠍l:.Ϣxɩ"9 A=t57#tah]*Ȓ݆WϜ@`©0tEي}gEw*xk Mӗ2 o,AT^p bD뻔^f)9+XˆSp_kzu1>qEuVWK)dH)O.c2t' Q0N[Ya1fV^yL RK$Y rx`'{[5Cqv$pGuɳ=/bLCF?8oBg0 nsW`pe'΀',' KWb4wr!RY47'{_T ޟ<` 1wMMs;)X .[ l L@w({IVGƲCtpf.|'Ac њـD6 ;ix-;XN-:x_8 0q( 8YUZd*r%nK_"%{< hta< wO3{P#:a%VrJFH{%ԓoH/7HSBBfǜN 4n(k%(_-,cPw!xFMˊ ̺՜^CYaP#x0КKZeA8ܿ>&5,$B=`y(hO AT% nTq)Yf xg vSi{eA)ԏ!M=E1.$ݻB/<)I_')q$ h ;}b;zL q:Et͢QЎajd> w8UAEb#FP8]UqZdūaޅ@˅WJ9<b/{m9ְW#vCnv;gY_3}gdcZ+=dY5=$\vA/t7BE )ku޹Vo[Zy*ӌptD͸@򑣣;%޷}ӿxEF eZMv;F >f5N :j!Wg<9A#e`VysKAd"N'XnÌhrS#{[D8 *#' c4HpiĀ2|<)~O{͐2*e/WܢjLpǁlT):bSAY3]:1VŊ MS)X~ D+8}`B\E>#} ڙ& }^6 Dk.^8iV7tZ٪tw5!xeXO/^WRhmvbS4DͰJܝ`WjL)P7|5ְì'nc#TP)/sE]Ṣ<|V a"~rQ2 iHyCU~`Aymu;! v&<I 9YlMI񑎹anm} ęw?uԥ.lf񡕴6[\]k\p,,~üo,R\n!\Sxׄ= ("ct/p% ;EG7{#D?# x%^WQzC&$29d&8n۟N 뫿Ʌ\r_:T&XLfI0Dml}-T;`0VHfAh8hc7'PIxPM9Hư 7C& ?Y? n7TN-QM޸IQ1I1)w !ydv$qwL_Ƨse(p5 p⣝s f PENW me+Iq<[7+ u_aaGSy=qQ}DFCclyt5Nt 4QfS¤wMvͽg [ݺ""8x[J+bS;X*jr>F?VB;E/eGznA c)X1k# boi߅hyGv{8zIV9 l4Wmh\Xy+ &V2 S_ 9I0 3b P pMaP]\CE$5l9`RD-r2%E!,_!RLy2QiK| D6 jNX.JN^ohG1I;"LR[<ӤCv Xoߐ[IY% so@ttZ{%7+ @T]b%Uͼ]xMg_ ǔ-7+D7Oeq\=E"A6,)`@T8\fhҴT ih ޺mK~1uZv' z1]qlYxjdٛ' oEVzkߗ#8rqװ7LGV|m/8Wr4( ȹ=&?X2Vuҏ% 0tjrT˂NѤP531N#'#nw=0B/e}c3:8?Zh45VGy#&V6`BpX+> *K׼|`,J.%)*o~z.vRy^j!va0b`sp!nUцl4R =^qN:}4 OY5ֹv%uٍv6L ]􂺍><ƈ2LLMtAm 7gw@NzOYy?lΕ} p\Q7ypP)  N89N>0W;KhAWy/gGC5XhGևҦZLD ڏ!Ԏ ꡉ*]v_C&v| YyBR` d yL`nr~ÁS?E NLcH pr= gŢoZXɈ<)1Q$4Q%Ў4CXq&E{O̪h^u8;Qm84nqx눛cxaCF|ɢZšG #M!E ,+ZqW cxƒ-eZ~0xX "[F*ʌ/;74QL&01lJd0j1U`㛒¬"l1)\e$./TA>@7p6yӝ{J"sdPQ}oƅM;RV@;>bc /-Nҗ~pVȋgM㾱<6ż@AƘ#xuǗL-5W:|DX&Ayu&M(iPuC@oyɹ§ "x+N ˲~'~6I!eJ"O-|zIWkf /P WY,!nxXZQ٪+t+5BD9VѬ淔a+q4y\j7*.ob7ԡYaej9ǬjOx@ ^f)kep_\bf$ӶY)qӫ"to鬑)9qt9͠X6&CH8. axa4c;2 Q-R`?,C~55>zOpKVw*=HQ] &&^S;hƵF-*T(X̵780@K"@Ukz\jE &ҽb<:yPe Umbzl/*La7LG~Zz\ NɤUj]fDiɅӜp 81nQȜZ̀dn7"WW@!q6>6#2Iࠨatۺլ: L^/Ru4?q_?'<ͅG8Eu: I2'Hf$g*ศAlk"Q4r9z_XtR#nCw\H |;tY5Cm׬D?w~IY)MPfR.'W$mAXq%wU _WYH>Y썟+}O\ݡӞL وTu[P#g9,6ý]b^Pok C}~0#'RA\e0;iOX96޴ƿ%F*/ ~ &yMgg*?xV&q&DEN( 5#|=I!({!;`Tg%ԆkP&lC 6|C8 ]!ah=7,$!.Ij0G: B:cEEp:g5p>8(F׽}ǖ=M"^P{:5`(bXE Ao@w"AX"jo  9ݍ5y1oJPQqCEPP|b,#XDX25ET4tJ2%%%ӭlcI9BƮp`:`{zƱfW]`՝77V?"kMlƆ^ xۊ:.ȁt3/g# śMC͞#U[E漢)zֽ* hk'xI5C\EdQK:Ɉy:O~4q:kJOU6/HNqBVm"Sü C0 @gB72"Cy4OT*_Qut/D"S b<RۃK~Y.O H%_->sz9‰ qw(L\G*~暈&5COoA5 oW;€ƙpiaâ?W]j8hjPiP+u6#W&EuqR[_-vr1o>d')΍;>x cJBW`Ǟq(k,Rц KތEA=8},MUX"cXӐ)Jo0_ mA;rL.A=*S4wX\a!:ˆl `C (N[wBwg 4I([XpHxUUU񉃄;p'4rQaO$t1e_W:C|dPyI%\R漃jMhU5O9v0>8kx|aP6U!_Dp eqO ہW!GzjJƃl1 3:@lWuP3V^rdh%k h?G#ihkv? %=?7HcOQC̐+y55rJzɕ1&w .1yRjH,M:x~7hh s/e],[R3@5ahC\Tjox\U"CE)U~q:uh /e ^A\1}4Nj["S 58B/\ -7q :Dف.6-ل5Io:A8ulc5#S[6H|2&/M5ǎ&&B=b /P.ڸ j?4<qx 񛽛D%?8ɜ 㚝po!v>U9UfB8~.O jn?DÀt2Z9݇yJ+F-Y*W?mh嚋1i8ˠC˵JPFХW,`g*I_f(Jd]Z"T<((=8Wc^S4b'!4nO>s =ZYۯq4 &yGL8ֻy>yE {FPcv&qPD͡Vs3f+gXRFS|CH.?]sqB>*@+eƾNœ=WК^sqBׇSlZxTBPt=|soBD4-jހ_x#xF٨ýo(ШNk}8r5ZGsIVêh4B G ng{OF醍ֱ\G-BWpPG}xn&*JÛ65+'o0 qZ}.8z !0.wٚZ/g{|W7Pm<2UɇQ9b=c)pԛ9ٕF/c Oosb$P;H X::)SJj}0haBJu'8L>q Cr'CG0)tc?HCOYܜ3*[q?8Ȉڋ8:x$Aֱ-n<.< {> g_8gI`~`΍ÿgz Xq>'bQˠ; `+lUP;mD =C2yHzuQPǜ*6 Zr?7 u=N21@:J,Ϭp5M@ +xngV-h ᱚbsXrӥJD^ nدhpIp.:jgۃ={wXfŴ&.A1 rK\ڲ XSn}dhbۋH҆53@ SB~!E {\EYƱ.CN+X2³T˚pmAyz00R#wb 7^N0GzCX%00ߌn/vCX.6P^bu\99 V#-fl@q{zv닑 * up@ "F>~ƈ"4rI#=ZNP ,{ƥu<;lLJ\P;X{38W []/njkE5QI#q|z\ӿ!P&WǬ] Qka"nvŕ oa%EٚLlU6@4Ώtm5@;<:{JzټlCM1#Z4bG߉`CۛSTlG,N|r}rC/!F=lՠNr ֆp<^|eՕzO qh}ecJB' GNؾ&X.6);39OhZoP Fh/:nOqrg%CIqg#{5fKe€s&Pvm>z_뙸 SQ'\Sk@]5rlN{rs:-aKf@-C0'JKxcDRtǞLD!7 vŮ ! vy5.(aR>d^F~ E۟iOt1 bu!_ĝ-%']qG܉77߻O]}$t]8 A Vq+P8 ߜqd =AW}Yh *Wy/EH|+69 DEom^$^<; ^tp=6*3@N հdgT"/cͲĐBDvmnP Jmn 6kdUwҫIJ`Ap$9ii/cƵ5ͱ jY+Oe &Vk $rR06f~R:x݉ s9:Jj \Z^G춃|ậ!*72y眤Ua?(SW8' Q3NOC۠_k΍)W˧XfAdQzo%R =qj>:qptԪA_,E$Zgۑ "!%m$Ӏ՘V*% g@9.{n#@2 vc^Ou 2?b]׸`/IX$–$OIӚu Va xpp0Cf&P#G|b$iup s9,7[ 2eIWnK'<⎲=q5jcIrdAWPycb%>@_޸ H]coy. NtUù.U(G~]a?.r }.\3&|bDné iExii9GAy^чxj\i:MSin3Gi1ArH*~r.pkixmJ6,B:9uVUwH?ѝ&pfdwÑ hl/(=$+t9x^|Es"%/ԕU{Ob Zeydb m$rNQn2g1üL8 |.8l9\ )+# 8WT1Yij)0p0d|LW#i0iQAg^q$1fӒ&2kh4mI$nKE`VIJÅh*%+(8~qT'7و$u|~ppM̩y` ֜QZfs[BU};YoÀ\C7]^Xl=Ә27ʵKd€|B;XUC_>g/@؋P,M$іyR$R!P<_x4$qpa@ջn3ٜcws VwL4թe_aN<=w\Azhho2~aVӐU"rȸI" :# J&ƾc]JQZ/W5O EfC 5؎J p%(P!#9k`B!k^80f#kGˌ@>(⇕$,O BT9t#q!91946_U'i;?0X ՝ U܅< &sip'J<1Mu"r6*Qd. ZƘ<&|c. kv#Sv k~Kt@5p:|n-GzDWwT)Lv6Pvq$;Mz7N{=i\gyPr|͔h> /PBɧU q9+nSBe4%VxдKMOL^$4;!SKD} :DXn37ڻ;;S26'90j_%*@CU~mh r&EZs~π;tpVRHnw#  j]ܬIa!k{փ- wk }RG:7Js_~M5\IvZN1?0>SJEAZ9%Z+ KeiNt땤(RmЬxwwo1TpnhA`^`2!1-E #smT 'ji4GE r޺4߳Rpod`P=g>D^Xab5G.~0I]mW7hG`zUYDm;ʼn'ȗe}EGbBOtXvc٬bZ\3+' _QPl'2~C91.p@us``ܔ]]w(}*k7l$|E"93zw@}mPyʳn5TyW[㯦_sQZ˅ /3 K&!ܖJ'#J#eVG+mʄ3b wqhh8%VJK 5C4c;ΐGOistQ (d>  r |@0<Ђaz;>;.UXE >0l{3x'xqydjɆuhz6XEZ ٭5lv6j8tx9Htt YN|W_sNhK SOXݥ=u#Cc! :"(CRHG0F7i p^2CdyoHC@o\#4wh)&o Ձ(w}`9Tn X+P06r]%Df<l`ue Ccj*yuxˡu&iQZ=ta*zƎh]x|e$l;KaFT| 5CGK8 Xw2C qyf)F`Zt \!p!>ُ<Θs.2U|d-fCgBȌ2&Ħ`J{;أFo5ֶ|x۠<[]?xS6 NXހY@WP!ȿŹ'y*嘨825i@Qi+HeE.8{ºAn -ۻI.xr*(> Scr0~6[NwiCU{ Gp }4N j؜-8`YrhpUUѠL{i JX^NsVsu\_8&_.V( @$+hf83 mL)va`2hjhR9jrK uG!kG 9ˠp*a mL WiM%v8>#B Hx{Bp A$.2&q16Ļ g$8Jޙ0O(v:S"s zp[պMyHn$/ Kig _u2D!4jWh;5r0@Džp 'Xb_?M>0hxR!rDN a9!c{U8Qeo:{SMQ98 ;XBM>!Wx=;;7&=9 UDVq0AvO#m[`wh|fmhqf`UVO%`4|`$ fY @8g2lKHƱ+eZz\F FD L䐨n r42x/5=ߌ6M8k:〆iӛi5^L'YIu*pW-w2J'X ?9aAZ剹-V:+Y>pZ@ּΎ0H!kMjj <U7$ڠɑ|w<"Px7ٞe/mGuz .2_ȁ:\I+8=^fFɊh.(G Ʀ3cT89iŌ ~s~GG IIz~P$:|ay#=N:9i)҅HO`CLt:F$kYWDSyJ慆51 ag?%EzC-Ūֳp[˅Y -@E/5p·*G BĆG NVf?.΁ lJPM2Ed:k|Gp4]p->ܗ2קKa#Í&#^, >7@wuI0VDp9M JkͲcQ3CO`I}QQ]%j`wW#VoLX/d,/1)~0Rh|V4oA(n2*֦A(XCVУ@zy2`\y5w۫&/MɅ[G]P-r5o9U:Bh󉠷Ԣ?YJ(shiai:l٪5T>SF `cɰu7pa8pxznJ(kA>KedNA!ynpgyRٚ?i60x1urV:KmUM]n1F:@p2dpݚ|bY&.8GQ-l15PHMՑ7\uwC8$MMqHXlz%ƪִu0(7E ` p4Ku9TGnQD#z{?Xw߄d5U}h @8NyE" 4U+ka jL; WGC3SWERLB]m+b70e?v[Y2% Gq&  |85qE8ĀlXKFE>L6SZ/g0s7„i ֶ3LӚ Ѹ4#%-$ [^3tlƖ8vi3|۔+o.EƑjƨYC:S?ɻpG_%N/g8pboK; ;wq4@\DX rbQI6.Hmԫ|hC 8rHY6|V'Vh 7P A\ЧK UKv\ч=Y9{[ыIl q)sʘ˗=;%n:1]ᢺDlHk^?X+T\αTݤ19r?iL4 Vbc{Y%K"TtMs`7Ĵ#w$v]u˭7! ϙ|`rBWʠ&A(biHeq- ?S) riS " -YLQR;|xpW+iK‘w%):\@'~.0?DRZTۚ]9 uni{k.єL-ap2Sf%k{PGS8 8@^}f R@ Vh)p還WF ecq"AR. {4=0y3fyL-QxK},H3xuR*-K0LAl1@z`pSMُ*5\L\!G ?!;=^oMQg9^2FY10Ԉl3 !lisc{9&T0q_y5{k/0,HݗˣG9ô u9#ϼݮ᜘w(ϜM|zgorÀ&8=97~xdbQp2. 1QƵ@M @^>A٣c7VM*!1"AQ #2a$3%h:¨+丫 H $%d^d'Ca'Àxʆ' j.]AZ?$x j̤(Skg n8Sfs['; /Yy< rvx$RB3xyx=23 ]V mhbOzPo#)wv& =<^^pt0 Gy@ء{VVPkOd$EoPd"޳OETaЬY$>k@o J;^L S0XOxXx8FV09X7|Yfb1$~LWazQY_y͙;L$4l*/5]>@8:aC8|8p0F/rdEHk,{;*SH?n߉y(>`o{q$(_8n/o]VZr@G{)0p0~r/:?҇Ä7֛;`>0q`і'ijr= wߌsߵ`À,x[>0Iӝ_}rbRi9@(&&Ay xUj7ߵ/ Bh'ԅP(YP6q /bR@(ʼF  F+P ߩeбAlvb{H+o Hs5A~5Qk`/uEOAYcn-.Dw"1n]|iU!RAC:*<an#XHU yx |eg@7$l+Y0l*T~%3pLXS#"}YYv1h h{cddVAJ- qcKlH_i`dٔF|2SnH!c/1b 1MF B1W׵*G۝pr=v=rHXB#<W`#FWX$#zR]+Y0Y,^?2Twi Hw`I/ގ9V'X_YJ `Z3ѥI6\rrDEJ]I'-!LT[_.mp#hhl0`FXS}=B}t*_u0IQ9M7–Uqx+nIb)uJM Q (N2x#g?:9_Kj`N,YFXٕBTQH9$|LhdfE %,|ֱVK $ mzG=4L %I;d+ I*ܜY_rk K, 靃"Bg@IF$= H 17sr`Wy9tv%VnL01h#K=I$3 >@@G}9QG/5 ,'8 E<_ncS& 㪟x*D@z{i(~Iq]IT=2$@+-'$ 19tԢ͑ORиV #_㗒tLV$PT͈E GUBxLs[`H)=24X Jd.iHq*$(h5܆#ۂSI j3:08"U (UX Cq0,E$p7#]3JzdA:k[!3#TbW<E!>3cZ7H &؈ 侌Q+'bL; !43lMm+yyGeUi=qŸI-Y,mNX|bz!p/Y><`8(r$S -*.cḣpb1gCI>B[0ӮP_+1g{O!@\Xu|LwT mP%)XF>A|E"5erqrT'0qu͊&>_K=d~K,LÁu]V}IC,Q^ɘ^*W> ZG)'.T* J~EbqJ0Lj\K'B{|_4ܸQl<_5-.С,,ʡVzl]4Txe ]d<|F&bpt7(uS7sg; Q*|[rt7>l[.+1Q]clyhYY*8cF4Pb*/f7t]z ssHy.ނ!qϼ6?sآ)O0C\4ocLYC $[慛aFoG+1hz>>9,Uۮᕁs8{V喛7إe&fg,!1 "AQ2a#qB00T lKFUɣsv9Ga\ؕ/ ޱ|(ùIG>3P;R˛.Er*u9"ɀ^T/![q(!w l0'OߪOU`"QT.~GL't6D:~?I! "}h1K2XϨ{(.Yp\ VN5RB 蹨Fc[υޢ1۲X ;_F"U\*'>~B}xp2aQ61ز}Љw ?`Pzg(\E"imOK._ f*Gr04@ɜ?I͜v>S5@Me%kĩR M*hr9dU*#[m$RΥKT7 0Z\9%ˏeϗ_H|BB Rny؂ T&cfA0hQ#5g)N~6:&YtIE:}6?B*ؕPA6NJ?Eǣ1$DYdtb NSu.EL8 R8' *( PES 8`Nlf 3*yk{BO%CƏRf5dfK,(*\3hǏ!rS(0Șke!3m_T]Hqs2?PJ@cVyݭA@UR8AX/O@e4na̯@JUl[L8Q\JoǦ0F^/^^ 1a|`yUőt<hhzgT̟Z lN;Sc1fnA3 GaVO& N/9_o" c圳-ʐlP,퉉Q, PuN/;6ffx|ln6B,;a"/p_P[FObb`♍1/ak"aaւrFf핌Ûcey/ Dɩ)7l/SA6$; 'dd|P)-T@cn}q` GjFo,eK0;Dj$o2ӄ(>_Fɏ]7.2[/r Ⱦʪ~GWCdb.B Lg'pF 5֍)U;aGc|vHgJOnyK?˗E ,[,:'`.2Ej:b"GuPl>%P"ނVbl$U&0P6H < bm\-p20ʨ%AER:$ BL'D#\ TYj5*̡p( F'iҪܜ]SeuTPC/s7gI.!1 A0Qaq@"2 ? _ b2ܔp5aNfx,% i"d_P0/0;I>J1󒓹,7B A&!XO 1+U@=satWܡE"m2֨BP(Ph^uތj'`>h:d6a+ eӲ TzKՏ\[!2]aMyJϞ]FO#_?*;kL' )؟d 7TQe+ tmڀbr#p>-[Rx.(V·ۧA*=| lQSoOȻ!((J&ڬRW o,丬L?JIvSNz,4blxg~*xֆ[crۈ,a#9fE+}(ǚA!e(!,Q⌣P*\G\UЭUXAAwWC K](70) (Ch(e7V!* APLd3n]* 7hl+ Ce1[%ɪmF߾J(JYX22wZVݳښ(F ^(;wddQWɄGOfWAڵ.2uaBEۮnҖAΞ Sў96Zbeets-1.4.6/test/rsrc/beetsplug/0000755000076500000240000000000013216774613017524 5ustar asampsonstaff00000000000000beets-1.4.6/test/rsrc/beetsplug/test.py0000644000076500000240000000112713025125202021034 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- from __future__ import division, absolute_import, print_function from beets.plugins import BeetsPlugin from beets import ui class TestPlugin(BeetsPlugin): def __init__(self): super(TestPlugin, self).__init__() self.is_test_plugin = True def commands(self): test = ui.Subcommand('test') test.func = lambda *args: None # Used in CompletionTest test.parser.add_option(u'-o', u'--option', dest='my_opt') plugin = ui.Subcommand('plugin') plugin.func = lambda *args: None return [test, plugin] beets-1.4.6/test/rsrc/unicode’d.mp30000644000076500000240000003102413216231705020726 0ustar asampsonstaff00000000000000ID34TIT2fullTPE1 the artistTRCK2/3TALB the albumTPOS4/5TDRC2001TCON the genreTBPM6TCMP1TDOR0000TIPL arrangerTPUB the labelTCOMthe composerTIT1the groupingTENCiTunes v7.6.2COMMengiTunPGAP0USLTengthe lyricsCOMMengthe commentsTPE2the album artistTXXXR128_ALBUM_GAIN0TXXXR128_TRACK_GAIN0TXXXREPLAYGAIN_TRACK_GAIN0.00 dBTXXX REPLAYGAIN_TRACK_PEAK0.000244TXXX;MusicBrainz Album Id9e873859-8aa4-4790-b985-5a953e8ef628UFID;http://musicbrainz.org8b882575-08a5-4452-a7a7-cbb8a1531f9eTXXX<MusicBrainz Artist Id7cf0ea9d-86b9-4dad-ba9e-2355a64899eaCOMMhengiTunNORM 000003E8 000003E8 000009C4 000009C4 00000000 00000000 00000007 00000007 00000000 00000000COMMengiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000` F=ɑA#F0C _ϣ)a2z`X" z)B"$) A ?vbB 4㰲+¤rR%;RQ[7UW6dTUT!ub8 x?.=L2!Զ#9#"5fE$ȉ,1Hez1!Ŗ)((,c)X,qET9N@t?` =m#tܯINi{ޞYgSdE/'̸g W&%irDu6.tܑmtP&,DuhbDN`Z{m/ÞjGH<&ClJP1I93ZHF;r/viÐ1 ܉2 y?z\+dDΪ1ckTIwb %MɬH#tRʹdꓳ1r ϳһl֕ lΆmCUc,$(t4BKZSQRŭ݌>l1f:Vm&DGB;?R+9}+ϳ+UbPS;1%; Rwt9BRw5ls>ʸ#uFlɕћԪFJȪyEus`" ?#ta2ft*3άVwVl̸c@@o2Q;> ?s:=v:VB/l-Z$ۙ5O'*MD' .zSW!a溡Z!3#NǞ#!cH5VA``g;RWTUսQeTľB%Og9F FTj9"(cJb#!ǐx3 E`, =ɯ#4vcqL8aPq2$48u?ݔ5RlogGЌi7ՎKw"1_ܧ8TDg;YF%P \RF S3: QB(́/ߟobnYz(uJ:IdZJ'J FfNe+ 8*&*q2 0 AAD0Q!2AaQE`6 AEt0/׿]s 仪D9FzӦ[7t+M29*FED2r9ҷI|Uzsp+ J8gRf$c1!Yz:d#*%P?{ɦ5\R삊Tv{$A Rki!ʁ(|rp<+)h05"dbBVAǣ4F:>y)ݐQ+7cfT[WhriO&׊aҶѺkLW0?pp1uzs8~9^;ar:hZRo|AwXo**>@0WuJFYYr\j*"H~mg*:+"ofi#E )JWAC,$aq)p1"PH(ѡ`8bo5 ɽH"3z׻r/Ȣ ʮd3їyNq{"Zu#eR se-ESip(0b3syNErӊyz.1,o5+9Euvfv}ȖND}l+3UTCo>%OyS!Xi;\8#)Ń:cŕt(P?f`x 9 ٤Gtb$vftgdkU-ƤVfR>ӭK5(i5; ъwi*"*.*1cxRH@#b@-FW?+kL*ks*gJ4`EviF"$z9Xa˔9pPXT]c`qy` .AMGt#%Yt㙬ꙉywکҥSkYO<6<&T눢!wBE2 J4\VK1`6q1@#W̫:*\ȪuoH(έb+JB{1jj7tT2+9#<ȩ* ayQ13&=B:``둂 Zp[JQ`A " k?dRɿWDJ2oRUdf3o5Kkʄybt3S5 , 1 rJ0#(@X$,84xD*νey5v}*KU2gRtK"oELʌE:2+ْ}H8fc )Bads!XPrx,($0<*5 4l`AH"|M[V۲NIbk3ߢՑZ~jg*ؔssBrQG )10dw<)]($aE8pjhF=宛k*"f5*YC^bꧻLղwcU<;0=Q֫:&q+T'TUgeR B32)D|]Mɺ+Y[֦0)b5EQEa!\hA!ذBg (C &P8|DV&Q`Aǣ4^,fd[Һ VLs ws)Pz^t252]ko泥]S2gsƙٞDka9#"%F DL* (B" XŽAb>Y//J$Y?RVT}ފrQKjZmu}vzttz%TS1#ƽj=ҪQ!0cH, Ê,w 810 jbA "}s@:XȮM*Ccr#DQz)Nb-LQl0׳ )k ;,5ܣ;" 8ЦA$qƁ\MEAB@( *-4їpRVlUK&u+<ϙtLv3ԳI{Y *R<=LUaqL (sD4P !8`A" w"ϒMwu]fFHSRKwqLOO3r1Gԭmͻ;=S8y2l#0Hp % _zZ2> ]C\ȄVGGuy(rJ[BUݟaj[ՙHLRyC%q]Qb->A!Y`1\X@` y?`6A"#ʭ&ʪ˝}V1ֳFnn:[mjsF!b)*Lcj*0p>$av!X4DH5ĨU(p@1@7R!#~ܦf5fb1ގS"iPvJ֮d1qq20:+T6abEl?2DD\M"B  c4\6 `.A"~T"ǞshDmYddSn:3c%5HQUJ:G4Q(tC\{+gO쮕Ԉ!O%f0@>q (4Q!0XeQCg?-I"*ḐYInjH~VkgT̎RK[F"Ψps<CA r$ 0RWb^Aߛ->Ş/&@z)-^𼎽me VE6$4CdP$BAţE ",8SW9 `5kZTC^5bTz-=iCB)auo,8c ""@”AD*<:* x:@DEEH8:.ͭGHNޱW%zDoe_?)leyX)ΜzR̈"]˕L'dxtS 3j ~ds`>A H" #c0&VZBrdpFEyFm *ξmYʊV}L+>j=,-*UcB= QSP"YH  ~(`JE*!bVs `#B(` n?mFb&@M[`beets-1.4.6/test/rsrc/date.mp30000644000076500000240000003102413025125202017046 0ustar asampsonstaff00000000000000ID34TIT2fullTPE1 the artistTRCK2/3TALB the albumTPOS4/5TDRC 1987-03-31TCON the genreUSLTengthe lyricsCOMMhengiTunNORM 80000000 00000000 00000000 00000000 00000224 00000000 00000008 00000000 000003AC 00000000TCMP1TENCiTunes v7.6.2COMMengthe commentsTBPM6COMMengiTunPGAP0TIT1the groupingCOMMengiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000TPE2the album artistTCOMthe composer` F=ɑA#F0C _ϣ)a2z`X" z)B"$) A ?vbB 4㰲+¤rR%;RQ[7UW6dTUT!ub8 x?.=L2!Զ#9#"5fE$ȉ,1Hez1!Ŗ)((,c)X,qET9N@t?` =m#tܯINi{ޞYgSdE/'̸g W&%irDu6.tܑmtP&,DuhbDN`Z{m/ÞjGH<&ClJP1I93ZHF;r/viÐ1 ܉2 y?z\+dDΪ1ckTIwb %MɬH#tRʹdꓳ1r ϳһl֕ lΆmCUc,$(t4BKZSQRŭ݌>l1f:Vm&DGB;?R+9}+ϳ+UbPS;1%; Rwt9BRw5ls>ʸ#uFlɕћԪFJȪyEus`" ?#ta2ft*3άVwVl̸c@@o2Q;> ?s:=v:VB/l-Z$ۙ5O'*MD' .zSW!a溡Z!3#NǞ#!cH5VA``g;RWTUսQeTľB%Og9F FTj9"(cJb#!ǐx3 E`, =ɯ#4vcqL8aPq2$48u?ݔ5RlogGЌi7ՎKw"1_ܧ8TDg;YF%P \RF S3: QB(́/ߟobnYz(uJ:IdZJ'J FfNe+ 8*&*q2 0 AAD0Q!2AaQE`6 AEt0/׿]s 仪D9FzӦ[7t+M29*FED2r9ҷI|Uzsp+ J8gRf$c1!Yz:d#*%P?{ɦ5\R삊Tv{$A Rki!ʁ(|rp<+)h05"dbBVAǣ4F:>y)ݐQ+7cfT[WhriO&׊aҶѺkLW0?pp1uzs8~9^;ar:hZRo|AwXo**>@0WuJFYYr\j*"H~mg*:+"ofi#E )JWAC,$aq)p1"PH(ѡ`8bo5 ɽH"3z׻r/Ȣ ʮd3їyNq{"Zu#eR se-ESip(0b3syNErӊyz.1,o5+9Euvfv}ȖND}l+3UTCo>%OyS!Xi;\8#)Ń:cŕt(P?f`x 9 ٤Gtb$vftgdkU-ƤVfR>ӭK5(i5; ъwi*"*.*1cxRH@#b@-FW?+kL*ks*gJ4`EviF"$z9Xa˔9pPXT]c`qy` .AMGt#%Yt㙬ꙉywکҥSkYO<6<&T눢!wBE2 J4\VK1`6q1@#W̫:*\ȪuoH(έb+JB{1jj7tT2+9#<ȩ* ayQ13&=B:``둂 Zp[JQ`A " k?dRɿWDJ2oRUdf3o5Kkʄybt3S5 , 1 rJ0#(@X$,84xD*νey5v}*KU2gRtK"oELʌE:2+ْ}H8fc )Bads!XPrx,($0<*5 4l`AH"|M[V۲NIbk3ߢՑZ~jg*ؔssBrQG )10dw<)]($aE8pjhF=宛k*"f5*YC^bꧻLղwcU<;0=Q֫:&q+T'TUgeR B32)D|]Mɺ+Y[֦0)b5EQEa!\hA!ذBg (C &P8|DV&Q`Aǣ4^,fd[Һ VLs ws)Pz^t252]ko泥]S2gsƙٞDka9#"%F DL* (B" XŽAb>Y//J$Y?RVT}ފrQKjZmu}vzttz%TS1#ƽj=ҪQ!0cH, Ê,w 810 jbA "}s@:XȮM*Ccr#DQz)Nb-LQl0׳ )k ;,5ܣ;" 8ЦA$qƁ\MEAB@( *-4їpRVlUK&u+<ϙtLv3ԳI{Y *R<=LUaqL (sD4P !8`A" w"ϒMwu]fFHSRKwqLOO3r1Gԭmͻ;=S8y2l#0Hp % _zZ2> ]C\ȄVGGuy(rJ[BUݟaj[ՙHLRyC%q]Qb->A!Y`1\X@` y?`6A"#ʭ&ʪ˝}V1ֳFnn:[mjsF!b)*Lcj*0p>$av!X4DH5ĨU(p@1@7R!#~ܦf5fb1ގS"iPvJ֮d1qq20:+T6abEl?2DD\M"B  c4\6 `.A"~T"ǞshDmYddSn:3c%5HQUJ:G4Q(tC\{+gO쮕Ԉ!O%f0@>q (4Q!0XeQCg?-I"*ḐYInjH~VkgT̎RK[F"Ψps<CA r$ 0RWb^Aߛ->Ş/&@z)-^𼎽me VE6$4CdP$BAţE ",8SW9 `5kZTC^5bTz-=iCB)auo,8c ""@”AD*<:* x:@DEEH8:.ͭGHNޱW%zDoe_?)leyX)ΜzR̈"]˕L'dxtS 3j ~ds`>A H" #c0&VZBrdpFEyFm *ξmYʊV}L+>j=,-*UcB= QSP"YH  ~(`JE*!bVs `#B(` n?mFb&@M[`beets-1.4.6/test/rsrc/full.aiff0000644000076500000240000025632013025125203017312 0ustar asampsonstaff00000000000000FORM\AIFFCOMMD@DSSNDXID3  ID3TIT2fullTPE1 the artistTRCK2/3TALB the albumTPOS4/5TDRC2001TCON the genreCOMMthe commentsCOMMhengiTunNORM 000003E8 000003E8 000009C4 000009C4 00000000 00000000 00000007 00000007 00000000 00000000TBPM6TCMP1TCOMthe composerTENCiTunes v7.6.2TIT1the groupingTPE2the album artistTPUB the labelTXXX;MusicBrainz Album Id9e873859-8aa4-4790-b985-5a953e8ef628TXXX<MusicBrainz Artist Id7cf0ea9d-86b9-4dad-ba9e-2355a64899eaTXXXREPLAYGAIN_ALBUM_GAIN0.00 dBTXXX REPLAYGAIN_ALBUM_PEAK0.000000TXXXREPLAYGAIN_TRACK_GAIN0.00 dBTXXX REPLAYGAIN_TRACK_PEAK0.000244UFID;http://musicbrainz.org8b882575-08a5-4452-a7a7-cbb8a1531f9eUSLTthe lyricsbeets-1.4.6/test/rsrc/unparseable.wma0000644000076500000240000005616013025125203020530 0ustar asampsonstaff000000000000000&ufbl2ܫG SehQ>ޱtʛ  _. Sebӫ Se4ů[wHgDL#DIANEpT3&ufbl,@^PHWM/YearOct 3, 1995ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104$WM/BeatsPerMinute6ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8a5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8a1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8aTRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8aWindows Media Audio V8a6&ufbl2K ] @ 6ӂ}m ;J$$$ ?'qU11%4` -I$IPc ԠIRUI10U5RIIjR` $Ę@JTBTI1,E4 ҒI:bf1&C` QR@H@1V` H :P@KI^o'@*@7C@ L $ %--i!THHEPjH@&B4JJ"0ҕ Sƶ|ALU2k?//߿,B(q1B~-Kl!+tH8 Hh-ߥ)B -P_PVCQn"v0{eIBVi~P6A \@A KTpk,RѤ vFPB(^o㧏yZUP(\KYFP}oM?_Rm>  p?,*}Kx)~yKoұ~(Κ\ysXߕ?-[}BSJ8lj9>q_ͧE'~ķH_%iM.]KV+s~_`;t㎟ߛc[O[t-A ?|T>Br-[>t~'AЄ%lE(?0i )޷Cn@4qR#)Ok(ZvϿ:`022{Qx6>T%[_y{7폷e)_y 즚|NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN @%,V,XU2 dDZb`D ZĒXpR/I &`c XH1$)1JI,2"MK A2B`*!&JADՀ$i E&(h+D!$``:h&F꤈l Fa؈ECv[0@00&)EdEBMCQ 3P 4R EB( JKBPPB0@[M/ۨJR`TOTQcM-ZZh n-:\kaS~O~p; yHZ ̡q!Ƹ8风G/ nkHH_pZݔSğߥmxi_;t8nO8}~itS[``pDsBP_ P=Bc'(cy'opc^vkL_KWQƷW ۟V}F =_SB4,(~o*Ǭl-vǾqNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ] @_mVb&6 H7#@&RI($L&@(ӣ" h&gQ0c "` 2QyJB%,D&`ȦS 2@0i jH*J 1 (i $ Ja0BJI%$X HD XB-#@!JoV ]G@c-TN I1QBJ L$i@J- .QB@~HDB%4ST(@JIJءXDS+e!/~BÊ#rQN}Jjۿ۟҇ϑ@vx-4>~m-Sm%J-U_q)4&)ZVnH~?V?'(O@}ȻgxۭVh y5aNڜ!m+(KϒHCzVB?YHC4Ombߴ5O>S*P|kv%/α8ͭS\tKq|B7KWOy#}_=Zo(ozCָ#$֖Oyr[xiQ[!^t{~lKupeQNP%θ NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN @-T߽Gé=4C $DΠ&I:MO`Ĵ6 FuaTAc A@a@-2E@i0,P I$h:I-/B(,ᴤcRL (HJMDi@IQ6 KLPLJRH4@YP 5" $ iJH`$fb`liC`)翝n6 5@L h$Ħ`+:HQE dQAE-|4h B(BA@){kW 8cCeEx o)1/t%cGv5oK(^NNNNNNNNNNNNNNNNNNNNNNNNNNNN2 @baED%iBLA2d 5fv  Kd @ :&*!!b"f@.%Z JR a,CP! %3U RDI%Q$HA@0(XVP 0,"*@0RKLTHU% (؈ þLUkI ͬnTbe.Ts!Dư Z1`  Ja$ k%Ԝ~5|m4;jΗM)$iH>R"“Y E @[)( %552JQ 4!(?Ї&*>vkO,)BRBi i`JROED(?K||PpXD`>'N}o?O+T- &e?GR OpUSo-(+\h}O〟rr7AFP">e][,in)V7[~E[GQo|CVaQiG]>U%eo@To)[[|'%>V _`J+{tԄ@x6ZmOͭ%k.kZJ|+SoDqc ßy2橮+Vֲ[/YO|iTOӔSՄinݔqۇnri6߇mlv{q/[i\kYB?4ҵiBpI?~n56ʺ펥Q/^iRZ`%|[u=|#߻~R>J[-qUo5ݞΗ`+[߾umN;}QoͻNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ]s  @AP{ges@ Z5J ĂƄ' H5 &*N䂂T`b J BRM D/HI*%t."A2iT"DH [Q% H-RL @hD pB6@"e KPI ,DB`D@ =یAUÕdʆb e@DHiBI)n,$JDX @BL4%)&KJ%lH!mىva4I/ nVЊ}HC-8PͥƄЌQEK? k :-)X>Z[KyOQKzRE8?h2A+tVOTքmߢ;zXŠRE->E鷈}BP;~%7\=_k[sဩ#=u4e."Pr}43pw/+{nq-)\|x%ּ/;֋e9Cޭ?nuÔ۰?~L/C`.7ϐkgW{~Gb/\JpԭV[EOMpe6[nk=R"?_ {->te`/7{pNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN  @O3 -9X᫣ZA):ՒT%A$d!LAD IJaIJl)% BHL Q%0&%0I HXRQ I4RDCe IiP&S5$P* e! A;DTMPIh"Pf2 XVy@$`4s0WX[$HbgD$Ul*$RDBQBhA4?& a/QAD"-x\R*IXR툤RA|_ r qjJGJhԿ(daGHmBB/MZ}VܷBh~IZ)(M M [ύ A8(Z[TNX n?/ݹPiM?) v*B/ݽhXG愾 Ihwz/7Ǡۿvq[[7q([ 0Th~G%Z-i&l>[o<87͢V׀?G"Ze$7pq>q g`?W2г8=7oŀEc汣ͺ}q  Vq Xߞ Zh'j 2 NNNNNNNNNNN  @1 E.wa(, B utL(fcD`.%I-H$HIS&$RI,)ZR6Ċ`BԔ)Am)HF(C&MH u(T$tH )A((Q5S'ɁAQI3q`$QDaS57$-%F,&o2hho`2I4aI a S蔬IJj!4  $-B  Jj(FOPV/ɷP_'ȡij.$:)G[H(Z-:RBl[nAJx%(+@|)+]hZK%/e?Z vPnV+|h񅺀[X i-U~$~T~c?iSPCN2;`>R-"۩ts&ܶ)Z[3X%q/MOV9E4/gR(l|[.FU)j_ܷhvPWQouX ӑ.Pd~-6vPt~u'[-mp.KOMxA')E6/)?#nv_r?\N}x*?U p^-H}XnIɞ(e`NNNNNNNNNNNNNNNNNNNNNN @%,HA~!f%֝z Xd $uՀP-P  1 J !5! %LI 3 DCja$ `T$  0  Y(LaP2-;*j@ ɖU2PvT@؀&bhh I *Z[- ĕ6I6]XDYC S PxPP MRHTڈ hJĤP쾠l-?B)لIoˠM (&2>-%+eb?㧊J(/߭/]|r) FR%`zY4')-"xInAZZAl>}Cn[-􂴰?R.7_?+e 8hU>G>_걖gVGT`'ߖSOS沅?e9A|!IndwCP@Jn!,-g__ [\cq`*-2mR8K揚['3O~|"Qqg_~OTV6 (B{ y?J[+{_-V7ܜ6>Jk+O/ŀ/ il,~,Fx())Ace.Z"NNNNNNNNNNNNNNNNNNNNNNNNN ]- I@_I44{`RALTĨɀRK@*L)H-` Lfgb*T 1 D)0IEC%0"0e@v%v+"T$9J*TdDJBh%au5)(5!" l"P[ %M! I(8AIh5jBN   [T4oaLlNκ0f$3)TłHv4B*0DJ)(Ja"C@(`)!4iJA| ot,_JI|RSCҁA)|)RB_Jn[in(n:V>|6SL)ED Vz4?|Gnsϩ[GJRNR] _Ʒ~J4>|u (#)oEoV2*4 :RR$~ے^kq;ZvKKOT[.|h~~qQX߯nUq,͡mSNSQW (?*ǡo=ߺ2`nNZ[g\HJߚ}V2ж_-߭" žv +rF=m!5ÀCm:?oǀNJUׅ-;c)G)۳ڪm4x +v.*?V=EJ|b~tNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNw@5Y J{l*'X 42 I l(C8 AtMY$R3(IKNɨA: ,LLAi(&BA20PU( LaI@$B+2 %(*U$&I5RD%%!BP!'R % 8S!AAAfXPFFP":˚9ʁ&H*b iB@HBH H@4Uv@j0@@%hPI(ثI$DGª?[&7d4%nފ* `*_ж8ihqin lZ->E(㦈HJx[SJ$q->Oo}KKE/mO>4SMJeHN]F ?5sz_oi B c}pP+e#)ܴ: Mc[iFRGXO7Ɨ[ ZBmo5?'oOp?6lzkR們Y[\Dt&e.Akt"x/.?\I-[P}Io>LH/U\5`<xB~vǂo݀%tN!_{eZak=HN@b#sgu"Hڛ]&;b&#`,nh$իI"%2C̘ p5; I&jhh@I-50 HJ" EMHKe)(4ՉTH"dVDK B@NAfUdRNdPB`ȄĈI %&\K"ڰ7 TlXKHcR%Q1 !&bZL@Y2HJ*! P XPL>J"" 4ER( >44lВĔR iK?  B2KRh[JV(~PP~}oD(Rx|%ic\ XGRFD[v[R-BQGij_~O)k}?9M5Qo~zG< mзotQMv(IZZH0(}~ ,XvyJGi?<h_6yFkk~$;/`{w~U\[\5 -, | _Ζ-(Zݑ- ~b?;uEaSKKK3H;}pC[罺ܵ斖>4>t?*xKO:~wo·eKυ+_ZNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN@*|W!eB( +lID5"2AVTH10dACDKphê[$N ڀI*BI" FD[ (a PH/$2PAI(A@! [Q`L @PDU I єI $A2CZL2 I2bRZ4nuB`l)"[@&!bL8`PH1NR:K@ Th%LE, JP]eZ|v"q>?TZv84~ /֟۷|\/ X*(J!Ko B4?\A\?ȠҔ[A߭OR+ Tб~T[_tEGBm}n~x [|$V5#Şݶ -Q\ ZZK5n'Վe)[չoG|P\ۨ?дnݞqдQ>^j"9j:~ ?6ݿ$'Ƿ[hk=4W<ӈT[=Uݹ18$Zq _ zاͭҊkVnQy_ pL'~te/݊2NNNNNNNNNNNNNNN ]@tǀ +W7 ;!@$I*bl.dDP"Ka$!0XȐX I" RQ)&D J V `R(EILVx1TAL A&BebDP@T$dB!"V0:5Du"J$CH@f$LImELG`,h,fI!fj2 B ÒP@t*Z)4&Z&PV)JjPcB >& ԗd-Е)~V BP?r5J~SQl|VT#;4-@'C괥!&h~T}~oRƚ8ҋzݸ_,S[~ֲ8쥨ͿK('+)OZt4%# AVoygX6즜"T5qKJ?߿̿[+$~k_)qe rt/ۥootnXÞhp>tht^n0B+?F(ߚ.8xMcyO@NNNNNNNNNNNNNNNNNNNNNNNNNNNNNN1@*ѳ.cՍ gm ʳ53($2A!0!6ȆEI  SwT$,DTKԊ0 )@! hM@ӄ pu "%5@HH˂BPmsI )HAЈuR@:%AJp H%"MdaD`$a[F64\᭹cPeI$-iI (:BABBP/#dЄh CJh*,@|A)M)B"M.Y\KA# 4%VCOޕ[E+(["޵Eq[(۲ xܕ_i}Z|?%z?hM N~ic{Rjn??5+m c-$t`.HZV-!."e%kB ˍ/+nJ)}nqt% „;n7 ƕpM (C4& <`R\CJmK҇+%?|EKv R O@)"r7Jh|M۸lj-P!!h`*rG%12|jh}oZ⦗Ͽvy}x꜠gPYFXIYGxҶGM ?i0K$[qkMÍ/>5r-Վzݾm (X6 7c0o_ΗK2߀,ZƊ([F{-ح1E=$tm>{zE9ۥrt\"x߾,@}luݔqFQL[ZHNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN]/@ q~&mk[x`.?km+x (}->_|*V=+/٥_ωV['tє?3O瀏YOԿ|_k rxAv_H-,i R /eO iKM4ۍ(A AKB-|$#Vʊj?hJRVݲK% k@PRA+iўɥq)V} (~~c=ȢX'_>j+O 8 (e/QCҵZfw,)}E5\4/ yGBOo~YŤ[-3!rtg(q]bPEـ Dّ>JUl3A$)i)A#+:3`XaQ;"  f{q wZĠ3zf#)Nq'" kr֣ g' )y*,εx" r BXVF7nhϦN|z%e0Ue B_`9oJDz0)1F8>7t#U4#Xޙ`xTaG~fHv<`? &63l:G 莵NR#Aש9OSԦ"#P dp2~s'P=&!PG<0NE_'1:\ ,l qԺ<>&ÞJ3}]dQ8`F8I+:a;0>m>;Mۏ /gp>bSciL z:~H5M3'A+&f (p0!d:%C9B+]3ZcJr`Ea,])\Biw,y@úD'ħ 0q=bmBAwH! DS 85 !`4^^:$9υgs`M%ZZt^UY(o"09#Nѷ#̬zW;t A-1dyߔ3^" "Fw,rjs%sfep=`G @5?`MYB^;C`B iRi60 9v(GYJ=olW3 B3QhL$/b<~8ΚSn*jXe7#"(@ Ad*`'ሬco>Va &%`F x}<fP[2qX.'Ip" *ؚ'Rz[oӬaXS2p'2!Zx$Vn%j(V`FF@c}}C2>w1s5{!X[/?Gx2"%X aԞrLa\Ri"?p\ @\beets-1.4.6/test/rsrc/min.flac0000644000076500000240000005260213025125203017130 0ustar asampsonstaff00000000000000fLaC" @D!voWel5 reference libFLAC 1.2.1 20070917 TITLE=min@!̡'hPhRs% $a̔Ô9C9)™e$ICI?(RaLɦ̡IS9ȓ4)ɚ Nr!(RyBxJDP999e‡=0L%3!(S&RaJI(S'0' 3L)̧ d Pg02PLBPɔg>i'Bee I8RPȁNd NLIL(Rg S% &JLICBPJhPBϟ%%% JaBr$He$B!?"~s3L))(I Rg(g03sȁB4ffLLOI9C2|%P )%2PJ (pC93330hsC ,.)=! NRg &rPIfg0'a3Rfe B!3)P@J8Pɠ|(PNPJB&Pae%32~dBag2|s@0S33' ”&D'0M (P9C3œ)")!2!(RP9d!†g(Re'JB!HS))L)8XDe0 )2S')2B2fs9C93% )2rPL?)2 'L'sdsJIL)2!3) RLaILaIɲR&sH L̔L̡)(D39%'" 2S%% 礡IL3?(S0РRg̔2P)ɔ@DM033&™'J)œ(2eNP)g%J2!IȄY&t&S$,醅'% IC% 8@P!9BRP$'3% dp&NfaIg2eBO)С"9&i)"(fg?C'™9@̡C8D2pfs9$I2s rP<“9))"xL̡.IRRr  JH|g0e LI4L,"8r))3I !3@$L"BBIBRBae2S2O (M aNfe% r)%$)()(d"y2†'L\Re'(pfS fP)(hdCfRdBe ’)4(g0d)P(RrR!B’$(JPIB)9)(r'a%Ô̔32P)3ϟ(y2pÜ)I<99)œ(S3)S&D&D3I)9ID@!339a dfa!s LB NL<)8g?PLJP4(JBa&PL@ fM L38P&RSg=De ɔI30NJJHD)9Y@2P)(L9zM0 ,Ϥ(X&xD4 J)9@e'I J2|(pNP!’PM JfdC'30!aÙ8D8D)(D)R N)>rL@Ls0aIBLBhD $IϜ) PŸ 9Rg33338S3'333$C'%'IP@dC338D4(Y3% 2D(JJBP&fffp pe J2P@%(OP̓J)̤Id3 fg3LLɦ2t D32S&C9L(D% I9C%'(rPe&S'BRs:)Xe&re!JaLILg9CC'2S dyL<2hfp3It(d ̔'C &RM% s)2(< 4L̡`A)%8r!)2|Brdd$ B Jyy(dBLK32P)%f)))(RLBe&y˜e&)2))(sza@! 0 BIB" XAP2yL33 Re BL))8PLda2P% C3d'IPRP0Jg(y2!<2p$B2fJfP̔,)"&fsCB̑0PNIa)Re3S dy(RIr8S39Rr‡É&P JfPL"Bϙ&DD8e&% H)I pg PfPÔ9ILIB)&zBO!))(Y2Ȅfr(L2 Rg Ȅ2M <2P̖P,2 fPsPeJ˅8RP̤P(fa,CS 3(s)(JfRfK0%)™2s&PI LC@JaNIB|aСIS!LD2RpaIɦI<,(sȆJ}Y2JP2D)24)(S' ="3̑ D"(dФ2…&e I9(p CY@e9IdJ39I@#0L̜ 3g(Rf"asCP,94fNRP)3(O32RJa҄% zdPLfs9)BdBNz"= !'(Pt(XpJ@ @Ɛ gœ(Y%BP% L(r2J9C9Ig(RPI@?@Ng)')hdgM!)RPBe'L4@BzL9aL̡2p%33BPIB)™4CaO9C Nd<>dffJ3LNaЙ9M!šHICɦHJdfg$B:Rg)29By)C)YРDIx !NC$C9= IJ%(r(e&PNg9Is9C3d2% dIIINfK33339B2Xr…2rd@ÔL4(S2s s3339!(Re 9IL)3:9 =>e$)3C38P8XD $NPС̤C9 ɔ?С"s&P Nɔ) t38D2RfS%32D2S9I)B!C)3Pe$IOBLr!C9y4g30LΘy )g3) h4̔"C"I)&g3)(RP9H3Ô42Pa&JI,"CCfJaId2I ffa†LHpJRB3)$!(fRe3392e$@…'% J!HR:aNe LI3  :4 P@S8SPpPJI2RPfxЧ'(P 8DaI8PJB~fa !BS% ')YpS0̤fe LhS9&D)!Jd)@))L)Lt(rfJ%%% LINPr9RaC9̝ I)Xf fsP)C(J|8RD%(y0aR%`m%eJ-RaLb4,W/չx]l9QLE1iJb6s| Wa|ݱ9vA8̫;q 8+%Ż2X}OCv/1 R]ΘsJ눾ȮW4,Mx 9A["a0Dmv砯ct{G$~9N0%bͫ=JܢSUPr;6ױneHPp0q??ۉLr<.}24Bl;1q@qmwIØO?&ss6əRk5L}: WR]}@Wv$m 2-vq ] T_?wmlC#w-ap[Tt.]]`ń/Qyo]}> ˸k PJ^I/dm+$#էӣkc4ޚC+1YGY\t,#XOm5;KI,1Ø/ֻ4 +ӗ:6)"XE-k* 0bL{SSqIT]0bɷu.Haw8|LPhcjt1e6U%ȵmWYWNnaF( ^-ċ\gZȮPu'9"&P@aX-շ'9v74gkV)'6;~j^_q7,W"Qs^UUiНW\NQ㙨yyu 81OONseY w`{Ь0AnTiNQ7}krfy(OCԷGa/:0 [m fq&`SFS8:> w%Kaoϻ፸`1J8i׀p W p9*o_4?߽F79K$o5OMܮ`dڪap{;}0Bm7Yg3300Hb!C030D +be4-Nz)d,Ds G'kК)0ݻ3Fe1ܩ$ivp)QtIL[]ڭKyNQ#T#GNcKL"G^)Ad*t\M۳3^iv~dEE#uݙ.dt@bU,xБU5V5aXEd-`eݜqD{(9T4˭爯Êۨن1@Xnǻ>%wᢕueyFWQevh >~f]cZnxBK)LK,D}7UTsɈkbWliN7# 26d;bp@jQB5(_ϑ{*fǗ1:~pLb~p~쇅pvx`Un5w?>zkޠ4jz9 b򤦸jzBgV+AJyQ=GkBxt9;#ys]$9wNܗ'Ej7%Kz9kJtz WhK򸞡Q.YL5zj\/э kדZmYhvېt JlJt T9F !1AQ "0aq24R#35BS$@rCTbDPs?$(Fo~mLqqRIHħ @.;1szM7VSN xQG#_W(O"͟+{ʒHG%zU7XFxC85#IBfJ'F".?PAr+F@ʉ\/`VhX͎Wvv<֑z?_X>NpctVt va{Xv  {> gCy9qUKS8/*fG[CLT1AKbſGmRaqdhݑ:g;%|m.ڽ"F` 0hYΐ miJYkJ-C#(vfQe\BћT5_⣥W> =LRK[fT,^r滓l\{dhxߊI9B]֖}ɷi@o:wL+6mlZF qҵIu)]Lc=g#Y&5ټʳE|?Wʳ~Yȿu_Vr/|7UʳiIt#3dy*sV܂ۻ[wr nAm-w ܂ۻ[wr nAm-w܂{pn2ZNOjVOgV= 7t| A4,,c#U썟qz:Y>??c}_/$5a?c~: ߪBKpuIȋmM`5*i[Ԓ<;uouLpkEV#e:#Y{+_ :HoVTmGwrUc۵o n5hogt|:l6]Rm{#{jhqTLt_牲]?Ҽ3q;eբ}ҿXKX(vC%N,s8z!.g X[|ݹ+=Z#ۛ{)#CQl+Dm+#ik* [+MPŀ J29not>1*cf=xZ V4ظ%cCY4Gw+#sηmҟXK>#qTap{ \5U< jIGx-#Kr;nota[-QY&`yj+IN/^(Z?8wYYn4?=|an ue-!s Nv Zx;ʲ}yDlIߐ۱?:OwВxVɹ\7. PHp߿%Ϛ ߹4z6q/,qY n 8nw7?ot$3pR>B#8\|68#wfdJF_ !z`6hōsNn*i_zkalu," ÅᤜVZ Oc_~&Wh4nrܜ1"V{%Bѭ -10B&s}C¦ς,q2.)bi:Y0qc S$ Ԓ:GcvdUs[.Ȫ Xϐ{Фk3c:/8\w}/F%ў!gY1?=%EboSC_=Opcq8١UNwphd?oѫ~ΖGwjEw(NOIPzD7cM3Cg=vٳoт3,q(attíLڳ^z(i/GBQccnObCû|cSBsPOE?rexFzsSPJtT g"e`f٥}%)̼Bj4GO-o+BTONc?+UŒAfeV?סO,Ƹ)قV7Vz,⦠>dTGiP;T:3S?1ڦޑYy ȋA_=ǃ3[3,^2VQkGAu{JXnSXkoXԕm+5='mc6KK⍟Tn<^=zCwSZqG{..@djYffqŮ^Jo産es=.S.ogM5ަ +^RƑMD öJM<w窬᫈[`Y>Ys+(kRA  G`5~JȑssTZ@@ lmY4"bS9´AF:1 Rk*ЛOo>VX!.ZXboyKUڃH+[C?g#]Mf3y}0՚i{.2.P_%_":so}剻QՎUVP!wl ڼ`2trp%L&#&-Gzt1gsϞK^{RfEr1 FV>5IJm9OCMc'S ͰJ>)״мBw??lǴ1aV辭¬P1O<>^yoiMÈ.UÍ1tj:uw-/<(=b=/yB?gעr:,g3(&GubD= $є- 5R(+JߠOx~Szf]CNQx`4VgW0 6Mϴ7)18peb5h=\}ԏVq5l%<}eˋjJEm A#EKtx6%nO'+3Eve&b'!AڡZ D.#ȀvZѹI<sON-?oI0Lby|xEL 16lK4Eã;ۇ cm1 jrJ*E2Ԝn!Dkͣ楷*065UxWlcWFiZ6 ʝwZؔ%U1.fn!4  mφS#־XM}ۥb Bme+o(i{Xy͒o>ի+T&ijdK Vy&|1y ,MEfKt]|Rr])% p2:4!\4LƁxٽu,~0t43Sqb56`.CX8Mic`ڢڳQg1n\!*vעUco9@|K!WRсH&: zP72ˠb^Ah8aLS 2sq&\n7y@oyR)heN=r"V={q0Ǧ+aҦK"/5qP7۫,x:FAUZ;o*d:#f|>t<]9YJ`õ;yQjKbӘG+ iX o+DLfu,rOCOa]uDK.],lS/k:dwF vqw  9@/R b]1fȁڽEV Ԟ#$[y 5MjX̰kOæ|.!W'G؃kEn~`1-FݱaR [~%diPo38t>fv=c:<3 ɾuVs j;˦O߃#ny>kco} UXflukia^I>6 ƏOԙa ¸k} -eLb4`,j}|%:JP\t,A]Fї1sUi$4 3H.EC{5'. rU{k9M>?Udˏ9MZ#T JNE㮰M?YSr7ym6K1e.a`̮_Q豑][-pi[7X\fzG^뛓3he-i,EA1Wвr 2|Q^fD|P찲mk tdy576lv&azUi^VKցsYbNՊ0X*Cj{2f$a._@t4MG%A"d؏RS7?J~.rM"R8̹LXtPjAU.3 ^P5@Wv~zxz]gJ5Ҭ4q "urOF2&歵Gh3뿼X 5i^3DU¦ajt#^ģOn#zl_Gv!P۠;p 41xn^'xjpA,&ЄIDp4o97 TA2X8[&*cF7: #gY%;}>0eڥsUqĬjKWZ}fBs)W>V(f"KtvVeqQ,;pOaQla%k\tMq-ͣf{\TāJOMƴJ9>\L:+?f379׃(yJZݳXr πLn^ ?y%r4Xv7]Z3Bo~.Dj7k1h||= mT*u?Po̱>3.x۷\AZQTiэFRӕWwf6WNDP^c<9]@6q֪O9Kb,T7x#.:bQXLmKaoX8MEk؍y5udL]hKw. Ac6۹97F O\ -g[ 57 =-YX@R1{x|/@Gff^ing?O[l@1BF3$!g#f Ҷ+5viZ8O*ez?A)j2cfqA 4$>WGjK.Ԡ =[2*nfZ|,$0 wevf*+"ZM|ǴZձA+ &RSmḎm(p{qS05pެ9q[ bZ2q1 G| VR1 {׼30q`FƄW%5 7nۉ(@ 4{B1n$NYJa#*X*o,>6񖛲zir^H/~e;>'!1AQaq ?!}8'H$gh3jHAD$u ĕ>x`ʍry9WZG1̺ {u+\a>YyXNb-$ {3Cl 48Dۇ0|ψo8>G8^;]P(M[HrY1suendYN *N7ݔ \sζy=ݿ@Ӱnଌ[EmyBI^&r}/9er50s"1X$4!t0޹NC#9j>.y yQCTϬW0\4kr'yݻsR8[YSFp Zp33cיaNoC3 13~(NgX)4' pbrtߤ0hr]h=yky-z77ҔѪ V{R]M-YbQC ʵe="]ZK<"SFt9pz|:ne%~{mvT"n?s~}#u= ;9j7:cዽ+eOiFgMֆC\kƲ uz/?Wֳuf\0s΄UቑcȜG7fVL ہwܰE̻iE׈6e^1QASlV ~P+[-moOq)z3u>6Fc==}:teQ2+&Ϥ®|w1m qF]_I#?~ ^p އف$(xI51Dj`^ `1ѫg9G:B}Z-eGoXruA!΍6T)oEo#Ѡiz&'-=IĿV]UVuّ,u/K-ֿ1kʭ>h}:ƠczQ@cֈ.tStkFOfV?UKi3htMIQNL٭>saTd%{uښD7h٦0mqFרav)ߏVwVBE6uׂ*lVFuQLNUNLϙ9k<:0pǼ(oq tykbIOZ `ݚN+06 `BrYw֥2W(Up ~ܟ-Ty;q`X2岘f%yN6e<$޸\u% )6 X}n\ ͛os':kpϘ=&,"䁞m_qŭQ%z31-{6}RrRq38V'7X؊}(Vku\0Xz%/Dz8 j#z4eGC)ɇDU@/-gs 1ELˎ+Cq$T8 #3o]2 pvƐ+B3/g\V׮PX3L#X,2x|rY OIELM`/EMP.(*8q⅝'!2rS%R8 #FF/gP&Ncbl?S8:C|]h:q1.4(1%^?JT4):bR޼wjq2/F&l(q+e`і\bg{9V0u㤷7^'U|=8gf6N(T[]GnXY=Ŧ/UoMMO;6[˨󙽱63-&w)9`7߈߾IKŃl8#uٟ`,ӏ3Kq/q~s k%}C-mt^%R _ }CИ]p%mdwc%08&}٥p=.l 1]M[Ng_8юz 'IvO秈l:3U_^C?0 {Rc8O2;*ʽyɺ/?X)hdh5L{̽>)W^7@)R#b#x%N7vKtg'8Nt>\K9\HeV;+4KWV4v zEB柙mÚtcx0@VBUt![dD(l.n!*ד2n*;66uME&ڷ j[y@U/NG- -#&Jozx!Sp}=aMO>'>>rWqԵ:chB1GgJpO'0 U(f !ͽ*^k*66C.kuTs:]4]6Ġb]#0y 卙-y.'dY'#E:f9Khh(X431uo*37:OmMNO.q4+-0Yу,B9^N7CXU1=e {Z'Z }Rӣ=X߬}}7MWC#`4 iVMN7VN ]-Fia{_LV*QD!'>>Xt%?m}5]#;P˶9 Wp0tI\+MBL Q݃}P6Ӥp‘ԏx2:tzsVVx?An c ܥpASPNcJD8?{RG_"ra?p>7)5"Jwh rz B3q>ss1A`-@˜_ю5ѣ8{ '#3 晉WY%ŷ/t~dl;8ȵCUz_A/3*dea5SCM."V^0UUk7OǙ<yڠ)=h>!d_Ŕ!{#Ą ԔOUzAwmMUkv`+ =l9Þ[ĸLW»ψ߄/|=~ʓ$~{Ds\]ΌRK6~UV'w:h>UiBυi|YbU(/ރYPv,AcGvfܐvq+SPpqIv:νA}V"~ 풫 SOYMw حxb*ڧTɊZMKfpQE]Zcp‚SZ}P`G>'RJ%oJlh׉srnrzLj"&}kqVJpYPmEນ*S;?IjfV ;qߩY{Yfno;wB}M%), K|T[. zt㗈614gYsyЬmr?j9H{JOC d & qh3N "uyj ؎M#&ͫ-(хE½eq5LB82;w@y(g دHc} JJA D%b_X%۝;Kzu̦.ZrY,nN6})GJcQeSnuhbHiGA iK c9^'k!Չ߃O}}|GӉW7^?hu cq4=%&ZBC>/=uuq8@ r&#藵EP> \V+D={v+\aBCFkJK& .I6A:%3 |n(bk&d {Yϡ{?l^+.k6~e8GSI_3ucVWI۫SU0;K#*] 1AUT9\ۙE^ʗo+t_2£=hn +~+!Zz]~ IJl+ e/|׸֐#2ٵApE 7X[m`59&^$/㈇~ILj<>'>%:r 0nF67S_ zpkc\6{ޡ%.r+OWɛ5 S3u5Gg^]|E s'#n 4Ԭzǣ|=M9x~"x103y"X= ~=};٬kZЏ8'>wVyE0,̭4￰a2]݆>]ñkgIAqW40w {;Ծ uaWrF-DJ\n #-V>GuPEa4qєL?s7>cT䅵u5x>yתȕ /^}`)[(E TB7swt_Ma)\t'xsyrSP _s1 ļ5Bc'j1Pt]VRD@,Yi$"Y|GsWQKFUCլ|l_Ɇ o/Y"IO 2Y>C&bn6 .˽ߴBw ljy#3 nW`fek}iSpUSA:e̸Ȍ{N}!u/v5se40 1{>9\y9B}B~>ң{1Б_zNm?Pzܯn^N-监,ckuW=D3/ν? ~{ET~30|T8NO}OxiY/=?zk8 Dc~tzx dh-~ {(;cXhz0r=yWG)cjhgҀ5? q>3,bV;!p/5Ϸ(Sp87޳g.́%ZIÝ2Cy0^^7;±zmW0߈^!c0g߹`0[/SiVi_`ǟĸpxLSʣ`|iPhS}.9Fx%1b !TΞ'.ه/ klx?؉) ri7-ey6#uJ>[SOʥJAv@i_ _-mq'AX=h:KWKfsizLEi'?1_[̀t &.b&t n2qoYo^&X;Q.'O:CYFa룑ް/eAmT1LS%4;IrFxBqK/jQr^="uS}-=kĴ N $g­fm Q:y̼U"(eA=>vruv,9:$i]c4:bw1U 3)Y+7~цn}) *w1>=+}< p+,W6/8`/Vx=aObG8~ӏ0O!ΙCÉ0Yp#\~0}nD@cXq1DZA$YqsK]wF\wyX.Psh=jR6['=)JƟLtr^cDFo?0o}D0SxV]]tl;] ʋo~x _\@I_A;U 2)V6F01MFf8٨Ϙxڏ?G~'0u)|Ϙ3sq:N3qsϟqpG>a:N%!1AQaq?kLhǜ5לS2x:|؏.d p;󆒔СK☾T+vzɤodTHVQ)y0[}Uv%`!B"#W&F^c*HHQ |b6:$;OfQբ»|ŋ1DeJ4i|(1_PZBS+dw1kpq@zWMyۼőzɷO=tV0 ȍ`a)ӎ794k˼CHari.1C3vd\[HG!s%.g<4rU4@Сs[ui(Hpm A6qCoXACg Tl!8cwRaP5-9xɥ^ /賱5B_@9~ T\9ڄEP9"Lt ^`fȉ˜x6׋@t3߬駷8o=4ps:Lj=\gX?t}{tLTvaS[ ,ySuoJ<E#4kIQ{FG}9':MpY}b6=}a;Bhމjܬ`Z8\ B9ac1\/AӁɀ$jO'P/(kSzgM?|z]x{QaDa6ɧ~>ܵIA[@IZ o&n pp4 QӶcj!RN@OcI NvV,OVgU%2"w8x-(55<L~9pwCxA"XHNd | 7𜢜o'`).I4"E4TPyTv?<x3nF- V\qg;i(lw˿b/b9 C $@۷٫7*O ڟkN/8 ƌmI՜pXj+&M)pSؾJmHx-3MZpR[޴d(E"&Vjy?lyۧ8QQo_Pv-vS]uNٶPiznC0ٸÓ~6Ӈ m ؚYzၘQX:o" (@\R 5)E~r枫4'kWB6]#wdD JɈ*6"#A &5;&=SqJR&s{?~g'W>\Te?"uȆA߾2#8'xVK >ZD)D.B>Q,A6u8r"kCU)TDIQ.D$xB41@ՐT]xÓFyhe>xPOٟ"3Ց6$*>𛐡:ߞ&X" ݍt&6#MOy`2S:ϓh1Lu+*q @&6*SAkp0 R2E3XNIES 6D1=O2 |Nrx$=>ޜW|Vî ЫIunBqɫKhM4PՃSB.nۦh_^I޺tɰ /lA4EB]Vrd(ЅRRxN"ҡ'.vRteMgmqAxoo;k獺lҔ;Ja 7&;_bq!_?8#^uփ Y!&XS 'w'"҃Oރ&A~GaPiuZ!?oC`h"SGBAz dj ?6%Ma"/H*zp.) '& \(xhu` D\Sc5Zk"axpQ-HzC[T%{t´84䗞rk)|w-2M`mmanI4@Ñ߫ /ǝ+er \Rs5᝷vpo<,[`kǏ&Rݾ2qÿxwU;5>>{gcd8_5 '{NF ]}k gd W8 {x5HҢ(uo;N8)-&[ai=  }$f|rsSCo"bd Ba`sǼ2' QP\O");e1GAf 3KrEނkqƠu5ǖX87-َ@\xU8_>WI9E-Tx2ɘ4׬]u72V'r8uqpy) |8r/W鄻LRgt Ueȉ(hkY9)zA F*%K"^5gcBbP$#pPr vyqeU9+,y40=!@}KaI9n uRLUAU/̓KTH*Cр8=bx|?[5TC5gQMs Ľo&Ԫ֫vd J8<|etz y0TIg,h9qsLR=kYϚQHau0]] 0`p@MTl ЙqBQ%~D+8Q6= ZCBְ<NsY3aܻڤI}B ٰ S!'djUVG(l ̮!]"8OZ;wюZKJ-tZ4@Վ;K7i ZFp $RT*oznG@)/YoOณBOUża0süQI*r/u~c8h&j 6Iȼ8Zir_n3Ksˑc 0IU,ƥX UlK&huCk81C^Ñ]km+gdc'":U~;mU!MdI%'Ц" @P0Erl]*}3#7_ ;9 Z OXC Em0(vt}3B,Zw?k4"jR/4 /@Ӽ)0kcB|a]mCtD)z 4"-e´`s=O?|6xb@h Xx*E(b'>v1`pBBĻmXVN5Rۻf*ۂ N2P霓SP TAljPTYF!pi#s;¸Zk}=etx{ӌ:], M$gۡzu(fFY"b>BLjoIƏjv=uw7sdOC8*^AFۙJtm3_Y*m r}sa,9 ! T]~Я *{ZqVn>Nb\jcpҎSF(D J,05^ {hzȾߦQND{Mb T4(WRN"ǐ :X! X1:T4A/qR>Q{"86evNZf 7>q `#\bzjb*F<}&]6Ax+.ŷҠڤ+HN֚5?(]N y<\4A!yM{#wgzv];%gJHC5M2պ}o&.tZ u+3nb+cs)Q^Ң$"CX@MDV#t-6X֧xqg{\1-ȩ8L/{Q.pv jOzzhݡWAŌ6w^0H89cǜmŭubrZ<G(Ry㯜J(1Ev]ozJDsk z4N DЩg7&H F|. C9;8GM?|I"{3lȫ%%rQ'CGy-ߑWBAFTO ?9Hbv)ƦJ2 3*8&-ތtZ sLu&*@n{;YECL"a;R"rqH+.jC yH:_ph^7q>܂%/5o}bvW8ǜSnQԉ@Ns^^2c9HUر҂DO*M|$#˲5n'hvZUv1a S1Qf "+Î;! \p٭{0خUЛQQί8FWz[}|bx.8H^uAl=|+uH$ľ?ri}+ό(FoLE1j4 WZ`UM/d6QEA/ yW ʶ'|{%3i}{̀z6 8u;ud^?,a( UXì]^9}kel&=,o냅_/=dX!{5;kUYt夂1ى-;~~$vM bu, /N D 4OCuEk/0s;.=>! IE'6\BZnF||2;5lUFbh)IMP.Y$@\}BR+gV9ǣtMkuY`a$r{6+b#ÆZWyjp0Sd+hcFt$PH](d{ŪqC:@Uc'<+QWe=9Q w؄` wlJy7*W#nFXA\˷ K5iIh; cA lf>.F渌]M?Fڀp`Kh)ĮNB{:t2v#'&y;5';q7xҎ5On V('1fXJ+1~pm*sdjuK4HJo`v]cx}kOesc=0]ap-5 (FO!P:6n;Ϯpދb.+7րrmޯUyK*Z^SfĠQoe`i 2lIqQU@a)Y45]8rXPz@V+o@>¯P uTU\\c*U/8 <Ӕ%ӳ3Dُ Q+Sn4)jAi]1@Cb= vOEH@vy0A@rnM2@EH;PnM*^t,(Q֑dKD @\If)5q]_6dp)vi:s^y--[W[4v6%^<`! p ~5>O>N@8Y9@ (=`jMSUo s#wnR?XT$Wָ" )]#f sѠHί Xrb$B]$}g:K_k ].#*6GDŻ4d TUm>@O Kx &Td .$HГLe5W1v\'+r"aqzkXEzWw(h/Pp<#{j?\l^I8bR鯎r~r.#U)Pǃ_>2k*Ug8*h-um/WtH zrj%{j@;(g { kmIYF~JDB]{pDG915- Frg7KriI=9*>zV. `>َ>t8 ްִ=ܰK9e* -!@C`e9Cӑ׹70S$cBm,D gsq?_a$4FRa g%qltUqOث49N*6,m$dvk8UFz"rcO"R鹀wzi|0|~&X3׿[q;|XX<IcsSc[*uZ}IA2p0{./hۘa]Sl'cO΃=jSh ҥr٪ ϯTXI ŘZ*t *I)?Ui8mȫz=4sJڞ*^ Ds&s)국::Qu=VԼNˈ[m[;h!} `⩲LtMJ.F+< |Qn 8Vć|*.9>K2L&s\!i?$y ֯ zvN{#ri89n69-!5oHT1ՙDge19 aNI'5VI'Cd.&ր1A63sg9*T*T3$mUzn$hpINnU\7籍UK[y'y)RZpmM?©mXq'N|0FSH뚫MԟpXQ,q\`gב&\< U[UIʼn}[im/U@G6ߞV*!R \ J`N|: FnAOぐ:o-tۜnP8s= '/.!1A2Q"q a?|й6I%6։˦]$9=+iۢ\t,h$Lꃿy丬RQD}F&ظ)ݱq8S>#gv(Y E rDLNZ = kZ|Rua~CԨerDLBL!a̸dݢPqVo;|L|4j'î'Le$ܐſ(1n$BRK5-Q99;b'%gӫ܇wcwfRJُI!dIxRd]$+u=v6E+ZM7GSBbRR"P8hihkKROAceCvM'1s~ƉP6:Õ^2#'/a[ %$nOO7qz'DO5֒Bz!d:F\RğU# ſ$d3Hs(t7~%TrNZog;\qyqMxKZIkS 2 6-"~)$ciF^ #b9%,74<1{)"y\"GmQ%{ =GxUz^oBqY1I| XM"$ uY9T[1˷F}JؿbF>#SR]~ FGhy%7u%rTp\K¥ԴcN"NGf;MTAܨxJ$]KFHLcʞe-Ȯ8;/qcZ쓳nMA;$zt~LMlazLDޚӇ[z 5%C} #j#Ob,I+cԄv%NQrJzzПLm]Nب$4"r&ISСH{otőPإgpa[W$U,jsJ:ֽORPօ 6΄8ZOFt{1Gj6vU:SginXUƤ;^,Т(beets-1.4.6/test/rsrc/unparseable.ape0000644000076500000240000003220413025125203020502 0ustar asampsonstaff00000000000000MAC 430' 0o|qDDP('+6Dgzn`CC-]XUڽΈ̹Ra%0'lO[&(H)"i%6aMJO1F@D8qlpE ]$ wvy0\v7I ̇m7.uM$#x|Sgb;<|:}|{H?k"{w "vt`OM".+."q=f; F&8e*3+GW3rKn>+{ &6u_zmeS)K+2k 8ݑc3L퍎vWܸёYຓ@1"kr}qIu [#wfC?sb [XA =\ yvJeׂf$0y}: ح[rz>Qx( ~ 95QK&)9ja`A8ז ";bR3 DW v)ϳS̲A}ꂇT֋DcXHwWE>':ЁȜqqoie4># -&}#}F5p㏱*"(taœ")B -,AEexjچuRHn_n(g'v9H^%!?"X@6כRy=ς8/3VM»8u!rP@ qSz~jwxTvT.'>Қ?`XT: A?1\@dF|ɶD6 X4,MR#&eEvS+vWsJ˞x̜{[>pQ t) `mnX^x-Ǯ=捴|trβ!ɰv ^T Nk19#i[@Sɑ&v_.oUtxLڽH/mfɋ.y 8KQEp:7'Nn 8lho=}9P}V{snsXiVӔEIH=҃sK{cyOgP\3yy8#;t2[m y0Qv{D3@ hlXqK!!ֹڇ%5P]|#i2 EL 7X)E[[Z$ѾƺŖKWÜeY#,ބ.e<[؆,I#ҷ5B 6)MP12] B'i"-,XQ3_@Y5ΌDdm P Ue*z =,c-gC6a1uc,a$zܤ VR堓$}~9EZ1H.1.j{N]aL.7 DQl89ggvDm^żDUV.OXEVſ>qzQ}(#MoN,-~bc97 W_ 'sNYO j]feu6݃3N+1g`j;Vn6pf_OBdkpuq\HMkbY2}Pe'qCG[J7P~O k3/A ]%h@ ozWl0ur[KŸ}ng@)E /׿A썚Fz ӓ͸B@[ˠ%!~n8oH#7=G q`KaHPX{z|\+|+Nn{W*2us1\$JE3۠(Ql \V 6춆R-]SM}.Z+)Bw{]׼B􎠣[~uk*[RȃK^W.yx^S`X T9 } ,f%czCd/oXfg/Lb zI9z,ik߸m2H:șU36V-!S)Tt ߊq4zM=03mc(f4E9Ji#$ր.fOۚaFaoFV68qc:u[P_eff$;R=!MƗS(bLIoW>铳ӶS{I=C.'?^HutqYn1o2z.:L?mtx) L\󬍩Kap\!)P#?#X58*l&#p(mL3BAV$\^G4yް"QsaR%VwPRou͑?è| j FZ UwLetޡ\]nf y*e6.PZ !i{Shgeyy& 6#F(;`;SX~N>^j߽#䬭V%ކd8tLF:n_ xrr V;!H٥IA_\dYtI˾z!%,"{( iůIemnqɅwX!qm~".5088A&kypg!O 5/#FvѾAaI$IM$V]t \^*BX/e`{h L5V4hE@Eh2H1C|gg߮ :O B90XwTl}$4]lFެ0eSb+T#A陡:^hQUfB\m\MrY^`AEQZ̪8#:V:gJoC0~}t4$i@b8epڵE+byb.v0H9('%qN){b?,-n xk ?UTʰ4vlP{%݋vuݦBnCjI4CLJXk?fCjp߿hq50RHU*-m:|5;Q&PrnLU5yI,OY*)XLN5 8LV6W 9Q R#+ KXg.n22KؗT݄F0XC,S_-, 3"mwrkBRrK.yV:; * 0kV!B Ԋ쿴祽Dbht+ǵ!dt'UNaRV3Jq(<xm%cw8Ҟ gVlz/Zφ{ꇕG@vpذUML-wm<[ iA8\0"k87ʞ?? Z"B'ry/ G)ʕHjd/EL48GV^Lr1P=g(F݇VY4dSLZ@,چqsUwOB؍i$B(ܻ/Sd3{YA S'X*=eB3;n1 rrG۶ͮT <ᗉysWC=Ijyg!=m\2}bD!b}W=Xj:ׅ*hA81 #$cWf'F]iJoYd md B~!=9- Ե yMMflpu敹6]k6%{둞: ųŤ`!T̺p=ygx(qZn@Eaec6hoU[u"}a䝓>:)߻^]Q#yC9ŅGDZj&⥎A=䲽umq>jJF!Z{^f! o:K\tJ8X@ j 4;07:M!3Vdܱ MppT]?lrV/b ֆ68.'[$<iﮂ授4 ͮ1܏.~pd>?վw8PLb\8Qmf\e+ͮ ?d)d󎍥WIr$YB8v@ؠ06f%.+1_ʣ^ <p[Gpw ٴEq6۪˴כ_-/k qNKLDu c_DM!wsV -U؟Aqg@Ukwp~SE9tݲdut}*&YezSE=|ç ¦ ;<_oL܍|'Y=2yn}VRtO A0g:K{]I}/jҏah_t I$5u&.q2^wdMMO1-)zuX<]I " ,/g |>u9V/5k-![& F {qwh y3]P\5NDٞocASVm>[}kE:"T ?W 'a3QjuxgnrjMQyϝhVW&E)ȫJ.E| 0\=e7sZ$ 5ޫw?\."<$)pRw6;n\+@iRxã7.V~傦Bmk4@%?-f$1Fb㕲PeCS r:J(qf9䈶L iNS()%wyyS($("-H2f>V$xbsh%uIm+'Nd럈?p˗8}pb謏̈Pī(KL.g kHa+x31+O̅ar7& vdVO6!3B4,F|sķw@*B`_]q>/ è"NiK&=.h-J;Rv;YDpT>Jm؄3aS J+`=*1o05Y1k2e:Ŏ5Ir W11( X=~`5;B,d5B KZO>S.T %*źoO4yJ8"B`G YS# UI 1ny)~Nj7Zg\|E 0nSōUP Rf=òUE!_?~ +ΰ[!W;#%tqe*ʎL Y9WTd +vs~V!@ݠ;WlUf6Gy %rLb@-,y MxR 伝o^dNfm%1[K;.B,:^&>/!3|֫SޗyTF?0E22>jQu?x~$[ӫ8{w!?Ia)!@ఽ@ jTz\+Uê߼TB+.%'Z oMO8b(g.EcGZBWx50'##9]!@&ݢFLF*ߕa6`1\jܦ#5~Z ;s؟o95 :!$`mJҨC+H;q !LOb5O~ 6DokCZ@ȶDtx 4 \_7tm9&DtܕhI >kFLqWPBX`#A#Xn$u1Y u|E)xH.-\t<7dƮ(T%(͂%ꖨǨ-7=aߒֱ@?T}pp(RW\T@ ;)BhR9߮㫴?}_l &Yod*ʿdD ]ha >yTMp2e q8/ߟj ǫɿϠk Y m].}`4ʐ1Sy;,ɯt r!M1:/i)XaX 4R(xIfQ3s/5',,Dzu[rwni T9Xs \ؠ=1 "co/ㅅ k?'-HWk uk1 0f-Д5gcq;Z5\Y5*[x RY b_(%La h5(yn>D-/H{uef/TCIǷR+FlĻU8fBa<9 UvnɆ`g K1ND߷ L? :YS3o 2b~g (/y'@I%JZ3 llY= ^!uaeA(34ܵSa^rrmgG`+n]8Ôī_` Hpn].2D{D|> ~mWxI#gz;*~ zi96@<ƺI&o /ޅt,=?vsܥ @R)e~3chiI˝99|F{C2V1SYe@ 9_,  9%b#2`O\f+e^Ϧ_~ag;i :3mm {w!dP-Sg0[{efO s@_!umhM}!+&6YH CD{_Ч0T7m`)C/4/a u}XcF SGÀbzEsڂXs ّ:d%NfV掶CXI +{A:3\4&0]-kM$8j]at /h& (ތj MΜ+Ϲ8,'CY0neC"ūB1N>R.p1)LӅWRWZl݌ &+U&ګu~X"c"l@u0' SXZ\%trn5*\Qz=@%|.TO"ظdGE"sXY!x}MAdL\ͽleQMY^))*}q  0epwv IW,2_FZl:%,,!9yӔ)[~CZD,M|N_J[1hn-^rs̫ymLBPyk#Q{xg[ Ha wwG՟^;{sB8s9s9sАUA6q HEiȤݣ$h r GR TI) 4d!RH!RH!R!b) *2,2,2밳:0C KMXckZiJ)RJ) YdAF!R!r)BCVU}SvteׅYn]8u}amXeY9~Xet]_XmVY_}xu]n̺ mc}Yf_w:㩪+ , Ƴ, *±,j°ڶ1ܾn,pk1Qu|_x tu]yf]utG8~@!+8$dY(Y(躢hiiiZgi),iiZfhk)˦jʲi캲mlۢiʲil,ۮ꺤Yyijgjʲiyjzh(j,[g試'j*˦ڲilmmim-jۮ,ۺ/iijg癦il+[(扦j,ʖ癪'ꉞk*˦jڪil-*ˮmʲnljjʲl˾ʪ)˦ڲi-۲˲iʲim.˲mlhm-*۲-,ۺʮo-p0l+ۺo2}DӔeS5mTUYvee}4m[UU[6MնeY}Yma4M6UUMմmYmaeveٷe[uוu_}e溲˲p p0 a1R9Q9 dA!9!92 RZ !Rk8ؠ)8@!+TX癢jڲcI'H牢im['牢ih뺺.뺲+릪ʮ,ª+˲m°ʲl۶oܺ‘.1 G @6pR4XhJ 0!B!RJ!0`B(4dE'C)RJ)RJ)RJ)RJ)RJ)RH)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJRJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)R pz0 HQJ)Ɯ1cI()b9RRi-9!RmsRZ13礤[9RRkVk5ZZ5לs͹k9לs1לs9s9s48ذ:IX`!+Ts9R9"s9!T9tBs9!9B!s: BBA!J(!B!:!B!B!RJ!B P`@ #R΄AA AQ3 BL9љbNj3S9tjA^2  (B1A U DDH.\]BBX@ 88'ST ""8>*,.02468:<@OggS@='02310276;:?CCBl% B8FkF#zJI`76BU5: K.[/I ~JJ@!T zwP{@ۂ_,5@4> k@!TU40Mt+Wу>vyPU|GאOӱpB&>4PU'@$O}%M;: >&* @ta)t (4Ore1M0BU 1>Y(sY&-RJgMkVOr2IUU@EO5nE~IӴJq)dU)$ @Or28Mj Y"eddcw d :Or`ۛTU:]\\{QFH4T05yk2XaEP + &E^ #&!ir}Ωaku)lY & Hyk2MP*jT1{Á1^_Y~vtZ}.95\1)_#yR NyB1,r(4ptmp sRL/g9?0M),Zu8 4X huҋ~>S$=cr7Ì0ʫb*߷QY4#Oű = P{dX]59  f)6֔A`f^!-VFļV ]tGNy49aM5Xd\%`8'\57M?/'x,ׯ}YnmcfFU)0޾ڝP[+T2\S G.WX;UFhkoF/u c:78 0d7\v}iEM M3"~b#Ah'R&y,]~MG-9#8뼁#s/y78Q1%8iup;_I5?&ҵdun{!:υzU.25Bɖ+q0Q*/CU&^ܱ-59q#} &68Zлt3WܙohmP-Y?{LX8&M f%ONɔͯbPwWI;w(*hRdPLK?7IB|_O&arvQ$kװdlLc/["R#lc,VݝHbz{8T78Q1JQ3=Hi M6Rm|f Z*(Nb&u@-jI&&ho\[Jx2 ^C7^:YGcFO}ݺ<~2P"Sމ!G&SIu8Q(BRHYB,-ox3CTX| ѳ^^^)5=%Gpb{fy](>(:;ubj:kGG:OggSD=59 % . :>]?TxLsԘMPX+q!^#a|S.㵕Q%(snl]N-{~w80BbqX9v)l}żbId=!t,W79 Rfo<ӗUe׫p0[(w"u8ބ2h"hr)=|P"r:I"q蹨R""u޽ ڇ.Asψnn7'e~ u0B c@2 8>h_6zyuY|$ԕŅS n'E0!>~v3ayz9Mk 15W7#eiw81hbFhzmp4<* œ"N|oSjlxMqIߖ'SΗWKw:&U0Tn;bb Pe 8P<0P8ӹj xz}]beets-1.4.6/test/rsrc/unparseable.opus0000644000076500000240000002010713025125203020722 0ustar asampsonstaff00000000000000OggSte/\OpusHeaddDOggSte/YOpusTags libopus 1.1&ENCODER=opusenc from opus-tools 0.1.2 DATE=Oct 3, 1995OggSte/dYo3@9>-EBSu53$/b0Ӕ:'X*[|Rc}:$zsDINrFw.!^x*iV}]{pT ̐2*`Kkd1%\舊|](hҮ䨒)4F[ =Ljn{>or3@lc7 VU qx[t]{ŹbС1Dt>רoBKʟqAm]ή1%6 aoie,]ؓt߉BsFmFy;&a:Wrېr0i1U_-@X)/C΍{.j݈*科>L`/!8=}ŵމćD#VmRZ;Ġ{T>vSB <c>+h}LU$#r!Q\aՙӏeX,R.bno|5|/9Bf1-7/5,aլw訐ԞJrU=7e#$zԞ ҅7 ^6roUZ X*e4^>G^|1Mu'r]o1Hr% 0,3VexD?1K+,Iq 3Yw,kr eLaձvB ݜ[aPc,DYKu?^k0<FW~xv#q䞦ʴ vŠG+bG٩VeIT`)L3ͼɯ3jaԁsc0fx{##Y i[~ Pz6 _4 aҍXZ1gWbڳ]*~S$íf+3ju[#v$XHe\nj*5zc [oesgɇÈ | |-O# bk0)Baխ]x|dۏы,0]Z՚I"1"0j;Zj#R-7ʷYCⶺ+/8/mOGZ^Zfe 5,ktͅ`\@QgJf˞o=p,FHNVL2|'vWg*uaR76Aa!7*s0of H!vwꑊpyE#U L%.G.#I4I(j g^f"~o +sW(4<~4fvi:LΕZΞ'p.L1-54UN] a[J2Ɏ]F@1r\e(lB\6&vU; HgmZr#XFû (q\daf/1@q#'z.B%CyGKRfQk*1ͬsb6O8)TPpNe0!1ؙ٧LdN,<19d"l&ao1 vJd |o,/b bl%\;ϓva)[" XGImp?!B2!BQA1Gas(a?U_ 8d~?dZ5?@mopԝDnB`t; ya?2X"M;lȰ,kaԝ08n"Oj E'ؘz=rz.Ht0u/}<{M io@Sx8+u𾊚NJ1'Jb; `۴<G$&5,*3ք3"v\ aլP*oq6Q [3!F@)P{gtsYrɈ(/?B yrxm_`/ZQ? e ?FggAx-G&ݦcIkȍN7;&)Fj0'6P;*a՛Y@ c t//VfLqkIyB+?zCέ(S$jz{Amw?_v;AwÝ'CMtu AmtJw%`aիGA0^.aE*)@Xb3XrD)-6B\!ؤ1BݡqRmj>݃0M{6QSVչè m,L[8} *wh8;)ۜf Nx2JaլW"ט0"J>XiU&A3J?7 S4v"9Nn=͙p;pU| cpZ !+x,V%.: L,z1ٻѰaOS«ƍCȬr4mؼa՛b`'#p' H{^qw2z\z&ꓺB"Lk*8M1Pņh7!-蟍McZ1?TfDPJ3 fEx *|͵.Ra0 \}$-d݀WCTNI3\2pg&Wv-4ى2e?MGvnꘙuHᢿi0%njr6U& ZQCṚh L=Zu=Tܽp4a՛3,D:€]\6k ʋ?GoCih ճk -CSO狷ĵ(@ 6O׾l6K.KA|ژQ[gpUuy 8e2 at\ntQʺU Ð`#,m+icG>A #݀F:l٤͵O3UC[zY.B# 1o >ǯ1>e{x=G *bTͧ||%?3Ǭ6Ei/bTAhwТ/dar^|xh%'GJC _6K5(]n-Ord 7MvrUMܸ!5M;ŏwVY7.Ν)xzzQ:򴄺(:j%# w3n44.}A-haggR=gϾ9d t/-Vx*ޡ+{÷l(R2MĬO@e ƹc<.e#OP8J=حJU8 [^l9DK\D#atq0ԇgKd.^R6H "*!͗vOQNY@q X(Db&z>ڦ[S;9LtNԕ'#eHۆ]\eL.@hTٛDGAOe[B$:n^$$?§O&*Yaq*:R&)0)1Le -`ݜv"Zn,9tHpC %ߨ/FK+2}(,#Bi4]xY ˽lQ-B ae89xMY.bLC\@euaYvۤt|arM {q$2C4T]Ow mLD3WS@9uݶVl5aPG aEH^"kpHnwװ5GFeečXa՟  L[qaժh8:Q}6(af4\;_YOTH,\mڜ+WW]pW=Ù.6Kʻ5~8d7O,],3zrOSPyiCWŏ[#m~a!X[Uz0aլ&NEoJ5_.m{(%, ,YE3I3M(pLr&}tN5Rӑ`f .Is6t v'3)%NzpRo\ 珞vl/7 CatZxQDW9Is-0iir^:j?PT-7N pXYsuoOAa"B--g͚nܪ*8ӖjRtL-/jyWQƕ u‚~.!`h>B<)#,~)aLu*Du=fQpayGB鯸BEr/ (%<؃ܴΙZkyFwݟ`OZaհj Ee m0^;(87 ׵khP(N@_#Ԝm(?Te2l n"-lYX_-o^|4 BꏈLW=Tz +H.E^fk`X" z)B"$) A ?vbB 4㰲+¤rR%;RQ[7UW6dTUT!ub8 x?.=L2!Զ#9#"5fE$ȉ,1Hez1!Ŗ)((,c)X,qET9N@t?` =m#tܯINi{ޞYgSdE/'̸g W&%irDu6.tܑmtP&,DuhbDN`Z{m/ÞjGH<&ClJP1I93ZHF;r/viÐ1 ܉2 y?z\+dDΪ1ckTIwb %MɬH#tRʹdꓳ1r ϳһl֕ lΆmCUc,$(t4BKZSQRŭ݌>l1f:Vm&DGB;?R+9}+ϳ+UbPS;1%; Rwt9BRw5ls>ʸ#uFlɕћԪFJȪyEus`" ?#ta2ft*3άVwVl̸c@@o2Q;> ?s:=v:VB/l-Z$ۙ5O'*MD' .zSW!a溡Z!3#NǞ#!cH5VA``g;RWTUսQeTľB%Og9F FTj9"(cJb#!ǐx3 E`, =ɯ#4vcqL8aPq2$48u?ݔ5RlogGЌi7ՎKw"1_ܧ8TDg;YF%P \RF S3: QB(́/ߟobnYz(uJ:IdZJ'J FfNe+ 8*&*q2 0 AAD0Q!2AaQE`6 AEt0/׿]s 仪D9FzӦ[7t+M29*FED2r9ҷI|Uzsp+ J8gRf$c1!Yz:d#*%P?{ɦ5\R삊Tv{$A Rki!ʁ(|rp<+)h05"dbBVAǣ4F:>y)ݐQ+7cfT[WhriO&׊aҶѺkLW0?pp1uzs8~9^;ar:hZRo|AwXo**>@0WuJFYYr\j*"H~mg*:+"ofi#E )JWAC,$aq)p1"PH(ѡ`8bo5 ɽH"3z׻r/Ȣ ʮd3їyNq{"Zu#eR se-ESip(0b3syNErӊyz.1,o5+9Euvfv}ȖND}l+3UTCo>%OyS!Xi;\8#)Ń:cŕt(P?f`x 9 ٤Gtb$vftgdkU-ƤVfR>ӭK5(i5; ъwi*"*.*1cxRH@#b@-FW?+kL*ks*gJ4`EviF"$z9Xa˔9pPXT]c`qy` .AMGt#%Yt㙬ꙉywکҥSkYO<6<&T눢!wBE2 J4\VK1`6q1@#W̫:*\ȪuoH(έb+JB{1jj7tT2+9#<ȩ* ayQ13&=B:``둂 Zp[JQ`A " k?dRɿWDJ2oRUdf3o5Kkʄybt3S5 , 1 rJ0#(@X$,84xD*νey5v}*KU2gRtK"oELʌE:2+ْ}H8fc )Bads!XPrx,($0<*5 4l`AH"|M[V۲NIbk3ߢՑZ~jg*ؔssBrQG )10dw<)]($aE8pjhF=宛k*"f5*YC^bꧻLղwcU<;0=Q֫:&q+T'TUgeR B32)D|]Mɺ+Y[֦0)b5EQEa!\hA!ذBg (C &P8|DV&Q`Aǣ4^,fd[Һ VLs ws)Pz^t252]ko泥]S2gsƙٞDka9#"%F DL* (B" XŽAb>Y//J$Y?RVT}ފrQKjZmu}vzttz%TS1#ƽj=ҪQ!0cH, Ê,w 810 jbA "}s@:XȮM*Ccr#DQz)Nb-LQl0׳ )k ;,5ܣ;" 8ЦA$qƁ\MEAB@( *-4їpRVlUK&u+<ϙtLv3ԳI{Y *R<=LUaqL (sD4P !8`A" w"ϒMwu]fFHSRKwqLOO3r1Gԭmͻ;=S8y2l#0Hp % _zZ2> ]C\ȄVGGuy(rJ[BUݟaj[ՙHLRyC%q]Qb->A!Y`1\X@` y?`6A"#ʭ&ʪ˝}V1ֳFnn:[mjsF!b)*Lcj*0p>$av!X4DH5ĨU(p@1@7R!#~ܦf5fb1ގS"iPvJ֮d1qq20:+T6abEl?2DD\M"B  c4\6 `.A"~T"ǞshDmYddSn:3c%5HQUJ:G4Q(tC\{+gO쮕Ԉ!O%f0@>q (4Q!0XeQCg?-I"*ḐYInjH~VkgT̎RK[F"Ψps<CA r$ 0RWb^Aߛ->Ş/&@z)-^𼎽me VE6$4CdP$BAţE ",8SW9 `5kZTC^5bTz-=iCB)auo,8c ""@”AD*<:* x:@DEEH8:.ͭGHNޱW%zDoe_?)leyX)ΜzR̈"]˕L'dxtS 3j ~ds`>A H" #c0&VZBrdpFEyFm *ξmYʊV}L+>j=,-*UcB= QSP"YH  ~(`JE*!bVs `#B(` n?mFb&@M[`beets-1.4.6/test/rsrc/emptylist.mp30000644000076500000240000002000013025125202020153 0ustar asampsonstaff00000000000000ID3gTSSE4LAME 32bits version 3.98.2 (http://www.mp3dev.org/)TIT2#Jump In The PoolTPE1Friendly FiresTALBFriendly FiresTCONTRCK1TLEN217040dXing w] "$'*,/1469<>ACFIKOQTWY\^adfiknpsvx{~dLAME3.98r4 $M]:di  4LAME3.98.2UUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUd"i  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdDi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdfi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUdi  4UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU>G9(& di  4*vŇXfN0!`8M:H1Ϸ?rJ^di  4Y C!Cb8b0`0a$Y˟9[wX{W/ 7adi  4 4 AA`8I/߹0tE8.DVdi  4tSEjfVt;-"WL$ `y8"ض !P,m8{o^z>/Di < t E#/sƧf#``L;cܳnl9n{ *;߱~1,ΕÑ"i~]d L{Lɏ*r%Ù +?4>U'e|y^{L&6§w? ϻϼֵoyX X쾞݌]q%tDH#2D΀xF]!BbلL#$ JrlFB.* 8I,hi5LT)6f)FD|;ox,lӔ/ts]K&yUV-+w@Ki/Ф7rTQ1oY^zSr-,9??K1gI`hslJɥR4y8VFeʶ7'apעK7/a^EYM~08X`2jWE7Mm,x*㴍 {gZy߿gyz̷sƧ? ZvH Ɋ@D7jSs/4_"/@QsmOSQW_Hʉ{kZl~+7,y-9iZ "FC 2 P!P(%y\@`k1EY1E}eCU0{,*?Mw%mi kԕ 61wd,mySkS~Uqイ^`RdCeg (d\ jᓵnY]?8/JG,N#0LŧxA~8UWS4@-d,xUOer1za"U=kHLJ)"@fGO0,`P EH<}݀lB+|&|¬j5ǦoYC9\εSSi7O!"FlⰟz9,C < Sr!͇ ؒ֜Z'LXA0bqk/5qѵk?^DQgv7Zl+\|OkkVs`$B^^r+JA=nY?td$8_wC 8pojXhȚp@勂$=q4Ȅ؆iZA륝9*Jؑwc\J>dFMI$ιp|^Ooso۔XtdC)B8 Jbw\d{擡J%Uqc8>2#1W V9h{U)'rkn2X8^ ` I9k.^QYpt`BJ+Io\Woms6~3,McNCrIJx2UBO60A 14L Z#I]G=&@fdjOerEeg)Y=7kSR;[SvĚ<8F/Μ!ՈF{IW1W<y'*8\N+bC[.6C N#|\pXg8D +tr)X(~l;\\t0jaBuJ]dC0^ac9 ]˼*[灌;H@@.9i4Ɉ5JEH38A_ּ'guu<{zz*PC OIlCXAĜ]FX(; EGJ R z_d^W2|qMeqKEu}a/T y ~aehs)E*ȬjW}ĕƐ,tR/lv؊,CSI3ڤZbz~SBY*֙:,l.6!8d~9 oRY6ZRe[?>Jg@V Uv-QbNG_Oݑ}zeaXC4S%< ePNQo R^ep}wZ>͌v-JpވE(̾d" jVsOerIJ aSİS9!IQuv/{Ku5tcl92XPZ;kMbz6MM pz.q}.8)2ܻW+#P * AJE;$.6dy?/Zo/z05X/LD |PT̚l(p&Hް$! o%:Ť[-3!rtg(q]bPEـ Dّ>JUl3A$)i)A#+:3`XaQ;"  f{q wZĠ3zf#)Nq'" kr֣ g' )y*,εx" r BXVF7nhϦN|z%e0Ue B_`9oJDz0)1F8>7t#U4#Xޙ`xTaG~fHv<`? &63l:G 莵NR#Aש9OSԦ"#P dp2~s'P=&!PG<0NE_'1:\ ,l qԺ<>&ÞJ3}]dQ8`F8I+:a;0>m>;Mۏ /gp>bSciL z:~H5M3'A+&f (p0!d:%C9B+]3ZcJr`Ea,])\Biw,y@úD'ħ 0q=bmBAwH! DS 85 !`4^^:$9υgs`M%ZZt^UY(o"09#Nѷ#̬zW;t A-1dyߔ3^" "Fw,rjs%sfep=`G @5?`MYB^;C`B iRi60 9v(GYJ=olW3 B3QhL$/b<~8ΚSn*jXe7#"(@ Ad*`'ሬco>Va &%`F x}<fP[2qX.'Ip" *ؚ'Rz[oӬaXS2p'2!Zx$Vn%j(V`FF@c}}C2>w1s5{!X[/?Gx2"%X aԞrLa\Ri"?p\ @\beets-1.4.6/test/rsrc/convert_stub.py0000755000076500000240000000222313120341455020607 0ustar asampsonstaff00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- """A tiny tool used to test the `convert` plugin. It copies a file and appends a specified text tag. """ from __future__ import division, absolute_import, print_function import sys import platform import locale PY2 = sys.version_info[0] == 2 # From `beets.util`. def arg_encoding(): try: return locale.getdefaultlocale()[1] or 'utf-8' except ValueError: return 'utf-8' def convert(in_file, out_file, tag): """Copy `in_file` to `out_file` and append the string `tag`. """ # On Python 3, encode the tag argument as bytes. if not isinstance(tag, bytes): tag = tag.encode('utf-8') # On Windows, use Unicode paths. On Python 3, we get the actual, # Unicode filenames. On Python 2, we get them as UTF-8 byes. if platform.system() == 'Windows' and PY2: in_file = in_file.decode('utf-8') out_file = out_file.decode('utf-8') with open(out_file, 'wb') as out_f: with open(in_file, 'rb') as in_f: out_f.write(in_f.read()) out_f.write(tag) if __name__ == '__main__': convert(sys.argv[1], sys.argv[2], sys.argv[3]) beets-1.4.6/test/rsrc/test_completion.sh0000644000076500000240000000566213025125203021266 0ustar asampsonstaff00000000000000# Function stub compopt() { return 0; } initcli() { COMP_WORDS=( "beet" "$@" ) let COMP_CWORD=${#COMP_WORDS[@]}-1 COMP_LINE="${COMP_WORDS[@]}" let COMP_POINT=${#COMP_LINE} _beet } completes() { for word in "$@"; do [[ " ${COMPREPLY[@]} " == *[[:space:]]$word[[:space:]]* ]] || return 1 done } COMMANDS='fields import list update remove stats version modify move write help' HELP_OPTS='-h --help' test_commands() { initcli '' && completes $COMMANDS && initcli -v '' && completes $COMMANDS && initcli -l help '' && completes $COMMANDS && initcli -d list '' && completes $COMMANDS && initcli -h '' && completes $COMMANDS && true } test_command_aliases() { initcli ls && completes list && initcli l && ! completes ls && initcli im && completes import && true } test_global_opts() { initcli - && completes \ -l --library \ -d --directory \ -h --help \ -c --config \ -v --verbose && true } test_global_file_opts() { # FIXME somehow file completion only works when the completion # function is called by the shell completion utilities. So we can't # test it here initcli --library '' && completes $(compgen -d) && initcli -l '' && completes $(compgen -d) && initcli --config '' && completes $(compgen -d) && initcli -c '' && completes $(compgen -d) && true } test_global_dir_opts() { initcli --directory '' && completes $(compgen -d) && initcli -d '' && completes $(compgen -d) && true } test_fields_command() { initcli fields - && completes -h --help && initcli fields '' && completes $(compgen -d) && true } test_import_files() { initcli import '' && completes $(compgen -d) && initcli import --copy -P '' && completes $(compgen -d) && initcli import --log '' && completes $(compgen -d) && true } test_import_options() { initcli imp - completes \ -h --help \ -c --copy -C --nocopy \ -w --write -W --nowrite \ -a --autotag -A --noautotag \ -p --resume -P --noresume \ -l --log --flat } test_list_options() { initcli list - completes \ -h --help \ -a --album \ -p --path } test_list_query() { initcli list 'x' && [[ -z "${COMPREPLY[@]}" ]] && initcli list 'art' && completes \ 'artist:' \ 'artpath:' && initcli list 'artits:x' && [[ -z "${COMPREPLY[@]}" ]] && true } test_help_command() { initcli help '' && completes $COMMANDS && true } test_plugin_command() { initcli te && completes test && initcli test - && completes -o --option && true } run_tests() { local tests=$(set | \ grep --extended-regexp --only-matching '^test_[a-zA-Z_]* \(\) $' |\ grep --extended-regexp --only-matching '[a-zA-Z_]*' ) local fail=0 if [[ -n $@ ]]; then tests="$@" fi for t in $tests; do $t || { fail=1 && echo "$t failed" >&2; } done return $fail } run_tests "$@" && echo "completion tests passed" beets-1.4.6/test/rsrc/full.flac0000644000076500000240000005260213025125203017307 0ustar asampsonstaff00000000000000fLaC" @D!voWelM reference libFLAC 1.2.1 20070917 TITLE=full TRACKNUMBER=2ARTIST=the artist DISCNUMBER=4ALBUM=the album TRACKTOTAL=3 DISCTOTAL=5 COMPILATION=1 DATE=2001GENRE=the genreCOMPOSER=the composerDESCRIPTION=the comments TOTALTRACKS=3DISC=4DISCC=5 YEAR=2001BPM=6lyrics=the lyricsgrouping=the grouping8musicbrainz_trackid=8b882575-08a5-4452-a7a7-cbb8a1531f9e9musicbrainz_artistid=7cf0ea9d-86b9-4dad-ba9e-2355a64899ea8musicbrainz_albumid=9e873859-8aa4-4790-b985-5a953e8ef628label=the labelpublisher=the label@!̡'hPhRs% $a̔Ô9C9)™e$ICI?(RaLɦ̡IS9ȓ4)ɚ Nr!(RyBxJDP999e‡=0L%3!(S&RaJI(S'0' 3L)̧ d Pg02PLBPɔg>i'Bee I8RPȁNd NLIL(Rg S% &JLICBPJhPBϟ%%% JaBr$He$B!?"~s3L))(I Rg(g03sȁB4ffLLOI9C2|%P )%2PJ (pC93330hsC ,.)=! NRg &rPIfg0'a3Rfe B!3)P@J8Pɠ|(PNPJB&Pae%32~dBag2|s@0S33' ”&D'0M (P9C3œ)")!2!(RP9d!†g(Re'JB!HS))L)8XDe0 )2S')2B2fs9C93% )2rPL?)2 'L'sdsJIL)2!3) RLaILaIɲR&sH L̔L̡)(D39%'" 2S%% 礡IL3?(S0РRg̔2P)ɔ@DM033&™'J)œ(2eNP)g%J2!IȄY&t&S$,醅'% IC% 8@P!9BRP$'3% dp&NfaIg2eBO)С"9&i)"(fg?C'™9@̡C8D2pfs9$I2s rP<“9))"xL̡.IRRr  JH|g0e LI4L,"8r))3I !3@$L"BBIBRBae2S2O (M aNfe% r)%$)()(d"y2†'L\Re'(pfS fP)(hdCfRdBe ’)4(g0d)P(RrR!B’$(JPIB)9)(r'a%Ô̔32P)3ϟ(y2pÜ)I<99)œ(S3)S&D&D3I)9ID@!339a dfa!s LB NL<)8g?PLJP4(JBa&PL@ fM L38P&RSg=De ɔI30NJJHD)9Y@2P)(L9zM0 ,Ϥ(X&xD4 J)9@e'I J2|(pNP!’PM JfdC'30!aÙ8D8D)(D)R N)>rL@Ls0aIBLBhD $IϜ) PŸ 9Rg33338S3'333$C'%'IP@dC338D4(Y3% 2D(JJBP&fffp pe J2P@%(OP̓J)̤Id3 fg3LLɦ2t D32S&C9L(D% I9C%'(rPe&S'BRs:)Xe&re!JaLILg9CC'2S dyL<2hfp3It(d ̔'C &RM% s)2(< 4L̡`A)%8r!)2|Brdd$ B Jyy(dBLK32P)%f)))(RLBe&y˜e&)2))(sza@! 0 BIB" XAP2yL33 Re BL))8PLda2P% C3d'IPRP0Jg(y2!<2p$B2fJfP̔,)"&fsCB̑0PNIa)Re3S dy(RIr8S39Rr‡É&P JfPL"Bϙ&DD8e&% H)I pg PfPÔ9ILIB)&zBO!))(Y2Ȅfr(L2 Rg Ȅ2M <2P̖P,2 fPsPeJ˅8RP̤P(fa,CS 3(s)(JfRfK0%)™2s&PI LC@JaNIB|aСIS!LD2RpaIɦI<,(sȆJ}Y2JP2D)24)(S' ="3̑ D"(dФ2…&e I9(p CY@e9IdJ39I@#0L̜ 3g(Rf"asCP,94fNRP)3(O32RJa҄% zdPLfs9)BdBNz"= !'(Pt(XpJ@ @Ɛ gœ(Y%BP% L(r2J9C9Ig(RPI@?@Ng)')hdgM!)RPBe'L4@BzL9aL̡2p%33BPIB)™4CaO9C Nd<>dffJ3LNaЙ9M!šHICɦHJdfg$B:Rg)29By)C)YРDIx !NC$C9= IJ%(r(e&PNg9Is9C3d2% dIIINfK33339B2Xr…2rd@ÔL4(S2s s3339!(Re 9IL)3:9 =>e$)3C38P8XD $NPС̤C9 ɔ?С"s&P Nɔ) t38D2RfS%32D2S9I)B!C)3Pe$IOBLr!C9y4g30LΘy )g3) h4̔"C"I)&g3)(RP9H3Ô42Pa&JI,"CCfJaId2I ffa†LHpJRB3)$!(fRe3392e$@…'% J!HR:aNe LI3  :4 P@S8SPpPJI2RPfxЧ'(P 8DaI8PJB~fa !BS% ')YpS0̤fe LhS9&D)!Jd)@))L)Lt(rfJ%%% LINPr9RaC9̝ I)Xf fsP)C(J|8RD%(y{sB8s9s9sАUA6q HEiȤݣ$h r GR TI) 4d!RH!RH!R!b) *2,2,2밳:0C KMXckZiJ)RJ) YdAF!R!r)BCVU}SvteׅYn]8u}amXeY9~Xet]_XmVY_}xu]n̺ mc}Yf_w:㩪+ , Ƴ, *±,j°ڶ1ܾn,pk1Qu|_x tu]yf]utG8~@!+8$dY(Y(躢hiiiZgi),iiZfhk)˦jʲi캲mlۢiʲil,ۮ꺤Yyijgjʲiyjzh(j,[g試'j*˦ڲilmmim-jۮ,ۺ/iijg癦il+[(扦j,ʖ癪'ꉞk*˦jڪil-*ˮmʲnljjʲl˾ʪ)˦ڲi-۲˲iʲim.˲mlhm-*۲-,ۺʮo-p0l+ۺo2}DӔeS5mTUYvee}4m[UU[6MնeY}Yma4M6UUMմmYmaeveٷe[uוu_}e溲˲p p0 a1R9Q9 dA!9!92 RZ !Rk8ؠ)8@!+TX癢jڲcI'H牢im['牢ih뺺.뺲+릪ʮ,ª+˲m°ʲl۶oܺ‘.1 G @6pR4XhJ 0!B!RJ!0`B(4dE'C)RJ)RJ)RJ)RJ)RJ)RH)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJRJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)R pz0 HQJ)Ɯ1cI()b9RRi-9!RmsRZ13礤[9RRkVk5ZZ5לs͹k9לs1לs9s9s48ذ:IX`!+Ts9R9"s9!T9tBs9!9B!s: BBA!J(!B!:!B!B!RJ!B P`@ #R΄AA AQ3 BL9љbNj3S9tjA^2  (B1A U DDH.\]BBX@ 88'ST ""8>*,.02468:<@OggS@='02310276;:?CCBl% B8FkF#zJI`76BU5: K.[/I ~JJ@!T zwP{@ۂ_,5@4> k@!TU40Mt+Wу>vyPU|GאOӱpB&>4PU'@$O}%M;: >&* @ta)t (4Ore1M0BU 1>Y(sY&-RJgMkVOr2IUU@EO5nE~IӴJq)dU)$ @Or28Mj Y"eddcw d :Or`ۛTU:]\\{QFH4T05yk2XaEP + &E^ #&!ir}Ωaku)lY & Hyk2MP*jT1{Á1^_Y~vtZ}.95\1)_#yR NyB1,r(4ptmp sRL/g9?0M),Zu8 4X huҋ~>S$=cr7Ì0ʫb*߷QY4#Oű = P{dX]59  f)6֔A`f^!-VFļV ]tGNy49aM5Xd\%`8'\57M?/'x,ׯ}YnmcfFU)0޾ڝP[+T2\S G.WX;UFhkoF/u c:78 0d7\v}iEM M3"~b#Ah'R&y,]~MG-9#8뼁#s/y78Q1%8iup;_I5?&ҵdun{!:υzU.25Bɖ+q0Q*/CU&^ܱ-59q#} &68Zлt3WܙohmP-Y?{LX8&M f%ONɔͯbPwWI;w(*hRdPLK?7IB|_O&arvQ$kװdlLc/["R#lc,VݝHbz{8T78Q1JQ3=Hi M6Rm|f Z*(Nb&u@-jI&&ho\[Jx2 ^C7^:YGcFO}ݺ<~2P"Sމ!G&SIu8Q(BRHYB,-ox3CTX| ѳ^^^)5=%Gpb{fy](>(:;ubj:kGG:OggSD=59 % . :>]?TxLsԘMPX+q!^#a|S.㵕Q%(snl]N-{~w80BbqX9v)l}żbId=!t,W79 Rfo<ӗUe׫p0[(w"u8ބ2h"hr)=|P"r:I"q蹨R""u޽ ڇ.Asψnn7'e~ u0B c@2 8>h_6zyuY|$ԕŅS n'E0!>~v3ayz9Mk 15W7#eiw81hbFhzmp4<* œ"N|oSjlxMqIߖ'SΗWKw:&U0Tn;bb Pe 8P<0P8ӹj xz}]beets-1.4.6/test/rsrc/itunes_library_windows.xml0000644000076500000240000001606713025125203023052 0ustar asampsonstaff00000000000000 Major Version1 Minor Version1 Date2015-05-11T15:27:14Z Application Version12.1.2.27 Features5 Show Content Ratings Music Folderfile://localhost/C:/Documents%20and%20Settings/Owner/My%20Documents/My%20Music/iTunes/iTunes%20Media/ Library Persistent IDB4C9F3EE26EFAF78 Tracks 180 Track ID180 NameTessellate Artistalt-J Album Artistalt-J AlbumAn Awesome Wave GenreAlternative KindMPEG audio file Size5525212 Total Time182674 Disc Number1 Disc Count1 Track Number3 Track Count13 Year2012 Date Modified2015-02-02T15:23:08Z Date Added2014-04-24T09:28:38Z Bit Rate238 Sample Rate44100 Play Count0 Play Date3513593824 Skip Count3 Skip Date2015-02-05T15:41:04Z Rating80 Album Rating80 Album Rating Computed Artwork Count1 Sort AlbumAwesome Wave Sort Artistalt-J Persistent ID20E89D1580C31363 Track TypeFile Locationfile://localhost/G:/Music/Alt-J/An%20Awesome%20Wave/03%20Tessellate.mp3 File Folder Count-1 Library Folder Count-1 183 Track ID183 NameBreezeblocks Artistalt-J Album Artistalt-J AlbumAn Awesome Wave GenreAlternative KindMPEG audio file Size6827195 Total Time227082 Disc Number1 Disc Count1 Track Number4 Track Count13 Year2012 Date Modified2015-02-02T15:23:08Z Date Added2014-04-24T09:28:38Z Bit Rate237 Sample Rate44100 Play Count31 Play Date3513594051 Play Date UTC2015-05-04T12:20:51Z Skip Count0 Rating100 Album Rating80 Album Rating Computed Artwork Count1 Sort AlbumAwesome Wave Sort Artistalt-J Persistent IDD7017B127B983D38 Track TypeFile Locationfile://localhost/G:/Music/Alt-J/An%20Awesome%20Wave/04%20Breezeblocks.mp3 File Folder Count-1 Library Folder Count-1 638 Track ID638 Name❦ (Ripe & Ruin) Artistalt-J Album Artistalt-J AlbumAn Awesome Wave KindMPEG audio file Size2173293 Total Time72097 Disc Number1 Disc Count1 Track Number2 Track Count13 Year2012 Date Modified2015-05-09T17:04:53Z Date Added2015-02-02T15:28:39Z Bit Rate233 Sample Rate44100 Play Count8 Play Date3514109973 Play Date UTC2015-05-10T11:39:33Z Skip Count1 Skip Date2015-02-02T15:29:10Z Album Rating80 Album Rating Computed Artwork Count1 Sort AlbumAwesome Wave Sort Artistalt-J Persistent ID183699FA0554D0E6 Track TypeFile Locationfile://localhost/G:/Experiments/Alt-J/An%20Awesome%20Wave/02%20%E2%9D%A6%20(Ripe%20&%20Ruin).mp3 File Folder Count4 Library Folder Count2 Playlists NameBibliotheek Master Playlist ID72 Playlist Persistent ID728AA5B1D00ED23B Visible All Items Playlist Items Track ID180 Track ID183 Track ID638 NameMuziek Playlist ID103 Playlist Persistent ID8120A002B0486AD7 Distinguished Kind4 Music All Items Playlist Items Track ID180 Track ID183 Track ID638 beets-1.4.6/test/rsrc/image.ogg0000644000076500000240000002307213025125203017275 0ustar asampsonstaff00000000000000OggS=[vorbisDOggS=2vorbisXiph.Org libVorbis I 20050304+METADATA_BLOCK_PICTURE=AAAAAwAAAAlpbWFnZS9wbmcAAAALYWxidW0gY292ZXIAAAACAAAAAwAAACAAAAAAAAAAm4lQTkcNChoKAAAADUlIRFIAAAACAAAAAwgCAAAANohJ1gAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB94CCg4MEEoBFpkAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAFUlEQVQI12P8//8/AwMDEwMDA4ICADkbAwPRhX8nAAAAAElFTkSuQmCCMETADATA_BLOCK_PICTURE=AAAACAAAAAppbWFnZS9qcGVnAAAACnRoZSBhcnRpc3QAAAACAAAAAwAAACAAAAAAAAACdP/Y/+AAEEpGSUYAAQEBAEgASAAA/9sAQwD//////////////////////////////////////////////////////////////////////////////////////9sAQwH//////////////////////////////////////////////////////////////////////////////////////8AAEQgAAwACAwERAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8AkoA//9k=vorbisBCVcT)FRJs1FbJBHsS9לk SP)RRic)RKI%t:'c[I֘kA RL)ĔRBS)ŔRJB%t:SJ(AsctJ$dLBH)JSNBH5R)sRRjA B АU@ P2((#9cI pIɱ$K,KDQU}6UUu]u]u 4d@H d Y F(BCVb(9&|sf9h*tp"In*s9's8srf1h&sf)h&sAks9qFsAj6s9j.s"Im.s9s9sspN8sZnBs>{sB8s9s9sАUA6q HEiȤݣ$h r GR TI) 4d!RH!RH!R!b) *2,2,2밳:0C KMXckZiJ)RJ) YdAF!R!r)BCVU}SvteׅYn]8u}amXeY9~Xet]_XmVY_}xu]n̺ mc}Yf_w:㩪+ , Ƴ, *±,j°ڶ1ܾn,pk1Qu|_x tu]yf]utG8~@!+8$dY(Y(躢hiiiZgi),iiZfhk)˦jʲi캲mlۢiʲil,ۮ꺤Yyijgjʲiyjzh(j,[g試'j*˦ڲilmmim-jۮ,ۺ/iijg癦il+[(扦j,ʖ癪'ꉞk*˦jڪil-*ˮmʲnljjʲl˾ʪ)˦ڲi-۲˲iʲim.˲mlhm-*۲-,ۺʮo-p0l+ۺo2}DӔeS5mTUYvee}4m[UU[6MնeY}Yma4M6UUMմmYmaeveٷe[uוu_}e溲˲p p0 a1R9Q9 dA!9!92 RZ !Rk8ؠ)8@!+TX癢jڲcI'H牢im['牢ih뺺.뺲+릪ʮ,ª+˲m°ʲl۶oܺ‘.1 G @6pR4XhJ 0!B!RJ!0`B(4dE'C)RJ)RJ)RJ)RJ)RJ)RH)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJRJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)R pz0 HQJ)Ɯ1cI()b9RRi-9!RmsRZ13礤[9RRkVk5ZZ5לs͹k9לs1לs9s9s48ذ:IX`!+Ts9R9"s9!T9tBs9!9B!s: BBA!J(!B!:!B!B!RJ!B P`@ #R΄AA AQ3 BL9љbNj3S9tjA^2  (B1A U DDH.\]BBX@ 88'ST ""8>*,.02468:<@OggS@='02310276;:?CCBl% B8FkF#zJI`76BU5: K.[/I ~JJ@!T zwP{@ۂ_,5@4> k@!TU40Mt+Wу>vyPU|GאOӱpB&>4PU'@$O}%M;: >&* @ta)t (4Ore1M0BU 1>Y(sY&-RJgMkVOr2IUU@EO5nE~IӴJq)dU)$ @Or28Mj Y"eddcw d :Or`ۛTU:]\\{QFH4T05yk2XaEP + &E^ #&!ir}Ωaku)lY & Hyk2MP*jT1{Á1^_Y~vtZ}.95\1)_#yR NyB1,r(4ptmp sRL/g9?0M),Zu8 4X huҋ~>S$=cr7Ì0ʫb*߷QY4#Oű = P{dX]59  f)6֔A`f^!-VFļV ]tGNy49aM5Xd\%`8'\57M?/'x,ׯ}YnmcfFU)0޾ڝP[+T2\S G.WX;UFhkoF/u c:78 0d7\v}iEM M3"~b#Ah'R&y,]~MG-9#8뼁#s/y78Q1%8iup;_I5?&ҵdun{!:υzU.25Bɖ+q0Q*/CU&^ܱ-59q#} &68Zлt3WܙohmP-Y?{LX8&M f%ONɔͯbPwWI;w(*hRdPLK?7IB|_O&arvQ$kװdlLc/["R#lc,VݝHbz{8T78Q1JQ3=Hi M6Rm|f Z*(Nb&u@-jI&&ho\[Jx2 ^C7^:YGcFO}ݺ<~2P"Sމ!G&SIu8Q(BRHYB,-ox3CTX| ѳ^^^)5=%Gpb{fy](>(:;ubj:kGG:OggSD=59 % . :>]?TxLsԘMPX+q!^#a|S.㵕Q%(snl]N-{~w80BbqX9v)l}żbId=!t,W79 Rfo<ӗUe׫p0[(w"u8ބ2h"hr)=|P"r:I"q蹨R""u޽ ڇ.Asψnn7'e~ u0B c@2 8>h_6zyuY|$ԕŅS n'E0!>~v3ayz9Mk 15W7#eiw81hbFhzmp4<* œ"N|oSjlxMqIߖ'SΗWKw:&U0Tn;bb Pe 8P<0P8ӹj xz}]beets-1.4.6/test/rsrc/empty.mp30000644000076500000240000002056713025125202017301 0ustar asampsonstaff00000000000000PInfo(!w  &&,,,33999@@FFFLLSSSYY```fflllssyyy9LAME3.97 -@$|B@!w_^P1@T5"LTBhNsy}@3;B2!?}C'Bbʀ,= 7cE`M.2 :Jw f}w[?ҍW:F B5纡^*+R:kB߿ۮ[&8Ub0'QX`he2yYR!) @} 86`v"6FӔ=؊v?VaVӯSˌIleM[#@~H'zU0fZc{Τd ti1+>bWnjkv}I*shC #9!uk{jk+Q@ڷ]+mFnWR; @aN#4HWJU?^g=vy*g^M)\ZW$4>dLN[>S3* G_xV#B)eTH N@k%GI,eU]zQ.,-"~}JW}TE> MB#D?\dO=!btph'rPX.vlKRW  =!'.|nXNkb aWIvȇ'HT'@if"rٕz:F{)Mk-!M.hq'fz0@YmVgwT x!G!'Gt g֏2K.ֵmޓkR1Uc& hFRnY =  '#UdVir N^1|QZ=O7Zҍ}wC[fB> R*( ?Όk;Lf*nSSysfiT*2ي&;+ȕ YScY4Fڙs(M4qȬ4EXu(fJ8vh dznE"p z3Ӣzr|RĄ @ 7#`'o׫bߗw.L7e@K)um.W[MFvR6"mvbfQYGuJd~2[qfBI+q ѠYl;4p2!fKCIY%>#޾?ޯy,bAY ,+ %*?XRĖ  =aH+-zvcF1ސV?B'ȋo$R[ dt# 4 KY$q:4s#M= gdLt2f;^c]L[;SD~"i]1N^ w/jO)MFW+Mȝ6_+io̊% ) $'\W'56?'p&ݙqRİɯ =9X< =}awLNgfV XuQNkJ$&gȩsЪȄ\?M?XCe+0Yli4,b`=@DW-oUIT;fʷj Ւ:tх s5K[$?#D ?x́-5 d_eS9$, RJͳ = 6!'a?`I]Kief;i#R /;:FS}Ls W=hc 4\j" E A8. `& r 句?򇬿-B}VATQ>Zv_WSQW3P ;N~U~xJn,!h,Ԛ dj-i DH\R  5A J @EV"keRw)Ьtz.E!KnvzW[u7NJ("5@aL=#Š@R䉕twiUGQlwE#GPN#_S̞?WcNB%n_Xa0ejR 5`!Ii̗31[-y?nK6▪{yN~;sU/ͯkMl0>P`P8XLT,aLqYu>yrvJKu|1Ef0 rl즽ե6\on-dfYi *'m9!D##98R e -A &"1Y2 iR+E'  NOJ(aؙd%y3;%frg,XPK1S&!Ib]aIW=˻K=\ilIus?W;e8gUYCcIˮ)MVX`L3b3E$TVRLe@w</wMr:jdZu@WWG=A .yX<,WQ`f[:+gdr2v02+#`PH"(M4D6ϋ2Jݲn0^gZ"ClVԪEy~a5TWdjja#TDL@l\ҶĀRKq@= vYq'j%#+ўl2Ř$h^ ёPI/^S4?̛3ѱ1WO\+u :!HN /s?@>;)*_jdJ$ǙYa L ʃ 8٫c} QmQXTQ7(Bm2 KR @= q6 HlAl|Tm(-r meȧ9YcwoŎawV)dK&Θxھ ]\)q]LBUReN~~WߞR;'Z\x,KMR }@ ;A6/ǘ9sIB Eu;#XVD8TuGtF[.+0lQAj$?~(@MNV' -6pP\Т/?B%Z*Jta.fԒp-s.9?K=K^gϬ3 :--: `@R %` 763 64"()芢C$3X-,t9Xƻ4eSUf?YjM %\ȸm `dND V€W5k3SH ~X);ZԟQ{- JIxeW+{Lo&I1ID`5h6 A1@RK6H$D~^?uEeƠQ : R yj6`&_@;vDC0u",gC #6|5n7Οgrn47]+ނaCO]:HOX0>he,(Tn54uZ<gZs.Mzffvtb[6TzjY: D*q8R =@ 5Ak6a(at$;^Z,Hں H(Dz3EЮe3;&Q9ou%n Cd=+_TX%8< bHG 01C Q53{sɖ~|A4͏˱S:#rm%di?2c_tיNe [|LL,%8&Y7l:1R @A}?ͨVln48T,EYN57C5Tlɂn{ Ӏ=guYeP~U (iq &pH@)HpB%7)M>-;ck9r/ą b]CA$0LYpodV{8tQ 1#moHtYerRDL(R = Ә]4zbf|0 C>HIV{װ2l)M1 R3Te芊 U,1$ ` \u"桅?m8T),K_^UQ3H>2%~#~:t(s8ע 2F `wk(RK@w6`ii3m EM_nLDWcI3 G!)\yE:p&CmMZ7| &ٞL*("bL$"]!*Q S¬eFg/4&g 蔿J1 T1K;n\Ěc(0N,ss)% S*DsubF䉜\(ćVZaB ,`vJ @ҷgs{<&Nr=yƾv8X;GӷʮQJ c1P&4R }@ } i85ad?m?2ZfyQ)%[+lgV#^*ۿ/!rT}'.U惒mQ\V`}fQNאL^Cw `Vq%K yC=Z$ԋ:t^\Ū*鋃 ,m' 2F R u@>ɂhLR)\S&7M"އk: a!%-R I ;IX6a p@,%w*LS3209TĞs)ʦEtT΋SVf{nF:B۶'bZ(H ̔ju[kie aez<Z2UE3-Pp;1T*Iv:5զWbKFmK^#.RJ U?t<(b+=5jjB5t{Jb" $/7ۭ_f˝{{9|8>0 `eKkI?róm)2<ϕs9!YybM **:mU[ݙ:daC (9 ?RN!&':8c3sA'4{2Kc>ȅXe43B3noJSc$4FWU"M3ž*j(Cp rݫ d<ͱD8L. Y={?]T.s]um9ڝ *氛_^_:V[4a /*B R m ٔ !cD󁑔@*ⲟK4#p[kUIG$7)g$lˡ?wg3vAܪ@3F$r@ٲODd9ƒJ7"5טWBMs[Ae[CPА )˴rSR6*O3z1ˎ^Y;gN6azճ$ mctt3^?wG/>}6WMH(B1#F#Ύ6?LV@HnR2dg[z݈=),Ľ>elh꩚8iAI F<$ p„(r4IR - 7Ƀ`!h( L t?ÓVs5BU)8&H?p ۫ u/5am>;3nY#KC-RziH$ e8fB-vtyfD =BszIba\T]_??;{~uڲcIUP%C+08W--R ] { ӤrF,'=AC'mP[Tہ&cn5-~g_3iZb4 gtH,2!ipM4 4̌ח˙HՖYZPɕ'#Y&!JG#,5k,Fϲ_˖( NFf\R Y !hrnLAME3.97R @oِ`hR] t4beets-1.4.6/test/rsrc/lyricstext.yaml0000644000076500000240000000650513120341455020622 0ustar asampsonstaff00000000000000# Song used by LyricsGooglePluginMachineryTest Beets_song: | beets is the media library management system for obsessive-compulsive music geeks the purpose of beets is to get your music collection right once and for all it catalogs your collection automatically improving its metadata as it goes it then provides a bouquet of tools for manipulating and accessing your music here's an example of beets' brainy tag corrector doing its because beets is designed as a library it can do almost anything you can imagine for your music collection via plugins beets becomes a panacea missing_texts: | Lyricsmania staff is working hard for you to add $TITLE lyrics as soon as they'll be released by $ARTIST, check back soon! In case you have the lyrics to $TITLE and want to send them to us, fill out the following form. # Songs lyrics used to test the different sources present in the google custom search engine. # Text is randomized for copyright infringement reason. Amsterdam: | coup corps coeur invitent mains comme trop morue le hantent mais la dames joli revenir aux mangent croquer pleine plantent rire de sortent pleins fortune d'amsterdam bruit ruisselants large poissons braguette leur putains blanches jusque pissent dans soleils dansent et port bien vertu nez sur chaleur femmes rotant dorment marins boivent bu les que d'un qui je une cou hambourg plus ils dents ou tournent or berges d'ailleurs tout ciel haubans ce son lueurs en lune ont mouchent leurs long frottant jusqu'en vous regard montrent langueurs chantent tordent pleure donnent drames mornes des panse pour un sent encore referment nappes au meurent geste quand puis alors frites grosses batave expire naissent reboivent oriflammes grave riant a enfin rance fier y bouffer s'entendre se mieux Lady_Madonna: | feed his money tuesday manage didn't head feet see arrives at in madonna rest morning children wonder how make thursday your to sunday music papers come tie you has was is listen suitcase ends friday run that needed breast they child baby mending on lady learned a nun like did wednesday bed think without afternoon night meet the playing lying Jazz_n_blues: | all shoes money through follow blow til father to his hit jazz kiss now cool bar cause 50 night heading i'll says yeah cash forgot blues out what for ways away fingers waiting got ever bold screen sixty throw wait on about last compton days o pick love wall had within jeans jd next miss standing from it's two long fight extravagant tell today more buy shopping that didn't what's but russian up can parkway balance my and gone am it as at in check if bags when cross machine take you drinks coke june wrong coming fancy's i n' impatient so the main's spend that's Hey_it_s_ok: | and forget be when please it against fighting mama cause ! again what said things papa hey to much lovers way wet was too do drink and i who forgive hey fourteen please know not wanted had myself ok friends bed times looked swear act found the my mean Black_magic_woman: | blind heart sticks just don't into back alone see need yes your out devil make that to black got you might me woman turning spell stop baby with 'round a on stone messin' magic i of tricks up leave turn bad so pick she's my can't beets-1.4.6/test/rsrc/unparseable.wv0000644000076500000240000003120013025125203020364 0ustar asampsonstaff00000000000000wvpk 2DD!RIFFXWAVEfmt DXdataXBWWGVHC et@ ɦf1/%k^\E7h JL9 u)q-8np#8ܹC#yT1= @Nc$lrc U $OJFQ &Qd4/ z @jQ g(l 43J~ñfИ1%hfTNc8HLaD R+9QT~DÌ2:QIPvj atX!HF )15qKP|MI=J`ձ+DXC=27TJ3)cD,"Q'#zPD6RY;ЎB4']'T D3@֜X J|pS Db2|! xgp暽=^jc'ɸe`&РDP+ƎXÜ% jYjXjl:d[>4& > ĉNЃh奀,d Nq<^@b>gӊQ:X-`FA\~fsIŒ N'vnBP.A92ic"Y 萌4FXa!P"cb(A6fȈ@q*.`[kjcY+xjXB$\R+D RfD6T,"l,R’WPxS="=i t 7T3A0`dÐQ0$2H;-b5` GN4COPM! YpJD  mؼ,x 1.0 MʕDԜz")6 P,hF\ҝt?F6xAkFXjM P#X`dI4=( 9 0Xt`h A#GO 4%Nv%N ZD3-97 .!iA X;#Q&܄쓭:LtnT hmPI(K Ђ-ZGOd7P@gY=(p%l!HLJ|fdA8>@k0'-?c͑ , f Υ dÒj)2q ej0hAP멮=a2`P\wP0UʼP!g X~l8H,f:L;Թ$vCH@x?V S0#":X3 VCE4:bqL$`<?L5/ie8{`hbXs`+CT2R(j*xyX #Mbp8E5c@#+QRHH>@@4Q%RHpS*-h H 569; LT>$F5hYda2CyG0AA>A#:`@=c.Tx1 ;dX% >FEN@H̝ `@X% Xb X ܙ AfS(1ٌ gfYDx aPJ4@"hB2/ ȉ|   džm*46͗Ǟ x ԴGp#x/b[ 4@@ -$TF9ng xXꌍšZ l a#*hTyA#qYOpHdJNbZziʧeʫХ2֙`|ҕ%I'( F*T֬H+ZA & K^ n( Ik`8#t2%aitAAAUfe"8NT Fc=pJCʰ .A<&&Y:M1yfK,($ < k߄Zk$A͒?AK@A3%f6 HS)k g;D{8@$p с$VM<-" S; G4Mg=AsMzMEe. 0zbei;¡d D L0FX9 Rw) 1& yWrem6M,ȴΡWŲrLdz㐲y1B6o' ʋQ*K"04E1qA@A0K`%(5'\Pg&R@3 Nu4FxA kF(;ĘKJ4G8 DH ccͰ2b=];pPepj@ RH)LusɼhB60fI)V2HL`p| %3JXTxļ ڔ3ɠ@"GȤ j6M (^kF1yfgLd:*%=XMA l*EJ@qD#PZ#DGXi /7KɐtA bS@t\P9n ѡk1V(Tr18Dt%i2E&(sϰʭ>4F D/P ̵#EX2y̕Dd%3LOx {p XPAj"ԩKN4u @X6'Rp T鐘 ZXܠ K`F&D!CI GtF6u9@2Fر@bdA&Nu$c1S,Wsr7@Gp4<M5E`\Xdأ$1D5p,O:6☡'*/"3P^%e"h"uCi eF=ʸH!K_uH$ͥZ30HqZ6I  \'VrePNf%$R-sp8`=0_4hcET؋AP_/1-D52/R@!G'xb́<%18l8pzp *1qP  9AʕD(P A HW"%"GىxihdN?T@ldA\a 0i4ѱ.B>b3sx8I 8\ w; !F :xSSJjO”!Ȑ2Py0WX$@7Rj;|#eT@j*/aaD lǺ8Z5SYxx4RIl"kFh"DCR{4CRdxf=R"T f2Cmpdaa#qL65zAR 09s} ddbAWn3bG1/tAq4h;#rk.9.(pXb7ոk9:X|rtġ@`KѮq +{%rC%\reaF(K' K =D#B-œ𢂦B.qHd9䐌.ʅ0pfa!{XTt0k!BJnW6 WHN IATF&ˡ4B T6]iq#l HO@чQFcw2H@-: a#BR#@ɐCgbhb,`aaC*b \B(n@S a2Rh&F̒C2eހPx*%cXd`F oRzͤOt(GcP7HDȞOLʈb(D kI DSb2FdC/RH<BD8`5@G`ijvKL> r|n06" 5Q%PaSO aYb!yn,\jdK!AQJ37c-T)'S( dsL]q4ʀ1t45z|p5/ UBcB;@雀U$/!T S!TfĢ{ptN@GbeFaY‚0N=z2`٧0I $H%Q_ |+PtM5A7"e(P"tΕ fh[c-0+)P,TV8\:2,U`}@NJad"TtC ׀UO\3Љ2'8: a!%, ^(t@hА/q9Őkw`qX`>O y@T$šI#qfd(xqɉLL6 S{ƚB8ь)`)0R̭AN5K5C5թҩ jȜ%:A:B8$%\8J*IM&%C hscQ#\B)4Z|I򝖘`  `9iP0bL$)RpBL7Yz$ aB;ǃ8!h) 7%Kf?\Aϵn,7X \M =o IKzEq 9HEH P^ΈȠh, 0HybR!5 q h4a\9AVMYC9H0n!0Y z4I3*V , b8D#RH'4<9r<ƩM!&IX8""31S 7W'dOG g10QbN5c n"&逥Ie-5_ѓ%jg' FA @(IyGeD FI7oI\Ɖͽ( ;4+BEX0yFs1RPaPjYplh,7$#SP| {\uP6Pa40 (B$:9I4P4yah`Z2QܐƸ (dOQt͕DeC>H*50ƠPB;@S8l^ pxJ."GQ͛"&gdҼIn0EEÃ/l#,0aZA 9 XIi;` Ѕ a^ 13@-2Qfyu(g02lͰ|f,Mi48;H4`&D X`"ͩ`4"/ = 8 m S+3k")KseJWс1AYd& :v Jij*If):@K5reو Z!&JS'`6+ 6tB\`ZetZG/M*B=J`RH Ƹ9r@XayH-Ђ72}I=Mi\DuXZpN# Ӝ(Kp$4AF0g 8a[:*F5Vw"dF$Rjp#b/<3BR`&C+Đ4bBY q32ci@MiDgʚ~HS!)7E8ARJhr:Ħ#>ǚBW&}0t> ,ȳO%莴c(eS$(@FOh %tD41uġI0Fć%,@D&4fTæ!ށC$l̴`5%RbTs}u P8AqLnJ($y@C;njt M4@ < *qY h\jhxm= 'jȈ u `8 tlyqpXH1 pG T9x$(T $FkD-2 Ps5QDsGB4x xpU8 PCai!06@ +Ð@ 0? T#:'BX58KO&lQ+` FX %V`T*Ra(R$@6!5\9Nk p` 6|FMyZ4˩Y QrW3{&@聆Mdv;b ZLlN+6h9@-ۃ+췂Dh4<:j5 \I\}T "re.hh`3.f#Y&ΏDTkHL@ 0 hNm@`gj2P!=XLCrg|dH =0Щ2؀ c'ua4 @ EAĚ=34.և; O#20JE&hC;-!n#z^u P^jfVDsHDl4(4թ&Gĉ5=- & <  A㬄EgẌEQ<#-3Hlc0O;j0/BiKzHJ&h26`'ZFH3W<(Րce@g^G<"ġ%ZQꔇE;hdG,!،1 9OmHDcExΡ]4!$jL ,Y0AfM$ 4Z$ ĥa#aNu (i:y\r/E60$4>>DF`z6% RGs|]0hu(Z.0d¤3R9t &"y ZH&5JE=YK`^tt?%y8]Y49 熜>ȱaN~$ahl  DYEpn"DA6=X9^v*8  t'5Wg8ŃG\(%BoaT aP i1,sɺ (X84Cicp$<&+\"@.i855Wa2 [B0˭Sr,Oh9@h ',A@≃pN'YM(,^](@M(@Z XiBxe*W3Os"Y OhE!e8POY0i`6I@#53d]SʡR.:?bfGRڹ0W̕pCD8JaTGsLЂ8\ckhB"),Ё0Dkb([>B}݀ Øq+ h4tK E8\BDKQx(#6e "Kcpi 6$8A 2 SOڃJt=D%iMpF?S#%B_~&` ʉtPCPoO` ZgdB,HТ{s8Gb(RD*kxyq+5Ԛc2U\1,Rd,Da aPU^3dKԸKJ(BY'8Tجr*G@F  M*̕q<0z ѐal4 :ĩ2)82NMI8isC0*`š`2nB5 f(cpՁ"Ҵe8 {ꃌ[DДrI1j6@l[g:ƊP0WG#h< !q!Pgc*0HG H$O7d%#2! h 5&|6p H5 (N$p *FBh%uL80L,PR67CD #{}\fUlM`=`A0CARK#p h5tjM*y1Y?@X0ϑ)M\($SMqYE1 !#-SE,2ihPqEBR) h %07U<C2(P4*\:`鐗eRAoj/ . =XM<HX :K ekd5Gcl LtJ:YC.  &iT1(zC H$%7kcj #TxDpL @tbl^ /0@Ր!h𨺣Rip 0ư*22 iM!Q fƑHHYɐCe1 6!>5E0aL iA,` >0t 7W%l*9*a{2l̙qB ȸ,<"re\!/pQPVpؒ!N)VdT..AKdqI8㠦TON~j#&؝Nc$RC@IA 7&5WFĉaHc !+PJ>P,Aτ.UJ"14q3RR1J 9np 9 Dl<O>qVх +-'<v3DH t h^fOjRy,)UQ?g)DP\L!28Q pe `Nq&Dc,b¡ѡTQkR "(H82d:bb,d h<\ReZ ihq')i^U" Q \ [YN%a [NXcU fS%3C8 hցjx &TBc:\B>eX$kOaxXa` Rc}m(Q@4g(a=P T)el%O$Act@TcQA<=N)I~*N:#(2lP 'XLi G  Y 13@_$JhZG@&Bd8=S"b`8;,5դaeVG'Ě,th` ApajUN=z57=8CCh٦8DnS"<(d}h?`! a/{'I)0}ƍ$gX!VPDW?EafĘ&vK,LNzP"!2R<Nj8`J%M L`qLB@gH3d%>pl/C Y^ JB9pL$eAƤ$",zɑM u$ ϝ xL6ƠBjBr2DAъXJ̋*F#/XpDhi6tȠnZ4C<@JC>*TJױ>؃dGsV@ 4Ec 6=Z A 5MAT`ZS 6P  :wC!lG^@~z$4^tj2e"OkAʃnDM(SX^ , -  Zk c;e $GT.桨 Sh qFOH,/h3o :K#@ki b $3L5D<5 k'KN 3dTrJ̎qa (%ѡ-K0APETAGEX8 DATEOct 3, 1995APETAGEX8beets-1.4.6/test/rsrc/full.ogg0000644000076500000240000002156113025125203017156 0ustar asampsonstaff00000000000000OggS=[vorbisDOggS=?2vorbisXiph.Org libVorbis I 20050304ALBUM=the albumARTIST=the artistBPM=6COMMENT=the comments COMPILATION=1COMPOSER=the composer DATE=2001DISC=4DISCC=5 DISCNUMBER=4 DISCTOTAL=5GENRE=the genreGROUPING=the groupingLYRICS=the lyrics TITLE=full TRACKNUMBER=2 TRACKTOTAL=3 YEAR=20018musicbrainz_trackid=8b882575-08a5-4452-a7a7-cbb8a1531f9e9musicbrainz_artistid=7cf0ea9d-86b9-4dad-ba9e-2355a64899ea8musicbrainz_albumid=9e873859-8aa4-4790-b985-5a953e8ef628label=the labelpublisher=the labelvorbisBCVcT)FRJs1FbJBHsS9לk SP)RRic)RKI%t:'c[I֘kA RL)ĔRBS)ŔRJB%t:SJ(AsctJ$dLBH)JSNBH5R)sRRjA B АU@ P2((#9cI pIɱ$K,KDQU}6UUu]u]u 4d@H d Y F(BCVb(9&|sf9h*tp"In*s9's8srf1h&sf)h&sAks9qFsAj6s9j.s"Im.s9s9sspN8sZnBs>{sB8s9s9sАUA6q HEiȤݣ$h r GR TI) 4d!RH!RH!R!b) *2,2,2밳:0C KMXckZiJ)RJ) YdAF!R!r)BCVU}SvteׅYn]8u}amXeY9~Xet]_XmVY_}xu]n̺ mc}Yf_w:㩪+ , Ƴ, *±,j°ڶ1ܾn,pk1Qu|_x tu]yf]utG8~@!+8$dY(Y(躢hiiiZgi),iiZfhk)˦jʲi캲mlۢiʲil,ۮ꺤Yyijgjʲiyjzh(j,[g試'j*˦ڲilmmim-jۮ,ۺ/iijg癦il+[(扦j,ʖ癪'ꉞk*˦jڪil-*ˮmʲnljjʲl˾ʪ)˦ڲi-۲˲iʲim.˲mlhm-*۲-,ۺʮo-p0l+ۺo2}DӔeS5mTUYvee}4m[UU[6MնeY}Yma4M6UUMմmYmaeveٷe[uוu_}e溲˲p p0 a1R9Q9 dA!9!92 RZ !Rk8ؠ)8@!+TX癢jڲcI'H牢im['牢ih뺺.뺲+릪ʮ,ª+˲m°ʲl۶oܺ‘.1 G @6pR4XhJ 0!B!RJ!0`B(4dE'C)RJ)RJ)RJ)RJ)RJ)RH)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJRJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)R pz0 HQJ)Ɯ1cI()b9RRi-9!RmsRZ13礤[9RRkVk5ZZ5לs͹k9לs1לs9s9s48ذ:IX`!+Ts9R9"s9!T9tBs9!9B!s: BBA!J(!B!:!B!B!RJ!B P`@ #R΄AA AQ3 BL9љbNj3S9tjA^2  (B1A U DDH.\]BBX@ 88'ST ""8>*,.02468:<@OggS@='02310276;:?CCBl% B8FkF#zJI`76BU5: K.[/I ~JJ@!T zwP{@ۂ_,5@4> k@!TU40Mt+Wу>vyPU|GאOӱpB&>4PU'@$O}%M;: >&* @ta)t (4Ore1M0BU 1>Y(sY&-RJgMkVOr2IUU@EO5nE~IӴJq)dU)$ @Or28Mj Y"eddcw d :Or`ۛTU:]\\{QFH4T05yk2XaEP + &E^ #&!ir}Ωaku)lY & Hyk2MP*jT1{Á1^_Y~vtZ}.95\1)_#yR NyB1,r(4ptmp sRL/g9?0M),Zu8 4X huҋ~>S$=cr7Ì0ʫb*߷QY4#Oű = P{dX]59  f)6֔A`f^!-VFļV ]tGNy49aM5Xd\%`8'\57M?/'x,ׯ}YnmcfFU)0޾ڝP[+T2\S G.WX;UFhkoF/u c:78 0d7\v}iEM M3"~b#Ah'R&y,]~MG-9#8뼁#s/y78Q1%8iup;_I5?&ҵdun{!:υzU.25Bɖ+q0Q*/CU&^ܱ-59q#} &68Zлt3WܙohmP-Y?{LX8&M f%ONɔͯbPwWI;w(*hRdPLK?7IB|_O&arvQ$kװdlLc/["R#lc,VݝHbz{8T78Q1JQ3=Hi M6Rm|f Z*(Nb&u@-jI&&ho\[Jx2 ^C7^:YGcFO}ݺ<~2P"Sމ!G&SIu8Q(BRHYB,-ox3CTX| ѳ^^^)5=%Gpb{fy](>(:;ubj:kGG:OggSD=59 % . :>]?TxLsԘMPX+q!^#a|S.㵕Q%(snl]N-{~w80BbqX9v)l}żbId=!t,W79 Rfo<ӗUe׫p0[(w"u8ބ2h"hr)=|P"r:I"q蹨R""u޽ ڇ.Asψnn7'e~ u0B c@2 8>h_6zyuY|$ԕŅS n'E0!>~v3ayz9Mk 15W7#eiw81hbFhzmp4<* œ"N|oSjlxMqIߖ'SΗWKw:&U0Tn;bb Pe 8P<0P8ӹj xz}]beets-1.4.6/test/rsrc/empty.mpc0000644000076500000240000000422413025125202017351 0ustar asampsonstaff00000000000000MP+'\@7s Df3?<>>s?EDD>@_@ @;8o E|_DȗDDDD""_|""ED""""""P 8hэm۶ӵ]K۶m^U6^j/{Ummkg{K[,Hmѥ&li۶sm"Ҕ(lu~傽gUӷUUo54i@NjT=SҩND&|/]|_8yAWDCx/]@by1!?A_O?>v`oyCWp @AWЗ /~ v>p2."?/AWu EDD@w@? t "|E|ȗ 䀈|n?," | ;mS( 9g|ΟKDD"mvk۶ۦm۴vm۶mmۦ]k6 8xhhopиpo"" """""/""HDDPDD7@ntݍp@m۶kmu.]۶]nۮk6mvq*N%+cEU5J X=Ws!r W8yA?^]/{@]? t 8:8p4tt 0? 0?? ]}__|q0+SaзK}>@P|{<0_Pt/_䋋'D/ "ct|/_ / _D{twh@ÁO/@/8x//,z@_*|=.Ls8 p AWPgw|ۧ{_>o?G@>H|_dm`g3+GW3rKn>+{ &6u_zmeS)K+2k 8ݑc3L퍎vWܸёYຓ@1"kr}qIu [#wfC?sb [XA =\ yvJeׂf$0y}: ح[rz>Qx( ~ 95QK&)9ja`A8ז ";bR3 DW v)ϳS̲A}ꂇT֋DcXHwWE>':ЁȜqqoie4># -&}#}F5p㏱*"(taœ")B -,AEexjچuRHn_n(g'v9H^%!?"X@6כRy=ς8/3VM»8u!rP@ qSz~jwxTvT.'>Қ?`XT: A?1\@dF|ɶD6 X4,MR#&eEvS+vWsJ˞x̜{[>pQ t) `mnX^x-Ǯ=捴|trβ!ɰv ^T Nk19#i[@Sɑ&v_.oUtxLڽH/mfɋ.y 8KQEp:7'Nn 8lho=}9P}V{snsXiVӔEIH=҃sK{cyOgP\3yy8#;t2[m y0Qv{D3@ hlXqK!!ֹڇ%5P]|#i2 EL 7X)E[[Z$ѾƺŖKWÜeY#,ބ.e<[؆,I#ҷ5B 6)MP12] B'i"-,XQ3_@Y5ΌDdm P Ue*z =,c-gC6a1uc,a$zܤ VR堓$}~9EZ1H.1.j{N]aL.7 DQl89ggvDm^żDUV.OXEVſ>qzQ}(#MoN,-~bc97 W_ 'sNYO j]feu6݃3N+1g`j;Vn6pf_OBdkpuq\HMkbY2}Pe'qCG[J7P~O k3/A ]%h@ ozWl0ur[KŸ}ng@)E /׿A썚Fz ӓ͸B@[ˠ%!~n8oH#7=G q`KaHPX{z|\+|+Nn{W*2us1\$JE3۠(Ql \V 6춆R-]SM}.Z+)Bw{]׼B􎠣[~uk*[RȃK^W.yx^S`X T9 } ,f%czCd/oXfg/Lb zI9z,ik߸m2H:șU36V-!S)Tt ߊq4zM=03mc(f4E9Ji#$ր.fOۚaFaoFV68qc:u[P_eff$;R=!MƗS(bLIoW>铳ӶS{I=C.'?^HutqYn1o2z.:L?mtx) L\󬍩Kap\!)P#?#X58*l&#p(mL3BAV$\^G4yް"QsaR%VwPRou͑?è| j FZ UwLetޡ\]nf y*e6.PZ !i{Shgeyy& 6#F(;`;SX~N>^j߽#䬭V%ކd8tLF:n_ xrr V;!H٥IA_\dYtI˾z!%,"{( iůIemnqɅwX!qm~".5088A&kypg!O 5/#FvѾAaI$IM$V]t \^*BX/e`{h L5V4hE@Eh2H1C|gg߮ :O B90XwTl}$4]lFެ0eSb+T#A陡:^hQUfB\m\MrY^`AEQZ̪8#:V:gJoC0~}t4$i@b8epڵE+byb.v0H9('%qN){b?,-n xk ?UTʰ4vlP{%݋vuݦBnCjI4CLJXk?fCjp߿hq50RHU*-m:|5;Q&PrnLU5yI,OY*)XLN5 8LV6W 9Q R#+ KXg.n22KؗT݄F0XC,S_-, 3"mwrkBRrK.yV:; * 0kV!B Ԋ쿴祽Dbht+ǵ!dt'UNaRV3Jq(<xm%cw8Ҟ gVlz/Zφ{ꇕG@vpذUML-wm<[ iA8\0"k87ʞ?? Z"B'ry/ G)ʕHjd/EL48GV^Lr1P=g(F݇VY4dSLZ@,چqsUwOB؍i$B(ܻ/Sd3{YA S'X*=eB3;n1 rrG۶ͮT <ᗉysWC=Ijyg!=m\2}bD!b}W=Xj:ׅ*hA81 #$cWf'F]iJoYd md B~!=9- Ե yMMflpu敹6]k6%{둞: ųŤ`!T̺p=ygx(qZn@Eaec6hoU[u"}a䝓>:)߻^]Q#yC9ŅGDZj&⥎A=䲽umq>jJF!Z{^f! o:K\tJ8X@ j 4;07:M!3Vdܱ MppT]?lrV/b ֆ68.'[$<iﮂ授4 ͮ1܏.~pd>?վw8PLb\8Qmf\e+ͮ ?d)d󎍥WIr$YB8v@ؠ06f%.+1_ʣ^ <p[Gpw ٴEq6۪˴כ_-/k qNKLDu c_DM!wsV -U؟Aqg@Ukwp~SE9tݲdut}*&YezSE=|ç ¦ ;<_oL܍|'Y=2yn}VRtO A0g:K{]I}/jҏah_t I$5u&.q2^wdMMO1-)zuX<]I " ,/g |>u9V/5k-![& F {qwh y3]P\5NDٞocASVm>[}kE:"T ?W 'a3QjuxgnrjMQyϝhVW&E)ȫJ.E| 0\=e7sZ$ 5ޫw?\."<$)pRw6;n\+@iRxã7.V~傦Bmk4@%?-f$1Fb㕲PeCS r:J(qf9䈶L iNS()%wyyS($("-H2f>V$xbsh%uIm+'Nd럈?p˗8}pb謏̈Pī(KL.g kHa+x31+O̅ar7& vdVO6!3B4,F|sķw@*B`_]q>/ è"NiK&=.h-J;Rv;YDpT>Jm؄3aS J+`=*1o05Y1k2e:Ŏ5Ir W11( X=~`5;B,d5B KZO>S.T %*źoO4yJ8"B`G YS# UI 1ny)~Nj7Zg\|E 0nSōUP Rf=òUE!_?~ +ΰ[!W;#%tqe*ʎL Y9WTd +vs~V!@ݠ;WlUf6Gy %rLb@-,y MxR 伝o^dNfm%1[K;.B,:^&>/!3|֫SޗyTF?0E22>jQu?x~$[ӫ8{w!?Ia)!@ఽ@ jTz\+Uê߼TB+.%'Z oMO8b(g.EcGZBWx50'##9]!@&ݢFLF*ߕa6`1\jܦ#5~Z ;s؟o95 :!$`mJҨC+H;q !LOb5O~ 6DokCZ@ȶDtx 4 \_7tm9&DtܕhI >kFLqWPBX`#A#Xn$u1Y u|E)xH.-\t<7dƮ(T%(͂%ꖨǨ-7=aߒֱ@?T}pp(RW\T@ ;)BhR9߮㫴?}_l &Yod*ʿdD ]ha >yTMp2e q8/ߟj ǫɿϠk Y m].}`4ʐ1Sy;,ɯt r!M1:/i)XaX 4R(xIfQ3s/5',,Dzu[rwni T9Xs \ؠ=1 "co/ㅅ k?'-HWk uk1 0f-Д5gcq;Z5\Y5*[x RY b_(%La h5(yn>D-/H{uef/TCIǷR+FlĻU8fBa<9 UvnɆ`g K1ND߷ L? :YS3o 2b~g (/y'@I%JZ3 llY= ^!uaeA(34ܵSa^rrmgG`+n]8Ôī_` Hpn].2D{D|> ~mWxI#gz;*~ zi96@<ƺI&o /ޅt,=?vsܥ @R)e~3chiI˝99|F{C2V1SYe@ 9_,  9%b#2`O\f+e^Ϧ_~ag;i :3mm {w!dP-Sg0[{efO s@_!umhM}!+&6YH CD{_Ч0T7m`)C/4/a u}XcF SGÀbzEsڂXs ّ:d%NfV掶CXI +{A:3\4&0]-kM$8j]at /h& (ތj MΜ+Ϲ8,'CY0neC"ūB1N>R.p1)LӅWRWZl݌ &+U&ګu~X"c"l@u0' SXZ\%trn5*\Qz=@%|.TO"ظdGE"sXY!x}MAdL\ͽleQMY^))*}q  0epwv IW,2_FZl:%,,!9yӔ)[~CZD,M|N_J[1hn-^rs̫ymLBPyk#Q{xg[ Ha wwG՟^;ޱtʛ  _. SePӫ Se"ů[wHgDL#DIANEpTWM/Pictureimage/pngalbum coverPNG  IHDR6I pHYs  tIME  JtEXtCommentCreated with GIMPWIDATc?9х'IENDB`3&ufbl,@^PWM/Picturetimage/jpegthe artistJFIFHHCC }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ??ّܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104$WM/BeatsPerMinute6ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8a5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8a1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8aTRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8aWindows Media Audio V8a6&ufbl2K ] @ 6ӂ}m ;J$$$ ?'qU11%4` -I$IPc ԠIRUI10U5RIIjR` $Ę@JTBTI1,E4 ҒI:bf1&C` QR@H@1V` H :P@KI^o'@*@7C@ L $ %--i!THHEPjH@&B4JJ"0ҕ Sƶ|ALU2k?//߿,B(q1B~-Kl!+tH8 Hh-ߥ)B -P_PVCQn"v0{eIBVi~P6A \@A KTpk,RѤ vFPB(^o㧏yZUP(\KYFP}oM?_Rm>  p?,*}Kx)~yKoұ~(Κ\ysXߕ?-[}BSJ8lj9>q_ͧE'~ķH_%iM.]KV+s~_`;t㎟ߛc[O[t-A ?|T>Br-[>t~'AЄ%lE(?0i )޷Cn@4qR#)Ok(ZvϿ:`022{Qx6>T%[_y{7폷e)_y 즚|NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN @%,V,XU2 dDZb`D ZĒXpR/I &`c XH1$)1JI,2"MK A2B`*!&JADՀ$i E&(h+D!$``:h&F꤈l Fa؈ECv[0@00&)EdEBMCQ 3P 4R EB( JKBPPB0@[M/ۨJR`TOTQcM-ZZh n-:\kaS~O~p; yHZ ̡q!Ƹ8风G/ nkHH_pZݔSğߥmxi_;t8nO8}~itS[``pDsBP_ P=Bc'(cy'opc^vkL_KWQƷW ۟V}F =_SB4,(~o*Ǭl-vǾqNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ] @_mVb&6 H7#@&RI($L&@(ӣ" h&gQ0c "` 2QyJB%,D&`ȦS 2@0i jH*J 1 (i $ Ja0BJI%$X HD XB-#@!JoV ]G@c-TN I1QBJ L$i@J- .QB@~HDB%4ST(@JIJءXDS+e!/~BÊ#rQN}Jjۿ۟҇ϑ@vx-4>~m-Sm%J-U_q)4&)ZVnH~?V?'(O@}ȻgxۭVh y5aNڜ!m+(KϒHCzVB?YHC4Ombߴ5O>S*P|kv%/α8ͭS\tKq|B7KWOy#}_=Zo(ozCָ#$֖Oyr[xiQ[!^t{~lKupeQNP%θ NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN @-T߽Gé=4C $DΠ&I:MO`Ĵ6 FuaTAc A@a@-2E@i0,P I$h:I-/B(,ᴤcRL (HJMDi@IQ6 KLPLJRH4@YP 5" $ iJH`$fb`liC`)翝n6 5@L h$Ħ`+:HQE dQAE-|4h B(BA@){kW 8cCeEx o)1/t%cGv5oK(^NNNNNNNNNNNNNNNNNNNNNNNNNNNN2 @baED%iBLA2d 5fv  Kd @ :&*!!b"f@.%Z JR a,CP! %3U RDI%Q$HA@0(XVP 0,"*@0RKLTHU% (؈ þLUkI ͬnTbe.Ts!Dư Z1`  Ja$ k%Ԝ~5|m4;jΗM)$iH>R"“Y E @[)( %552JQ 4!(?Ї&*>vkO,)BRBi i`JROED(?K||PpXD`>'N}o?O+T- &e?GR OpUSo-(+\h}O〟rr7AFP">e][,in)V7[~E[GQo|CVaQiG]>U%eo@To)[[|'%>V _`J+{tԄ@x6ZmOͭ%k.kZJ|+SoDqc ßy2橮+Vֲ[/YO|iTOӔSՄinݔqۇnri6߇mlv{q/[i\kYB?4ҵiBpI?~n56ʺ펥Q/^iRZ`%|[u=|#߻~R>J[-qUo5ݞΗ`+[߾umN;}QoͻNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ]s  @AP{ges@ Z5J ĂƄ' H5 &*N䂂T`b J BRM D/HI*%t."A2iT"DH [Q% H-RL @hD pB6@"e KPI ,DB`D@ =یAUÕdʆb e@DHiBI)n,$JDX @BL4%)&KJ%lH!mىva4I/ nVЊ}HC-8PͥƄЌQEK? k :-)X>Z[KyOQKzRE8?h2A+tVOTքmߢ;zXŠRE->E鷈}BP;~%7\=_k[sဩ#=u4e."Pr}43pw/+{nq-)\|x%ּ/;֋e9Cޭ?nuÔ۰?~L/C`.7ϐkgW{~Gb/\JpԭV[EOMpe6[nk=R"?_ {->te`/7{pNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN  @O3 -9X᫣ZA):ՒT%A$d!LAD IJaIJl)% BHL Q%0&%0I HXRQ I4RDCe IiP&S5$P* e! A;DTMPIh"Pf2 XVy@$`4s0WX[$HbgD$Ul*$RDBQBhA4?& a/QAD"-x\R*IXR툤RA|_ r qjJGJhԿ(daGHmBB/MZ}VܷBh~IZ)(M M [ύ A8(Z[TNX n?/ݹPiM?) v*B/ݽhXG愾 Ihwz/7Ǡۿvq[[7q([ 0Th~G%Z-i&l>[o<87͢V׀?G"Ze$7pq>q g`?W2г8=7oŀEc汣ͺ}q  Vq Xߞ Zh'j 2 NNNNNNNNNNN  @1 E.wa(, B utL(fcD`.%I-H$HIS&$RI,)ZR6Ċ`BԔ)Am)HF(C&MH u(T$tH )A((Q5S'ɁAQI3q`$QDaS57$-%F,&o2hho`2I4aI a S蔬IJj!4  $-B  Jj(FOPV/ɷP_'ȡij.$:)G[H(Z-:RBl[nAJx%(+@|)+]hZK%/e?Z vPnV+|h񅺀[X i-U~$~T~c?iSPCN2;`>R-"۩ts&ܶ)Z[3X%q/MOV9E4/gR(l|[.FU)j_ܷhvPWQouX ӑ.Pd~-6vPt~u'[-mp.KOMxA')E6/)?#nv_r?\N}x*?U p^-H}XnIɞ(e`NNNNNNNNNNNNNNNNNNNNNN @%,HA~!f%֝z Xd $uՀP-P  1 J !5! %LI 3 DCja$ `T$  0  Y(LaP2-;*j@ ɖU2PvT@؀&bhh I *Z[- ĕ6I6]XDYC S PxPP MRHTڈ hJĤP쾠l-?B)لIoˠM (&2>-%+eb?㧊J(/߭/]|r) FR%`zY4')-"xInAZZAl>}Cn[-􂴰?R.7_?+e 8hU>G>_걖gVGT`'ߖSOS沅?e9A|!IndwCP@Jn!,-g__ [\cq`*-2mR8K揚['3O~|"Qqg_~OTV6 (B{ y?J[+{_-V7ܜ6>Jk+O/ŀ/ il,~,Fx())Ace.Z"NNNNNNNNNNNNNNNNNNNNNNNNN ]- I@_I44{`RALTĨɀRK@*L)H-` Lfgb*T 1 D)0IEC%0"0e@v%v+"T$9J*TdDJBh%au5)(5!" l"P[ %M! I(8AIh5jBN   [T4oaLlNκ0f$3)TłHv4B*0DJ)(Ja"C@(`)!4iJA| ot,_JI|RSCҁA)|)RB_Jn[in(n:V>|6SL)ED Vz4?|Gnsϩ[GJRNR] _Ʒ~J4>|u (#)oEoV2*4 :RR$~ے^kq;ZvKKOT[.|h~~qQX߯nUq,͡mSNSQW (?*ǡo=ߺ2`nNZ[g\HJߚ}V2ж_-߭" žv +rF=m!5ÀCm:?oǀNJUׅ-;c)G)۳ڪm4x +v.*?V=EJ|b~tNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNw@5Y J{l*'X 42 I l(C8 AtMY$R3(IKNɨA: ,LLAi(&BA20PU( LaI@$B+2 %(*U$&I5RD%%!BP!'R % 8S!AAAfXPFFP":˚9ʁ&H*b iB@HBH H@4Uv@j0@@%hPI(ثI$DGª?[&7d4%nފ* `*_ж8ihqin lZ->E(㦈HJx[SJ$q->Oo}KKE/mO>4SMJeHN]F ?5sz_oi B c}pP+e#)ܴ: Mc[iFRGXO7Ɨ[ ZBmo5?'oOp?6lzkR們Y[\Dt&e.Akt"x/.?\I-[P}Io>LH/U\5`<xB~vǂo݀%tN!_{eZak=HN@b#sgu"Hڛ]&;b&#`,nh$իI"%2C̘ p5; I&jhh@I-50 HJ" EMHKe)(4ՉTH"dVDK B@NAfUdRNdPB`ȄĈI %&\K"ڰ7 TlXKHcR%Q1 !&bZL@Y2HJ*! P XPL>J"" 4ER( >44lВĔR iK?  B2KRh[JV(~PP~}oD(Rx|%ic\ XGRFD[v[R-BQGij_~O)k}?9M5Qo~zG< mзotQMv(IZZH0(}~ ,XvyJGi?<h_6yFkk~$;/`{w~U\[\5 -, | _Ζ-(Zݑ- ~b?;uEaSKKK3H;}pC[罺ܵ斖>4>t?*xKO:~wo·eKυ+_ZNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN@*|W!eB( +lID5"2AVTH10dACDKphê[$N ڀI*BI" FD[ (a PH/$2PAI(A@! [Q`L @PDU I єI $A2CZL2 I2bRZ4nuB`l)"[@&!bL8`PH1NR:K@ Th%LE, JP]eZ|v"q>?TZv84~ /֟۷|\/ X*(J!Ko B4?\A\?ȠҔ[A߭OR+ Tб~T[_tEGBm}n~x [|$V5#Şݶ -Q\ ZZK5n'Վe)[չoG|P\ۨ?дnݞqдQ>^j"9j:~ ?6ݿ$'Ƿ[hk=4W<ӈT[=Uݹ18$Zq _ zاͭҊkVnQy_ pL'~te/݊2NNNNNNNNNNNNNNN ]@tǀ +W7 ;!@$I*bl.dDP"Ka$!0XȐX I" RQ)&D J V `R(EILVx1TAL A&BebDP@T$dB!"V0:5Du"J$CH@f$LImELG`,h,fI!fj2 B ÒP@t*Z)4&Z&PV)JjPcB >& ԗd-Е)~V BP?r5J~SQl|VT#;4-@'C괥!&h~T}~oRƚ8ҋzݸ_,S[~ֲ8쥨ͿK('+)OZt4%# AVoygX6즜"T5qKJ?߿̿[+$~k_)qe rt/ۥootnXÞhp>tht^n0B+?F(ߚ.8xMcyO@NNNNNNNNNNNNNNNNNNNNNNNNNNNNNN1@*ѳ.cՍ gm ʳ53($2A!0!6ȆEI  SwT$,DTKԊ0 )@! hM@ӄ pu "%5@HH˂BPmsI )HAЈuR@:%AJp H%"MdaD`$a[F64\᭹cPeI$-iI (:BABBP/#dЄh CJh*,@|A)M)B"M.Y\KA# 4%VCOޕ[E+(["޵Eq[(۲ xܕ_i}Z|?%z?hM N~ic{Rjn??5+m c-$t`.HZV-!."e%kB ˍ/+nJ)}nqt% „;n7 ƕpM (C4& <`R\CJmK҇+%?|EKv R O@)"r7Jh|M۸lj-P!!h`*rG%12|jh}oZ⦗Ͽvy}x꜠gPYFXIYGxҶGM ?i0K$[qkMÍ/>5r-Վzݾm (X6 7c0o_ΗK2߀,ZƊ([F{-ح1E=$tm>{zE9ۥrt\"x߾,@}luݔqFQL[ZHNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN]/@ q~&mk[x`.?km+x (}->_|*V=+/٥_ωV['tє?3O瀏YOԿ|_k rxAv_H-,i R /eO iKM4ۍ(A AKB-|$#Vʊj?hJRVݲK% k@PRA+iўɥq)V} (~~c=ȢX'_>j+O 8 (e/QCҵZfw,)}E5\4/ yGBOo~Y3+GW3rKn>+{ &6u_zmeS)K+2k 8ݑc3L퍎vWܸёYຓ@1"kr}qIu [#wfC?sb [XA =\ yvJeׂf$0y}: ح[rz>Qx( ~ 95QK&)9ja`A8ז ";bR3 DW v)ϳS̲A}ꂇT֋DcXHwWE>':ЁȜqqoie4># -&}#}F5p㏱*"(taœ")B -,AEexjچuRHn_n(g'v9H^%!?"X@6כRy=ς8/3VM»8u!rP@ qSz~jwxTvT.'>Қ?`XT: A?1\@dF|ɶD6 X4,MR#&eEvS+vWsJ˞x̜{[>pQ t) `mnX^x-Ǯ=捴|trβ!ɰv ^T Nk19#i[@Sɑ&v_.oUtxLڽH/mfɋ.y 8KQEp:7'Nn 8lho=}9P}V{snsXiVӔEIH=҃sK{cyOgP\3yy8#;t2[m y0Qv{D3@ hlXqK!!ֹڇ%5P]|#i2 EL 7X)E[[Z$ѾƺŖKWÜeY#,ބ.e<[؆,I#ҷ5B 6)MP12] B'i"-,XQ3_@Y5ΌDdm P Ue*z =,c-gC6a1uc,a$zܤ VR堓$}~9EZ1H.1.j{N]aL.7 DQl89ggvDm^żDUV.OXEVſ>qzQ}(#MoN,-~bc97 W_ 'sNYO j]feu6݃3N+1g`j;Vn6pf_OBdkpuq\HMkbY2}Pe'qCG[J7P~O k3/A ]%h@ ozWl0ur[KŸ}ng@)E /׿A썚Fz ӓ͸B@[ˠ%!~n8oH#7=G q`KaHPX{z|\+|+Nn{W*2us1\$JE3۠(Ql \V 6춆R-]SM}.Z+)Bw{]׼B􎠣[~uk*[RȃK^W.yx^S`X T9 } ,f%czCd/oXfg/Lb zI9z,ik߸m2H:șU36V-!S)Tt ߊq4zM=03mc(f4E9Ji#$ր.fOۚaFaoFV68qc:u[P_eff$;R=!MƗS(bLIoW>铳ӶS{I=C.'?^HutqYn1o2z.:L?mtx) L\󬍩Kap\!)P#?#X58*l&#p(mL3BAV$\^G4yް"QsaR%VwPRou͑?è| j FZ UwLetޡ\]nf y*e6.PZ !i{Shgeyy& 6#F(;`;SX~N>^j߽#䬭V%ކd8tLF:n_ xrr V;!H٥IA_\dYtI˾z!%,"{( iůIemnqɅwX!qm~".5088A&kypg!O 5/#FvѾAaI$IM$V]t \^*BX/e`{h L5V4hE@Eh2H1C|gg߮ :O B90XwTl}$4]lFެ0eSb+T#A陡:^hQUfB\m\MrY^`AEQZ̪8#:V:gJoC0~}t4$i@b8epڵE+byb.v0H9('%qN){b?,-n xk ?UTʰ4vlP{%݋vuݦBnCjI4CLJXk?fCjp߿hq50RHU*-m:|5;Q&PrnLU5yI,OY*)XLN5 8LV6W 9Q R#+ KXg.n22KؗT݄F0XC,S_-, 3"mwrkBRrK.yV:; * 0kV!B Ԋ쿴祽Dbht+ǵ!dt'UNaRV3Jq(<xm%cw8Ҟ gVlz/Zφ{ꇕG@vpذUML-wm<[ iA8\0"k87ʞ?? Z"B'ry/ G)ʕHjd/EL48GV^Lr1P=g(F݇VY4dSLZ@,چqsUwOB؍i$B(ܻ/Sd3{YA S'X*=eB3;n1 rrG۶ͮT <ᗉysWC=Ijyg!=m\2}bD!b}W=Xj:ׅ*hA81 #$cWf'F]iJoYd md B~!=9- Ե yMMflpu敹6]k6%{둞: ųŤ`!T̺p=ygx(qZn@Eaec6hoU[u"}a䝓>:)߻^]Q#yC9ŅGDZj&⥎A=䲽umq>jJF!Z{^f! o:K\tJ8X@ j 4;07:M!3Vdܱ MppT]?lrV/b ֆ68.'[$<iﮂ授4 ͮ1܏.~pd>?վw8PLb\8Qmf\e+ͮ ?d)d󎍥WIr$YB8v@ؠ06f%.+1_ʣ^ <p[Gpw ٴEq6۪˴כ_-/k qNKLDu c_DM!wsV -U؟Aqg@Ukwp~SE9tݲdut}*&YezSE=|ç ¦ ;<_oL܍|'Y=2yn}VRtO A0g:K{]I}/jҏah_t I$5u&.q2^wdMMO1-)zuX<]I " ,/g |>u9V/5k-![& F {qwh y3]P\5NDٞocASVm>[}kE:"T ?W 'a3QjuxgnrjMQyϝhVW&E)ȫJ.E| 0\=e7sZ$ 5ޫw?\."<$)pRw6;n\+@iRxã7.V~傦Bmk4@%?-f$1Fb㕲PeCS r:J(qf9䈶L iNS()%wyyS($("-H2f>V$xbsh%uIm+'Nd럈?p˗8}pb謏̈Pī(KL.g kHa+x31+O̅ar7& vdVO6!3B4,F|sķw@*B`_]q>/ è"NiK&=.h-J;Rv;YDpT>Jm؄3aS J+`=*1o05Y1k2e:Ŏ5Ir W11( X=~`5;B,d5B KZO>S.T %*źoO4yJ8"B`G YS# UI 1ny)~Nj7Zg\|E 0nSōUP Rf=òUE!_?~ +ΰ[!W;#%tqe*ʎL Y9WTd +vs~V!@ݠ;WlUf6Gy %rLb@-,y MxR 伝o^dNfm%1[K;.B,:^&>/!3|֫SޗyTF?0E22>jQu?x~$[ӫ8{w!?Ia)!@ఽ@ jTz\+Uê߼TB+.%'Z oMO8b(g.EcGZBWx50'##9]!@&ݢFLF*ߕa6`1\jܦ#5~Z ;s؟o95 :!$`mJҨC+H;q !LOb5O~ 6DokCZ@ȶDtx 4 \_7tm9&DtܕhI >kFLqWPBX`#A#Xn$u1Y u|E)xH.-\t<7dƮ(T%(͂%ꖨǨ-7=aߒֱ@?T}pp(RW\T@ ;)BhR9߮㫴?}_l &Yod*ʿdD ]ha >yTMp2e q8/ߟj ǫɿϠk Y m].}`4ʐ1Sy;,ɯt r!M1:/i)XaX 4R(xIfQ3s/5',,Dzu[rwni T9Xs \ؠ=1 "co/ㅅ k?'-HWk uk1 0f-Д5gcq;Z5\Y5*[x RY b_(%La h5(yn>D-/H{uef/TCIǷR+FlĻU8fBa<9 UvnɆ`g K1ND߷ L? :YS3o 2b~g (/y'@I%JZ3 llY= ^!uaeA(34ܵSa^rrmgG`+n]8Ôī_` Hpn].2D{D|> ~mWxI#gz;*~ zi96@<ƺI&o /ޅt,=?vsܥ @R)e~3chiI˝99|F{C2V1SYe@ 9_,  9%b#2`O\f+e^Ϧ_~ag;i :3mm {w!dP-Sg0[{efO s@_!umhM}!+&6YH CD{_Ч0T7m`)C/4/a u}XcF SGÀbzEsڂXs ّ:d%NfV掶CXI +{A:3\4&0]-kM$8j]at /h& (ތj MΜ+Ϲ8,'CY0neC"ūB1N>R.p1)LӅWRWZl݌ &+U&ګu~X"c"l@u0' SXZ\%trn5*\Qz=@%|.TO"ظdGE"sXY!x}MAdL\ͽleQMY^))*}q  0epwv IW,2_FZl:%,,!9yӔ)[~CZD,M|N_J[1hn-^rs̫ymLBPyk#Q{xg[ Ha wwG՟^;ޱtʛ  _. Sebӫ Se4ů[wHgDL#DIANEpT3&ufblB fullthe artist@^P"WM/AlbumTitlethe album TITLE full6WM/ContentGroupDescriptionthe groupingBPM6WM/Lyricsthe lyrics LABELthe labelTOTALTRACKS3WM/Year 2001 DATE 2001WM/Composerthe composer(MUSICBRAINZ_ALBUMIDJ9e873859-8aa4-4790-b985-5a953e8ef628WM/PartOfSet4*MusicBrainz/Track IdJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/TrackNumber2,MusicBrainz/Artist IdJ7cf0ea9d-86b9-4dad-ba9e-2355a64899eaWM/Commentsthe commentsTRACKTOTAL3*MusicBrainz/Album IdJ9e873859-8aa4-4790-b985-5a953e8ef628GROUPINGthe grouping DISCC5*MUSICBRAINZ_ARTISTIDJ7cf0ea9d-86b9-4dad-ba9e-2355a64899eaLYRICSthe lyrics"WM/IsCompilationTotalDiscs5 YEAR 2001WM/Publisherthe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104$WM/BeatsPerMinute6ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104$WM/BeatsPerMinute6ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8a5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8a1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8aTRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104ܷ Ser@iM[_\D+Pÿa aD> @Rц1HdARц1HWindows Media Audio V8aWindows Media Audio V8a6&ufbl2K ] @ 6ӂ}m ;J$$$ ?'qU11%4` -I$IPc ԠIRUI10U5RIIjR` $Ę@JTBTI1,E4 ҒI:bf1&C` QR@H@1V` H :P@KI^o'@*@7C@ L $ %--i!THHEPjH@&B4JJ"0ҕ Sƶ|ALU2k?//߿,B(q1B~-Kl!+tH8 Hh-ߥ)B -P_PVCQn"v0{eIBVi~P6A \@A KTpk,RѤ vFPB(^o㧏yZUP(\KYFP}oM?_Rm>  p?,*}Kx)~yKoұ~(Κ\ysXߕ?-[}BSJ8lj9>q_ͧE'~ķH_%iM.]KV+s~_`;t㎟ߛc[O[t-A ?|T>Br-[>t~'AЄ%lE(?0i )޷Cn@4qR#)Ok(ZvϿ:`022{Qx6>T%[_y{7폷e)_y 즚|NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN @%,V,XU2 dDZb`D ZĒXpR/I &`c XH1$)1JI,2"MK A2B`*!&JADՀ$i E&(h+D!$``:h&F꤈l Fa؈ECv[0@00&)EdEBMCQ 3P 4R EB( JKBPPB0@[M/ۨJR`TOTQcM-ZZh n-:\kaS~O~p; yHZ ̡q!Ƹ8风G/ nkHH_pZݔSğߥmxi_;t8nO8}~itS[``pDsBP_ P=Bc'(cy'opc^vkL_KWQƷW ۟V}F =_SB4,(~o*Ǭl-vǾqNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ] @_mVb&6 H7#@&RI($L&@(ӣ" h&gQ0c "` 2QyJB%,D&`ȦS 2@0i jH*J 1 (i $ Ja0BJI%$X HD XB-#@!JoV ]G@c-TN I1QBJ L$i@J- .QB@~HDB%4ST(@JIJءXDS+e!/~BÊ#rQN}Jjۿ۟҇ϑ@vx-4>~m-Sm%J-U_q)4&)ZVnH~?V?'(O@}ȻgxۭVh y5aNڜ!m+(KϒHCzVB?YHC4Ombߴ5O>S*P|kv%/α8ͭS\tKq|B7KWOy#}_=Zo(ozCָ#$֖Oyr[xiQ[!^t{~lKupeQNP%θ NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN @-T߽Gé=4C $DΠ&I:MO`Ĵ6 FuaTAc A@a@-2E@i0,P I$h:I-/B(,ᴤcRL (HJMDi@IQ6 KLPLJRH4@YP 5" $ iJH`$fb`liC`)翝n6 5@L h$Ħ`+:HQE dQAE-|4h B(BA@){kW 8cCeEx o)1/t%cGv5oK(^NNNNNNNNNNNNNNNNNNNNNNNNNNNN2 @baED%iBLA2d 5fv  Kd @ :&*!!b"f@.%Z JR a,CP! %3U RDI%Q$HA@0(XVP 0,"*@0RKLTHU% (؈ þLUkI ͬnTbe.Ts!Dư Z1`  Ja$ k%Ԝ~5|m4;jΗM)$iH>R"“Y E @[)( %552JQ 4!(?Ї&*>vkO,)BRBi i`JROED(?K||PpXD`>'N}o?O+T- &e?GR OpUSo-(+\h}O〟rr7AFP">e][,in)V7[~E[GQo|CVaQiG]>U%eo@To)[[|'%>V _`J+{tԄ@x6ZmOͭ%k.kZJ|+SoDqc ßy2橮+Vֲ[/YO|iTOӔSՄinݔqۇnri6߇mlv{q/[i\kYB?4ҵiBpI?~n56ʺ펥Q/^iRZ`%|[u=|#߻~R>J[-qUo5ݞΗ`+[߾umN;}QoͻNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ]s  @AP{ges@ Z5J ĂƄ' H5 &*N䂂T`b J BRM D/HI*%t."A2iT"DH [Q% H-RL @hD pB6@"e KPI ,DB`D@ =یAUÕdʆb e@DHiBI)n,$JDX @BL4%)&KJ%lH!mىva4I/ nVЊ}HC-8PͥƄЌQEK? k :-)X>Z[KyOQKzRE8?h2A+tVOTքmߢ;zXŠRE->E鷈}BP;~%7\=_k[sဩ#=u4e."Pr}43pw/+{nq-)\|x%ּ/;֋e9Cޭ?nuÔ۰?~L/C`.7ϐkgW{~Gb/\JpԭV[EOMpe6[nk=R"?_ {->te`/7{pNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN  @O3 -9X᫣ZA):ՒT%A$d!LAD IJaIJl)% BHL Q%0&%0I HXRQ I4RDCe IiP&S5$P* e! A;DTMPIh"Pf2 XVy@$`4s0WX[$HbgD$Ul*$RDBQBhA4?& a/QAD"-x\R*IXR툤RA|_ r qjJGJhԿ(daGHmBB/MZ}VܷBh~IZ)(M M [ύ A8(Z[TNX n?/ݹPiM?) v*B/ݽhXG愾 Ihwz/7Ǡۿvq[[7q([ 0Th~G%Z-i&l>[o<87͢V׀?G"Ze$7pq>q g`?W2г8=7oŀEc汣ͺ}q  Vq Xߞ Zh'j 2 NNNNNNNNNNN  @1 E.wa(, B utL(fcD`.%I-H$HIS&$RI,)ZR6Ċ`BԔ)Am)HF(C&MH u(T$tH )A((Q5S'ɁAQI3q`$QDaS57$-%F,&o2hho`2I4aI a S蔬IJj!4  $-B  Jj(FOPV/ɷP_'ȡij.$:)G[H(Z-:RBl[nAJx%(+@|)+]hZK%/e?Z vPnV+|h񅺀[X i-U~$~T~c?iSPCN2;`>R-"۩ts&ܶ)Z[3X%q/MOV9E4/gR(l|[.FU)j_ܷhvPWQouX ӑ.Pd~-6vPt~u'[-mp.KOMxA')E6/)?#nv_r?\N}x*?U p^-H}XnIɞ(e`NNNNNNNNNNNNNNNNNNNNNN @%,HA~!f%֝z Xd $uՀP-P  1 J !5! %LI 3 DCja$ `T$  0  Y(LaP2-;*j@ ɖU2PvT@؀&bhh I *Z[- ĕ6I6]XDYC S PxPP MRHTڈ hJĤP쾠l-?B)لIoˠM (&2>-%+eb?㧊J(/߭/]|r) FR%`zY4')-"xInAZZAl>}Cn[-􂴰?R.7_?+e 8hU>G>_걖gVGT`'ߖSOS沅?e9A|!IndwCP@Jn!,-g__ [\cq`*-2mR8K揚['3O~|"Qqg_~OTV6 (B{ y?J[+{_-V7ܜ6>Jk+O/ŀ/ il,~,Fx())Ace.Z"NNNNNNNNNNNNNNNNNNNNNNNNN ]- I@_I44{`RALTĨɀRK@*L)H-` Lfgb*T 1 D)0IEC%0"0e@v%v+"T$9J*TdDJBh%au5)(5!" l"P[ %M! I(8AIh5jBN   [T4oaLlNκ0f$3)TłHv4B*0DJ)(Ja"C@(`)!4iJA| ot,_JI|RSCҁA)|)RB_Jn[in(n:V>|6SL)ED Vz4?|Gnsϩ[GJRNR] _Ʒ~J4>|u (#)oEoV2*4 :RR$~ے^kq;ZvKKOT[.|h~~qQX߯nUq,͡mSNSQW (?*ǡo=ߺ2`nNZ[g\HJߚ}V2ж_-߭" žv +rF=m!5ÀCm:?oǀNJUׅ-;c)G)۳ڪm4x +v.*?V=EJ|b~tNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNw@5Y J{l*'X 42 I l(C8 AtMY$R3(IKNɨA: ,LLAi(&BA20PU( LaI@$B+2 %(*U$&I5RD%%!BP!'R % 8S!AAAfXPFFP":˚9ʁ&H*b iB@HBH H@4Uv@j0@@%hPI(ثI$DGª?[&7d4%nފ* `*_ж8ihqin lZ->E(㦈HJx[SJ$q->Oo}KKE/mO>4SMJeHN]F ?5sz_oi B c}pP+e#)ܴ: Mc[iFRGXO7Ɨ[ ZBmo5?'oOp?6lzkR們Y[\Dt&e.Akt"x/.?\I-[P}Io>LH/U\5`<xB~vǂo݀%tN!_{eZak=HN@b#sgu"Hڛ]&;b&#`,nh$իI"%2C̘ p5; I&jhh@I-50 HJ" EMHKe)(4ՉTH"dVDK B@NAfUdRNdPB`ȄĈI %&\K"ڰ7 TlXKHcR%Q1 !&bZL@Y2HJ*! P XPL>J"" 4ER( >44lВĔR iK?  B2KRh[JV(~PP~}oD(Rx|%ic\ XGRFD[v[R-BQGij_~O)k}?9M5Qo~zG< mзotQMv(IZZH0(}~ ,XvyJGi?<h_6yFkk~$;/`{w~U\[\5 -, | _Ζ-(Zݑ- ~b?;uEaSKKK3H;}pC[罺ܵ斖>4>t?*xKO:~wo·eKυ+_ZNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN@*|W!eB( +lID5"2AVTH10dACDKphê[$N ڀI*BI" FD[ (a PH/$2PAI(A@! [Q`L @PDU I єI $A2CZL2 I2bRZ4nuB`l)"[@&!bL8`PH1NR:K@ Th%LE, JP]eZ|v"q>?TZv84~ /֟۷|\/ X*(J!Ko B4?\A\?ȠҔ[A߭OR+ Tб~T[_tEGBm}n~x [|$V5#Şݶ -Q\ ZZK5n'Վe)[չoG|P\ۨ?дnݞqдQ>^j"9j:~ ?6ݿ$'Ƿ[hk=4W<ӈT[=Uݹ18$Zq _ zاͭҊkVnQy_ pL'~te/݊2NNNNNNNNNNNNNNN ]@tǀ +W7 ;!@$I*bl.dDP"Ka$!0XȐX I" RQ)&D J V `R(EILVx1TAL A&BebDP@T$dB!"V0:5Du"J$CH@f$LImELG`,h,fI!fj2 B ÒP@t*Z)4&Z&PV)JjPcB >& ԗd-Е)~V BP?r5J~SQl|VT#;4-@'C괥!&h~T}~oRƚ8ҋzݸ_,S[~ֲ8쥨ͿK('+)OZt4%# AVoygX6즜"T5qKJ?߿̿[+$~k_)qe rt/ۥootnXÞhp>tht^n0B+?F(ߚ.8xMcyO@NNNNNNNNNNNNNNNNNNNNNNNNNNNNNN1@*ѳ.cՍ gm ʳ53($2A!0!6ȆEI  SwT$,DTKԊ0 )@! hM@ӄ pu "%5@HH˂BPmsI )HAЈuR@:%AJp H%"MdaD`$a[F64\᭹cPeI$-iI (:BABBP/#dЄh CJh*,@|A)M)B"M.Y\KA# 4%VCOޕ[E+(["޵Eq[(۲ xܕ_i}Z|?%z?hM N~ic{Rjn??5+m c-$t`.HZV-!."e%kB ˍ/+nJ)}nqt% „;n7 ƕpM (C4& <`R\CJmK҇+%?|EKv R O@)"r7Jh|M۸lj-P!!h`*rG%12|jh}oZ⦗Ͽvy}x꜠gPYFXIYGxҶGM ?i0K$[qkMÍ/>5r-Վzݾm (X6 7c0o_ΗK2߀,ZƊ([F{-ح1E=$tm>{zE9ۥrt\"x߾,@}luݔqFQL[ZHNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN]/@ q~&mk[x`.?km+x (}->_|*V=+/٥_ωV['tє?3O瀏YOԿ|_k rxAv_H-,i R /eO iKM4ۍ(A AKB-|$#Vʊj?hJRVݲK% k@PRA+iўɥq)V} (~~c=ȢX'_>j+O 8 (e/QCҵZfw,)}E5\4/ yGBOo~YŤ[-3!rtg(q]bPEـ Dّ>JUl3A$)i)A#+:3`XaQ;"  f{q wZĠ3zf#)Nq'" kr֣ g' )y*,εx" r BXVF7nhϦN|z%e0Ue B_`9oJDz0)1F8>7t#U4#Xޙ`xTaG~fHv<`? &63l:G 莵NR#Aש9OSԦ"#P dp2~s'P=&!PG<0NE_'1:\ ,l qԺ<>&ÞJ3}]dQ8`F8I+:a;0>m>;Mۏ /gp>bSciL z:~H5M3'A+&f (p0!d:%C9B+]3ZcJr`Ea,])\Biw,y@úD'ħ 0q=bmBAwH! DS 85 !`4^^:$9υgs`M%ZZt^UY(o"09#Nѷ#̬zW;t A-1dyߔ3^" "Fw,rjs%sfep=`G @5?`MYB^;C`B iRi60 9v(GYJ=olW3 B3QhL$/b<~8ΚSn*jXe7#"(@ Ad*`'ሬco>Va &%`F x}<fP[2qX.'Ip" *ؚ'Rz[oӬaXS2p'2!Zx$Vn%j(V`FF@c}}C2>w1s5{!X[/?Gx2"%X aԞrLa\Ri"?p\ @\beets-1.4.6/test/rsrc/empty.opus0000644000076500000240000002006313025125202017557 0ustar asampsonstaff00000000000000OggSte/\OpusHeaddDOggSte/EOpusTags libopus 1.1&ENCODER=opusenc from opus-tools 0.1.2 OggSte/dYo3@9>-EBSu53$/b0Ӕ:'X*[|Rc}:$zsDINrFw.!^x*iV}]{pT ̐2*`Kkd1%\舊|](hҮ䨒)4F[ =Ljn{>or3@lc7 VU qx[t]{ŹbС1Dt>רoBKʟqAm]ή1%6 aoie,]ؓt߉BsFmFy;&a:Wrېr0i1U_-@X)/C΍{.j݈*科>L`/!8=}ŵމćD#VmRZ;Ġ{T>vSB <c>+h}LU$#r!Q\aՙӏeX,R.bno|5|/9Bf1-7/5,aլw訐ԞJrU=7e#$zԞ ҅7 ^6roUZ X*e4^>G^|1Mu'r]o1Hr% 0,3VexD?1K+,Iq 3Yw,kr eLaձvB ݜ[aPc,DYKu?^k0<FW~xv#q䞦ʴ vŠG+bG٩VeIT`)L3ͼɯ3jaԁsc0fx{##Y i[~ Pz6 _4 aҍXZ1gWbڳ]*~S$íf+3ju[#v$XHe\nj*5zc [oesgɇÈ | |-O# bk0)Baխ]x|dۏы,0]Z՚I"1"0j;Zj#R-7ʷYCⶺ+/8/mOGZ^Zfe 5,ktͅ`\@QgJf˞o=p,FHNVL2|'vWg*uaR76Aa!7*s0of H!vwꑊpyE#U L%.G.#I4I(j g^f"~o +sW(4<~4fvi:LΕZΞ'p.L1-54UN] a[J2Ɏ]F@1r\e(lB\6&vU; HgmZr#XFû (q\daf/1@q#'z.B%CyGKRfQk*1ͬsb6O8)TPpNe0!1ؙ٧LdN,<19d"l&ao1 vJd |o,/b bl%\;ϓva)[" XGImp?!B2!BQA1Gas(a?U_ 8d~?dZ5?@mopԝDnB`t; ya?2X"M;lȰ,kaԝ08n"Oj E'ؘz=rz.Ht0u/}<{M io@Sx8+u𾊚NJ1'Jb; `۴<G$&5,*3ք3"v\ aլP*oq6Q [3!F@)P{gtsYrɈ(/?B yrxm_`/ZQ? e ?FggAx-G&ݦcIkȍN7;&)Fj0'6P;*a՛Y@ c t//VfLqkIyB+?zCέ(S$jz{Amw?_v;AwÝ'CMtu AmtJw%`aիGA0^.aE*)@Xb3XrD)-6B\!ؤ1BݡqRmj>݃0M{6QSVչè m,L[8} *wh8;)ۜf Nx2JaլW"ט0"J>XiU&A3J?7 S4v"9Nn=͙p;pU| cpZ !+x,V%.: L,z1ٻѰaOS«ƍCȬr4mؼa՛b`'#p' H{^qw2z\z&ꓺB"Lk*8M1Pņh7!-蟍McZ1?TfDPJ3 fEx *|͵.Ra0 \}$-d݀WCTNI3\2pg&Wv-4ى2e?MGvnꘙuHᢿi0%njr6U& ZQCṚh L=Zu=Tܽp4a՛3,D:€]\6k ʋ?GoCih ճk -CSO狷ĵ(@ 6O׾l6K.KA|ژQ[gpUuy 8e2 at\ntQʺU Ð`#,m+icG>A #݀F:l٤͵O3UC[zY.B# 1o >ǯ1>e{x=G *bTͧ||%?3Ǭ6Ei/bTAhwТ/dar^|xh%'GJC _6K5(]n-Ord 7MvrUMܸ!5M;ŏwVY7.Ν)xzzQ:򴄺(:j%# w3n44.}A-haggR=gϾ9d t/-Vx*ޡ+{÷l(R2MĬO@e ƹc<.e#OP8J=حJU8 [^l9DK\D#atq0ԇgKd.^R6H "*!͗vOQNY@q X(Db&z>ڦ[S;9LtNԕ'#eHۆ]\eL.@hTٛDGAOe[B$:n^$$?§O&*Yaq*:R&)0)1Le -`ݜv"Zn,9tHpC %ߨ/FK+2}(,#Bi4]xY ˽lQ-B ae89xMY.bLC\@euaYvۤt|arM {q$2C4T]Ow mLD3WS@9uݶVl5aPG aEH^"kpHnwװ5GFeečXa՟  L[qaժh8:Q}6(af4\;_YOTH,\mڜ+WW]pW=Ù.6Kʻ5~8d7O,],3zrOSPyiCWŏ[#m~a!X[Uz0aլ&NEoJ5_.m{(%, ,YE3I3M(pLr&}tN5Rӑ`f .Is6t v'3)%NzpRo\ 珞vl/7 CatZxQDW9Is-0iir^:j?PT-7N pXYsuoOAa"B--g͚nܪ*8ӖjRtL-/jyWQƕ u‚~.!`h>B<)#,~)aLu*Du=fQpayGB鯸BEr/ (%<؃ܴΙZkyFwݟ`OZaհj Ee m0^;(87 ׵khP(N@_#Ԝm(?Te2l n"-lYX_-o^|4 BꏈLW=Tz +H.E^fk`X" z)B"$) A ?vbB 4㰲+¤rR%;RQ[7UW6dTUT!ub8 x?.=L2!Զ#9#"5fE$ȉ,1Hez1!Ŗ)((,c)X,qET9N@t?` =m#tܯINi{ޞYgSdE/'̸g W&%irDu6.tܑmtP&,DuhbDN`Z{m/ÞjGH<&ClJP1I93ZHF;r/viÐ1 ܉2 y?z\+dDΪ1ckTIwb %MɬH#tRʹdꓳ1r ϳһl֕ lΆmCUc,$(t4BKZSQRŭ݌>l1f:Vm&DGB;?R+9}+ϳ+UbPS;1%; Rwt9BRw5ls>ʸ#uFlɕћԪFJȪyEus`" ?#ta2ft*3άVwVl̸c@@o2Q;> ?s:=v:VB/l-Z$ۙ5O'*MD' .zSW!a溡Z!3#NǞ#!cH5VA``g;RWTUսQeTľB%Og9F FTj9"(cJb#!ǐx3 E`, =ɯ#4vcqL8aPq2$48u?ݔ5RlogGЌi7ՎKw"1_ܧ8TDg;YF%P \RF S3: QB(́/ߟobnYz(uJ:IdZJ'J FfNe+ 8*&*q2 0 AAD0Q!2AaQE`6 AEt0/׿]s 仪D9FzӦ[7t+M29*FED2r9ҷI|Uzsp+ J8gRf$c1!Yz:d#*%P?{ɦ5\R삊Tv{$A Rki!ʁ(|rp<+)h05"dbBVAǣ4F:>y)ݐQ+7cfT[WhriO&׊aҶѺkLW0?pp1uzs8~9^;ar:hZRo|AwXo**>@0WuJFYYr\j*"H~mg*:+"ofi#E )JWAC,$aq)p1"PH(ѡ`8bo5 ɽH"3z׻r/Ȣ ʮd3їyNq{"Zu#eR se-ESip(0b3syNErӊyz.1,o5+9Euvfv}ȖND}l+3UTCo>%OyS!Xi;\8#)Ń:cŕt(P?f`x 9 ٤Gtb$vftgdkU-ƤVfR>ӭK5(i5; ъwi*"*.*1cxRH@#b@-FW?+kL*ks*gJ4`EviF"$z9Xa˔9pPXT]c`qy` .AMGt#%Yt㙬ꙉywکҥSkYO<6<&T눢!wBE2 J4\VK1`6q1@#W̫:*\ȪuoH(έb+JB{1jj7tT2+9#<ȩ* ayQ13&=B:``둂 Zp[JQ`A " k?dRɿWDJ2oRUdf3o5Kkʄybt3S5 , 1 rJ0#(@X$,84xD*νey5v}*KU2gRtK"oELʌE:2+ْ}H8fc )Bads!XPrx,($0<*5 4l`AH"|M[V۲NIbk3ߢՑZ~jg*ؔssBrQG )10dw<)]($aE8pjhF=宛k*"f5*YC^bꧻLղwcU<;0=Q֫:&q+T'TUgeR B32)D|]Mɺ+Y[֦0)b5EQEa!\hA!ذBg (C &P8|DV&Q`Aǣ4^,fd[Һ VLs ws)Pz^t252]ko泥]S2gsƙٞDka9#"%F DL* (B" XŽAb>Y//J$Y?RVT}ފrQKjZmu}vzttz%TS1#ƽj=ҪQ!0cH, Ê,w 810 jbA "}s@:XȮM*Ccr#DQz)Nb-LQl0׳ )k ;,5ܣ;" 8ЦA$qƁ\MEAB@( *-4їpRVlUK&u+<ϙtLv3ԳI{Y *R<=LUaqL (sD4P !8`A" w"ϒMwu]fFHSRKwqLOO3r1Gԭmͻ;=S8y2l#0Hp % _zZ2> ]C\ȄVGGuy(rJ[BUݟaj[ՙHLRyC%q]Qb->A!Y`1\X@` y?`6A"#ʭ&ʪ˝}V1ֳFnn:[mjsF!b)*Lcj*0p>$av!X4DH5ĨU(p@1@7R!#~ܦf5fb1ގS"iPvJ֮d1qq20:+T6abEl?2DD\M"B  c4\6 `.A"~T"ǞshDmYddSn:3c%5HQUJ:G4Q(tC\{+gO쮕Ԉ!O%f0@>q (4Q!0XeQCg?-I"*ḐYInjH~VkgT̎RK[F"Ψps<CA r$ 0RWb^Aߛ->Ş/&@z)-^𼎽me VE6$4CdP$BAţE ",8SW9 `5kZTC^5bTz-=iCB)auo,8c ""@”AD*<:* x:@DEEH8:.ͭGHNޱW%zDoe_?)leyX)ΜzR̈"]˕L'dxtS 3j ~ds`>A H" #c0&VZBrdpFEyFm *ξmYʊV}L+>j=,-*UcB= QSP"YH  ~(`JE*!bVs `#B(` n?mFb&@M[`beets-1.4.6/test/rsrc/date_with_slashes.ogg0000644000076500000240000002056213025125202021705 0ustar asampsonstaff00000000000000OggS=[vorbisDOggS=9s@2vorbisXiph.Org libVorbis I 20050304date=2005/06/05vorbisBCVcT)FRJs1FbJBHsS9לk SP)RRic)RKI%t:'c[I֘kA RL)ĔRBS)ŔRJB%t:SJ(AsctJ$dLBH)JSNBH5R)sRRjA B АU@ P2((#9cI pIɱ$K,KDQU}6UUu]u]u 4d@H d Y F(BCVb(9&|sf9h*tp"In*s9's8srf1h&sf)h&sAks9qFsAj6s9j.s"Im.s9s9sspN8sZnBs>{sB8s9s9sАUA6q HEiȤݣ$h r GR TI) 4d!RH!RH!R!b) *2,2,2밳:0C KMXckZiJ)RJ) YdAF!R!r)BCVU}SvteׅYn]8u}amXeY9~Xet]_XmVY_}xu]n̺ mc}Yf_w:㩪+ , Ƴ, *±,j°ڶ1ܾn,pk1Qu|_x tu]yf]utG8~@!+8$dY(Y(躢hiiiZgi),iiZfhk)˦jʲi캲mlۢiʲil,ۮ꺤Yyijgjʲiyjzh(j,[g試'j*˦ڲilmmim-jۮ,ۺ/iijg癦il+[(扦j,ʖ癪'ꉞk*˦jڪil-*ˮmʲnljjʲl˾ʪ)˦ڲi-۲˲iʲim.˲mlhm-*۲-,ۺʮo-p0l+ۺo2}DӔeS5mTUYvee}4m[UU[6MնeY}Yma4M6UUMմmYmaeveٷe[uוu_}e溲˲p p0 a1R9Q9 dA!9!92 RZ !Rk8ؠ)8@!+TX癢jڲcI'H牢im['牢ih뺺.뺲+릪ʮ,ª+˲m°ʲl۶oܺ‘.1 G @6pR4XhJ 0!B!RJ!0`B(4dE'C)RJ)RJ)RJ)RJ)RJ)RH)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJRJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)R pz0 HQJ)Ɯ1cI()b9RRi-9!RmsRZ13礤[9RRkVk5ZZ5לs͹k9לs1לs9s9s48ذ:IX`!+Ts9R9"s9!T9tBs9!9B!s: BBA!J(!B!:!B!B!RJ!B P`@ #R΄AA AQ3 BL9љbNj3S9tjA^2  (B1A U DDH.\]BBX@ 88'ST ""8>*,.02468:<@OggS@='02310276;:?CCBl% B8FkF#zJI`76BU5: K.[/I ~JJ@!T zwP{@ۂ_,5@4> k@!TU40Mt+Wу>vyPU|GאOӱpB&>4PU'@$O}%M;: >&* @ta)t (4Ore1M0BU 1>Y(sY&-RJgMkVOr2IUU@EO5nE~IӴJq)dU)$ @Or28Mj Y"eddcw d :Or`ۛTU:]\\{QFH4T05yk2XaEP + &E^ #&!ir}Ωaku)lY & Hyk2MP*jT1{Á1^_Y~vtZ}.95\1)_#yR NyB1,r(4ptmp sRL/g9?0M),Zu8 4X huҋ~>S$=cr7Ì0ʫb*߷QY4#Oű = P{dX]59  f)6֔A`f^!-VFļV ]tGNy49aM5Xd\%`8'\57M?/'x,ׯ}YnmcfFU)0޾ڝP[+T2\S G.WX;UFhkoF/u c:78 0d7\v}iEM M3"~b#Ah'R&y,]~MG-9#8뼁#s/y78Q1%8iup;_I5?&ҵdun{!:υzU.25Bɖ+q0Q*/CU&^ܱ-59q#} &68Zлt3WܙohmP-Y?{LX8&M f%ONɔͯbPwWI;w(*hRdPLK?7IB|_O&arvQ$kװdlLc/["R#lc,VݝHbz{8T78Q1JQ3=Hi M6Rm|f Z*(Nb&u@-jI&&ho\[Jx2 ^C7^:YGcFO}ݺ<~2P"Sމ!G&SIu8Q(BRHYB,-ox3CTX| ѳ^^^)5=%Gpb{fy](>(:;ubj:kGG:OggSD=59 % . :>]?TxLsԘMPX+q!^#a|S.㵕Q%(snl]N-{~w80BbqX9v)l}żbId=!t,W79 Rfo<ӗUe׫p0[(w"u8ބ2h"hr)=|P"r:I"q蹨R""u޽ ڇ.Asψnn7'e~ u0B c@2 8>h_6zyuY|$ԕŅS n'E0!>~v3ayz9Mk 15W7#eiw81hbFhzmp4<* œ"N|oSjlxMqIߖ'SΗWKw:&U0Tn;bb Pe 8P<0P8ӹj xz}]beets-1.4.6/test/rsrc/only-magic-bytes.jpg0000644000076500000240000000115613025125203021401 0ustar asampsonstaff00000000000000HHCC }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ??beets-1.4.6/test/rsrc/image-2x3.jpg0000644000076500000240000000116413025125203017711 0ustar asampsonstaff00000000000000JFIFHHCC }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ??beets-1.4.6/test/rsrc/image.m4a0000644000076500000240000001334613025125203017205 0ustar asampsonstaff00000000000000 ftypM4A M4A mp42isom moovlmvhdL_LD@trak\tkhdL_L@mdia mdhdL_LDU"hdlrsounminfsmhd$dinfdref url stblgstsdWmp4aD3esds"@xstts.(stscstsz.22""'-+1/+&0''&%)(-,*).)(*%)-4&6.*,$,3/'& stco udta meta"hdlrmdirappl^ilst7covrdataPNG  IHDR6I pHYs  tIME  JtEXtCommentCreated with GIMPWIDATc?9х'IENDB`data JFIFHHCC }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ??cmtdatacommentfreefree(mdat\11;<4`Ife^P0#ܷbĻVO(*Jq8q-@>Ť[-3!rtg(q]bPEـ Dّ>JUl3A$)i)A#+:3`XaQ;"  f{q wZĠ3zf#)Nq'" kr֣ g' )y*,εx" r BXVF7nhϦN|z%e0Ue B_`9oJDz0)1F8>7t#U4#Xޙ`xTaG~fHv<`? &63l:G 莵NR#Aש9OSԦ"#P dp2~s'P=&!PG<0NE_'1:\ ,l qԺ<>&ÞJ3}]dQ8`F8I+:a;0>m>;Mۏ /gp>bSciL z:~H5M3'A+&f (p0!d:%C9B+]3ZcJr`Ea,])\Biw,y@úD'ħ 0q=bmBAwH! DS 85 !`4^^:$9υgs`M%ZZt^UY(o"09#Nѷ#̬zW;t A-1dyߔ3^" "Fw,rjs%sfep=`G @5?`MYB^;C`B iRi60 9v(GYJ=olW3 B3QhL$/b<~8ΚSn*jXe7#"(@ Ad*`'ሬco>Va &%`F x}<fP[2qX.'Ip" *ؚ'Rz[oӬaXS2p'2!Zx$Vn%j(V`FF@c}}C2>w1s5{!X[/?Gx2"%X aԞrLa\Ri"?p\ @\beets-1.4.6/test/rsrc/image-2x3.png0000644000076500000240000000023313025125203017711 0ustar asampsonstaff00000000000000PNG  IHDR6I pHYs  tIME  JtEXtCommentCreated with GIMPWIDATc?9х'IENDB`beets-1.4.6/test/rsrc/empty.aiff0000644000076500000240000025427613025125202017515 0ustar asampsonstaff00000000000000FORMXAIFFCOMMD@DSSNDXbeets-1.4.6/test/rsrc/unparseable.flac0000644000076500000240000005260213025125203020646 0ustar asampsonstaff00000000000000fLaC" @D!voWel( Mutagen 1.22DATE=Oct 3, 1995 @!̡'hPhRs% $a̔Ô9C9)™e$ICI?(RaLɦ̡IS9ȓ4)ɚ Nr!(RyBxJDP999e‡=0L%3!(S&RaJI(S'0' 3L)̧ d Pg02PLBPɔg>i'Bee I8RPȁNd NLIL(Rg S% &JLICBPJhPBϟ%%% JaBr$He$B!?"~s3L))(I Rg(g03sȁB4ffLLOI9C2|%P )%2PJ (pC93330hsC ,.)=! NRg &rPIfg0'a3Rfe B!3)P@J8Pɠ|(PNPJB&Pae%32~dBag2|s@0S33' ”&D'0M (P9C3œ)")!2!(RP9d!†g(Re'JB!HS))L)8XDe0 )2S')2B2fs9C93% )2rPL?)2 'L'sdsJIL)2!3) RLaILaIɲR&sH L̔L̡)(D39%'" 2S%% 礡IL3?(S0РRg̔2P)ɔ@DM033&™'J)œ(2eNP)g%J2!IȄY&t&S$,醅'% IC% 8@P!9BRP$'3% dp&NfaIg2eBO)С"9&i)"(fg?C'™9@̡C8D2pfs9$I2s rP<“9))"xL̡.IRRr  JH|g0e LI4L,"8r))3I !3@$L"BBIBRBae2S2O (M aNfe% r)%$)()(d"y2†'L\Re'(pfS fP)(hdCfRdBe ’)4(g0d)P(RrR!B’$(JPIB)9)(r'a%Ô̔32P)3ϟ(y2pÜ)I<99)œ(S3)S&D&D3I)9ID@!339a dfa!s LB NL<)8g?PLJP4(JBa&PL@ fM L38P&RSg=De ɔI30NJJHD)9Y@2P)(L9zM0 ,Ϥ(X&xD4 J)9@e'I J2|(pNP!’PM JfdC'30!aÙ8D8D)(D)R N)>rL@Ls0aIBLBhD $IϜ) PŸ 9Rg33338S3'333$C'%'IP@dC338D4(Y3% 2D(JJBP&fffp pe J2P@%(OP̓J)̤Id3 fg3LLɦ2t D32S&C9L(D% I9C%'(rPe&S'BRs:)Xe&re!JaLILg9CC'2S dyL<2hfp3It(d ̔'C &RM% s)2(< 4L̡`A)%8r!)2|Brdd$ B Jyy(dBLK32P)%f)))(RLBe&y˜e&)2))(sza@! 0 BIB" XAP2yL33 Re BL))8PLda2P% C3d'IPRP0Jg(y2!<2p$B2fJfP̔,)"&fsCB̑0PNIa)Re3S dy(RIr8S39Rr‡É&P JfPL"Bϙ&DD8e&% H)I pg PfPÔ9ILIB)&zBO!))(Y2Ȅfr(L2 Rg Ȅ2M <2P̖P,2 fPsPeJ˅8RP̤P(fa,CS 3(s)(JfRfK0%)™2s&PI LC@JaNIB|aСIS!LD2RpaIɦI<,(sȆJ}Y2JP2D)24)(S' ="3̑ D"(dФ2…&e I9(p CY@e9IdJ39I@#0L̜ 3g(Rf"asCP,94fNRP)3(O32RJa҄% zdPLfs9)BdBNz"= !'(Pt(XpJ@ @Ɛ gœ(Y%BP% L(r2J9C9Ig(RPI@?@Ng)')hdgM!)RPBe'L4@BzL9aL̡2p%33BPIB)™4CaO9C Nd<>dffJ3LNaЙ9M!šHICɦHJdfg$B:Rg)29By)C)YРDIx !NC$C9= IJ%(r(e&PNg9Is9C3d2% dIIINfK33339B2Xr…2rd@ÔL4(S2s s3339!(Re 9IL)3:9 =>e$)3C38P8XD $NPС̤C9 ɔ?С"s&P Nɔ) t38D2RfS%32D2S9I)B!C)3Pe$IOBLr!C9y4g30LΘy )g3) h4̔"C"I)&g3)(RP9H3Ô42Pa&JI,"CCfJaId2I ffa†LHpJRB3)$!(fRe3392e$@…'% J!HR:aNe LI3  :4 P@S8SPpPJI2RPfxЧ'(P 8DaI8PJB~fa !BS% ')YpS0̤fe LhS9&D)!Jd)@))L)Lt(rfJ%%% LINPr9RaC9̝ I)Xf fsP)C(J|8RD%(yŤ[-3!rtg(q]bPEـ Dّ>JUl3A$)i)A#+:3`XaQ;"  f{q wZĠ3zf#)Nq'" kr֣ g' )y*,εx" r BXVF7nhϦN|z%e0Ue B_`9oJDz0)1F8>7t#U4#Xޙ`xTaG~fHv<`? &63l:G 莵NR#Aש9OSԦ"#P dp2~s'P=&!PG<0NE_'1:\ ,l qԺ<>&ÞJ3}]dQ8`F8I+:a;0>m>;Mۏ /gp>bSciL z:~H5M3'A+&f (p0!d:%C9B+]3ZcJr`Ea,])\Biw,y@úD'ħ 0q=bmBAwH! DS 85 !`4^^:$9υgs`M%ZZt^UY(o"09#Nѷ#̬zW;t A-1dyߔ3^" "Fw,rjs%sfep=`G @5?`MYB^;C`B iRi60 9v(GYJ=olW3 B3QhL$/b<~8ΚSn*jXe7#"(@ Ad*`'ሬco>Va &%`F x}<fP[2qX.'Ip" *ؚ'Rz[oӬaXS2p'2!Zx$Vn%j(V`FF@c}}C2>w1s5{!X[/?Gx2"%X aԞrLa\Ri"?p\ @\beets-1.4.6/test/rsrc/partial.flac0000644000076500000240000005260213025125203020001 0ustar asampsonstaff00000000000000fLaC" @D!voWel reference libFLAC 1.2.1 20070917 TITLE=partial TRACKNUMBER=2ARTIST=the artist DISCNUMBER=4ALBUM=the albumDISC=4 COMPILATION=0 g@!̡'hPhRs% $a̔Ô9C9)™e$ICI?(RaLɦ̡IS9ȓ4)ɚ Nr!(RyBxJDP999e‡=0L%3!(S&RaJI(S'0' 3L)̧ d Pg02PLBPɔg>i'Bee I8RPȁNd NLIL(Rg S% &JLICBPJhPBϟ%%% JaBr$He$B!?"~s3L))(I Rg(g03sȁB4ffLLOI9C2|%P )%2PJ (pC93330hsC ,.)=! NRg &rPIfg0'a3Rfe B!3)P@J8Pɠ|(PNPJB&Pae%32~dBag2|s@0S33' ”&D'0M (P9C3œ)")!2!(RP9d!†g(Re'JB!HS))L)8XDe0 )2S')2B2fs9C93% )2rPL?)2 'L'sdsJIL)2!3) RLaILaIɲR&sH L̔L̡)(D39%'" 2S%% 礡IL3?(S0РRg̔2P)ɔ@DM033&™'J)œ(2eNP)g%J2!IȄY&t&S$,醅'% IC% 8@P!9BRP$'3% dp&NfaIg2eBO)С"9&i)"(fg?C'™9@̡C8D2pfs9$I2s rP<“9))"xL̡.IRRr  JH|g0e LI4L,"8r))3I !3@$L"BBIBRBae2S2O (M aNfe% r)%$)()(d"y2†'L\Re'(pfS fP)(hdCfRdBe ’)4(g0d)P(RrR!B’$(JPIB)9)(r'a%Ô̔32P)3ϟ(y2pÜ)I<99)œ(S3)S&D&D3I)9ID@!339a dfa!s LB NL<)8g?PLJP4(JBa&PL@ fM L38P&RSg=De ɔI30NJJHD)9Y@2P)(L9zM0 ,Ϥ(X&xD4 J)9@e'I J2|(pNP!’PM JfdC'30!aÙ8D8D)(D)R N)>rL@Ls0aIBLBhD $IϜ) PŸ 9Rg33338S3'333$C'%'IP@dC338D4(Y3% 2D(JJBP&fffp pe J2P@%(OP̓J)̤Id3 fg3LLɦ2t D32S&C9L(D% I9C%'(rPe&S'BRs:)Xe&re!JaLILg9CC'2S dyL<2hfp3It(d ̔'C &RM% s)2(< 4L̡`A)%8r!)2|Brdd$ B Jyy(dBLK32P)%f)))(RLBe&y˜e&)2))(sza@! 0 BIB" XAP2yL33 Re BL))8PLda2P% C3d'IPRP0Jg(y2!<2p$B2fJfP̔,)"&fsCB̑0PNIa)Re3S dy(RIr8S39Rr‡É&P JfPL"Bϙ&DD8e&% H)I pg PfPÔ9ILIB)&zBO!))(Y2Ȅfr(L2 Rg Ȅ2M <2P̖P,2 fPsPeJ˅8RP̤P(fa,CS 3(s)(JfRfK0%)™2s&PI LC@JaNIB|aСIS!LD2RpaIɦI<,(sȆJ}Y2JP2D)24)(S' ="3̑ D"(dФ2…&e I9(p CY@e9IdJ39I@#0L̜ 3g(Rf"asCP,94fNRP)3(O32RJa҄% zdPLfs9)BdBNz"= !'(Pt(XpJ@ @Ɛ gœ(Y%BP% L(r2J9C9Ig(RPI@?@Ng)')hdgM!)RPBe'L4@BzL9aL̡2p%33BPIB)™4CaO9C Nd<>dffJ3LNaЙ9M!šHICɦHJdfg$B:Rg)29By)C)YРDIx !NC$C9= IJ%(r(e&PNg9Is9C3d2% dIIINfK33339B2Xr…2rd@ÔL4(S2s s3339!(Re 9IL)3:9 =>e$)3C38P8XD $NPС̤C9 ɔ?С"s&P Nɔ) t38D2RfS%32D2S9I)B!C)3Pe$IOBLr!C9y4g30LΘy )g3) h4̔"C"I)&g3)(RP9H3Ô42Pa&JI,"CCfJaId2I ffa†LHpJRB3)$!(fRe3392e$@…'% J!HR:aNe LI3  :4 P@S8SPpPJI2RPfxЧ'(P 8DaI8PJB~fa !BS% ')YpS0̤fe LhS9&D)!Jd)@))L)Lt(rfJ%%% LINPr9RaC9̝ I)Xf fsP)C(J|8RD%(y`X" z)B"$) A ?vbB 4㰲+¤rR%;RQ[7UW6dTUT!ub8 x?.=L2!Զ#9#"5fE$ȉ,1Hez1!Ŗ)((,c)X,qET9N@t?` =m#tܯINi{ޞYgSdE/'̸g W&%irDu6.tܑmtP&,DuhbDN`Z{m/ÞjGH<&ClJP1I93ZHF;r/viÐ1 ܉2 y?z\+dDΪ1ckTIwb %MɬH#tRʹdꓳ1r ϳһl֕ lΆmCUc,$(t4BKZSQRŭ݌>l1f:Vm&DGB;?R+9}+ϳ+UbPS;1%; Rwt9BRw5ls>ʸ#uFlɕћԪFJȪyEus`" ?#ta2ft*3άVwVl̸c@@o2Q;> ?s:=v:VB/l-Z$ۙ5O'*MD' .zSW!a溡Z!3#NǞ#!cH5VA``g;RWTUսQeTľB%Og9F FTj9"(cJb#!ǐx3 E`, =ɯ#4vcqL8aPq2$48u?ݔ5RlogGЌi7ՎKw"1_ܧ8TDg;YF%P \RF S3: QB(́/ߟobnYz(uJ:IdZJ'J FfNe+ 8*&*q2 0 AAD0Q!2AaQE`6 AEt0/׿]s 仪D9FzӦ[7t+M29*FED2r9ҷI|Uzsp+ J8gRf$c1!Yz:d#*%P?{ɦ5\R삊Tv{$A Rki!ʁ(|rp<+)h05"dbBVAǣ4F:>y)ݐQ+7cfT[WhriO&׊aҶѺkLW0?pp1uzs8~9^;ar:hZRo|AwXo**>@0WuJFYYr\j*"H~mg*:+"ofi#E )JWAC,$aq)p1"PH(ѡ`8bo5 ɽH"3z׻r/Ȣ ʮd3їyNq{"Zu#eR se-ESip(0b3syNErӊyz.1,o5+9Euvfv}ȖND}l+3UTCo>%OyS!Xi;\8#)Ń:cŕt(P?f`x 9 ٤Gtb$vftgdkU-ƤVfR>ӭK5(i5; ъwi*"*.*1cxRH@#b@-FW?+kL*ks*gJ4`EviF"$z9Xa˔9pPXT]c`qy` .AMGt#%Yt㙬ꙉywکҥSkYO<6<&T눢!wBE2 J4\VK1`6q1@#W̫:*\ȪuoH(έb+JB{1jj7tT2+9#<ȩ* ayQ13&=B:``둂 Zp[JQ`A " k?dRɿWDJ2oRUdf3o5Kkʄybt3S5 , 1 rJ0#(@X$,84xD*νey5v}*KU2gRtK"oELʌE:2+ْ}H8fc )Bads!XPrx,($0<*5 4l`AH"|M[V۲NIbk3ߢՑZ~jg*ؔssBrQG )10dw<)]($aE8pjhF=宛k*"f5*YC^bꧻLղwcU<;0=Q֫:&q+T'TUgeR B32)D|]Mɺ+Y[֦0)b5EQEa!\hA!ذBg (C &P8|DV&Q`Aǣ4^,fd[Һ VLs ws)Pz^t252]ko泥]S2gsƙٞDka9#"%F DL* (B" XŽAb>Y//J$Y?RVT}ފrQKjZmu}vzttz%TS1#ƽj=ҪQ!0cH, Ê,w 810 jbA "}s@:XȮM*Ccr#DQz)Nb-LQl0׳ )k ;,5ܣ;" 8ЦA$qƁ\MEAB@( *-4їpRVlUK&u+<ϙtLv3ԳI{Y *R<=LUaqL (sD4P !8`A" w"ϒMwu]fFHSRKwqLOO3r1Gԭmͻ;=S8y2l#0Hp % _zZ2> ]C\ȄVGGuy(rJ[BUݟaj[ՙHLRyC%q]Qb->A!Y`1\X@` y?`6A"#ʭ&ʪ˝}V1ֳFnn:[mjsF!b)*Lcj*0p>$av!X4DH5ĨU(p@1@7R!#~ܦf5fb1ގS"iPvJ֮d1qq20:+T6abEl?2DD\M"B  c4\6 `.A"~T"ǞshDmYddSn:3c%5HQUJ:G4Q(tC\{+gO쮕Ԉ!O%f0@>q (4Q!0XeQCg?-I"*ḐYInjH~VkgT̎RK[F"Ψps<CA r$ 0RWb^Aߛ->Ş/&@z)-^𼎽me VE6$4CdP$BAţE ",8SW9 `5kZTC^5bTz-=iCB)auo,8c ""@”AD*<:* x:@DEEH8:.ͭGHNޱW%zDoe_?)leyX)ΜzR̈"]˕L'dxtS 3j ~ds`>A H" #c0&VZBrdpFEyFm *ξmYʊV}L+>j=,-*UcB= QSP"YH  ~(`JE*!bVs `#B(` n?mFb&@M[`beets-1.4.6/test/rsrc/unparseable.mp30000644000076500000240000002260113025125203020434 0ustar asampsonstaff00000000000000ID3TDRC Oct 3, 1995PInfo(!w  &&,,,33999@@FFFLLSSSYY```fflllssyyy9LAME3.97 -@$|B@!w_^P1@T5"LTBhNsy}@3;B2!?}C'Bbʀ,= 7cE`M.2 :Jw f}w[?ҍW:F B5纡^*+R:kB߿ۮ[&8Ub0'QX`he2yYR!) @} 86`v"6FӔ=؊v?VaVӯSˌIleM[#@~H'zU0fZc{Τd ti1+>bWnjkv}I*shC #9!uk{jk+Q@ڷ]+mFnWR; @aN#4HWJU?^g=vy*g^M)\ZW$4>dLN[>S3* G_xV#B)eTH N@k%GI,eU]zQ.,-"~}JW}TE> MB#D?\dO=!btph'rPX.vlKRW  =!'.|nXNkb aWIvȇ'HT'@if"rٕz:F{)Mk-!M.hq'fz0@YmVgwT x!G!'Gt g֏2K.ֵmޓkR1Uc& hFRnY =  '#UdVir N^1|QZ=O7Zҍ}wC[fB> R*( ?Όk;Lf*nSSysfiT*2ي&;+ȕ YScY4Fڙs(M4qȬ4EXu(fJ8vh dznE"p z3Ӣzr|RĄ @ 7#`'o׫bߗw.L7e@K)um.W[MFvR6"mvbfQYGuJd~2[qfBI+q ѠYl;4p2!fKCIY%>#޾?ޯy,bAY ,+ %*?XRĖ  =aH+-zvcF1ސV?B'ȋo$R[ dt# 4 KY$q:4s#M= gdLt2f;^c]L[;SD~"i]1N^ w/jO)MFW+Mȝ6_+io̊% ) $'\W'56?'p&ݙqRİɯ =9X< =}awLNgfV XuQNkJ$&gȩsЪȄ\?M?XCe+0Yli4,b`=@DW-oUIT;fʷj Ւ:tх s5K[$?#D ?x́-5 d_eS9$, RJͳ = 6!'a?`I]Kief;i#R /;:FS}Ls W=hc 4\j" E A8. `& r 句?򇬿-B}VATQ>Zv_WSQW3P ;N~U~xJn,!h,Ԛ dj-i DH\R  5A J @EV"keRw)Ьtz.E!KnvzW[u7NJ("5@aL=#Š@R䉕twiUGQlwE#GPN#_S̞?WcNB%n_Xa0ejR 5`!Ii̗31[-y?nK6▪{yN~;sU/ͯkMl0>P`P8XLT,aLqYu>yrvJKu|1Ef0 rl즽ե6\on-dfYi *'m9!D##98R e -A &"1Y2 iR+E'  NOJ(aؙd%y3;%frg,XPK1S&!Ib]aIW=˻K=\ilIus?W;e8gUYCcIˮ)MVX`L3b3E$TVRLe@w</wMr:jdZu@WWG=A .yX<,WQ`f[:+gdr2v02+#`PH"(M4D6ϋ2Jݲn0^gZ"ClVԪEy~a5TWdjja#TDL@l\ҶĀRKq@= vYq'j%#+ўl2Ř$h^ ёPI/^S4?̛3ѱ1WO\+u :!HN /s?@>;)*_jdJ$ǙYa L ʃ 8٫c} QmQXTQ7(Bm2 KR @= q6 HlAl|Tm(-r meȧ9YcwoŎawV)dK&Θxھ ]\)q]LBUReN~~WߞR;'Z\x,KMR }@ ;A6/ǘ9sIB Eu;#XVD8TuGtF[.+0lQAj$?~(@MNV' -6pP\Т/?B%Z*Jta.fԒp-s.9?K=K^gϬ3 :--: `@R %` 763 64"()芢C$3X-,t9Xƻ4eSUf?YjM %\ȸm `dND V€W5k3SH ~X);ZԟQ{- JIxeW+{Lo&I1ID`5h6 A1@RK6H$D~^?uEeƠQ : R yj6`&_@;vDC0u",gC #6|5n7Οgrn47]+ނaCO]:HOX0>he,(Tn54uZ<gZs.Mzffvtb[6TzjY: D*q8R =@ 5Ak6a(at$;^Z,Hں H(Dz3EЮe3;&Q9ou%n Cd=+_TX%8< bHG 01C Q53{sɖ~|A4͏˱S:#rm%di?2c_tיNe [|LL,%8&Y7l:1R @A}?ͨVln48T,EYN57C5Tlɂn{ Ӏ=guYeP~U (iq &pH@)HpB%7)M>-;ck9r/ą b]CA$0LYpodV{8tQ 1#moHtYerRDL(R = Ә]4zbf|0 C>HIV{װ2l)M1 R3Te芊 U,1$ ` \u"桅?m8T),K_^UQ3H>2%~#~:t(s8ע 2F `wk(RK@w6`ii3m EM_nLDWcI3 G!)\yE:p&CmMZ7| &ٞL*("bL$"]!*Q S¬eFg/4&g 蔿J1 T1K;n\Ěc(0N,ss)% S*DsubF䉜\(ćVZaB ,`vJ @ҷgs{<&Nr=yƾv8X;GӷʮQJ c1P&4R }@ } i85ad?m?2ZfyQ)%[+lgV#^*ۿ/!rT}'.U惒mQ\V`}fQNאL^Cw `Vq%K yC=Z$ԋ:t^\Ū*鋃 ,m' 2F R u@>ɂhLR)\S&7M"އk: a!%-R I ;IX6a p@,%w*LS3209TĞs)ʦEtT΋SVf{nF:B۶'bZ(H ̔ju[kie aez<Z2UE3-Pp;1T*Iv:5զWbKFmK^#.RJ U?t<(b+=5jjB5t{Jb" $/7ۭ_f˝{{9|8>0 `eKkI?róm)2<ϕs9!YybM **:mU[ݙ:daC (9 ?RN!&':8c3sA'4{2Kc>ȅXe43B3noJSc$4FWU"M3ž*j(Cp rݫ d<ͱD8L. Y={?]T.s]um9ڝ *氛_^_:V[4a /*B R m ٔ !cD󁑔@*ⲟK4#p[kUIG$7)g$lˡ?wg3vAܪ@3F$r@ٲODd9ƒJ7"5טWBMs[Ae[CPА )˴rSR6*O3z1ˎ^Y;gN6azճ$ mctt3^?wG/>}6WMH(B1#F#Ύ6?LV@HnR2dg[z݈=),Ľ>elh꩚8iAI F<$ p„(r4IR - 7Ƀ`!h( L t?ÓVs5BU)8&H?p ۫ u/5am>;3nY#KC-RziH$ e8fB-vtyfD =BszIba\T]_??;{~uڲcIUP%C+08W--R ] { ӤrF,'=AC'mP[Tہ&cn5-~g_3iZb4 gtH,2!ipM4 4̌ח˙HՖYZPɕ'#Y&!JG#,5k,Fϲ_˖( NFf\R Y !hrnLAME3.97R @oِ`hR] t4beets-1.4.6/test/rsrc/unparseable.mpc0000644000076500000240000000435413025125203020521 0ustar asampsonstaff00000000000000MP+'\@7s Df3?<>>s?EDD>@_@ @;8o E|_DȗDDDD""_|""ED""""""P 8hэm۶ӵ]K۶m^U6^j/{Ummkg{K[,Hmѥ&li۶sm"Ҕ(lu~傽gUӷUUo54i@NjT=SҩND&|/]|_8yAWDCx/]@by1!?A_O?>v`oyCWp @AWЗ /~ v>p2."?/AWu EDD@w@? t "|E|ȗ 䀈|n?," | ;mS( 9g|ΟKDD"mvk۶ۦm۴vm۶mmۦ]k6 8xhhopиpo"" """""/""HDDPDD7@ntݍp@m۶kmu.]۶]nۮk6mvq*N%+cEU5J X=Ws!r W8yA?^]/{@]? t 8:8p4tt 0? 0?? ]}__|q0+SaзK}>@P|{<0_Pt/_䋋'D/ "ct|/_ / _D{twh@ÁO/@/8x//,z@_*|=.Ls8 p AWPgw|ۧ{_>o?G@>H|_dm`gxn@AG]s~Ý\RqsyFs$;sA <9?AT݀]n@;09F1t0O@c@^vTFhR5O L('4RKeu U|RR.؊! DRJBR)!'&J(Z @,T3DV*3AĨS'l*S["(+9BPP%1"!ANw#)"I R.P!S"*rI(BS  $D) JS S]r#r'xBS  r!CcT;#O o}M&wҊ?'xvpbeets-1.4.6/test/rsrc/t_time.m4a0000644000076500000240000001334613025125203017404 0ustar asampsonstaff00000000000000 ftypM4A M4A mp42isom moovlmvhdL_LD@trak\tkhdL_L@mdia mdhdL_LDU"hdlrsounminfsmhd$dinfdref url stblgstsdWmp4aD3esds"@xstts.(stscstsz.22""'-+1/+&0''&%)(-,*).)(*%)-4&6.*,$,3/'& stco udta meta"hdlrmdirapplilstnamdatafull"ARTdatathe artist$wrtdatathe composer!albdatathe album!gendatathe genre trkndatadiskdata,day$data1987-03-31T07:00:00Zcpildatapgapdatatmpodata6too.dataiTunes v7.6.2, QuickTime 7.4.5----meancom.apple.iTunesnameiTunNORMjdata 00000000 00000000 00000000 00000000 00000228 00000000 00000008 00000000 00000187 00000000----meancom.apple.iTunesnameiTunSMPBdata 00000000 00000840 0000037C 000000000000AC44 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000"lyrdatathe lyrics(aART datathe album artist$cmtdatathe comments$grpdatathe groupingfreefree(mdat\11;<4`Ife^P0#ܷbĻVO(*Jq8q-@>Ť[-3!rtg(q]bPEـ Dّ>JUl3A$)i)A#+:3`XaQ;"  f{q wZĠ3zf#)Nq'" kr֣ g' )y*,εx" r BXVF7nhϦN|z%e0Ue B_`9oJDz0)1F8>7t#U4#Xޙ`xTaG~fHv<`? &63l:G 莵NR#Aש9OSԦ"#P dp2~s'P=&!PG<0NE_'1:\ ,l qԺ<>&ÞJ3}]dQ8`F8I+:a;0>m>;Mۏ /gp>bSciL z:~H5M3'A+&f (p0!d:%C9B+]3ZcJr`Ea,])\Biw,y@úD'ħ 0q=bmBAwH! DS 85 !`4^^:$9υgs`M%ZZt^UY(o"09#Nѷ#̬zW;t A-1dyߔ3^" "Fw,rjs%sfep=`G @5?`MYB^;C`B iRi60 9v(GYJ=olW3 B3QhL$/b<~8ΚSn*jXe7#"(@ Ad*`'ሬco>Va &%`F x}<fP[2qX.'Ip" *ؚ'Rz[oӬaXS2p'2!Zx$Vn%j(V`FF@c}}C2>w1s5{!X[/?Gx2"%X aԞrLa\Ri"?p\ @\beets-1.4.6/test/rsrc/space_time.mp30000644000076500000240000003102413025125203020243 0ustar asampsonstaff00000000000000ID34TIT2fullTPE1 the artistTRCK2/3TALB the albumTPOS4/5TDRC2009-09-04T14:20TCON the genreCOMMhengiTunNORM 80000000 00000000 00000000 00000000 00000224 00000000 00000008 00000000 000003AC 00000000TCMP1TENCiTunes v7.6.2COMMengthe commentsTBPM6TIT1the groupingCOMMengiTunPGAP0COMMengiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000USLTengthe lyricsTCOMthe composerTPE2the album artist` F=ɑA#F0C _ϣ)a2z`X" z)B"$) A ?vbB 4㰲+¤rR%;RQ[7UW6dTUT!ub8 x?.=L2!Զ#9#"5fE$ȉ,1Hez1!Ŗ)((,c)X,qET9N@t?` =m#tܯINi{ޞYgSdE/'̸g W&%irDu6.tܑmtP&,DuhbDN`Z{m/ÞjGH<&ClJP1I93ZHF;r/viÐ1 ܉2 y?z\+dDΪ1ckTIwb %MɬH#tRʹdꓳ1r ϳһl֕ lΆmCUc,$(t4BKZSQRŭ݌>l1f:Vm&DGB;?R+9}+ϳ+UbPS;1%; Rwt9BRw5ls>ʸ#uFlɕћԪFJȪyEus`" ?#ta2ft*3άVwVl̸c@@o2Q;> ?s:=v:VB/l-Z$ۙ5O'*MD' .zSW!a溡Z!3#NǞ#!cH5VA``g;RWTUսQeTľB%Og9F FTj9"(cJb#!ǐx3 E`, =ɯ#4vcqL8aPq2$48u?ݔ5RlogGЌi7ՎKw"1_ܧ8TDg;YF%P \RF S3: QB(́/ߟobnYz(uJ:IdZJ'J FfNe+ 8*&*q2 0 AAD0Q!2AaQE`6 AEt0/׿]s 仪D9FzӦ[7t+M29*FED2r9ҷI|Uzsp+ J8gRf$c1!Yz:d#*%P?{ɦ5\R삊Tv{$A Rki!ʁ(|rp<+)h05"dbBVAǣ4F:>y)ݐQ+7cfT[WhriO&׊aҶѺkLW0?pp1uzs8~9^;ar:hZRo|AwXo**>@0WuJFYYr\j*"H~mg*:+"ofi#E )JWAC,$aq)p1"PH(ѡ`8bo5 ɽH"3z׻r/Ȣ ʮd3їyNq{"Zu#eR se-ESip(0b3syNErӊyz.1,o5+9Euvfv}ȖND}l+3UTCo>%OyS!Xi;\8#)Ń:cŕt(P?f`x 9 ٤Gtb$vftgdkU-ƤVfR>ӭK5(i5; ъwi*"*.*1cxRH@#b@-FW?+kL*ks*gJ4`EviF"$z9Xa˔9pPXT]c`qy` .AMGt#%Yt㙬ꙉywکҥSkYO<6<&T눢!wBE2 J4\VK1`6q1@#W̫:*\ȪuoH(έb+JB{1jj7tT2+9#<ȩ* ayQ13&=B:``둂 Zp[JQ`A " k?dRɿWDJ2oRUdf3o5Kkʄybt3S5 , 1 rJ0#(@X$,84xD*νey5v}*KU2gRtK"oELʌE:2+ْ}H8fc )Bads!XPrx,($0<*5 4l`AH"|M[V۲NIbk3ߢՑZ~jg*ؔssBrQG )10dw<)]($aE8pjhF=宛k*"f5*YC^bꧻLղwcU<;0=Q֫:&q+T'TUgeR B32)D|]Mɺ+Y[֦0)b5EQEa!\hA!ذBg (C &P8|DV&Q`Aǣ4^,fd[Һ VLs ws)Pz^t252]ko泥]S2gsƙٞDka9#"%F DL* (B" XŽAb>Y//J$Y?RVT}ފrQKjZmu}vzttz%TS1#ƽj=ҪQ!0cH, Ê,w 810 jbA "}s@:XȮM*Ccr#DQz)Nb-LQl0׳ )k ;,5ܣ;" 8ЦA$qƁ\MEAB@( *-4їpRVlUK&u+<ϙtLv3ԳI{Y *R<=LUaqL (sD4P !8`A" w"ϒMwu]fFHSRKwqLOO3r1Gԭmͻ;=S8y2l#0Hp % _zZ2> ]C\ȄVGGuy(rJ[BUݟaj[ՙHLRyC%q]Qb->A!Y`1\X@` y?`6A"#ʭ&ʪ˝}V1ֳFnn:[mjsF!b)*Lcj*0p>$av!X4DH5ĨU(p@1@7R!#~ܦf5fb1ގS"iPvJ֮d1qq20:+T6abEl?2DD\M"B  c4\6 `.A"~T"ǞshDmYddSn:3c%5HQUJ:G4Q(tC\{+gO쮕Ԉ!O%f0@>q (4Q!0XeQCg?-I"*ḐYInjH~VkgT̎RK[F"Ψps<CA r$ 0RWb^Aߛ->Ş/&@z)-^𼎽me VE6$4CdP$BAţE ",8SW9 `5kZTC^5bTz-=iCB)auo,8c ""@”AD*<:* x:@DEEH8:.ͭGHNޱW%zDoe_?)leyX)ΜzR̈"]˕L'dxtS 3j ~ds`>A H" #c0&VZBrdpFEyFm *ξmYʊV}L+>j=,-*UcB= QSP"YH  ~(`JE*!bVs `#B(` n?mFb&@M[`beets-1.4.6/test/rsrc/full.dsf0000644000076500000240000033550313035502157017172 0ustar asampsonstaff00000000000000DSD C\fmt 4"Vdata ID3]TIT2fullTPE1 the artistTALB the albumTRCK2/3TPOS4/5TDRC2001TBPM6TIT1 the groupingTCOM the composerTCON the genreTCMP1TPUB the labelCOMMengthe commentsUSLTengthe lyricsUFID;http://musicbrainz.org8b882575-08a5-4452-a7a7-cbb8a1531f9eTXXX;MusicBrainz Artist Id7cf0ea9d-86b9-4dad-ba9e-2355a64899eaTXXX:MusicBrainz Album Id9e873859-8aa4-4790-b985-5a953e8ef628beets-1.4.6/test/rsrc/image.flac0000644000076500000240000005260213025125203017427 0ustar asampsonstaff00000000000000fLaC" @D!voWel image/png album coverPNG  IHDR6I pHYs  tIME  JtEXtCommentCreated with GIMPWIDATc?9х'IENDB` image/jpeg the artisttJFIFHHCC }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ??ف@!̡'hPhRs% $a̔Ô9C9)™e$ICI?(RaLɦ̡IS9ȓ4)ɚ Nr!(RyBxJDP999e‡=0L%3!(S&RaJI(S'0' 3L)̧ d Pg02PLBPɔg>i'Bee I8RPȁNd NLIL(Rg S% &JLICBPJhPBϟ%%% JaBr$He$B!?"~s3L))(I Rg(g03sȁB4ffLLOI9C2|%P )%2PJ (pC93330hsC ,.)=! NRg &rPIfg0'a3Rfe B!3)P@J8Pɠ|(PNPJB&Pae%32~dBag2|s@0S33' ”&D'0M (P9C3œ)")!2!(RP9d!†g(Re'JB!HS))L)8XDe0 )2S')2B2fs9C93% )2rPL?)2 'L'sdsJIL)2!3) RLaILaIɲR&sH L̔L̡)(D39%'" 2S%% 礡IL3?(S0РRg̔2P)ɔ@DM033&™'J)œ(2eNP)g%J2!IȄY&t&S$,醅'% IC% 8@P!9BRP$'3% dp&NfaIg2eBO)С"9&i)"(fg?C'™9@̡C8D2pfs9$I2s rP<“9))"xL̡.IRRr  JH|g0e LI4L,"8r))3I !3@$L"BBIBRBae2S2O (M aNfe% r)%$)()(d"y2†'L\Re'(pfS fP)(hdCfRdBe ’)4(g0d)P(RrR!B’$(JPIB)9)(r'a%Ô̔32P)3ϟ(y2pÜ)I<99)œ(S3)S&D&D3I)9ID@!339a dfa!s LB NL<)8g?PLJP4(JBa&PL@ fM L38P&RSg=De ɔI30NJJHD)9Y@2P)(L9zM0 ,Ϥ(X&xD4 J)9@e'I J2|(pNP!’PM JfdC'30!aÙ8D8D)(D)R N)>rL@Ls0aIBLBhD $IϜ) PŸ 9Rg33338S3'333$C'%'IP@dC338D4(Y3% 2D(JJBP&fffp pe J2P@%(OP̓J)̤Id3 fg3LLɦ2t D32S&C9L(D% I9C%'(rPe&S'BRs:)Xe&re!JaLILg9CC'2S dyL<2hfp3It(d ̔'C &RM% s)2(< 4L̡`A)%8r!)2|Brdd$ B Jyy(dBLK32P)%f)))(RLBe&y˜e&)2))(sza@! 0 BIB" XAP2yL33 Re BL))8PLda2P% C3d'IPRP0Jg(y2!<2p$B2fJfP̔,)"&fsCB̑0PNIa)Re3S dy(RIr8S39Rr‡É&P JfPL"Bϙ&DD8e&% H)I pg PfPÔ9ILIB)&zBO!))(Y2Ȅfr(L2 Rg Ȅ2M <2P̖P,2 fPsPeJ˅8RP̤P(fa,CS 3(s)(JfRfK0%)™2s&PI LC@JaNIB|aСIS!LD2RpaIɦI<,(sȆJ}Y2JP2D)24)(S' ="3̑ D"(dФ2…&e I9(p CY@e9IdJ39I@#0L̜ 3g(Rf"asCP,94fNRP)3(O32RJa҄% zdPLfs9)BdBNz"= !'(Pt(XpJ@ @Ɛ gœ(Y%BP% L(r2J9C9Ig(RPI@?@Ng)')hdgM!)RPBe'L4@BzL9aL̡2p%33BPIB)™4CaO9C Nd<>dffJ3LNaЙ9M!šHICɦHJdfg$B:Rg)29By)C)YРDIx !NC$C9= IJ%(r(e&PNg9Is9C3d2% dIIINfK33339B2Xr…2rd@ÔL4(S2s s3339!(Re 9IL)3:9 =>e$)3C38P8XD $NPС̤C9 ɔ?С"s&P Nɔ) t38D2RfS%32D2S9I)B!C)3Pe$IOBLr!C9y4g30LΘy )g3) h4̔"C"I)&g3)(RP9H3Ô42Pa&JI,"CCfJaId2I ffa†LHpJRB3)$!(fRe3392e$@…'% J!HR:aNe LI3  :4 P@S8SPpPJI2RPfxЧ'(P 8DaI8PJB~fa !BS% ')YpS0̤fe LhS9&D)!Jd)@))L)Lt(rfJ%%% LINPr9RaC9̝ I)Xf fsP)C(J|8RD%(y Major Version1 Minor Version1 Date2015-05-08T14:36:28Z Application Version12.1.2.27 Features5 Show Content Ratings Music Folderfile:////Music/ Library Persistent ID1ABA8417E4946A32 Tracks 634 Track ID634 NameTessellate Artistalt-J Album Artistalt-J AlbumAn Awesome Wave GenreAlternative KindMPEG audio file Size5525212 Total Time182674 Disc Number1 Disc Count1 Track Number3 Track Count13 Year2012 Date Modified2015-02-02T15:23:08Z Date Added2014-04-24T09:28:38Z Bit Rate238 Sample Rate44100 Play Count0 Play Date3513593824 Skip Count3 Skip Date2015-02-05T15:41:04Z Rating80 Album Rating80 Album Rating Computed Artwork Count1 Sort AlbumAwesome Wave Sort Artistalt-J Persistent ID20E89D1580C31363 Track TypeFile Locationfile:///Music/Alt-J/An%20Awesome%20Wave/03%20Tessellate.mp3 File Folder Count4 Library Folder Count2 636 Track ID636 NameBreezeblocks Artistalt-J Album Artistalt-J AlbumAn Awesome Wave GenreAlternative KindMPEG audio file Size6827195 Total Time227082 Disc Number1 Disc Count1 Track Number4 Track Count13 Year2012 Date Modified2015-02-02T15:23:08Z Date Added2014-04-24T09:28:38Z Bit Rate237 Sample Rate44100 Play Count31 Play Date3513594051 Play Date UTC2015-05-04T12:20:51Z Skip Count0 Rating100 Album Rating80 Album Rating Computed Artwork Count1 Sort AlbumAwesome Wave Sort Artistalt-J Persistent IDD7017B127B983D38 Track TypeFile Locationfile://localhost/Music/Alt-J/An%20Awesome%20Wave/04%20Breezeblocks.mp3 File Folder Count4 Library Folder Count2 638 Track ID638 Name❦ (Ripe & Ruin) Artistalt-J Album Artistalt-J AlbumAn Awesome Wave KindMPEG audio file Size2173293 Total Time72097 Disc Number1 Disc Count1 Track Number2 Track Count13 Year2012 Date Modified2015-05-09T17:04:53Z Date Added2015-02-02T15:28:39Z Bit Rate233 Sample Rate44100 Play Count8 Play Date3514109973 Play Date UTC2015-05-10T11:39:33Z Skip Count1 Skip Date2015-02-02T15:29:10Z Album Rating80 Album Rating Computed Artwork Count1 Sort AlbumAwesome Wave Sort Artistalt-J Persistent ID183699FA0554D0E6 Track TypeFile Locationfile:///Music/Alt-J/An%20Awesome%20Wave/02%20%E2%9D%A6%20(Ripe%20&%20Ruin).mp3 File Folder Count4 Library Folder Count2 Playlists NameLibrary Master Playlist ID11480 Playlist Persistent IDCD6FF684E7A6A166 Visible All Items Playlist Items Track ID634 Track ID636 Track ID638 NameMusic Playlist ID16906 Playlist Persistent ID4FB2E64E0971DD45 Distinguished Kind4 Music All Items Playlist Items Track ID634 Track ID636 Track ID638 beets-1.4.6/test/rsrc/full.wv0000644000076500000240000003243113025125203017034 0ustar asampsonstaff00000000000000wvpk 2DD!RIFFXWAVEfmt DXdataXBWWGVHC et@ ɦf1/%k^\E7h JL9 u)q-8np#8ܹC#yT1= @Nc$lrc U $OJFQ &Qd4/ z @jQ g(l 43J~ñfИ1%hfTNc8HLaD R+9QT~DÌ2:QIPvj atX!HF )15qKP|MI=J`ձ+DXC=27TJ3)cD,"Q'#zPD6RY;ЎB4']'T D3@֜X J|pS Db2|! xgp暽=^jc'ɸe`&РDP+ƎXÜ% jYjXjl:d[>4& > ĉNЃh奀,d Nq<^@b>gӊQ:X-`FA\~fsIŒ N'vnBP.A92ic"Y 萌4FXa!P"cb(A6fȈ@q*.`[kjcY+xjXB$\R+D RfD6T,"l,R’WPxS="=i t 7T3A0`dÐQ0$2H;-b5` GN4COPM! YpJD  mؼ,x 1.0 MʕDԜz")6 P,hF\ҝt?F6xAkFXjM P#X`dI4=( 9 0Xt`h A#GO 4%Nv%N ZD3-97 .!iA X;#Q&܄쓭:LtnT hmPI(K Ђ-ZGOd7P@gY=(p%l!HLJ|fdA8>@k0'-?c͑ , f Υ dÒj)2q ej0hAP멮=a2`P\wP0UʼP!g X~l8H,f:L;Թ$vCH@x?V S0#":X3 VCE4:bqL$`<?L5/ie8{`hbXs`+CT2R(j*xyX #Mbp8E5c@#+QRHH>@@4Q%RHpS*-h H 569; LT>$F5hYda2CyG0AA>A#:`@=c.Tx1 ;dX% >FEN@H̝ `@X% Xb X ܙ AfS(1ٌ gfYDx aPJ4@"hB2/ ȉ|   džm*46͗Ǟ x ԴGp#x/b[ 4@@ -$TF9ng xXꌍšZ l a#*hTyA#qYOpHdJNbZziʧeʫХ2֙`|ҕ%I'( F*T֬H+ZA & K^ n( Ik`8#t2%aitAAAUfe"8NT Fc=pJCʰ .A<&&Y:M1yfK,($ < k߄Zk$A͒?AK@A3%f6 HS)k g;D{8@$p с$VM<-" S; G4Mg=AsMzMEe. 0zbei;¡d D L0FX9 Rw) 1& yWrem6M,ȴΡWŲrLdz㐲y1B6o' ʋQ*K"04E1qA@A0K`%(5'\Pg&R@3 Nu4FxA kF(;ĘKJ4G8 DH ccͰ2b=];pPepj@ RH)LusɼhB60fI)V2HL`p| %3JXTxļ ڔ3ɠ@"GȤ j6M (^kF1yfgLd:*%=XMA l*EJ@qD#PZ#DGXi /7KɐtA bS@t\P9n ѡk1V(Tr18Dt%i2E&(sϰʭ>4F D/P ̵#EX2y̕Dd%3LOx {p XPAj"ԩKN4u @X6'Rp T鐘 ZXܠ K`F&D!CI GtF6u9@2Fر@bdA&Nu$c1S,Wsr7@Gp4<M5E`\Xdأ$1D5p,O:6☡'*/"3P^%e"h"uCi eF=ʸH!K_uH$ͥZ30HqZ6I  \'VrePNf%$R-sp8`=0_4hcET؋AP_/1-D52/R@!G'xb́<%18l8pzp *1qP  9AʕD(P A HW"%"GىxihdN?T@ldA\a 0i4ѱ.B>b3sx8I 8\ w; !F :xSSJjO”!Ȑ2Py0WX$@7Rj;|#eT@j*/aaD lǺ8Z5SYxx4RIl"kFh"DCR{4CRdxf=R"T f2Cmpdaa#qL65zAR 09s} ddbAWn3bG1/tAq4h;#rk.9.(pXb7ոk9:X|rtġ@`KѮq +{%rC%\reaF(K' K =D#B-œ𢂦B.qHd9䐌.ʅ0pfa!{XTt0k!BJnW6 WHN IATF&ˡ4B T6]iq#l HO@чQFcw2H@-: a#BR#@ɐCgbhb,`aaC*b \B(n@S a2Rh&F̒C2eހPx*%cXd`F oRzͤOt(GcP7HDȞOLʈb(D kI DSb2FdC/RH<BD8`5@G`ijvKL> r|n06" 5Q%PaSO aYb!yn,\jdK!AQJ37c-T)'S( dsL]q4ʀ1t45z|p5/ UBcB;@雀U$/!T S!TfĢ{ptN@GbeFaY‚0N=z2`٧0I $H%Q_ |+PtM5A7"e(P"tΕ fh[c-0+)P,TV8\:2,U`}@NJad"TtC ׀UO\3Љ2'8: a!%, ^(t@hА/q9Őkw`qX`>O y@T$šI#qfd(xqɉLL6 S{ƚB8ь)`)0R̭AN5K5C5թҩ jȜ%:A:B8$%\8J*IM&%C hscQ#\B)4Z|I򝖘`  `9iP0bL$)RpBL7Yz$ aB;ǃ8!h) 7%Kf?\Aϵn,7X \M =o IKzEq 9HEH P^ΈȠh, 0HybR!5 q h4a\9AVMYC9H0n!0Y z4I3*V , b8D#RH'4<9r<ƩM!&IX8""31S 7W'dOG g10QbN5c n"&逥Ie-5_ѓ%jg' FA @(IyGeD FI7oI\Ɖͽ( ;4+BEX0yFs1RPaPjYplh,7$#SP| {\uP6Pa40 (B$:9I4P4yah`Z2QܐƸ (dOQt͕DeC>H*50ƠPB;@S8l^ pxJ."GQ͛"&gdҼIn0EEÃ/l#,0aZA 9 XIi;` Ѕ a^ 13@-2Qfyu(g02lͰ|f,Mi48;H4`&D X`"ͩ`4"/ = 8 m S+3k")KseJWс1AYd& :v Jij*If):@K5reو Z!&JS'`6+ 6tB\`ZetZG/M*B=J`RH Ƹ9r@XayH-Ђ72}I=Mi\DuXZpN# Ӝ(Kp$4AF0g 8a[:*F5Vw"dF$Rjp#b/<3BR`&C+Đ4bBY q32ci@MiDgʚ~HS!)7E8ARJhr:Ħ#>ǚBW&}0t> ,ȳO%莴c(eS$(@FOh %tD41uġI0Fć%,@D&4fTæ!ށC$l̴`5%RbTs}u P8AqLnJ($y@C;njt M4@ < *qY h\jhxm= 'jȈ u `8 tlyqpXH1 pG T9x$(T $FkD-2 Ps5QDsGB4x xpU8 PCai!06@ +Ð@ 0? T#:'BX58KO&lQ+` FX %V`T*Ra(R$@6!5\9Nk p` 6|FMyZ4˩Y QrW3{&@聆Mdv;b ZLlN+6h9@-ۃ+췂Dh4<:j5 \I\}T "re.hh`3.f#Y&ΏDTkHL@ 0 hNm@`gj2P!=XLCrg|dH =0Щ2؀ c'ua4 @ EAĚ=34.և; O#20JE&hC;-!n#z^u P^jfVDsHDl4(4թ&Gĉ5=- & <  A㬄EgẌEQ<#-3Hlc0O;j0/BiKzHJ&h26`'ZFH3W<(Րce@g^G<"ġ%ZQꔇE;hdG,!،1 9OmHDcExΡ]4!$jL ,Y0AfM$ 4Z$ ĥa#aNu (i:y\r/E60$4>>DF`z6% RGs|]0hu(Z.0d¤3R9t &"y ZH&5JE=YK`^tt?%y8]Y49 熜>ȱaN~$ahl  DYEpn"DA6=X9^v*8  t'5Wg8ŃG\(%BoaT aP i1,sɺ (X84Cicp$<&+\"@.i855Wa2 [B0˭Sr,Oh9@h ',A@≃pN'YM(,^](@M(@Z XiBxe*W3Os"Y OhE!e8POY0i`6I@#53d]SʡR.:?bfGRڹ0W̕pCD8JaTGsLЂ8\ckhB"),Ё0Dkb([>B}݀ Øq+ h4tK E8\BDKQx(#6e "Kcpi 6$8A 2 SOڃJt=D%iMpF?S#%B_~&` ʉtPCPoO` ZgdB,HТ{s8Gb(RD*kxyq+5Ԛc2U\1,Rd,Da aPU^3dKԸKJ(BY'8Tجr*G@F  M*̕q<0z ѐal4 :ĩ2)82NMI8isC0*`š`2nB5 f(cpՁ"Ҵe8 {ꃌ[DДrI1j6@l[g:ƊP0WG#h< !q!Pgc*0HG H$O7d%#2! h 5&|6p H5 (N$p *FBh%uL80L,PR67CD #{}\fUlM`=`A0CARK#p h5tjM*y1Y?@X0ϑ)M\($SMqYE1 !#-SE,2ihPqEBR) h %07U<C2(P4*\:`鐗eRAoj/ . =XM<HX :K ekd5Gcl LtJ:YC.  &iT1(zC H$%7kcj #TxDpL @tbl^ /0@Ր!h𨺣Rip 0ư*22 iM!Q fƑHHYɐCe1 6!>5E0aL iA,` >0t 7W%l*9*a{2l̙qB ȸ,<"re\!/pQPVpؒ!N)VdT..AKdqI8㠦TON~j#&؝Nc$RC@IA 7&5WFĉaHc !+PJ>P,Aτ.UJ"14q3RR1J 9np 9 Dl<O>qVх +-'<v3DH t h^fOjRy,)UQ?g)DP\L!28Q pe `Nq&Dc,b¡ѡTQkR "(H82d:bb,d h<\ReZ ihq')i^U" Q \ [YN%a [NXcU fS%3C8 hցjx &TBc:\B>eX$kOaxXa` Rc}m(Q@4g(a=P T)el%O$Act@TcQA<=N)I~*N:#(2lP 'XLi G  Y 13@_$JhZG@&Bd8=S"b`8;,5դaeVG'Ě,th` ApajUN=z57=8CCh٦8DnS"<(d}h?`! a/{'I)0}ƍ$gX!VPDW?EafĘ&vK,LNzP"!2R<Nj8`J%M L`qLB@gH3d%>pl/C Y^ JB9pL$eAƤ$",zɑM u$ ϝ xL6ƠBjBr2DAъXJ̋*F#/XpDhi6tȠnZ4C<@JC>*TJױ>؃dGsV@ 4Ec 6=Z A 5MAT`ZS 6P  :wC!lG^@~z$4^tj2e"OkAʃnDM(SX^ , -  Zk c;e $GT.桨 Sh qFOH,/h3o :K#@ki b $3L5D<5 k'KN 3dTrJ̎qa (%ѡ-K0APETAGEXbpm6disc4part4discc5trackc3year2001date2001titlefulldisctotal5track02/03totaldiscs5tracktotal3discnumber4compilation1totaltracks3 genrethe genre albumthe album labelthe label lyricsthe lyrics artistthe artist publisherthe label commentthe comments composerthe composer groupingthe grouping$musicbrainz_albumid9e873859-8aa4-4790-b985-5a953e8ef628$musicbrainz_trackid8b882575-08a5-4452-a7a7-cbb8a1531f9e$musicbrainz_artistid7cf0ea9d-86b9-4dad-ba9e-2355a64899eaAPETAGEXbeets-1.4.6/test/test_the.py0000644000076500000240000000533513025125203016737 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- """Tests for the 'the' plugin""" from __future__ import division, absolute_import, print_function import unittest from test import _common from beets import config from beetsplug.the import ThePlugin, PATTERN_A, PATTERN_THE, FORMAT class ThePluginTest(_common.TestCase): def test_unthe_with_default_patterns(self): self.assertEqual(ThePlugin().unthe(u'', PATTERN_THE), '') self.assertEqual(ThePlugin().unthe(u'The Something', PATTERN_THE), u'Something, The') self.assertEqual(ThePlugin().unthe(u'The The', PATTERN_THE), u'The, The') self.assertEqual(ThePlugin().unthe(u'The The', PATTERN_THE), u'The, The') self.assertEqual(ThePlugin().unthe(u'The The X', PATTERN_THE), u'The X, The') self.assertEqual(ThePlugin().unthe(u'the The', PATTERN_THE), u'The, the') self.assertEqual(ThePlugin().unthe(u'Protected The', PATTERN_THE), u'Protected The') self.assertEqual(ThePlugin().unthe(u'A Boy', PATTERN_A), u'Boy, A') self.assertEqual(ThePlugin().unthe(u'a girl', PATTERN_A), u'girl, a') self.assertEqual(ThePlugin().unthe(u'An Apple', PATTERN_A), u'Apple, An') self.assertEqual(ThePlugin().unthe(u'An A Thing', PATTERN_A), u'A Thing, An') self.assertEqual(ThePlugin().unthe(u'the An Arse', PATTERN_A), u'the An Arse') def test_unthe_with_strip(self): config['the']['strip'] = True self.assertEqual(ThePlugin().unthe(u'The Something', PATTERN_THE), u'Something') self.assertEqual(ThePlugin().unthe(u'An A', PATTERN_A), u'A') def test_template_function_with_defaults(self): ThePlugin().patterns = [PATTERN_THE, PATTERN_A] self.assertEqual(ThePlugin().the_template_func(u'The The'), u'The, The') self.assertEqual(ThePlugin().the_template_func(u'An A'), u'A, An') def test_custom_pattern(self): config['the']['patterns'] = [u'^test\s'] config['the']['format'] = FORMAT self.assertEqual(ThePlugin().the_template_func(u'test passed'), u'passed, test') def test_custom_format(self): config['the']['patterns'] = [PATTERN_THE, PATTERN_A] config['the']['format'] = u'{1} ({0})' self.assertEqual(ThePlugin().the_template_func(u'The A'), u'The (A)') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_bucket.py0000644000076500000240000001660513025125203017436 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'bucket' plugin.""" from __future__ import division, absolute_import, print_function import unittest from beetsplug import bucket from beets import config, ui from test.helper import TestHelper class BucketPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.plugin = bucket.BucketPlugin() def tearDown(self): self.teardown_beets() def _setup_config(self, bucket_year=[], bucket_alpha=[], bucket_alpha_regex={}, extrapolate=False): config['bucket']['bucket_year'] = bucket_year config['bucket']['bucket_alpha'] = bucket_alpha config['bucket']['bucket_alpha_regex'] = bucket_alpha_regex config['bucket']['extrapolate'] = extrapolate self.plugin.setup() def test_year_single_year(self): """If a single year is given, range starts from this year and stops at the year preceding the one of next bucket.""" self._setup_config(bucket_year=['1950s', '1970s']) self.assertEqual(self.plugin._tmpl_bucket('1959'), '1950s') self.assertEqual(self.plugin._tmpl_bucket('1969'), '1950s') def test_year_single_year_last_folder(self): """If a single year is given for the last bucket, extend it to current year.""" self._setup_config(bucket_year=['1950', '1970']) self.assertEqual(self.plugin._tmpl_bucket('2014'), '1970') self.assertEqual(self.plugin._tmpl_bucket('2025'), '2025') def test_year_two_years(self): """Buckets can be named with the 'from-to' syntax.""" self._setup_config(bucket_year=['1950-59', '1960-1969']) self.assertEqual(self.plugin._tmpl_bucket('1959'), '1950-59') self.assertEqual(self.plugin._tmpl_bucket('1969'), '1960-1969') def test_year_multiple_years(self): """Buckets can be named by listing all the years""" self._setup_config(bucket_year=['1950,51,52,53']) self.assertEqual(self.plugin._tmpl_bucket('1953'), '1950,51,52,53') self.assertEqual(self.plugin._tmpl_bucket('1974'), '1974') def test_year_out_of_range(self): """If no range match, return the year""" self._setup_config(bucket_year=['1950-59', '1960-69']) self.assertEqual(self.plugin._tmpl_bucket('1974'), '1974') self._setup_config(bucket_year=[]) self.assertEqual(self.plugin._tmpl_bucket('1974'), '1974') def test_year_out_of_range_extrapolate(self): """If no defined range match, extrapolate all ranges using the most common syntax amongst existing buckets and return the matching one.""" self._setup_config(bucket_year=['1950-59', '1960-69'], extrapolate=True) self.assertEqual(self.plugin._tmpl_bucket('1914'), '1910-19') # pick single year format self._setup_config(bucket_year=['1962-81', '2002', '2012'], extrapolate=True) self.assertEqual(self.plugin._tmpl_bucket('1983'), '1982') # pick from-end format self._setup_config(bucket_year=['1962-81', '2002', '2012-14'], extrapolate=True) self.assertEqual(self.plugin._tmpl_bucket('1983'), '1982-01') # extrapolate add ranges, but never modifies existing ones self._setup_config(bucket_year=['1932', '1942', '1952', '1962-81', '2002'], extrapolate=True) self.assertEqual(self.plugin._tmpl_bucket('1975'), '1962-81') def test_alpha_all_chars(self): """Alphabet buckets can be named by listing all their chars""" self._setup_config(bucket_alpha=['ABCD', 'FGH', 'IJKL']) self.assertEqual(self.plugin._tmpl_bucket('garry'), 'FGH') def test_alpha_first_last_chars(self): """Alphabet buckets can be named by listing the 'from-to' syntax""" self._setup_config(bucket_alpha=['0->9', 'A->D', 'F-H', 'I->Z']) self.assertEqual(self.plugin._tmpl_bucket('garry'), 'F-H') self.assertEqual(self.plugin._tmpl_bucket('2pac'), '0->9') def test_alpha_out_of_range(self): """If no range match, return the initial""" self._setup_config(bucket_alpha=['ABCD', 'FGH', 'IJKL']) self.assertEqual(self.plugin._tmpl_bucket('errol'), 'E') self._setup_config(bucket_alpha=[]) self.assertEqual(self.plugin._tmpl_bucket('errol'), 'E') def test_alpha_regex(self): """Check regex is used""" self._setup_config(bucket_alpha=['foo', 'bar'], bucket_alpha_regex={'foo': '^[a-d]', 'bar': '^[e-z]'}) self.assertEqual(self.plugin._tmpl_bucket('alpha'), 'foo') self.assertEqual(self.plugin._tmpl_bucket('delta'), 'foo') self.assertEqual(self.plugin._tmpl_bucket('zeta'), 'bar') self.assertEqual(self.plugin._tmpl_bucket('Alpha'), 'A') def test_alpha_regex_mix(self): """Check mixing regex and non-regex is possible""" self._setup_config(bucket_alpha=['A - D', 'E - L'], bucket_alpha_regex={'A - D': '^[0-9a-dA-D…äÄ]'}) self.assertEqual(self.plugin._tmpl_bucket('alpha'), 'A - D') self.assertEqual(self.plugin._tmpl_bucket('Ärzte'), 'A - D') self.assertEqual(self.plugin._tmpl_bucket('112'), 'A - D') self.assertEqual(self.plugin._tmpl_bucket('…and Oceans'), 'A - D') self.assertEqual(self.plugin._tmpl_bucket('Eagles'), 'E - L') def test_bad_alpha_range_def(self): """If bad alpha range definition, a UserError is raised.""" with self.assertRaises(ui.UserError): self._setup_config(bucket_alpha=['$%']) def test_bad_year_range_def_no4digits(self): """If bad year range definition, a UserError is raised. Range origin must be expressed on 4 digits. """ with self.assertRaises(ui.UserError): self._setup_config(bucket_year=['62-64']) def test_bad_year_range_def_nodigits(self): """If bad year range definition, a UserError is raised. At least the range origin must be declared. """ with self.assertRaises(ui.UserError): self._setup_config(bucket_year=['nodigits']) def check_span_from_str(self, sstr, dfrom, dto): d = bucket.span_from_str(sstr) self.assertEqual(dfrom, d['from']) self.assertEqual(dto, d['to']) def test_span_from_str(self): self.check_span_from_str("1980 2000", 1980, 2000) self.check_span_from_str("1980 00", 1980, 2000) self.check_span_from_str("1930 00", 1930, 2000) self.check_span_from_str("1930 50", 1930, 1950) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_datequery.py0000644000076500000240000002754713122272074020202 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test for dbcore's date-based queries. """ from __future__ import division, absolute_import, print_function from test import _common from datetime import datetime, timedelta import unittest import time from beets.dbcore.query import _parse_periods, DateInterval, DateQuery,\ InvalidQueryArgumentValueError def _date(string): return datetime.strptime(string, '%Y-%m-%dT%H:%M:%S') def _datepattern(datetimedate): return datetimedate.strftime('%Y-%m-%dT%H:%M:%S') class DateIntervalTest(unittest.TestCase): def test_year_precision_intervals(self): self.assertContains('2000..2001', '2000-01-01T00:00:00') self.assertContains('2000..2001', '2001-06-20T14:15:16') self.assertContains('2000..2001', '2001-12-31T23:59:59') self.assertExcludes('2000..2001', '1999-12-31T23:59:59') self.assertExcludes('2000..2001', '2002-01-01T00:00:00') self.assertContains('2000..', '2000-01-01T00:00:00') self.assertContains('2000..', '2099-10-11T00:00:00') self.assertExcludes('2000..', '1999-12-31T23:59:59') self.assertContains('..2001', '2001-12-31T23:59:59') self.assertExcludes('..2001', '2002-01-01T00:00:00') self.assertContains('-1d..1d', _datepattern(datetime.now())) self.assertExcludes('-2d..-1d', _datepattern(datetime.now())) def test_day_precision_intervals(self): self.assertContains('2000-06-20..2000-06-20', '2000-06-20T00:00:00') self.assertContains('2000-06-20..2000-06-20', '2000-06-20T10:20:30') self.assertContains('2000-06-20..2000-06-20', '2000-06-20T23:59:59') self.assertExcludes('2000-06-20..2000-06-20', '2000-06-19T23:59:59') self.assertExcludes('2000-06-20..2000-06-20', '2000-06-21T00:00:00') def test_month_precision_intervals(self): self.assertContains('1999-12..2000-02', '1999-12-01T00:00:00') self.assertContains('1999-12..2000-02', '2000-02-15T05:06:07') self.assertContains('1999-12..2000-02', '2000-02-29T23:59:59') self.assertExcludes('1999-12..2000-02', '1999-11-30T23:59:59') self.assertExcludes('1999-12..2000-02', '2000-03-01T00:00:00') def test_hour_precision_intervals(self): # test with 'T' separator self.assertExcludes('2000-01-01T12..2000-01-01T13', '2000-01-01T11:59:59') self.assertContains('2000-01-01T12..2000-01-01T13', '2000-01-01T12:00:00') self.assertContains('2000-01-01T12..2000-01-01T13', '2000-01-01T12:30:00') self.assertContains('2000-01-01T12..2000-01-01T13', '2000-01-01T13:30:00') self.assertContains('2000-01-01T12..2000-01-01T13', '2000-01-01T13:59:59') self.assertExcludes('2000-01-01T12..2000-01-01T13', '2000-01-01T14:00:00') self.assertExcludes('2000-01-01T12..2000-01-01T13', '2000-01-01T14:30:00') # test non-range query self.assertContains('2008-12-01T22', '2008-12-01T22:30:00') self.assertExcludes('2008-12-01T22', '2008-12-01T23:30:00') def test_minute_precision_intervals(self): self.assertExcludes('2000-01-01T12:30..2000-01-01T12:31', '2000-01-01T12:29:59') self.assertContains('2000-01-01T12:30..2000-01-01T12:31', '2000-01-01T12:30:00') self.assertContains('2000-01-01T12:30..2000-01-01T12:31', '2000-01-01T12:30:30') self.assertContains('2000-01-01T12:30..2000-01-01T12:31', '2000-01-01T12:31:59') self.assertExcludes('2000-01-01T12:30..2000-01-01T12:31', '2000-01-01T12:32:00') def test_second_precision_intervals(self): self.assertExcludes('2000-01-01T12:30:50..2000-01-01T12:30:55', '2000-01-01T12:30:49') self.assertContains('2000-01-01T12:30:50..2000-01-01T12:30:55', '2000-01-01T12:30:50') self.assertContains('2000-01-01T12:30:50..2000-01-01T12:30:55', '2000-01-01T12:30:55') self.assertExcludes('2000-01-01T12:30:50..2000-01-01T12:30:55', '2000-01-01T12:30:56') def test_unbounded_endpoints(self): self.assertContains('..', date=datetime.max) self.assertContains('..', date=datetime.min) self.assertContains('..', '1000-01-01T00:00:00') def assertContains(self, interval_pattern, date_pattern=None, date=None): # noqa if date is None: date = _date(date_pattern) (start, end) = _parse_periods(interval_pattern) interval = DateInterval.from_periods(start, end) self.assertTrue(interval.contains(date)) def assertExcludes(self, interval_pattern, date_pattern): # noqa date = _date(date_pattern) (start, end) = _parse_periods(interval_pattern) interval = DateInterval.from_periods(start, end) self.assertFalse(interval.contains(date)) def _parsetime(s): return time.mktime(datetime.strptime(s, '%Y-%m-%d %H:%M').timetuple()) class DateQueryTest(_common.LibTestCase): def setUp(self): super(DateQueryTest, self).setUp() self.i.added = _parsetime('2013-03-30 22:21') self.i.store() def test_single_month_match_fast(self): query = DateQuery('added', '2013-03') matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_single_month_nonmatch_fast(self): query = DateQuery('added', '2013-04') matched = self.lib.items(query) self.assertEqual(len(matched), 0) def test_single_month_match_slow(self): query = DateQuery('added', '2013-03') self.assertTrue(query.match(self.i)) def test_single_month_nonmatch_slow(self): query = DateQuery('added', '2013-04') self.assertFalse(query.match(self.i)) def test_single_day_match_fast(self): query = DateQuery('added', '2013-03-30') matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_single_day_nonmatch_fast(self): query = DateQuery('added', '2013-03-31') matched = self.lib.items(query) self.assertEqual(len(matched), 0) class DateQueryTestRelative(_common.LibTestCase): def setUp(self): super(DateQueryTestRelative, self).setUp() self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M')) self.i.store() def test_single_month_match_fast(self): query = DateQuery('added', datetime.now().strftime('%Y-%m')) matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_single_month_nonmatch_fast(self): query = DateQuery('added', (datetime.now() + timedelta(days=30)) .strftime('%Y-%m')) matched = self.lib.items(query) self.assertEqual(len(matched), 0) def test_single_month_match_slow(self): query = DateQuery('added', datetime.now().strftime('%Y-%m')) self.assertTrue(query.match(self.i)) def test_single_month_nonmatch_slow(self): query = DateQuery('added', (datetime.now() + timedelta(days=30)) .strftime('%Y-%m')) self.assertFalse(query.match(self.i)) def test_single_day_match_fast(self): query = DateQuery('added', datetime.now().strftime('%Y-%m-%d')) matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_single_day_nonmatch_fast(self): query = DateQuery('added', (datetime.now() + timedelta(days=1)) .strftime('%Y-%m-%d')) matched = self.lib.items(query) self.assertEqual(len(matched), 0) class DateQueryTestRelativeMore(_common.LibTestCase): def setUp(self): super(DateQueryTestRelativeMore, self).setUp() self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M')) self.i.store() def test_relative(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '-4' + timespan + '..+4' + timespan) matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_relative_fail(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '-2' + timespan + '..-1' + timespan) matched = self.lib.items(query) self.assertEqual(len(matched), 0) def test_start_relative(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '-4' + timespan + '..') matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_start_relative_fail(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '4' + timespan + '..') matched = self.lib.items(query) self.assertEqual(len(matched), 0) def test_end_relative(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '..+4' + timespan) matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_end_relative_fail(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '..-4' + timespan) matched = self.lib.items(query) self.assertEqual(len(matched), 0) class DateQueryConstructTest(unittest.TestCase): def test_long_numbers(self): with self.assertRaises(InvalidQueryArgumentValueError): DateQuery('added', '1409830085..1412422089') def test_too_many_components(self): with self.assertRaises(InvalidQueryArgumentValueError): DateQuery('added', '12-34-56-78') def test_invalid_date_query(self): q_list = [ '2001-01-0a', '2001-0a', '200a', '2001-01-01..2001-01-0a', '2001-0a..2001-01', '200a..2002', '20aa..', '..2aa' ] for q in q_list: with self.assertRaises(InvalidQueryArgumentValueError): DateQuery('added', q) def test_datetime_uppercase_t_separator(self): date_query = DateQuery('added', '2000-01-01T12') self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12)) self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13)) def test_datetime_lowercase_t_separator(self): date_query = DateQuery('added', '2000-01-01t12') self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12)) self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13)) def test_datetime_space_separator(self): date_query = DateQuery('added', '2000-01-01 12') self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12)) self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13)) def test_datetime_invalid_separator(self): with self.assertRaises(InvalidQueryArgumentValueError): DateQuery('added', '2000-01-01x12') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/helper.py0000644000076500000240000004720313206372465016416 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """This module includes various helpers that provide fixtures, capture information or mock the environment. - The `control_stdin` and `capture_stdout` context managers allow one to interact with the user interface. - `has_program` checks the presence of a command on the system. - The `generate_album_info` and `generate_track_info` functions return fixtures to be used when mocking the autotagger. - The `TestImportSession` allows one to run importer code while controlling the interactions through code. - The `TestHelper` class encapsulates various fixtures that can be set up. """ from __future__ import division, absolute_import, print_function import sys import os import os.path import shutil import subprocess from tempfile import mkdtemp, mkstemp from contextlib import contextmanager from six import StringIO from enum import Enum import beets from beets import logging from beets import config import beets.plugins from beets.library import Library, Item, Album from beets import importer from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.mediafile import MediaFile, Image from beets import util from beets.util import MoveOperation # TODO Move AutotagMock here from test import _common import six class LogCapture(logging.Handler): def __init__(self): logging.Handler.__init__(self) self.messages = [] def emit(self, record): self.messages.append(six.text_type(record.msg)) @contextmanager def capture_log(logger='beets'): capture = LogCapture() log = logging.getLogger(logger) log.addHandler(capture) try: yield capture.messages finally: log.removeHandler(capture) @contextmanager def control_stdin(input=None): """Sends ``input`` to stdin. >>> with control_stdin('yes'): ... input() 'yes' """ org = sys.stdin sys.stdin = StringIO(input) if six.PY2: # StringIO encoding attr isn't writable in python >= 3 sys.stdin.encoding = 'utf-8' try: yield sys.stdin finally: sys.stdin = org @contextmanager def capture_stdout(): """Save stdout in a StringIO. >>> with capture_stdout() as output: ... print('spam') ... >>> output.getvalue() 'spam' """ org = sys.stdout sys.stdout = capture = StringIO() if six.PY2: # StringIO encoding attr isn't writable in python >= 3 sys.stdout.encoding = 'utf-8' try: yield sys.stdout finally: sys.stdout = org print(capture.getvalue()) def _convert_args(args): """Convert args to bytestrings for Python 2 and convert them to strings on Python 3. """ for i, elem in enumerate(args): if six.PY2: if isinstance(elem, six.text_type): args[i] = elem.encode(util.arg_encoding()) else: if isinstance(elem, bytes): args[i] = elem.decode(util.arg_encoding()) return args def has_program(cmd, args=['--version']): """Returns `True` if `cmd` can be executed. """ full_cmd = _convert_args([cmd] + args) try: with open(os.devnull, 'wb') as devnull: subprocess.check_call(full_cmd, stderr=devnull, stdout=devnull, stdin=devnull) except OSError: return False except subprocess.CalledProcessError: return False else: return True class TestHelper(object): """Helper mixin for high-level cli and plugin tests. This mixin provides methods to isolate beets' global state provide fixtures. """ # TODO automate teardown through hook registration def setup_beets(self, disk=False): """Setup pristine global configuration and library for testing. Sets ``beets.config`` so we can safely use any functionality that uses the global configuration. All paths used are contained in a temporary directory Sets the following properties on itself. - ``temp_dir`` Path to a temporary directory containing all files specific to beets - ``libdir`` Path to a subfolder of ``temp_dir``, containing the library's media files. Same as ``config['directory']``. - ``config`` The global configuration used by beets. - ``lib`` Library instance created with the settings from ``config``. Make sure you call ``teardown_beets()`` afterwards. """ self.create_temp_dir() os.environ['BEETSDIR'] = util.py3_path(self.temp_dir) self.config = beets.config self.config.clear() self.config.read() self.config['plugins'] = [] self.config['verbose'] = 1 self.config['ui']['color'] = False self.config['threaded'] = False self.libdir = os.path.join(self.temp_dir, b'libdir') os.mkdir(self.libdir) self.config['directory'] = util.py3_path(self.libdir) if disk: dbpath = util.bytestring_path( self.config['library'].as_filename() ) else: dbpath = ':memory:' self.lib = Library(dbpath, self.libdir) def teardown_beets(self): self.lib._close() if 'BEETSDIR' in os.environ: del os.environ['BEETSDIR'] self.remove_temp_dir() self.config.clear() beets.config.read(user=False, defaults=True) def load_plugins(self, *plugins): """Load and initialize plugins by names. Similar setting a list of plugins in the configuration. Make sure you call ``unload_plugins()`` afterwards. """ # FIXME this should eventually be handled by a plugin manager beets.config['plugins'] = plugins beets.plugins.load_plugins(plugins) beets.plugins.find_plugins() # Take a backup of the original _types to restore when unloading Item._original_types = dict(Item._types) Album._original_types = dict(Album._types) Item._types.update(beets.plugins.types(Item)) Album._types.update(beets.plugins.types(Album)) def unload_plugins(self): """Unload all plugins and remove the from the configuration. """ # FIXME this should eventually be handled by a plugin manager beets.config['plugins'] = [] beets.plugins._classes = set() beets.plugins._instances = {} Item._types = Item._original_types Album._types = Album._original_types def create_importer(self, item_count=1, album_count=1): """Create files to import and return corresponding session. Copies the specified number of files to a subdirectory of `self.temp_dir` and creates a `TestImportSession` for this path. """ import_dir = os.path.join(self.temp_dir, b'import') if not os.path.isdir(import_dir): os.mkdir(import_dir) album_no = 0 while album_count: album = util.bytestring_path(u'album {0}'.format(album_no)) album_dir = os.path.join(import_dir, album) if os.path.exists(album_dir): album_no += 1 continue os.mkdir(album_dir) album_count -= 1 track_no = 0 album_item_count = item_count while album_item_count: title = u'track {0}'.format(track_no) src = os.path.join(_common.RSRC, b'full.mp3') title_file = util.bytestring_path('{0}.mp3'.format(title)) dest = os.path.join(album_dir, title_file) if os.path.exists(dest): track_no += 1 continue album_item_count -= 1 shutil.copy(src, dest) mediafile = MediaFile(dest) mediafile.update({ 'artist': 'artist', 'albumartist': 'album artist', 'title': title, 'album': album, 'mb_albumid': None, 'mb_trackid': None, }) mediafile.save() config['import']['quiet'] = True config['import']['autotag'] = False config['import']['resume'] = False return TestImportSession(self.lib, loghandler=None, query=None, paths=[import_dir]) # Library fixtures methods def create_item(self, **values): """Return an `Item` instance with sensible default values. The item receives its attributes from `**values` paratmeter. The `title`, `artist`, `album`, `track`, `format` and `path` attributes have defaults if they are not given as parameters. The `title` attribute is formated with a running item count to prevent duplicates. The default for the `path` attribute respects the `format` value. The item is attached to the database from `self.lib`. """ item_count = self._get_item_count() values_ = { 'title': u't\u00eftle {0}', 'artist': u'the \u00e4rtist', 'album': u'the \u00e4lbum', 'track': item_count, 'format': 'MP3', } values_.update(values) values_['title'] = values_['title'].format(item_count) values_['db'] = self.lib item = Item(**values_) if 'path' not in values: item['path'] = 'audio.' + item['format'].lower() # mtime needs to be set last since other assignments reset it. item.mtime = 12345 return item def add_item(self, **values): """Add an item to the library and return it. Creates the item by passing the parameters to `create_item()`. If `path` is not set in `values` it is set to `item.destination()`. """ # When specifying a path, store it normalized (as beets does # ordinarily). if 'path' in values: values['path'] = util.normpath(values['path']) item = self.create_item(**values) item.add(self.lib) # Ensure every item has a path. if 'path' not in values: item['path'] = item.destination() item.store() return item def add_item_fixture(self, **values): """Add an item with an actual audio file to the library. """ item = self.create_item(**values) extension = item['format'].lower() item['path'] = os.path.join(_common.RSRC, util.bytestring_path('min.' + extension)) item.add(self.lib) item.move(operation=MoveOperation.COPY) item.store() return item def add_album(self, **values): item = self.add_item(**values) return self.lib.add_album([item]) def add_item_fixtures(self, ext='mp3', count=1): """Add a number of items with files to the database. """ # TODO base this on `add_item()` items = [] path = os.path.join(_common.RSRC, util.bytestring_path('full.' + ext)) for i in range(count): item = Item.from_path(path) item.album = u'\u00e4lbum {0}'.format(i) # Check unicode paths item.title = u't\u00eftle {0}'.format(i) # mtime needs to be set last since other assignments reset it. item.mtime = 12345 item.add(self.lib) item.move(operation=MoveOperation.COPY) item.store() items.append(item) return items def add_album_fixture(self, track_count=1, ext='mp3'): """Add an album with files to the database. """ items = [] path = os.path.join(_common.RSRC, util.bytestring_path('full.' + ext)) for i in range(track_count): item = Item.from_path(path) item.album = u'\u00e4lbum' # Check unicode paths item.title = u't\u00eftle {0}'.format(i) # mtime needs to be set last since other assignments reset it. item.mtime = 12345 item.add(self.lib) item.move(operation=MoveOperation.COPY) item.store() items.append(item) return self.lib.add_album(items) def create_mediafile_fixture(self, ext='mp3', images=[]): """Copies a fixture mediafile with the extension to a temporary location and returns the path. It keeps track of the created locations and will delete the with `remove_mediafile_fixtures()` `images` is a subset of 'png', 'jpg', and 'tiff'. For each specified extension a cover art image is added to the media file. """ src = os.path.join(_common.RSRC, util.bytestring_path('full.' + ext)) handle, path = mkstemp() os.close(handle) shutil.copyfile(src, path) if images: mediafile = MediaFile(path) imgs = [] for img_ext in images: file = util.bytestring_path('image-2x3.{0}'.format(img_ext)) img_path = os.path.join(_common.RSRC, file) with open(img_path, 'rb') as f: imgs.append(Image(f.read())) mediafile.images = imgs mediafile.save() if not hasattr(self, '_mediafile_fixtures'): self._mediafile_fixtures = [] self._mediafile_fixtures.append(path) return path def remove_mediafile_fixtures(self): if hasattr(self, '_mediafile_fixtures'): for path in self._mediafile_fixtures: os.remove(path) def _get_item_count(self): if not hasattr(self, '__item_count'): count = 0 self.__item_count = count + 1 return count # Running beets commands def run_command(self, *args, **kwargs): """Run a beets command with an arbitrary amount of arguments. The Library` defaults to `self.lib`, but can be overridden with the keyword argument `lib`. """ sys.argv = ['beet'] # avoid leakage from test suite args lib = None if hasattr(self, 'lib'): lib = self.lib lib = kwargs.get('lib', lib) beets.ui._raw_main(_convert_args(list(args)), lib) def run_with_output(self, *args): with capture_stdout() as out: self.run_command(*args) return util.text_string(out.getvalue()) # Safe file operations def create_temp_dir(self): """Create a temporary directory and assign it into `self.temp_dir`. Call `remove_temp_dir` later to delete it. """ temp_dir = mkdtemp() self.temp_dir = util.bytestring_path(temp_dir) def remove_temp_dir(self): """Delete the temporary directory created by `create_temp_dir`. """ shutil.rmtree(self.temp_dir) def touch(self, path, dir=None, content=''): """Create a file at `path` with given content. If `dir` is given, it is prepended to `path`. After that, if the path is relative, it is resolved with respect to `self.temp_dir`. """ if dir: path = os.path.join(dir, path) if not os.path.isabs(path): path = os.path.join(self.temp_dir, path) parent = os.path.dirname(path) if not os.path.isdir(parent): os.makedirs(util.syspath(parent)) with open(util.syspath(path), 'a+') as f: f.write(content) return path class TestImportSession(importer.ImportSession): """ImportSession that can be controlled programaticaly. >>> lib = Library(':memory:') >>> importer = TestImportSession(lib, paths=['/path/to/import']) >>> importer.add_choice(importer.action.SKIP) >>> importer.add_choice(importer.action.ASIS) >>> importer.default_choice = importer.action.APPLY >>> importer.run() This imports ``/path/to/import`` into `lib`. It skips the first album and imports thesecond one with metadata from the tags. For the remaining albums, the metadata from the autotagger will be applied. """ def __init__(self, *args, **kwargs): super(TestImportSession, self).__init__(*args, **kwargs) self._choices = [] self._resolutions = [] default_choice = importer.action.APPLY def add_choice(self, choice): self._choices.append(choice) def clear_choices(self): self._choices = [] def choose_match(self, task): try: choice = self._choices.pop(0) except IndexError: choice = self.default_choice if choice == importer.action.APPLY: return task.candidates[0] elif isinstance(choice, int): return task.candidates[choice - 1] else: return choice choose_item = choose_match Resolution = Enum('Resolution', 'REMOVE SKIP KEEPBOTH MERGE') default_resolution = 'REMOVE' def add_resolution(self, resolution): assert isinstance(resolution, self.Resolution) self._resolutions.append(resolution) def resolve_duplicate(self, task, found_duplicates): try: res = self._resolutions.pop(0) except IndexError: res = self.default_resolution if res == self.Resolution.SKIP: task.set_choice(importer.action.SKIP) elif res == self.Resolution.REMOVE: task.should_remove_duplicates = True elif res == self.Resolution.MERGE: task.should_merge_duplicates = True def generate_album_info(album_id, track_ids): """Return `AlbumInfo` populated with mock data. Sets the album info's `album_id` field is set to the corresponding argument. For each value in `track_ids` the `TrackInfo` from `generate_track_info` is added to the album info's `tracks` field. Most other fields of the album and track info are set to "album info" and "track info", respectively. """ tracks = [generate_track_info(id) for id in track_ids] album = AlbumInfo( album_id=u'album info', album=u'album info', artist=u'album info', artist_id=u'album info', tracks=tracks, ) for field in ALBUM_INFO_FIELDS: setattr(album, field, u'album info') return album ALBUM_INFO_FIELDS = ['album', 'album_id', 'artist', 'artist_id', 'asin', 'albumtype', 'va', 'label', 'artist_sort', 'releasegroup_id', 'catalognum', 'language', 'country', 'albumstatus', 'media', 'albumdisambig', 'artist_credit', 'data_source', 'data_url'] def generate_track_info(track_id='track info', values={}): """Return `TrackInfo` populated with mock data. The `track_id` field is set to the corresponding argument. All other string fields are set to "track info". """ track = TrackInfo( title=u'track info', track_id=track_id, ) for field in TRACK_INFO_FIELDS: setattr(track, field, u'track info') for field, value in values.items(): setattr(track, field, value) return track TRACK_INFO_FIELDS = ['artist', 'artist_id', 'artist_sort', 'disctitle', 'artist_credit', 'data_source', 'data_url'] beets-1.4.6/test/test_query.py0000644000076500000240000011003513122501062017315 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Various tests for querying the library database. """ from __future__ import division, absolute_import, print_function from functools import partial from mock import patch import os import sys import unittest from test import _common from test import helper import beets.library from beets import dbcore from beets.dbcore import types from beets.dbcore.query import (NoneQuery, ParsingError, InvalidQueryArgumentValueError) from beets.library import Library, Item from beets import util import platform import six class TestHelper(helper.TestHelper): def assertInResult(self, item, results): # noqa result_ids = [i.id for i in results] self.assertIn(item.id, result_ids) def assertNotInResult(self, item, results): # noqa result_ids = [i.id for i in results] self.assertNotIn(item.id, result_ids) class AnyFieldQueryTest(_common.LibTestCase): def test_no_restriction(self): q = dbcore.query.AnyFieldQuery( 'title', beets.library.Item._fields.keys(), dbcore.query.SubstringQuery ) self.assertEqual(self.lib.items(q).get().title, 'the title') def test_restriction_completeness(self): q = dbcore.query.AnyFieldQuery('title', [u'title'], dbcore.query.SubstringQuery) self.assertEqual(self.lib.items(q).get().title, u'the title') def test_restriction_soundness(self): q = dbcore.query.AnyFieldQuery('title', [u'artist'], dbcore.query.SubstringQuery) self.assertEqual(self.lib.items(q).get(), None) def test_eq(self): q1 = dbcore.query.AnyFieldQuery('foo', [u'bar'], dbcore.query.SubstringQuery) q2 = dbcore.query.AnyFieldQuery('foo', [u'bar'], dbcore.query.SubstringQuery) self.assertEqual(q1, q2) q2.query_class = None self.assertNotEqual(q1, q2) class AssertsMixin(object): def assert_items_matched(self, results, titles): self.assertEqual(set([i.title for i in results]), set(titles)) def assert_albums_matched(self, results, albums): self.assertEqual(set([a.album for a in results]), set(albums)) # A test case class providing a library with some dummy data and some # assertions involving that data. class DummyDataTestCase(_common.TestCase, AssertsMixin): def setUp(self): super(DummyDataTestCase, self).setUp() self.lib = beets.library.Library(':memory:') items = [_common.item() for _ in range(3)] items[0].title = u'foo bar' items[0].artist = u'one' items[0].album = u'baz' items[0].year = 2001 items[0].comp = True items[1].title = u'baz qux' items[1].artist = u'two' items[1].album = u'baz' items[1].year = 2002 items[1].comp = True items[2].title = u'beets 4 eva' items[2].artist = u'three' items[2].album = u'foo' items[2].year = 2003 items[2].comp = False for item in items: self.lib.add(item) self.lib.add_album(items[:2]) def assert_items_matched_all(self, results): self.assert_items_matched(results, [ u'foo bar', u'baz qux', u'beets 4 eva', ]) class GetTest(DummyDataTestCase): def test_get_empty(self): q = u'' results = self.lib.items(q) self.assert_items_matched_all(results) def test_get_none(self): q = None results = self.lib.items(q) self.assert_items_matched_all(results) def test_get_one_keyed_term(self): q = u'title:qux' results = self.lib.items(q) self.assert_items_matched(results, [u'baz qux']) def test_get_one_keyed_regexp(self): q = u'artist::t.+r' results = self.lib.items(q) self.assert_items_matched(results, [u'beets 4 eva']) def test_get_one_unkeyed_term(self): q = u'three' results = self.lib.items(q) self.assert_items_matched(results, [u'beets 4 eva']) def test_get_one_unkeyed_regexp(self): q = u':x$' results = self.lib.items(q) self.assert_items_matched(results, [u'baz qux']) def test_get_no_matches(self): q = u'popebear' results = self.lib.items(q) self.assert_items_matched(results, []) def test_invalid_key(self): q = u'pope:bear' results = self.lib.items(q) # Matches nothing since the flexattr is not present on the # objects. self.assert_items_matched(results, []) def test_term_case_insensitive(self): q = u'oNE' results = self.lib.items(q) self.assert_items_matched(results, [u'foo bar']) def test_regexp_case_sensitive(self): q = u':oNE' results = self.lib.items(q) self.assert_items_matched(results, []) q = u':one' results = self.lib.items(q) self.assert_items_matched(results, [u'foo bar']) def test_term_case_insensitive_with_key(self): q = u'artist:thrEE' results = self.lib.items(q) self.assert_items_matched(results, [u'beets 4 eva']) def test_key_case_insensitive(self): q = u'ArTiST:three' results = self.lib.items(q) self.assert_items_matched(results, [u'beets 4 eva']) def test_unkeyed_term_matches_multiple_columns(self): q = u'baz' results = self.lib.items(q) self.assert_items_matched(results, [ u'foo bar', u'baz qux', ]) def test_unkeyed_regexp_matches_multiple_columns(self): q = u':z$' results = self.lib.items(q) self.assert_items_matched(results, [ u'foo bar', u'baz qux', ]) def test_keyed_term_matches_only_one_column(self): q = u'title:baz' results = self.lib.items(q) self.assert_items_matched(results, [u'baz qux']) def test_keyed_regexp_matches_only_one_column(self): q = u'title::baz' results = self.lib.items(q) self.assert_items_matched(results, [ u'baz qux', ]) def test_multiple_terms_narrow_search(self): q = u'qux baz' results = self.lib.items(q) self.assert_items_matched(results, [ u'baz qux', ]) def test_multiple_regexps_narrow_search(self): q = u':baz :qux' results = self.lib.items(q) self.assert_items_matched(results, [u'baz qux']) def test_mixed_terms_regexps_narrow_search(self): q = u':baz qux' results = self.lib.items(q) self.assert_items_matched(results, [u'baz qux']) def test_single_year(self): q = u'year:2001' results = self.lib.items(q) self.assert_items_matched(results, [u'foo bar']) def test_year_range(self): q = u'year:2000..2002' results = self.lib.items(q) self.assert_items_matched(results, [ u'foo bar', u'baz qux', ]) def test_singleton_true(self): q = u'singleton:true' results = self.lib.items(q) self.assert_items_matched(results, [u'beets 4 eva']) def test_singleton_false(self): q = u'singleton:false' results = self.lib.items(q) self.assert_items_matched(results, [u'foo bar', u'baz qux']) def test_compilation_true(self): q = u'comp:true' results = self.lib.items(q) self.assert_items_matched(results, [u'foo bar', u'baz qux']) def test_compilation_false(self): q = u'comp:false' results = self.lib.items(q) self.assert_items_matched(results, [u'beets 4 eva']) def test_unknown_field_name_no_results(self): q = u'xyzzy:nonsense' results = self.lib.items(q) titles = [i.title for i in results] self.assertEqual(titles, []) def test_unknown_field_name_no_results_in_album_query(self): q = u'xyzzy:nonsense' results = self.lib.albums(q) names = [a.album for a in results] self.assertEqual(names, []) def test_item_field_name_matches_nothing_in_album_query(self): q = u'format:nonsense' results = self.lib.albums(q) names = [a.album for a in results] self.assertEqual(names, []) def test_unicode_query(self): item = self.lib.items().get() item.title = u'caf\xe9' item.store() q = u'title:caf\xe9' results = self.lib.items(q) self.assert_items_matched(results, [u'caf\xe9']) def test_numeric_search_positive(self): q = dbcore.query.NumericQuery('year', u'2001') results = self.lib.items(q) self.assertTrue(results) def test_numeric_search_negative(self): q = dbcore.query.NumericQuery('year', u'1999') results = self.lib.items(q) self.assertFalse(results) def test_invalid_query(self): with self.assertRaises(InvalidQueryArgumentValueError) as raised: dbcore.query.NumericQuery('year', u'199a') self.assertIn(u'not an int', six.text_type(raised.exception)) with self.assertRaises(InvalidQueryArgumentValueError) as raised: dbcore.query.RegexpQuery('year', u'199(') exception_text = six.text_type(raised.exception) self.assertIn(u'not a regular expression', exception_text) if sys.version_info >= (3, 5): self.assertIn(u'unterminated subpattern', exception_text) else: self.assertIn(u'unbalanced parenthesis', exception_text) self.assertIsInstance(raised.exception, ParsingError) class MatchTest(_common.TestCase): def setUp(self): super(MatchTest, self).setUp() self.item = _common.item() def test_regex_match_positive(self): q = dbcore.query.RegexpQuery('album', u'^the album$') self.assertTrue(q.match(self.item)) def test_regex_match_negative(self): q = dbcore.query.RegexpQuery('album', u'^album$') self.assertFalse(q.match(self.item)) def test_regex_match_non_string_value(self): q = dbcore.query.RegexpQuery('disc', u'^6$') self.assertTrue(q.match(self.item)) def test_substring_match_positive(self): q = dbcore.query.SubstringQuery('album', u'album') self.assertTrue(q.match(self.item)) def test_substring_match_negative(self): q = dbcore.query.SubstringQuery('album', u'ablum') self.assertFalse(q.match(self.item)) def test_substring_match_non_string_value(self): q = dbcore.query.SubstringQuery('disc', u'6') self.assertTrue(q.match(self.item)) def test_year_match_positive(self): q = dbcore.query.NumericQuery('year', u'1') self.assertTrue(q.match(self.item)) def test_year_match_negative(self): q = dbcore.query.NumericQuery('year', u'10') self.assertFalse(q.match(self.item)) def test_bitrate_range_positive(self): q = dbcore.query.NumericQuery('bitrate', u'100000..200000') self.assertTrue(q.match(self.item)) def test_bitrate_range_negative(self): q = dbcore.query.NumericQuery('bitrate', u'200000..300000') self.assertFalse(q.match(self.item)) def test_open_range(self): dbcore.query.NumericQuery('bitrate', u'100000..') def test_eq(self): q1 = dbcore.query.MatchQuery('foo', u'bar') q2 = dbcore.query.MatchQuery('foo', u'bar') q3 = dbcore.query.MatchQuery('foo', u'baz') q4 = dbcore.query.StringFieldQuery('foo', u'bar') self.assertEqual(q1, q2) self.assertNotEqual(q1, q3) self.assertNotEqual(q1, q4) self.assertNotEqual(q3, q4) class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): def setUp(self): super(PathQueryTest, self).setUp() # This is the item we'll try to match. self.i.path = util.normpath('/a/b/c.mp3') self.i.title = u'path item' self.i.album = u'path album' self.i.store() self.lib.add_album([self.i]) # A second item for testing exclusion. i2 = _common.item() i2.path = util.normpath('/x/y/z.mp3') i2.title = 'another item' i2.album = 'another album' self.lib.add(i2) self.lib.add_album([i2]) # Unadorned path queries with path separators in them are considered # path queries only when the path in question actually exists. So we # mock the existence check to return true. self.patcher_exists = patch('beets.library.os.path.exists') self.patcher_exists.start().return_value = True # We have to create function samefile as it does not exist on # Windows and python 2.7 self.patcher_samefile = patch('beets.library.os.path.samefile', create=True) self.patcher_samefile.start().return_value = True def tearDown(self): super(PathQueryTest, self).tearDown() self.patcher_samefile.stop() self.patcher_exists.stop() def test_path_exact_match(self): q = u'path:/a/b/c.mp3' results = self.lib.items(q) self.assert_items_matched(results, [u'path item']) results = self.lib.albums(q) self.assert_albums_matched(results, []) def test_parent_directory_no_slash(self): q = u'path:/a' results = self.lib.items(q) self.assert_items_matched(results, [u'path item']) results = self.lib.albums(q) self.assert_albums_matched(results, [u'path album']) def test_parent_directory_with_slash(self): q = u'path:/a/' results = self.lib.items(q) self.assert_items_matched(results, [u'path item']) results = self.lib.albums(q) self.assert_albums_matched(results, [u'path album']) def test_no_match(self): q = u'path:/xyzzy/' results = self.lib.items(q) self.assert_items_matched(results, []) results = self.lib.albums(q) self.assert_albums_matched(results, []) def test_fragment_no_match(self): q = u'path:/b/' results = self.lib.items(q) self.assert_items_matched(results, []) results = self.lib.albums(q) self.assert_albums_matched(results, []) def test_nonnorm_path(self): q = u'path:/x/../a/b' results = self.lib.items(q) self.assert_items_matched(results, [u'path item']) results = self.lib.albums(q) self.assert_albums_matched(results, [u'path album']) def test_slashed_query_matches_path(self): q = u'/a/b' results = self.lib.items(q) self.assert_items_matched(results, [u'path item']) results = self.lib.albums(q) self.assert_albums_matched(results, [u'path album']) @unittest.skip('unfixed (#1865)') def test_path_query_in_or_query(self): q = '/a/b , /a/b' results = self.lib.items(q) self.assert_items_matched(results, ['path item']) def test_non_slashed_does_not_match_path(self): q = u'c.mp3' results = self.lib.items(q) self.assert_items_matched(results, []) results = self.lib.albums(q) self.assert_albums_matched(results, []) def test_slashes_in_explicit_field_does_not_match_path(self): q = u'title:/a/b' results = self.lib.items(q) self.assert_items_matched(results, []) def test_path_item_regex(self): q = u'path::c\\.mp3$' results = self.lib.items(q) self.assert_items_matched(results, [u'path item']) def test_path_album_regex(self): q = u'path::b' results = self.lib.albums(q) self.assert_albums_matched(results, [u'path album']) def test_escape_underscore(self): self.add_album(path=b'/a/_/title.mp3', title=u'with underscore', album=u'album with underscore') q = u'path:/a/_' results = self.lib.items(q) self.assert_items_matched(results, [u'with underscore']) results = self.lib.albums(q) self.assert_albums_matched(results, [u'album with underscore']) def test_escape_percent(self): self.add_album(path=b'/a/%/title.mp3', title=u'with percent', album=u'album with percent') q = u'path:/a/%' results = self.lib.items(q) self.assert_items_matched(results, [u'with percent']) results = self.lib.albums(q) self.assert_albums_matched(results, [u'album with percent']) def test_escape_backslash(self): self.add_album(path=br'/a/\x/title.mp3', title=u'with backslash', album=u'album with backslash') q = u'path:/a/\\\\x' results = self.lib.items(q) self.assert_items_matched(results, [u'with backslash']) results = self.lib.albums(q) self.assert_albums_matched(results, [u'album with backslash']) def test_case_sensitivity(self): self.add_album(path=b'/A/B/C2.mp3', title=u'caps path') makeq = partial(beets.library.PathQuery, u'path', '/A/B') results = self.lib.items(makeq(case_sensitive=True)) self.assert_items_matched(results, [u'caps path']) results = self.lib.items(makeq(case_sensitive=False)) self.assert_items_matched(results, [u'path item', u'caps path']) # Check for correct case sensitivity selection (this check # only works on non-Windows OSes). with _common.system_mock('Darwin'): # exists = True and samefile = True => Case insensitive q = makeq() self.assertEqual(q.case_sensitive, False) # exists = True and samefile = False => Case sensitive self.patcher_samefile.stop() self.patcher_samefile.start().return_value = False try: q = makeq() self.assertEqual(q.case_sensitive, True) finally: self.patcher_samefile.stop() self.patcher_samefile.start().return_value = True # Test platform-aware default sensitivity when the library path # does not exist. For the duration of this check, we change the # `os.path.exists` mock to return False. self.patcher_exists.stop() self.patcher_exists.start().return_value = False try: with _common.system_mock('Darwin'): q = makeq() self.assertEqual(q.case_sensitive, True) with _common.system_mock('Windows'): q = makeq() self.assertEqual(q.case_sensitive, False) finally: # Restore the `os.path.exists` mock to its original state. self.patcher_exists.stop() self.patcher_exists.start().return_value = True @patch('beets.library.os') def test_path_sep_detection(self, mock_os): mock_os.sep = '/' mock_os.altsep = None mock_os.path.exists = lambda p: True is_path = beets.library.PathQuery.is_path_query self.assertTrue(is_path('/foo/bar')) self.assertTrue(is_path('foo/bar')) self.assertTrue(is_path('foo/')) self.assertFalse(is_path('foo')) self.assertTrue(is_path('foo/:bar')) self.assertFalse(is_path('foo:bar/')) self.assertFalse(is_path('foo:/bar')) def test_detect_absolute_path(self): if platform.system() == 'Windows': # Because the absolute path begins with something like C:, we # can't disambiguate it from an ordinary query. self.skipTest('Windows absolute paths do not work as queries') # Don't patch `os.path.exists`; we'll actually create a file when # it exists. self.patcher_exists.stop() is_path = beets.library.PathQuery.is_path_query try: path = self.touch(os.path.join(b'foo', b'bar')) path = path.decode('utf-8') # The file itself. self.assertTrue(is_path(path)) # The parent directory. parent = os.path.dirname(path) self.assertTrue(is_path(parent)) # Some non-existent path. self.assertFalse(is_path(path + u'baz')) finally: # Restart the `os.path.exists` patch. self.patcher_exists.start() def test_detect_relative_path(self): self.patcher_exists.stop() is_path = beets.library.PathQuery.is_path_query try: self.touch(os.path.join(b'foo', b'bar')) # Temporarily change directory so relative paths work. cur_dir = os.getcwd() try: os.chdir(self.temp_dir) self.assertTrue(is_path(u'foo/')) self.assertTrue(is_path(u'foo/bar')) self.assertTrue(is_path(u'foo/bar:tagada')) self.assertFalse(is_path(u'bar')) finally: os.chdir(cur_dir) finally: self.patcher_exists.start() class IntQueryTest(unittest.TestCase, TestHelper): def setUp(self): self.lib = Library(':memory:') def tearDown(self): Item._types = {} def test_exact_value_match(self): item = self.add_item(bpm=120) matched = self.lib.items(u'bpm:120').get() self.assertEqual(item.id, matched.id) def test_range_match(self): item = self.add_item(bpm=120) self.add_item(bpm=130) matched = self.lib.items(u'bpm:110..125') self.assertEqual(1, len(matched)) self.assertEqual(item.id, matched.get().id) def test_flex_range_match(self): Item._types = {'myint': types.Integer()} item = self.add_item(myint=2) matched = self.lib.items(u'myint:2').get() self.assertEqual(item.id, matched.id) def test_flex_dont_match_missing(self): Item._types = {'myint': types.Integer()} self.add_item() matched = self.lib.items(u'myint:2').get() self.assertIsNone(matched) def test_no_substring_match(self): self.add_item(bpm=120) matched = self.lib.items(u'bpm:12').get() self.assertIsNone(matched) class BoolQueryTest(unittest.TestCase, TestHelper): def setUp(self): self.lib = Library(':memory:') Item._types = {'flexbool': types.Boolean()} def tearDown(self): Item._types = {} def test_parse_true(self): item_true = self.add_item(comp=True) item_false = self.add_item(comp=False) matched = self.lib.items(u'comp:true') self.assertInResult(item_true, matched) self.assertNotInResult(item_false, matched) def test_flex_parse_true(self): item_true = self.add_item(flexbool=True) item_false = self.add_item(flexbool=False) matched = self.lib.items(u'flexbool:true') self.assertInResult(item_true, matched) self.assertNotInResult(item_false, matched) def test_flex_parse_false(self): item_true = self.add_item(flexbool=True) item_false = self.add_item(flexbool=False) matched = self.lib.items(u'flexbool:false') self.assertInResult(item_false, matched) self.assertNotInResult(item_true, matched) def test_flex_parse_1(self): item_true = self.add_item(flexbool=True) item_false = self.add_item(flexbool=False) matched = self.lib.items(u'flexbool:1') self.assertInResult(item_true, matched) self.assertNotInResult(item_false, matched) def test_flex_parse_0(self): item_true = self.add_item(flexbool=True) item_false = self.add_item(flexbool=False) matched = self.lib.items(u'flexbool:0') self.assertInResult(item_false, matched) self.assertNotInResult(item_true, matched) def test_flex_parse_any_string(self): # TODO this should be the other way around item_true = self.add_item(flexbool=True) item_false = self.add_item(flexbool=False) matched = self.lib.items(u'flexbool:something') self.assertInResult(item_false, matched) self.assertNotInResult(item_true, matched) class DefaultSearchFieldsTest(DummyDataTestCase): def test_albums_matches_album(self): albums = list(self.lib.albums(u'baz')) self.assertEqual(len(albums), 1) def test_albums_matches_albumartist(self): albums = list(self.lib.albums([u'album artist'])) self.assertEqual(len(albums), 1) def test_items_matches_title(self): items = self.lib.items(u'beets') self.assert_items_matched(items, [u'beets 4 eva']) def test_items_does_not_match_year(self): items = self.lib.items(u'2001') self.assert_items_matched(items, []) class NoneQueryTest(unittest.TestCase, TestHelper): def setUp(self): self.lib = Library(':memory:') def test_match_singletons(self): singleton = self.add_item() album_item = self.add_album().items().get() matched = self.lib.items(NoneQuery(u'album_id')) self.assertInResult(singleton, matched) self.assertNotInResult(album_item, matched) def test_match_after_set_none(self): item = self.add_item(rg_track_gain=0) matched = self.lib.items(NoneQuery(u'rg_track_gain')) self.assertNotInResult(item, matched) item['rg_track_gain'] = None item.store() matched = self.lib.items(NoneQuery(u'rg_track_gain')) self.assertInResult(item, matched) class NotQueryMatchTest(_common.TestCase): """Test `query.NotQuery` matching against a single item, using the same cases and assertions as on `MatchTest`, plus assertion on the negated queries (ie. assertTrue(q) -> assertFalse(NotQuery(q))). """ def setUp(self): super(NotQueryMatchTest, self).setUp() self.item = _common.item() def test_regex_match_positive(self): q = dbcore.query.RegexpQuery(u'album', u'^the album$') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_regex_match_negative(self): q = dbcore.query.RegexpQuery(u'album', u'^album$') self.assertFalse(q.match(self.item)) self.assertTrue(dbcore.query.NotQuery(q).match(self.item)) def test_regex_match_non_string_value(self): q = dbcore.query.RegexpQuery(u'disc', u'^6$') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_substring_match_positive(self): q = dbcore.query.SubstringQuery(u'album', u'album') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_substring_match_negative(self): q = dbcore.query.SubstringQuery(u'album', u'ablum') self.assertFalse(q.match(self.item)) self.assertTrue(dbcore.query.NotQuery(q).match(self.item)) def test_substring_match_non_string_value(self): q = dbcore.query.SubstringQuery(u'disc', u'6') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_year_match_positive(self): q = dbcore.query.NumericQuery(u'year', u'1') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_year_match_negative(self): q = dbcore.query.NumericQuery(u'year', u'10') self.assertFalse(q.match(self.item)) self.assertTrue(dbcore.query.NotQuery(q).match(self.item)) def test_bitrate_range_positive(self): q = dbcore.query.NumericQuery(u'bitrate', u'100000..200000') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_bitrate_range_negative(self): q = dbcore.query.NumericQuery(u'bitrate', u'200000..300000') self.assertFalse(q.match(self.item)) self.assertTrue(dbcore.query.NotQuery(q).match(self.item)) def test_open_range(self): q = dbcore.query.NumericQuery(u'bitrate', u'100000..') dbcore.query.NotQuery(q) class NotQueryTest(DummyDataTestCase): """Test `query.NotQuery` against the dummy data: - `test_type_xxx`: tests for the negation of a particular XxxQuery class. - `test_get_yyy`: tests on query strings (similar to `GetTest`) """ def assertNegationProperties(self, q): # noqa """Given a Query `q`, assert that: - q OR not(q) == all items - q AND not(q) == 0 - not(not(q)) == q """ not_q = dbcore.query.NotQuery(q) # assert using OrQuery, AndQuery q_or = dbcore.query.OrQuery([q, not_q]) q_and = dbcore.query.AndQuery([q, not_q]) self.assert_items_matched_all(self.lib.items(q_or)) self.assert_items_matched(self.lib.items(q_and), []) # assert manually checking the item titles all_titles = set([i.title for i in self.lib.items()]) q_results = set([i.title for i in self.lib.items(q)]) not_q_results = set([i.title for i in self.lib.items(not_q)]) self.assertEqual(q_results.union(not_q_results), all_titles) self.assertEqual(q_results.intersection(not_q_results), set()) # round trip not_not_q = dbcore.query.NotQuery(not_q) self.assertEqual(set([i.title for i in self.lib.items(q)]), set([i.title for i in self.lib.items(not_not_q)])) def test_type_and(self): # not(a and b) <-> not(a) or not(b) q = dbcore.query.AndQuery([ dbcore.query.BooleanQuery(u'comp', True), dbcore.query.NumericQuery(u'year', u'2002')], ) not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, [u'foo bar', u'beets 4 eva']) self.assertNegationProperties(q) def test_type_anyfield(self): q = dbcore.query.AnyFieldQuery(u'foo', [u'title', u'artist', u'album'], dbcore.query.SubstringQuery) not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, [u'baz qux']) self.assertNegationProperties(q) def test_type_boolean(self): q = dbcore.query.BooleanQuery(u'comp', True) not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, [u'beets 4 eva']) self.assertNegationProperties(q) def test_type_date(self): q = dbcore.query.DateQuery(u'added', u'2000-01-01') not_results = self.lib.items(dbcore.query.NotQuery(q)) # query date is in the past, thus the 'not' results should contain all # items self.assert_items_matched(not_results, [u'foo bar', u'baz qux', u'beets 4 eva']) self.assertNegationProperties(q) def test_type_false(self): q = dbcore.query.FalseQuery() not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched_all(not_results) self.assertNegationProperties(q) def test_type_match(self): q = dbcore.query.MatchQuery(u'year', u'2003') not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, [u'foo bar', u'baz qux']) self.assertNegationProperties(q) def test_type_none(self): q = dbcore.query.NoneQuery(u'rg_track_gain') not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, []) self.assertNegationProperties(q) def test_type_numeric(self): q = dbcore.query.NumericQuery(u'year', u'2001..2002') not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, [u'beets 4 eva']) self.assertNegationProperties(q) def test_type_or(self): # not(a or b) <-> not(a) and not(b) q = dbcore.query.OrQuery([dbcore.query.BooleanQuery(u'comp', True), dbcore.query.NumericQuery(u'year', u'2002')]) not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, [u'beets 4 eva']) self.assertNegationProperties(q) def test_type_regexp(self): q = dbcore.query.RegexpQuery(u'artist', u'^t') not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, [u'foo bar']) self.assertNegationProperties(q) def test_type_substring(self): q = dbcore.query.SubstringQuery(u'album', u'ba') not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, [u'beets 4 eva']) self.assertNegationProperties(q) def test_type_true(self): q = dbcore.query.TrueQuery() not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, []) self.assertNegationProperties(q) def test_get_prefixes_keyed(self): """Test both negation prefixes on a keyed query.""" q0 = u'-title:qux' q1 = u'^title:qux' results0 = self.lib.items(q0) results1 = self.lib.items(q1) self.assert_items_matched(results0, [u'foo bar', u'beets 4 eva']) self.assert_items_matched(results1, [u'foo bar', u'beets 4 eva']) def test_get_prefixes_unkeyed(self): """Test both negation prefixes on an unkeyed query.""" q0 = u'-qux' q1 = u'^qux' results0 = self.lib.items(q0) results1 = self.lib.items(q1) self.assert_items_matched(results0, [u'foo bar', u'beets 4 eva']) self.assert_items_matched(results1, [u'foo bar', u'beets 4 eva']) def test_get_one_keyed_regexp(self): q = u'-artist::t.+r' results = self.lib.items(q) self.assert_items_matched(results, [u'foo bar', u'baz qux']) def test_get_one_unkeyed_regexp(self): q = u'-:x$' results = self.lib.items(q) self.assert_items_matched(results, [u'foo bar', u'beets 4 eva']) def test_get_multiple_terms(self): q = u'baz -bar' results = self.lib.items(q) self.assert_items_matched(results, [u'baz qux']) def test_get_mixed_terms(self): q = u'baz -title:bar' results = self.lib.items(q) self.assert_items_matched(results, [u'baz qux']) def test_fast_vs_slow(self): """Test that the results are the same regardless of the `fast` flag for negated `FieldQuery`s. TODO: investigate NoneQuery(fast=False), as it is raising AttributeError: type object 'NoneQuery' has no attribute 'field' at NoneQuery.match() (due to being @classmethod, and no self?) """ classes = [(dbcore.query.DateQuery, [u'added', u'2001-01-01']), (dbcore.query.MatchQuery, [u'artist', u'one']), # (dbcore.query.NoneQuery, ['rg_track_gain']), (dbcore.query.NumericQuery, [u'year', u'2002']), (dbcore.query.StringFieldQuery, [u'year', u'2001']), (dbcore.query.RegexpQuery, [u'album', u'^.a']), (dbcore.query.SubstringQuery, [u'title', u'x'])] for klass, args in classes: q_fast = dbcore.query.NotQuery(klass(*(args + [True]))) q_slow = dbcore.query.NotQuery(klass(*(args + [False]))) try: self.assertEqual([i.title for i in self.lib.items(q_fast)], [i.title for i in self.lib.items(q_slow)]) except NotImplementedError: # ignore classes that do not provide `fast` implementation pass def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/lyrics_download_samples.py0000644000076500000240000000324113025125202022031 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Fabrice Laporte # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import os import sys import requests import test_lyrics def mkdir_p(path): try: os.makedirs(path) except OSError: if os.path.isdir(path): pass else: raise def safe_open_w(path): """Open "path" for writing, creating any parent directories as needed. """ mkdir_p(os.path.dirname(path)) return open(path, 'w') def main(argv=None): """Download one lyrics sample page per referenced source. """ if argv is None: argv = sys.argv print(u'Fetching samples from:') for s in test_lyrics.GOOGLE_SOURCES + test_lyrics.DEFAULT_SOURCES: print(s['url']) url = s['url'] + s['path'] fn = test_lyrics.url_to_filename(url) if not os.path.isfile(fn): html = requests.get(url, verify=False).text with safe_open_w(fn) as f: f.write(html.encode('utf-8')) if __name__ == "__main__": sys.exit(main()) beets-1.4.6/test/test_hidden.py0000644000076500000240000000516413025125203017412 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'hidden' utility.""" from __future__ import division, absolute_import, print_function import unittest import sys import tempfile from beets.util import hidden from beets import util import subprocess import errno import ctypes class HiddenFileTest(unittest.TestCase): def setUp(self): pass def test_osx_hidden(self): if not sys.platform == 'darwin': self.skipTest('sys.platform is not darwin') return with tempfile.NamedTemporaryFile(delete=False) as f: try: command = ["chflags", "hidden", f.name] subprocess.Popen(command).wait() except OSError as e: if e.errno == errno.ENOENT: self.skipTest("unable to find chflags") else: raise e self.assertTrue(hidden.is_hidden(f.name)) def test_windows_hidden(self): if not sys.platform == 'win32': self.skipTest('sys.platform is not windows') return # FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation. hidden_mask = 2 with tempfile.NamedTemporaryFile() as f: # Hide the file using success = ctypes.windll.kernel32.SetFileAttributesW(f.name, hidden_mask) if not success: self.skipTest("unable to set file attributes") self.assertTrue(hidden.is_hidden(f.name)) def test_other_hidden(self): if sys.platform == 'darwin' or sys.platform == 'win32': self.skipTest('sys.platform is known') return with tempfile.NamedTemporaryFile(prefix='.tmp') as f: fn = util.bytestring_path(f.name) self.assertTrue(hidden.is_hidden(fn)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_replaygain.py0000644000076500000240000001350013120341455020310 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Thomas Scholtes # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import unittest import six from test.helper import TestHelper, has_program from beets import config from beets.mediafile import MediaFile from beetsplug.replaygain import (FatalGstreamerPluginReplayGainError, GStreamerBackend) try: import gi gi.require_version('Gst', '1.0') GST_AVAILABLE = True except (ImportError, ValueError): GST_AVAILABLE = False if any(has_program(cmd, ['-v']) for cmd in ['mp3gain', 'aacgain']): GAIN_PROG_AVAILABLE = True else: GAIN_PROG_AVAILABLE = False if has_program('bs1770gain', ['--replaygain']): LOUDNESS_PROG_AVAILABLE = True else: LOUDNESS_PROG_AVAILABLE = False class ReplayGainCliTestBase(TestHelper): def setUp(self): self.setup_beets() self.config['replaygain']['backend'] = self.backend try: self.load_plugins('replaygain') except Exception: import sys # store exception info so an error in teardown does not swallow it exc_info = sys.exc_info() try: self.teardown_beets() self.unload_plugins() except Exception: # if load_plugins() failed then setup is incomplete and # teardown operations may fail. In particular # {Item,Album} # may not have the _original_types attribute in unload_plugins pass six.reraise(exc_info[1], None, exc_info[2]) album = self.add_album_fixture(2) for item in album.items(): self._reset_replaygain(item) def tearDown(self): self.teardown_beets() self.unload_plugins() def _reset_replaygain(self, item): item['rg_track_peak'] = None item['rg_track_gain'] = None item['rg_album_gain'] = None item['rg_album_gain'] = None item.write() item.store() def test_cli_saves_track_gain(self): for item in self.lib.items(): self.assertIsNone(item.rg_track_peak) self.assertIsNone(item.rg_track_gain) mediafile = MediaFile(item.path) self.assertIsNone(mediafile.rg_track_peak) self.assertIsNone(mediafile.rg_track_gain) self.run_command('replaygain') # Skip the test if rg_track_peak and rg_track gain is None, assuming # that it could only happen if the decoder plugins are missing. if all(i.rg_track_peak is None and i.rg_track_gain is None for i in self.lib.items()): self.skipTest(u'decoder plugins could not be loaded.') for item in self.lib.items(): self.assertIsNotNone(item.rg_track_peak) self.assertIsNotNone(item.rg_track_gain) mediafile = MediaFile(item.path) self.assertAlmostEqual( mediafile.rg_track_peak, item.rg_track_peak, places=6) self.assertAlmostEqual( mediafile.rg_track_gain, item.rg_track_gain, places=2) def test_cli_skips_calculated_tracks(self): self.run_command(u'replaygain') item = self.lib.items()[0] peak = item.rg_track_peak item.rg_track_gain = 0.0 self.run_command(u'replaygain') self.assertEqual(item.rg_track_gain, 0.0) self.assertEqual(item.rg_track_peak, peak) def test_cli_saves_album_gain_to_file(self): for item in self.lib.items(): mediafile = MediaFile(item.path) self.assertIsNone(mediafile.rg_album_peak) self.assertIsNone(mediafile.rg_album_gain) self.run_command(u'replaygain', u'-a') peaks = [] gains = [] for item in self.lib.items(): mediafile = MediaFile(item.path) peaks.append(mediafile.rg_album_peak) gains.append(mediafile.rg_album_gain) # Make sure they are all the same self.assertEqual(max(peaks), min(peaks)) self.assertEqual(max(gains), min(gains)) self.assertNotEqual(max(gains), 0.0) self.assertNotEqual(max(peaks), 0.0) @unittest.skipIf(not GST_AVAILABLE, u'gstreamer cannot be found') class ReplayGainGstCliTest(ReplayGainCliTestBase, unittest.TestCase): backend = u'gstreamer' def setUp(self): try: # Check if required plugins can be loaded by instantiating a # GStreamerBackend (via its .__init__). config['replaygain']['targetlevel'] = 89 GStreamerBackend(config['replaygain'], None) except FatalGstreamerPluginReplayGainError as e: # Skip the test if plugins could not be loaded. self.skipTest(str(e)) super(ReplayGainGstCliTest, self).setUp() @unittest.skipIf(not GAIN_PROG_AVAILABLE, u'no *gain command found') class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase): backend = u'command' @unittest.skipIf(not LOUDNESS_PROG_AVAILABLE, u'bs1770gain cannot be found') class ReplayGainLdnsCliTest(ReplayGainCliTestBase, unittest.TestCase): backend = u'bs1770gain' def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_ui_commands.py0000644000076500000240000000732113025125203020452 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test module for file ui/commands.py """ from __future__ import division, absolute_import, print_function import os import shutil import unittest from test import _common from beets import library from beets import ui from beets.ui import commands class QueryTest(_common.TestCase): def setUp(self): super(QueryTest, self).setUp() self.libdir = os.path.join(self.temp_dir, b'testlibdir') os.mkdir(self.libdir) # Add a file to the library but don't copy it in yet. self.lib = library.Library(':memory:', self.libdir) # Alternate destination directory. self.otherdir = os.path.join(self.temp_dir, b'testotherdir') def add_item(self, filename=b'srcfile', templatefile=b'full.mp3'): itempath = os.path.join(self.libdir, filename) shutil.copy(os.path.join(_common.RSRC, templatefile), itempath) item = library.Item.from_path(itempath) self.lib.add(item) return item, itempath def add_album(self, items): album = self.lib.add_album(items) return album def check_do_query(self, num_items, num_albums, q=(), album=False, also_items=True): items, albums = commands._do_query( self.lib, q, album, also_items) self.assertEqual(len(items), num_items) self.assertEqual(len(albums), num_albums) def test_query_empty(self): with self.assertRaises(ui.UserError): commands._do_query(self.lib, (), False) def test_query_empty_album(self): with self.assertRaises(ui.UserError): commands._do_query(self.lib, (), True) def test_query_item(self): self.add_item() self.check_do_query(1, 0, album=False) self.add_item() self.check_do_query(2, 0, album=False) def test_query_album(self): item, itempath = self.add_item() self.add_album([item]) self.check_do_query(1, 1, album=True) self.check_do_query(0, 1, album=True, also_items=False) item, itempath = self.add_item() item2, itempath = self.add_item() self.add_album([item, item2]) self.check_do_query(3, 2, album=True) self.check_do_query(0, 2, album=True, also_items=False) class FieldsTest(_common.LibTestCase): def setUp(self): super(FieldsTest, self).setUp() self.io.install() def tearDown(self): self.io.restore() def remove_keys(self, l, text): for i in text: try: l.remove(i) except ValueError: pass def test_fields_func(self): commands.fields_func(self.lib, [], []) items = library.Item.all_keys() albums = library.Album.all_keys() output = self.io.stdout.get().split() self.remove_keys(items, output) self.remove_keys(albums, output) self.assertEqual(len(items), 0) self.assertEqual(len(albums), 0) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_mbsubmit.py0000644000076500000240000000524613025125203020002 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson and Diego Moreda. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import unittest from test.helper import capture_stdout, control_stdin, TestHelper from test.test_importer import ImportHelper, AutotagStub from test.test_ui_importer import TerminalImportSessionSetup class MBSubmitPluginTest(TerminalImportSessionSetup, unittest.TestCase, ImportHelper, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('mbsubmit') self._create_import_dir(2) self._setup_import_session() self.matcher = AutotagStub().install() def tearDown(self): self.unload_plugins() self.teardown_beets() self.matcher.restore() def test_print_tracks_output(self): """Test the output of the "print tracks" choice.""" self.matcher.matching = AutotagStub.BAD with capture_stdout() as output: with control_stdin('\n'.join(['p', 's'])): # Print tracks; Skip self.importer.run() # Manually build the string for comparing the output. tracklist = (u'Print tracks? ' u'01. Tag Title 1 - Tag Artist (0:01)\n' u'02. Tag Title 2 - Tag Artist (0:01)') self.assertIn(tracklist, output.getvalue()) def test_print_tracks_output_as_tracks(self): """Test the output of the "print tracks" choice, as singletons.""" self.matcher.matching = AutotagStub.BAD with capture_stdout() as output: with control_stdin('\n'.join(['t', 's', 'p', 's'])): # as Tracks; Skip; Print tracks; Skip self.importer.run() # Manually build the string for comparing the output. tracklist = (u'Print tracks? ' u'02. Tag Title 2 - Tag Artist (0:01)') self.assertIn(tracklist, output.getvalue()) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_types_plugin.py0000644000076500000240000001234313025125203020676 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import time from datetime import datetime import unittest from test.helper import TestHelper from beets.util.confit import ConfigValueError class TypesPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('types') def tearDown(self): self.unload_plugins() self.teardown_beets() def test_integer_modify_and_query(self): self.config['types'] = {'myint': 'int'} item = self.add_item(artist=u'aaa') # Do not match unset values out = self.list(u'myint:1..3') self.assertEqual(u'', out) self.modify(u'myint=2') item.load() self.assertEqual(item['myint'], 2) # Match in range out = self.list(u'myint:1..3') self.assertIn('aaa', out) def test_album_integer_modify_and_query(self): self.config['types'] = {'myint': u'int'} album = self.add_album(albumartist=u'aaa') # Do not match unset values out = self.list_album(u'myint:1..3') self.assertEqual(u'', out) self.modify(u'-a', u'myint=2') album.load() self.assertEqual(album['myint'], 2) # Match in range out = self.list_album(u'myint:1..3') self.assertIn('aaa', out) def test_float_modify_and_query(self): self.config['types'] = {'myfloat': u'float'} item = self.add_item(artist=u'aaa') # Do not match unset values out = self.list(u'myfloat:10..0') self.assertEqual(u'', out) self.modify(u'myfloat=-9.1') item.load() self.assertEqual(item['myfloat'], -9.1) # Match in range out = self.list(u'myfloat:-10..0') self.assertIn('aaa', out) def test_bool_modify_and_query(self): self.config['types'] = {'mybool': u'bool'} true = self.add_item(artist=u'true') false = self.add_item(artist=u'false') self.add_item(artist=u'unset') # Do not match unset values out = self.list(u'mybool:true, mybool:false') self.assertEqual(u'', out) # Set true self.modify(u'mybool=1', u'artist:true') true.load() self.assertEqual(true['mybool'], True) # Set false self.modify(u'mybool=false', u'artist:false') false.load() self.assertEqual(false['mybool'], False) # Query bools out = self.list(u'mybool:true', u'$artist $mybool') self.assertEqual(u'true True', out) out = self.list(u'mybool:false', u'$artist $mybool') # Dealing with unset fields? # self.assertEqual('false False', out) # out = self.list('mybool:', '$artist $mybool') # self.assertIn('unset $mybool', out) def test_date_modify_and_query(self): self.config['types'] = {'mydate': u'date'} # FIXME parsing should also work with default time format self.config['time_format'] = '%Y-%m-%d' old = self.add_item(artist=u'prince') new = self.add_item(artist=u'britney') # Do not match unset values out = self.list(u'mydate:..2000') self.assertEqual(u'', out) self.modify(u'mydate=1999-01-01', u'artist:prince') old.load() self.assertEqual(old['mydate'], mktime(1999, 1, 1)) self.modify(u'mydate=1999-12-30', u'artist:britney') new.load() self.assertEqual(new['mydate'], mktime(1999, 12, 30)) # Match in range out = self.list(u'mydate:..1999-07', u'$artist $mydate') self.assertEqual(u'prince 1999-01-01', out) # FIXME some sort of timezone issue here # out = self.list('mydate:1999-12-30', '$artist $mydate') # self.assertEqual('britney 1999-12-30', out) def test_unknown_type_error(self): self.config['types'] = {'flex': 'unkown type'} with self.assertRaises(ConfigValueError): self.run_command(u'ls') def modify(self, *args): return self.run_with_output(u'modify', u'--yes', u'--nowrite', u'--nomove', *args) def list(self, query, fmt=u'$artist - $album - $title'): return self.run_with_output(u'ls', u'-f', fmt, query).strip() def list_album(self, query, fmt=u'$albumartist - $album - $title'): return self.run_with_output(u'ls', u'-a', u'-f', fmt, query).strip() def mktime(*args): return time.mktime(datetime(*args).timetuple()) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_smartplaylist.py0000644000076500000240000002024413025125203021063 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Bruno Cauet. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function from os import path, remove from tempfile import mkdtemp from shutil import rmtree import unittest from mock import Mock, MagicMock from beetsplug.smartplaylist import SmartPlaylistPlugin from beets.library import Item, Album, parse_query_string from beets.dbcore import OrQuery from beets.dbcore.query import NullSort, MultipleSort, FixedFieldSort from beets.util import syspath, bytestring_path, py3_path, CHAR_REPLACE from beets.ui import UserError from beets import config from test.helper import TestHelper class SmartPlaylistTest(unittest.TestCase): def test_build_queries(self): spl = SmartPlaylistPlugin() self.assertEqual(spl._matched_playlists, None) self.assertEqual(spl._unmatched_playlists, None) config['smartplaylist']['playlists'].set([]) spl.build_queries() self.assertEqual(spl._matched_playlists, set()) self.assertEqual(spl._unmatched_playlists, set()) config['smartplaylist']['playlists'].set([ {'name': u'foo', 'query': u'FOO foo'}, {'name': u'bar', 'album_query': [u'BAR bar1', u'BAR bar2']}, {'name': u'baz', 'query': u'BAZ baz', 'album_query': u'BAZ baz'} ]) spl.build_queries() self.assertEqual(spl._matched_playlists, set()) foo_foo = parse_query_string(u'FOO foo', Item) baz_baz = parse_query_string(u'BAZ baz', Item) baz_baz2 = parse_query_string(u'BAZ baz', Album) bar_bar = OrQuery((parse_query_string(u'BAR bar1', Album)[0], parse_query_string(u'BAR bar2', Album)[0])) self.assertEqual(spl._unmatched_playlists, set([ (u'foo', foo_foo, (None, None)), (u'baz', baz_baz, baz_baz2), (u'bar', (None, None), (bar_bar, None)), ])) def test_build_queries_with_sorts(self): spl = SmartPlaylistPlugin() config['smartplaylist']['playlists'].set([ {'name': u'no_sort', 'query': u'foo'}, {'name': u'one_sort', 'query': u'foo year+'}, {'name': u'only_empty_sorts', 'query': [u'foo', u'bar']}, {'name': u'one_non_empty_sort', 'query': [u'foo year+', u'bar']}, {'name': u'multiple_sorts', 'query': [u'foo year+', u'bar genre-']}, {'name': u'mixed', 'query': [u'foo year+', u'bar', u'baz genre+ id-']} ]) spl.build_queries() sorts = dict((name, sort) for name, (_, sort), _ in spl._unmatched_playlists) asseq = self.assertEqual # less cluttered code sort = FixedFieldSort # short cut since we're only dealing with this asseq(sorts["no_sort"], NullSort()) asseq(sorts["one_sort"], sort(u'year')) asseq(sorts["only_empty_sorts"], None) asseq(sorts["one_non_empty_sort"], sort(u'year')) asseq(sorts["multiple_sorts"], MultipleSort([sort('year'), sort(u'genre', False)])) asseq(sorts["mixed"], MultipleSort([sort('year'), sort(u'genre'), sort(u'id', False)])) def test_matches(self): spl = SmartPlaylistPlugin() a = MagicMock(Album) i = MagicMock(Item) self.assertFalse(spl.matches(i, None, None)) self.assertFalse(spl.matches(a, None, None)) query = Mock() query.match.side_effect = {i: True}.__getitem__ self.assertTrue(spl.matches(i, query, None)) self.assertFalse(spl.matches(a, query, None)) a_query = Mock() a_query.match.side_effect = {a: True}.__getitem__ self.assertFalse(spl.matches(i, None, a_query)) self.assertTrue(spl.matches(a, None, a_query)) self.assertTrue(spl.matches(i, query, a_query)) self.assertTrue(spl.matches(a, query, a_query)) def test_db_changes(self): spl = SmartPlaylistPlugin() nones = None, None pl1 = '1', (u'q1', None), nones pl2 = '2', (u'q2', None), nones pl3 = '3', (u'q3', None), nones spl._unmatched_playlists = set([pl1, pl2, pl3]) spl._matched_playlists = set() spl.matches = Mock(return_value=False) spl.db_change(None, u"nothing") self.assertEqual(spl._unmatched_playlists, set([pl1, pl2, pl3])) self.assertEqual(spl._matched_playlists, set()) spl.matches.side_effect = lambda _, q, __: q == u'q3' spl.db_change(None, u"matches 3") self.assertEqual(spl._unmatched_playlists, set([pl1, pl2])) self.assertEqual(spl._matched_playlists, set([pl3])) spl.matches.side_effect = lambda _, q, __: q == u'q1' spl.db_change(None, u"matches 3") self.assertEqual(spl._matched_playlists, set([pl1, pl3])) self.assertEqual(spl._unmatched_playlists, set([pl2])) def test_playlist_update(self): spl = SmartPlaylistPlugin() i = Mock(path=b'/tagada.mp3') i.evaluate_template.side_effect = \ lambda pl, _: pl.replace(b'$title', b'ta:ga:da').decode() lib = Mock() lib.replacements = CHAR_REPLACE lib.items.return_value = [i] lib.albums.return_value = [] q = Mock() a_q = Mock() pl = b'$title-my.m3u', (q, None), (a_q, None) spl._matched_playlists = [pl] dir = bytestring_path(mkdtemp()) config['smartplaylist']['relative_to'] = False config['smartplaylist']['playlist_dir'] = py3_path(dir) try: spl.update_playlists(lib) except Exception: rmtree(dir) raise lib.items.assert_called_once_with(q, None) lib.albums.assert_called_once_with(a_q, None) m3u_filepath = path.join(dir, b'ta_ga_da-my_playlist_.m3u') self.assertTrue(path.exists(m3u_filepath)) with open(syspath(m3u_filepath), 'rb') as f: content = f.read() rmtree(dir) self.assertEqual(content, b'/tagada.mp3\n') class SmartPlaylistCLITest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.item = self.add_item() config['smartplaylist']['playlists'].set([ {'name': 'my_playlist.m3u', 'query': self.item.title}, {'name': 'all.m3u', 'query': u''} ]) config['smartplaylist']['playlist_dir'].set(py3_path(self.temp_dir)) self.load_plugins('smartplaylist') def tearDown(self): self.unload_plugins() self.teardown_beets() def test_splupdate(self): with self.assertRaises(UserError): self.run_with_output(u'splupdate', u'tagada') self.run_with_output(u'splupdate', u'my_playlist') m3u_path = path.join(self.temp_dir, b'my_playlist.m3u') self.assertTrue(path.exists(m3u_path)) with open(m3u_path, 'rb') as f: self.assertEqual(f.read(), self.item.path + b"\n") remove(m3u_path) self.run_with_output(u'splupdate', u'my_playlist.m3u') with open(m3u_path, 'rb') as f: self.assertEqual(f.read(), self.item.path + b"\n") remove(m3u_path) self.run_with_output(u'splupdate') for name in (b'my_playlist.m3u', b'all.m3u'): with open(path.join(self.temp_dir, name), 'rb') as f: self.assertEqual(f.read(), self.item.path + b"\n") def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_vfs.py0000644000076500000240000000327113025125203016752 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the virtual filesystem builder..""" from __future__ import division, absolute_import, print_function import unittest from test import _common from beets import library from beets import vfs class VFSTest(_common.TestCase): def setUp(self): super(VFSTest, self).setUp() self.lib = library.Library(':memory:', path_formats=[ (u'default', u'albums/$album/$title'), (u'singleton:true', u'tracks/$artist/$title'), ]) self.lib.add(_common.item()) self.lib.add_album([_common.item()]) self.tree = vfs.libtree(self.lib) def test_singleton_item(self): self.assertEqual(self.tree.dirs['tracks'].dirs['the artist']. files['the title'], 1) def test_album_item(self): self.assertEqual(self.tree.dirs['albums'].dirs['the album']. files['the title'], 2) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_ftintitle.py0000644000076500000240000001525113025125203020157 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'ftintitle' plugin.""" from __future__ import division, absolute_import, print_function import unittest from test.helper import TestHelper from beetsplug import ftintitle class FtInTitlePluginFunctional(unittest.TestCase, TestHelper): def setUp(self): """Set up configuration""" self.setup_beets() self.load_plugins('ftintitle') def tearDown(self): self.unload_plugins() self.teardown_beets() def _ft_add_item(self, path, artist, title, aartist): return self.add_item(path=path, artist=artist, artist_sort=artist, title=title, albumartist=aartist) def _ft_set_config(self, ftformat, drop=False, auto=True): self.config['ftintitle']['format'] = ftformat self.config['ftintitle']['drop'] = drop self.config['ftintitle']['auto'] = auto def test_functional_drop(self): item = self._ft_add_item('/', u'Alice ft Bob', u'Song 1', u'Alice') self.run_command('ftintitle', '-d') item.load() self.assertEqual(item['artist'], u'Alice') self.assertEqual(item['title'], u'Song 1') def test_functional_not_found(self): item = self._ft_add_item('/', u'Alice ft Bob', u'Song 1', u'George') self.run_command('ftintitle', '-d') item.load() # item should be unchanged self.assertEqual(item['artist'], u'Alice ft Bob') self.assertEqual(item['title'], u'Song 1') def test_functional_custom_format(self): self._ft_set_config('feat. {0}') item = self._ft_add_item('/', u'Alice ft Bob', u'Song 1', u'Alice') self.run_command('ftintitle') item.load() self.assertEqual(item['artist'], u'Alice') self.assertEqual(item['title'], u'Song 1 feat. Bob') self._ft_set_config('featuring {0}') item = self._ft_add_item('/', u'Alice feat. Bob', u'Song 1', u'Alice') self.run_command('ftintitle') item.load() self.assertEqual(item['artist'], u'Alice') self.assertEqual(item['title'], u'Song 1 featuring Bob') self._ft_set_config('with {0}') item = self._ft_add_item('/', u'Alice feat Bob', u'Song 1', u'Alice') self.run_command('ftintitle') item.load() self.assertEqual(item['artist'], u'Alice') self.assertEqual(item['title'], u'Song 1 with Bob') class FtInTitlePluginTest(unittest.TestCase): def setUp(self): """Set up configuration""" ftintitle.FtInTitlePlugin() def test_find_feat_part(self): test_cases = [ { 'artist': 'Alice ft. Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice feat Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice featuring Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice & Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice and Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice With Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice defeat Bob', 'album_artist': 'Alice', 'feat_part': None }, { 'artist': 'Alice & Bob', 'album_artist': 'Bob', 'feat_part': 'Alice' }, { 'artist': 'Alice ft. Bob', 'album_artist': 'Bob', 'feat_part': 'Alice' }, { 'artist': 'Alice ft. Carol', 'album_artist': 'Bob', 'feat_part': None }, ] for test_case in test_cases: feat_part = ftintitle.find_feat_part( test_case['artist'], test_case['album_artist'] ) self.assertEqual(feat_part, test_case['feat_part']) def test_split_on_feat(self): parts = ftintitle.split_on_feat(u'Alice ft. Bob') self.assertEqual(parts, (u'Alice', u'Bob')) parts = ftintitle.split_on_feat(u'Alice feat Bob') self.assertEqual(parts, (u'Alice', u'Bob')) parts = ftintitle.split_on_feat(u'Alice feat. Bob') self.assertEqual(parts, (u'Alice', u'Bob')) parts = ftintitle.split_on_feat(u'Alice featuring Bob') self.assertEqual(parts, (u'Alice', u'Bob')) parts = ftintitle.split_on_feat(u'Alice & Bob') self.assertEqual(parts, (u'Alice', u'Bob')) parts = ftintitle.split_on_feat(u'Alice and Bob') self.assertEqual(parts, (u'Alice', u'Bob')) parts = ftintitle.split_on_feat(u'Alice With Bob') self.assertEqual(parts, (u'Alice', u'Bob')) parts = ftintitle.split_on_feat(u'Alice defeat Bob') self.assertEqual(parts, (u'Alice defeat Bob', None)) def test_contains_feat(self): self.assertTrue(ftintitle.contains_feat(u'Alice ft. Bob')) self.assertTrue(ftintitle.contains_feat(u'Alice feat. Bob')) self.assertTrue(ftintitle.contains_feat(u'Alice feat Bob')) self.assertTrue(ftintitle.contains_feat(u'Alice featuring Bob')) self.assertTrue(ftintitle.contains_feat(u'Alice & Bob')) self.assertTrue(ftintitle.contains_feat(u'Alice and Bob')) self.assertTrue(ftintitle.contains_feat(u'Alice With Bob')) self.assertFalse(ftintitle.contains_feat(u'Alice defeat Bob')) self.assertFalse(ftintitle.contains_feat(u'Aliceft.Bob')) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_lyrics.py0000644000076500000240000003764313143374201017500 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'lyrics' plugin.""" from __future__ import absolute_import, division, print_function import os import re import six import sys import unittest from mock import patch from test import _common from beets import logging from beets.library import Item from beets.util import bytestring_path, confit from beetsplug import lyrics from mock import MagicMock log = logging.getLogger('beets.test_lyrics') raw_backend = lyrics.Backend({}, log) google = lyrics.Google(MagicMock(), log) class LyricsPluginTest(unittest.TestCase): def setUp(self): """Set up configuration.""" lyrics.LyricsPlugin() def test_search_artist(self): item = Item(artist='Alice ft. Bob', title='song') self.assertIn(('Alice ft. Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice feat Bob', title='song') self.assertIn(('Alice feat Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice feat. Bob', title='song') self.assertIn(('Alice feat. Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice feats Bob', title='song') self.assertIn(('Alice feats Bob', ['song']), lyrics.search_pairs(item)) self.assertNotIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice featuring Bob', title='song') self.assertIn(('Alice featuring Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice & Bob', title='song') self.assertIn(('Alice & Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice and Bob', title='song') self.assertIn(('Alice and Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice and Bob', title='song') self.assertEqual(('Alice and Bob', ['song']), list(lyrics.search_pairs(item))[0]) def test_search_pairs_multi_titles(self): item = Item(title='1 / 2', artist='A') self.assertIn(('A', ['1 / 2']), lyrics.search_pairs(item)) self.assertIn(('A', ['1', '2']), lyrics.search_pairs(item)) item = Item(title='1/2', artist='A') self.assertIn(('A', ['1/2']), lyrics.search_pairs(item)) self.assertIn(('A', ['1', '2']), lyrics.search_pairs(item)) def test_search_pairs_titles(self): item = Item(title='Song (live)', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song (live)']), lyrics.search_pairs(item)) item = Item(title='Song (live) (new)', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song (live) (new)']), lyrics.search_pairs(item)) item = Item(title='Song (live (new))', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song (live (new))']), lyrics.search_pairs(item)) item = Item(title='Song ft. B', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song ft. B']), lyrics.search_pairs(item)) item = Item(title='Song featuring B', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song featuring B']), lyrics.search_pairs(item)) item = Item(title='Song and B', artist='A') self.assertNotIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song and B']), lyrics.search_pairs(item)) item = Item(title='Song: B', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song: B']), lyrics.search_pairs(item)) def test_remove_credits(self): self.assertEqual( lyrics.remove_credits("""It's close to midnight Lyrics brought by example.com"""), "It's close to midnight" ) self.assertEqual( lyrics.remove_credits("""Lyrics brought by example.com"""), "" ) # don't remove 2nd verse for the only reason it contains 'lyrics' word text = """Look at all the shit that i done bought her See lyrics ain't nothin if the beat aint crackin""" self.assertEqual(lyrics.remove_credits(text), text) def test_is_lyrics(self): texts = ['LyricsMania.com - Copyright (c) 2013 - All Rights Reserved'] texts += ["""All material found on this site is property\n of mywickedsongtext brand"""] for t in texts: self.assertFalse(google.is_lyrics(t)) def test_slugify(self): text = u"http://site.com/\xe7afe-au_lait(boisson)" self.assertEqual(google.slugify(text), 'http://site.com/cafe_au_lait') def test_scrape_strip_cruft(self): text = u"""  one
two !

four""" self.assertEqual(lyrics._scrape_strip_cruft(text, True), "one\ntwo !\n\nfour") def test_scrape_strip_scripts(self): text = u"""foobaz""" self.assertEqual(lyrics._scrape_strip_cruft(text, True), "foobaz") def test_scrape_strip_tag_in_comment(self): text = u"""fooqux""" self.assertEqual(lyrics._scrape_strip_cruft(text, True), "fooqux") def test_scrape_merge_paragraphs(self): text = u"one

two

three" self.assertEqual(lyrics._scrape_merge_paragraphs(text), "one\ntwo\nthree") def test_missing_lyrics(self): self.assertFalse(google.is_lyrics(LYRICS_TEXTS['missing_texts'])) def url_to_filename(url): url = re.sub(r'https?://|www.', '', url) fn = "".join(x for x in url if (x.isalnum() or x == '/')) fn = fn.split('/') fn = os.path.join(LYRICS_ROOT_DIR, bytestring_path(fn[0]), bytestring_path(fn[-1] + '.txt')) return fn class MockFetchUrl(object): def __init__(self, pathval='fetched_path'): self.pathval = pathval self.fetched = None def __call__(self, url, filename=None): self.fetched = url fn = url_to_filename(url) with open(fn, 'r') as f: content = f.read() return content def is_lyrics_content_ok(title, text): """Compare lyrics text to expected lyrics for given title.""" if not text: return keywords = set(LYRICS_TEXTS[google.slugify(title)].split()) words = set(x.strip(".?, ") for x in text.lower().split()) return keywords <= words LYRICS_ROOT_DIR = os.path.join(_common.RSRC, b'lyrics') LYRICS_TEXTS = confit.load_yaml(os.path.join(_common.RSRC, b'lyricstext.yaml')) class LyricsGoogleBaseTest(unittest.TestCase): def setUp(self): """Set up configuration.""" try: __import__('bs4') except ImportError: self.skipTest('Beautiful Soup 4 not available') if sys.version_info[:3] < (2, 7, 3): self.skipTest("Python's built-in HTML parser is not good enough") class LyricsPluginSourcesTest(LyricsGoogleBaseTest): """Check that beets google custom search engine sources are correctly scraped. """ DEFAULT_SONG = dict(artist=u'The Beatles', title=u'Lady Madonna') DEFAULT_SOURCES = [ dict(DEFAULT_SONG, backend=lyrics.LyricsWiki), dict(artist=u'Santana', title=u'Black magic woman', backend=lyrics.MusiXmatch), dict(DEFAULT_SONG, backend=lyrics.Genius), ] GOOGLE_SOURCES = [ dict(DEFAULT_SONG, url=u'http://www.absolutelyrics.com', path=u'/lyrics/view/the_beatles/lady_madonna'), dict(DEFAULT_SONG, url=u'http://www.azlyrics.com', path=u'/lyrics/beatles/ladymadonna.html'), dict(DEFAULT_SONG, url=u'http://www.chartlyrics.com', path=u'/_LsLsZ7P4EK-F-LD4dJgDQ/Lady+Madonna.aspx'), dict(DEFAULT_SONG, url=u'http://www.elyricsworld.com', path=u'/lady_madonna_lyrics_beatles.html'), dict(url=u'http://www.lacoccinelle.net', artist=u'Jacques Brel', title=u"Amsterdam", path=u'/paroles-officielles/275679.html'), dict(DEFAULT_SONG, url=u'http://letras.mus.br/', path=u'the-beatles/275/'), dict(DEFAULT_SONG, url='http://www.lyricsmania.com/', path='lady_madonna_lyrics_the_beatles.html'), dict(DEFAULT_SONG, url=u'http://lyrics.wikia.com/', path=u'The_Beatles:Lady_Madonna'), dict(DEFAULT_SONG, url=u'http://www.lyricsmode.com', path=u'/lyrics/b/beatles/lady_madonna.html'), dict(url=u'http://www.lyricsontop.com', artist=u'Amy Winehouse', title=u"Jazz'n'blues", path=u'/amy-winehouse-songs/jazz-n-blues-lyrics.html'), dict(DEFAULT_SONG, url='http://www.metrolyrics.com/', path='lady-madonna-lyrics-beatles.html'), dict(url='http://www.musica.com/', path='letras.asp?letra=2738', artist=u'Santana', title=u'Black magic woman'), dict(url=u'http://www.paroles.net/', artist=u'Lilly Wood & the prick', title=u"Hey it's ok", path=u'lilly-wood-the-prick/paroles-hey-it-s-ok'), dict(DEFAULT_SONG, url='http://www.songlyrics.com', path=u'/the-beatles/lady-madonna-lyrics'), dict(DEFAULT_SONG, url=u'http://www.sweetslyrics.com', path=u'/761696.The%20Beatles%20-%20Lady%20Madonna.html') ] def setUp(self): LyricsGoogleBaseTest.setUp(self) self.plugin = lyrics.LyricsPlugin() @unittest.skipUnless(os.environ.get( 'BEETS_TEST_LYRICS_SOURCES', '0') == '1', 'lyrics sources testing not enabled') def test_backend_sources_ok(self): """Test default backends with songs known to exist in respective databases. """ errors = [] for s in self.DEFAULT_SOURCES: res = s['backend'](self.plugin.config, self.plugin._log).fetch( s['artist'], s['title']) if not is_lyrics_content_ok(s['title'], res): errors.append(s['backend'].__name__) self.assertFalse(errors) @unittest.skipUnless(os.environ.get( 'BEETS_TEST_LYRICS_SOURCES', '0') == '1', 'lyrics sources testing not enabled') def test_google_sources_ok(self): """Test if lyrics present on websites registered in beets google custom search engine are correctly scraped. """ for s in self.GOOGLE_SOURCES: url = s['url'] + s['path'] res = lyrics.scrape_lyrics_from_html( raw_backend.fetch_url(url)) self.assertTrue(google.is_lyrics(res), url) self.assertTrue(is_lyrics_content_ok(s['title'], res), url) class LyricsGooglePluginMachineryTest(LyricsGoogleBaseTest): """Test scraping heuristics on a fake html page. """ source = dict(url=u'http://www.example.com', artist=u'John Doe', title=u'Beets song', path=u'/lyrics/beetssong') def setUp(self): """Set up configuration""" LyricsGoogleBaseTest.setUp(self) self.plugin = lyrics.LyricsPlugin() @patch.object(lyrics.Backend, 'fetch_url', MockFetchUrl()) def test_mocked_source_ok(self): """Test that lyrics of the mocked page are correctly scraped""" url = self.source['url'] + self.source['path'] res = lyrics.scrape_lyrics_from_html(raw_backend.fetch_url(url)) self.assertTrue(google.is_lyrics(res), url) self.assertTrue(is_lyrics_content_ok(self.source['title'], res), url) @patch.object(lyrics.Backend, 'fetch_url', MockFetchUrl()) def test_is_page_candidate_exact_match(self): """Test matching html page title with song infos -- when song infos are present in the title. """ from bs4 import SoupStrainer, BeautifulSoup s = self.source url = six.text_type(s['url'] + s['path']) html = raw_backend.fetch_url(url) soup = BeautifulSoup(html, "html.parser", parse_only=SoupStrainer('title')) self.assertEqual( google.is_page_candidate(url, soup.title.string, s['title'], s['artist']), True, url) def test_is_page_candidate_fuzzy_match(self): """Test matching html page title with song infos -- when song infos are not present in the title. """ s = self.source url = s['url'] + s['path'] url_title = u'example.com | Beats song by John doe' # very small diffs (typo) are ok eg 'beats' vs 'beets' with same artist self.assertEqual(google.is_page_candidate(url, url_title, s['title'], s['artist']), True, url) # reject different title url_title = u'example.com | seets bong lyrics by John doe' self.assertEqual(google.is_page_candidate(url, url_title, s['title'], s['artist']), False, url) def test_is_page_candidate_special_chars(self): """Ensure that `is_page_candidate` doesn't crash when the artist and such contain special regular expression characters. """ # https://github.com/beetbox/beets/issues/1673 s = self.source url = s['url'] + s['path'] url_title = u'foo' google.is_page_candidate(url, url_title, s['title'], u'Sunn O)))') class SlugTests(unittest.TestCase): def test_slug(self): # plain ascii passthrough text = u"test" self.assertEqual(lyrics.slug(text), 'test') # german unicode and capitals text = u"Mørdag" self.assertEqual(lyrics.slug(text), 'mordag') # more accents and quotes text = u"l'été c'est fait pour jouer" self.assertEqual(lyrics.slug(text), 'l-ete-c-est-fait-pour-jouer') # accents, parens and spaces text = u"\xe7afe au lait (boisson)" self.assertEqual(lyrics.slug(text), 'cafe-au-lait-boisson') text = u"Multiple spaces -- and symbols! -- merged" self.assertEqual(lyrics.slug(text), 'multiple-spaces-and-symbols-merged') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_importadded.py0000644000076500000240000001614413120341455020460 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Stig Inge Lea Bjornsen. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function """Tests for the `importadded` plugin.""" import os import unittest from test.test_importer import ImportHelper, AutotagStub from beets import importer from beets import util from beetsplug.importadded import ImportAddedPlugin _listeners = ImportAddedPlugin.listeners def preserve_plugin_listeners(): """Preserve the initial plugin listeners as they would otherwise be deleted after the first setup / tear down cycle. """ if not ImportAddedPlugin.listeners: ImportAddedPlugin.listeners = _listeners def modify_mtimes(paths, offset=-60000): for i, path in enumerate(paths, start=1): mstat = os.stat(path) os.utime(path, (mstat.st_atime, mstat.st_mtime + offset * i)) class ImportAddedTest(unittest.TestCase, ImportHelper): # The minimum mtime of the files to be imported min_mtime = None def setUp(self): preserve_plugin_listeners() self.setup_beets() self.load_plugins('importadded') self._create_import_dir(2) # Different mtimes on the files to be imported in order to test the # plugin modify_mtimes((mfile.path for mfile in self.media_files)) self.min_mtime = min(os.path.getmtime(mfile.path) for mfile in self.media_files) self.matcher = AutotagStub().install() self.matcher.macthin = AutotagStub.GOOD self._setup_import_session() self.importer.add_choice(importer.action.APPLY) def tearDown(self): self.unload_plugins() self.teardown_beets() self.matcher.restore() def find_media_file(self, item): """Find the pre-import MediaFile for an Item""" for m in self.media_files: if m.title.replace('Tag', 'Applied') == item.title: return m raise AssertionError(u"No MediaFile found for Item " + util.displayable_path(item.path)) def assertEqualTimes(self, first, second, msg=None): # noqa """For comparing file modification times at a sufficient precision""" self.assertAlmostEqual(first, second, places=4, msg=msg) def assertAlbumImport(self): # noqa self.importer.run() album = self.lib.albums().get() self.assertEqual(album.added, self.min_mtime) for item in album.items(): self.assertEqual(item.added, self.min_mtime) def test_import_album_with_added_dates(self): self.assertAlbumImport() def test_import_album_inplace_with_added_dates(self): self.config['import']['copy'] = False self.config['import']['move'] = False self.config['import']['link'] = False self.config['import']['hardlink'] = False self.assertAlbumImport() def test_import_album_with_preserved_mtimes(self): self.config['importadded']['preserve_mtimes'] = True self.importer.run() album = self.lib.albums().get() self.assertEqual(album.added, self.min_mtime) for item in album.items(): self.assertEqualTimes(item.added, self.min_mtime) mediafile_mtime = os.path.getmtime(self.find_media_file(item).path) self.assertEqualTimes(item.mtime, mediafile_mtime) self.assertEqualTimes(os.path.getmtime(item.path), mediafile_mtime) def test_reimported_album_skipped(self): # Import and record the original added dates self.importer.run() album = self.lib.albums().get() album_added_before = album.added items_added_before = dict((item.path, item.added) for item in album.items()) # Newer Item path mtimes as if Beets had modified them modify_mtimes(items_added_before.keys(), offset=10000) # Reimport self._setup_import_session(import_dir=album.path) self.importer.run() # Verify the reimported items album = self.lib.albums().get() self.assertEqualTimes(album.added, album_added_before) items_added_after = dict((item.path, item.added) for item in album.items()) for item_path, added_after in items_added_after.items(): self.assertEqualTimes(items_added_before[item_path], added_after, u"reimport modified Item.added for " + util.displayable_path(item_path)) def test_import_singletons_with_added_dates(self): self.config['import']['singletons'] = True self.importer.run() for item in self.lib.items(): mfile = self.find_media_file(item) self.assertEqualTimes(item.added, os.path.getmtime(mfile.path)) def test_import_singletons_with_preserved_mtimes(self): self.config['import']['singletons'] = True self.config['importadded']['preserve_mtimes'] = True self.importer.run() for item in self.lib.items(): mediafile_mtime = os.path.getmtime(self.find_media_file(item).path) self.assertEqualTimes(item.added, mediafile_mtime) self.assertEqualTimes(item.mtime, mediafile_mtime) self.assertEqualTimes(os.path.getmtime(item.path), mediafile_mtime) def test_reimported_singletons_skipped(self): self.config['import']['singletons'] = True # Import and record the original added dates self.importer.run() items_added_before = dict((item.path, item.added) for item in self.lib.items()) # Newer Item path mtimes as if Beets had modified them modify_mtimes(items_added_before.keys(), offset=10000) # Reimport import_dir = os.path.dirname(list(items_added_before.keys())[0]) self._setup_import_session(import_dir=import_dir, singletons=True) self.importer.run() # Verify the reimported items items_added_after = dict((item.path, item.added) for item in self.lib.items()) for item_path, added_after in items_added_after.items(): self.assertEqualTimes(items_added_before[item_path], added_after, u"reimport modified Item.added for " + util.displayable_path(item_path)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_info.py0000644000076500000240000000741213025125203017110 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import unittest from test.helper import TestHelper from beets.mediafile import MediaFile from beets.util import displayable_path class InfoTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('info') def tearDown(self): self.unload_plugins() self.teardown_beets() def test_path(self): path = self.create_mediafile_fixture() mediafile = MediaFile(path) mediafile.albumartist = 'AAA' mediafile.disctitle = 'DDD' mediafile.genres = ['a', 'b', 'c'] mediafile.composer = None mediafile.save() out = self.run_with_output('info', path) self.assertIn(path, out) self.assertIn('albumartist: AAA', out) self.assertIn('disctitle: DDD', out) self.assertIn('genres: a; b; c', out) self.assertNotIn('composer:', out) self.remove_mediafile_fixtures() def test_item_query(self): item1, item2 = self.add_item_fixtures(count=2) item1.album = 'xxxx' item1.write() item1.album = 'yyyy' item1.store() out = self.run_with_output('info', 'album:yyyy') self.assertIn(displayable_path(item1.path), out) self.assertIn(u'album: xxxx', out) self.assertNotIn(displayable_path(item2.path), out) def test_item_library_query(self): item, = self.add_item_fixtures() item.album = 'xxxx' item.store() out = self.run_with_output('info', '--library', 'album:xxxx') self.assertIn(displayable_path(item.path), out) self.assertIn(u'album: xxxx', out) def test_collect_item_and_path(self): path = self.create_mediafile_fixture() mediafile = MediaFile(path) item, = self.add_item_fixtures() item.album = mediafile.album = 'AAA' item.tracktotal = mediafile.tracktotal = 5 item.title = 'TTT' mediafile.title = 'SSS' item.write() item.store() mediafile.save() out = self.run_with_output('info', '--summarize', 'album:AAA', path) self.assertIn(u'album: AAA', out) self.assertIn(u'tracktotal: 5', out) self.assertIn(u'title: [various]', out) self.remove_mediafile_fixtures() def test_include_pattern(self): item, = self.add_item_fixtures() item.album = 'xxxx' item.store() out = self.run_with_output('info', '--library', 'album:xxxx', '--include-keys', '*lbu*') self.assertIn(displayable_path(item.path), out) self.assertNotIn(u'title:', out) self.assertIn(u'album: xxxx', out) def test_custom_format(self): self.add_item_fixtures() out = self.run_with_output('info', '--library', '--format', '$track. $title - $artist ($length)') self.assertEqual(u'02. tïtle 0 - the artist (0:01)\n', out) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_pipeline.py0000644000076500000240000001546513025125203017771 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test the "pipeline.py" restricted parallel programming library. """ from __future__ import division, absolute_import, print_function import six import unittest from beets.util import pipeline # Some simple pipeline stages for testing. def _produce(num=5): for i in range(num): yield i def _work(): i = None while True: i = yield i i *= 2 def _consume(l): while True: i = yield l.append(i) # A worker that raises an exception. class TestException(Exception): pass def _exc_work(num=3): i = None while True: i = yield i if i == num: raise TestException() i *= 2 # A worker that yields a bubble. def _bub_work(num=3): i = None while True: i = yield i if i == num: i = pipeline.BUBBLE else: i *= 2 # Yet another worker that yields multiple messages. def _multi_work(): i = None while True: i = yield i i = pipeline.multiple([i, -i]) class SimplePipelineTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline((_produce(), _work(), _consume(self.l))) def test_run_sequential(self): self.pl.run_sequential() self.assertEqual(self.l, [0, 2, 4, 6, 8]) def test_run_parallel(self): self.pl.run_parallel() self.assertEqual(self.l, [0, 2, 4, 6, 8]) def test_pull(self): pl = pipeline.Pipeline((_produce(), _work())) self.assertEqual(list(pl.pull()), [0, 2, 4, 6, 8]) def test_pull_chain(self): pl = pipeline.Pipeline((_produce(), _work())) pl2 = pipeline.Pipeline((pl.pull(), _work())) self.assertEqual(list(pl2.pull()), [0, 4, 8, 12, 16]) class ParallelStageTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline(( _produce(), (_work(), _work()), _consume(self.l) )) def test_run_sequential(self): self.pl.run_sequential() self.assertEqual(self.l, [0, 2, 4, 6, 8]) def test_run_parallel(self): self.pl.run_parallel() # Order possibly not preserved; use set equality. self.assertEqual(set(self.l), set([0, 2, 4, 6, 8])) def test_pull(self): pl = pipeline.Pipeline((_produce(), (_work(), _work()))) self.assertEqual(list(pl.pull()), [0, 2, 4, 6, 8]) class ExceptionTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline((_produce(), _exc_work(), _consume(self.l))) def test_run_sequential(self): self.assertRaises(TestException, self.pl.run_sequential) def test_run_parallel(self): self.assertRaises(TestException, self.pl.run_parallel) def test_pull(self): pl = pipeline.Pipeline((_produce(), _exc_work())) pull = pl.pull() for i in range(3): next(pull) if six.PY2: self.assertRaises(TestException, pull.next) else: self.assertRaises(TestException, pull.__next__) class ParallelExceptionTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline(( _produce(), (_exc_work(), _exc_work()), _consume(self.l) )) def test_run_parallel(self): self.assertRaises(TestException, self.pl.run_parallel) class ConstrainedThreadedPipelineTest(unittest.TestCase): def test_constrained(self): l = [] # Do a "significant" amount of work... pl = pipeline.Pipeline((_produce(1000), _work(), _consume(l))) # ... with only a single queue slot. pl.run_parallel(1) self.assertEqual(l, [i * 2 for i in range(1000)]) def test_constrained_exception(self): # Raise an exception in a constrained pipeline. l = [] pl = pipeline.Pipeline((_produce(1000), _exc_work(), _consume(l))) self.assertRaises(TestException, pl.run_parallel, 1) def test_constrained_parallel(self): l = [] pl = pipeline.Pipeline(( _produce(1000), (_work(), _work()), _consume(l) )) pl.run_parallel(1) self.assertEqual(set(l), set(i * 2 for i in range(1000))) class BubbleTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline((_produce(), _bub_work(), _consume(self.l))) def test_run_sequential(self): self.pl.run_sequential() self.assertEqual(self.l, [0, 2, 4, 8]) def test_run_parallel(self): self.pl.run_parallel() self.assertEqual(self.l, [0, 2, 4, 8]) def test_pull(self): pl = pipeline.Pipeline((_produce(), _bub_work())) self.assertEqual(list(pl.pull()), [0, 2, 4, 8]) class MultiMessageTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline(( _produce(), _multi_work(), _consume(self.l) )) def test_run_sequential(self): self.pl.run_sequential() self.assertEqual(self.l, [0, 0, 1, -1, 2, -2, 3, -3, 4, -4]) def test_run_parallel(self): self.pl.run_parallel() self.assertEqual(self.l, [0, 0, 1, -1, 2, -2, 3, -3, 4, -4]) def test_pull(self): pl = pipeline.Pipeline((_produce(), _multi_work())) self.assertEqual(list(pl.pull()), [0, 0, 1, -1, 2, -2, 3, -3, 4, -4]) class StageDecoratorTest(unittest.TestCase): def test_stage_decorator(self): @pipeline.stage def add(n, i): return i + n pl = pipeline.Pipeline([ iter([1, 2, 3]), add(2) ]) self.assertEqual(list(pl.pull()), [3, 4, 5]) def test_mutator_stage_decorator(self): @pipeline.mutator_stage def setkey(key, item): item[key] = True pl = pipeline.Pipeline([ iter([{'x': False}, {'a': False}]), setkey('x'), ]) self.assertEqual(list(pl.pull()), [{'x': True}, {'a': False, 'x': True}]) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_mbsync.py0000644000076500000240000001037613025125203017453 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import unittest from mock import patch from test.helper import TestHelper,\ generate_album_info, \ generate_track_info, \ capture_log from beets import config from beets.library import Item class MbsyncCliTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('mbsync') def tearDown(self): self.unload_plugins() self.teardown_beets() @patch('beets.autotag.hooks.album_for_mbid') @patch('beets.autotag.hooks.track_for_mbid') def test_update_library(self, track_for_mbid, album_for_mbid): album_for_mbid.return_value = \ generate_album_info('album id', ['track id']) track_for_mbid.return_value = \ generate_track_info(u'singleton track id', {'title': u'singleton info'}) album_item = Item( album=u'old title', mb_albumid=u'album id', mb_trackid=u'track id', path='' ) album = self.lib.add_album([album_item]) item = Item( title=u'old title', mb_trackid=u'singleton track id', path='', ) self.lib.add(item) self.run_command('mbsync') item.load() self.assertEqual(item.title, u'singleton info') album_item.load() self.assertEqual(album_item.title, u'track info') album.load() self.assertEqual(album.album, u'album info') def test_message_when_skipping(self): config['format_item'] = u'$artist - $album - $title' config['format_album'] = u'$albumartist - $album' # Test album with no mb_albumid. # The default format for an album include $albumartist so # set that here, too. album_invalid = Item( albumartist=u'album info', album=u'album info', path='' ) self.lib.add_album([album_invalid]) # default format with capture_log('beets.mbsync') as logs: self.run_command('mbsync') e = u'mbsync: Skipping album with no mb_albumid: ' + \ u'album info - album info' self.assertEqual(e, logs[0]) # custom format with capture_log('beets.mbsync') as logs: self.run_command('mbsync', '-f', "'$album'") e = u"mbsync: Skipping album with no mb_albumid: 'album info'" self.assertEqual(e, logs[0]) # restore the config config['format_item'] = u'$artist - $album - $title' config['format_album'] = u'$albumartist - $album' # Test singleton with no mb_trackid. # The default singleton format includes $artist and $album # so we need to stub them here item_invalid = Item( artist=u'album info', album=u'album info', title=u'old title', path='', ) self.lib.add(item_invalid) # default format with capture_log('beets.mbsync') as logs: self.run_command('mbsync') e = u'mbsync: Skipping singleton with no mb_trackid: ' + \ u'album info - album info - old title' self.assertEqual(e, logs[0]) # custom format with capture_log('beets.mbsync') as logs: self.run_command('mbsync', '-f', "'$title'") e = u"mbsync: Skipping singleton with no mb_trackid: 'old title'" self.assertEqual(e, logs[0]) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_lastgenre.py0000644000076500000240000002110213122272074020140 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'lastgenre' plugin.""" from __future__ import division, absolute_import, print_function import unittest from mock import Mock from test import _common from beetsplug import lastgenre from beets import config from test.helper import TestHelper import six class LastGenrePluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.plugin = lastgenre.LastGenrePlugin() def tearDown(self): self.teardown_beets() def _setup_config(self, whitelist=False, canonical=False, count=1): config['lastgenre']['canonical'] = canonical config['lastgenre']['count'] = count if isinstance(whitelist, (bool, six.string_types)): # Filename, default, or disabled. config['lastgenre']['whitelist'] = whitelist self.plugin.setup() if not isinstance(whitelist, (bool, six.string_types)): # Explicit list of genres. self.plugin.whitelist = whitelist def test_default(self): """Fetch genres with whitelist and c14n deactivated """ self._setup_config() self.assertEqual(self.plugin._resolve_genres(['delta blues']), u'Delta Blues') def test_c14n_only(self): """Default c14n tree funnels up to most common genre except for *wrong* genres that stay unchanged. """ self._setup_config(canonical=True, count=99) self.assertEqual(self.plugin._resolve_genres(['delta blues']), u'Blues') self.assertEqual(self.plugin._resolve_genres(['iota blues']), u'Iota Blues') def test_whitelist_only(self): """Default whitelist rejects *wrong* (non existing) genres. """ self._setup_config(whitelist=True) self.assertEqual(self.plugin._resolve_genres(['iota blues']), u'') def test_whitelist_c14n(self): """Default whitelist and c14n both activated result in all parents genres being selected (from specific to common). """ self._setup_config(canonical=True, whitelist=True, count=99) self.assertEqual(self.plugin._resolve_genres(['delta blues']), u'Delta Blues, Blues') def test_whitelist_custom(self): """Keep only genres that are in the whitelist. """ self._setup_config(whitelist=set(['blues', 'rock', 'jazz']), count=2) self.assertEqual(self.plugin._resolve_genres(['pop', 'blues']), u'Blues') self._setup_config(canonical='', whitelist=set(['rock'])) self.assertEqual(self.plugin._resolve_genres(['delta blues']), u'') def test_count(self): """Keep the n first genres, as we expect them to be sorted from more to less popular. """ self._setup_config(whitelist=set(['blues', 'rock', 'jazz']), count=2) self.assertEqual(self.plugin._resolve_genres( ['jazz', 'pop', 'rock', 'blues']), u'Jazz, Rock') def test_count_c14n(self): """Keep the n first genres, after having applied c14n when necessary """ self._setup_config(whitelist=set(['blues', 'rock', 'jazz']), canonical=True, count=2) # thanks to c14n, 'blues' superseeds 'country blues' and takes the # second slot self.assertEqual(self.plugin._resolve_genres( ['jazz', 'pop', 'country blues', 'rock']), u'Jazz, Blues') def test_c14n_whitelist(self): """Genres first pass through c14n and are then filtered """ self._setup_config(canonical=True, whitelist=set(['rock'])) self.assertEqual(self.plugin._resolve_genres(['delta blues']), u'') def test_empty_string_enables_canonical(self): """For backwards compatibility, setting the `canonical` option to the empty string enables it using the default tree. """ self._setup_config(canonical='', count=99) self.assertEqual(self.plugin._resolve_genres(['delta blues']), u'Blues') def test_empty_string_enables_whitelist(self): """Again for backwards compatibility, setting the `whitelist` option to the empty string enables the default set of genres. """ self._setup_config(whitelist='') self.assertEqual(self.plugin._resolve_genres(['iota blues']), u'') def test_no_duplicate(self): """Remove duplicated genres. """ self._setup_config(count=99) self.assertEqual(self.plugin._resolve_genres(['blues', 'blues']), u'Blues') def test_tags_for(self): class MockPylastElem(object): def __init__(self, name): self.name = name def get_name(self): return self.name class MockPylastObj(object): def get_top_tags(self): tag1 = Mock() tag1.weight = 90 tag1.item = MockPylastElem(u'Pop') tag2 = Mock() tag2.weight = 40 tag2.item = MockPylastElem(u'Rap') return [tag1, tag2] plugin = lastgenre.LastGenrePlugin() res = plugin._tags_for(MockPylastObj()) self.assertEqual(res, [u'pop', u'rap']) res = plugin._tags_for(MockPylastObj(), min_weight=50) self.assertEqual(res, [u'pop']) def test_get_genre(self): mock_genres = {'track': u'1', 'album': u'2', 'artist': u'3'} def mock_fetch_track_genre(self, obj=None): return mock_genres['track'] def mock_fetch_album_genre(self, obj): return mock_genres['album'] def mock_fetch_artist_genre(self, obj): return mock_genres['artist'] lastgenre.LastGenrePlugin.fetch_track_genre = mock_fetch_track_genre lastgenre.LastGenrePlugin.fetch_album_genre = mock_fetch_album_genre lastgenre.LastGenrePlugin.fetch_artist_genre = mock_fetch_artist_genre self._setup_config(whitelist=False) item = _common.item() item.genre = mock_genres['track'] config['lastgenre'] = {'force': False} res = self.plugin._get_genre(item) self.assertEqual(res, (item.genre, u'keep')) config['lastgenre'] = {'force': True, 'source': u'track'} res = self.plugin._get_genre(item) self.assertEqual(res, (mock_genres['track'], u'track')) config['lastgenre'] = {'source': u'album'} res = self.plugin._get_genre(item) self.assertEqual(res, (mock_genres['album'], u'album')) config['lastgenre'] = {'source': u'artist'} res = self.plugin._get_genre(item) self.assertEqual(res, (mock_genres['artist'], u'artist')) mock_genres['artist'] = None res = self.plugin._get_genre(item) self.assertEqual(res, (item.genre, u'original')) config['lastgenre'] = {'fallback': u'rap'} item.genre = None res = self.plugin._get_genre(item) self.assertEqual(res, (config['lastgenre']['fallback'].get(), u'fallback')) def test_sort_by_depth(self): self._setup_config(canonical=True) # Normal case. tags = ('electronic', 'ambient', 'post-rock', 'downtempo') res = self.plugin._sort_by_depth(tags) self.assertEqual( res, ['post-rock', 'downtempo', 'ambient', 'electronic']) # Non-canonical tag ('chillout') present. tags = ('electronic', 'ambient', 'chillout') res = self.plugin._sort_by_depth(tags) self.assertEqual(res, ['ambient', 'electronic']) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_permissions.py0000644000076500000240000000531513025125203020530 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- """Tests for the 'permissions' plugin. """ from __future__ import division, absolute_import, print_function import os import platform import unittest from mock import patch, Mock from test.helper import TestHelper from beets.util import displayable_path from beetsplug.permissions import (check_permissions, convert_perm, dirs_in_library) class PermissionsPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('permissions') self.config['permissions'] = { 'file': '777', 'dir': '777'} def tearDown(self): self.teardown_beets() self.unload_plugins() def test_permissions_on_album_imported(self): self.do_thing(True) def test_permissions_on_item_imported(self): self.config['import']['singletons'] = True self.do_thing(True) @patch("os.chmod", Mock()) def test_failing_to_set_permissions(self): self.do_thing(False) def do_thing(self, expect_success): if platform.system() == 'Windows': self.skipTest('permissions not available on Windows') def get_stat(v): return os.stat( os.path.join(self.temp_dir, b'import', *v)).st_mode & 0o777 self.importer = self.create_importer() typs = ['file', 'dir'] track_file = (b'album 0', b'track 0.mp3') self.exp_perms = { True: {k: convert_perm(self.config['permissions'][k].get()) for k in typs}, False: {k: get_stat(v) for (k, v) in zip(typs, (track_file, ()))} } self.importer.run() item = self.lib.items().get() self.assertPerms(item.path, 'file', expect_success) for path in dirs_in_library(self.lib.directory, item.path): self.assertPerms(path, 'dir', expect_success) def assertPerms(self, path, typ, expect_success): # noqa for x in [(True, self.exp_perms[expect_success][typ], '!='), (False, self.exp_perms[not expect_success][typ], '==')]: msg = u'{} : {} {} {}'.format( displayable_path(path), oct(os.stat(path).st_mode), x[2], oct(x[1]) ) self.assertEqual(x[0], check_permissions(path, x[1]), msg=msg) def test_convert_perm_from_string(self): self.assertEqual(convert_perm('10'), 8) def test_convert_perm_from_int(self): self.assertEqual(convert_perm(10), 8) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_plugin_mediafield.py0000644000076500000240000000733713036550537021642 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests the facility that lets plugins add custom field to MediaFile. """ from __future__ import division, absolute_import, print_function import os import six import shutil import unittest from test import _common from beets.library import Item from beets import mediafile from beets.plugins import BeetsPlugin from beets.util import bytestring_path field_extension = mediafile.MediaField( mediafile.MP3DescStorageStyle(u'customtag'), mediafile.MP4StorageStyle('----:com.apple.iTunes:customtag'), mediafile.StorageStyle('customtag'), mediafile.ASFStorageStyle('customtag'), ) class ExtendedFieldTestMixin(_common.TestCase): def _mediafile_fixture(self, name, extension='mp3'): name = bytestring_path(name + '.' + extension) src = os.path.join(_common.RSRC, name) target = os.path.join(self.temp_dir, name) shutil.copy(src, target) return mediafile.MediaFile(target) def test_extended_field_write(self): plugin = BeetsPlugin() plugin.add_media_field('customtag', field_extension) try: mf = self._mediafile_fixture('empty') mf.customtag = u'F#' mf.save() mf = mediafile.MediaFile(mf.path) self.assertEqual(mf.customtag, u'F#') finally: delattr(mediafile.MediaFile, 'customtag') Item._media_fields.remove('customtag') def test_write_extended_tag_from_item(self): plugin = BeetsPlugin() plugin.add_media_field('customtag', field_extension) try: mf = self._mediafile_fixture('empty') self.assertIsNone(mf.customtag) item = Item(path=mf.path, customtag=u'Gb') item.write() mf = mediafile.MediaFile(mf.path) self.assertEqual(mf.customtag, u'Gb') finally: delattr(mediafile.MediaFile, 'customtag') Item._media_fields.remove('customtag') def test_read_flexible_attribute_from_file(self): plugin = BeetsPlugin() plugin.add_media_field('customtag', field_extension) try: mf = self._mediafile_fixture('empty') mf.update({'customtag': u'F#'}) mf.save() item = Item.from_path(mf.path) self.assertEqual(item['customtag'], u'F#') finally: delattr(mediafile.MediaFile, 'customtag') Item._media_fields.remove('customtag') def test_invalid_descriptor(self): with self.assertRaises(ValueError) as cm: mediafile.MediaFile.add_field('somekey', True) self.assertIn(u'must be an instance of MediaField', six.text_type(cm.exception)) def test_overwrite_property(self): with self.assertRaises(ValueError) as cm: mediafile.MediaFile.add_field('artist', mediafile.MediaField()) self.assertIn(u'property "artist" already exists', six.text_type(cm.exception)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_mediafile.py0000644000076500000240000007471513122272074020115 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Automatically-generated blanket testing for the MediaFile metadata layer. """ from __future__ import division, absolute_import, print_function import os import shutil import datetime import time import unittest from six import assertCountEqual from test import _common from beets.mediafile import MediaFile, Image, \ ImageType, CoverArtField, UnreadableFileError class ArtTestMixin(object): """Test reads and writes of the ``art`` property. """ @property def png_data(self): if not self._png_data: image_file = os.path.join(_common.RSRC, b'image-2x3.png') with open(image_file, 'rb') as f: self._png_data = f.read() return self._png_data _png_data = None @property def jpg_data(self): if not self._jpg_data: image_file = os.path.join(_common.RSRC, b'image-2x3.jpg') with open(image_file, 'rb') as f: self._jpg_data = f.read() return self._jpg_data _jpg_data = None @property def tiff_data(self): if not self._jpg_data: image_file = os.path.join(_common.RSRC, b'image-2x3.tiff') with open(image_file, 'rb') as f: self._jpg_data = f.read() return self._jpg_data _jpg_data = None def test_set_png_art(self): mediafile = self._mediafile_fixture('empty') mediafile.art = self.png_data mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(mediafile.art, self.png_data) def test_set_jpg_art(self): mediafile = self._mediafile_fixture('empty') mediafile.art = self.jpg_data mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(mediafile.art, self.jpg_data) def test_delete_art(self): mediafile = self._mediafile_fixture('empty') mediafile.art = self.jpg_data mediafile.save() mediafile = MediaFile(mediafile.path) self.assertIsNotNone(mediafile.art) del mediafile.art mediafile.save() mediafile = MediaFile(mediafile.path) self.assertIsNone(mediafile.art) class ImageStructureTestMixin(ArtTestMixin): """Test reading and writing multiple image tags. The tests use the `image` media file fixture. The tags of these files include two images, on in the PNG format, the other in JPEG format. If the tag format supports it they also include additional metadata. """ def test_read_image_structures(self): mediafile = self._mediafile_fixture('image') self.assertEqual(len(mediafile.images), 2) image = next(i for i in mediafile.images if i.mime_type == 'image/png') self.assertEqual(image.data, self.png_data) self.assertExtendedImageAttributes(image, desc=u'album cover', type=ImageType.front) image = next(i for i in mediafile.images if i.mime_type == 'image/jpeg') self.assertEqual(image.data, self.jpg_data) self.assertExtendedImageAttributes(image, desc=u'the artist', type=ImageType.artist) def test_set_image_structure(self): mediafile = self._mediafile_fixture('empty') image = Image(data=self.png_data, desc=u'album cover', type=ImageType.front) mediafile.images = [image] mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(len(mediafile.images), 1) image = mediafile.images[0] self.assertEqual(image.data, self.png_data) self.assertEqual(image.mime_type, 'image/png') self.assertExtendedImageAttributes(image, desc=u'album cover', type=ImageType.front) def test_add_image_structure(self): mediafile = self._mediafile_fixture('image') self.assertEqual(len(mediafile.images), 2) image = Image(data=self.png_data, desc=u'the composer', type=ImageType.composer) mediafile.images += [image] mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(len(mediafile.images), 3) images = (i for i in mediafile.images if i.desc == u'the composer') image = next(images, None) self.assertExtendedImageAttributes( image, desc=u'the composer', type=ImageType.composer ) def test_delete_image_structures(self): mediafile = self._mediafile_fixture('image') self.assertEqual(len(mediafile.images), 2) del mediafile.images mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(len(mediafile.images), 0) def test_guess_cover(self): mediafile = self._mediafile_fixture('image') self.assertEqual(len(mediafile.images), 2) cover = CoverArtField.guess_cover_image(mediafile.images) self.assertEqual(cover.desc, u'album cover') self.assertEqual(mediafile.art, cover.data) def assertExtendedImageAttributes(self, image, **kwargs): # noqa """Ignore extended image attributes in the base tests. """ pass class ExtendedImageStructureTestMixin(ImageStructureTestMixin): """Checks for additional attributes in the image structure. Like the base `ImageStructureTestMixin`, per-format test classes should include this mixin to add image-related tests. """ def assertExtendedImageAttributes(self, image, desc=None, type=None): # noqa self.assertEqual(image.desc, desc) self.assertEqual(image.type, type) def test_add_tiff_image(self): mediafile = self._mediafile_fixture('image') self.assertEqual(len(mediafile.images), 2) image = Image(data=self.tiff_data, desc=u'the composer', type=ImageType.composer) mediafile.images += [image] mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(len(mediafile.images), 3) # WMA does not preserve the order, so we have to work around this image = list(filter(lambda i: i.mime_type == 'image/tiff', mediafile.images))[0] self.assertExtendedImageAttributes( image, desc=u'the composer', type=ImageType.composer) class LazySaveTestMixin(object): """Mediafile should only write changes when tags have changed """ @unittest.skip(u'not yet implemented') def test_unmodified(self): mediafile = self._mediafile_fixture('full') mtime = self._set_past_mtime(mediafile.path) self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) mediafile.save() self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) @unittest.skip(u'not yet implemented') def test_same_tag_value(self): mediafile = self._mediafile_fixture('full') mtime = self._set_past_mtime(mediafile.path) self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) mediafile.title = mediafile.title mediafile.save() self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) def test_update_same_tag_value(self): mediafile = self._mediafile_fixture('full') mtime = self._set_past_mtime(mediafile.path) self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) mediafile.update({'title': mediafile.title}) mediafile.save() self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) @unittest.skip(u'not yet implemented') def test_tag_value_change(self): mediafile = self._mediafile_fixture('full') mtime = self._set_past_mtime(mediafile.path) self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) mediafile.title = mediafile.title mediafile.album = u'another' mediafile.save() self.assertNotEqual(os.stat(mediafile.path).st_mtime, mtime) def test_update_changed_tag_value(self): mediafile = self._mediafile_fixture('full') mtime = self._set_past_mtime(mediafile.path) self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) mediafile.update({'title': mediafile.title, 'album': u'another'}) mediafile.save() self.assertNotEqual(os.stat(mediafile.path).st_mtime, mtime) def _set_past_mtime(self, path): mtime = round(time.time() - 10000) os.utime(path, (mtime, mtime)) return mtime class GenreListTestMixin(object): """Tests access to the ``genres`` property as a list. """ def test_read_genre_list(self): mediafile = self._mediafile_fixture('full') assertCountEqual(self, mediafile.genres, ['the genre']) def test_write_genre_list(self): mediafile = self._mediafile_fixture('empty') mediafile.genres = [u'one', u'two'] mediafile.save() mediafile = MediaFile(mediafile.path) assertCountEqual(self, mediafile.genres, [u'one', u'two']) def test_write_genre_list_get_first(self): mediafile = self._mediafile_fixture('empty') mediafile.genres = [u'one', u'two'] mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(mediafile.genre, u'one') def test_append_genre_list(self): mediafile = self._mediafile_fixture('full') self.assertEqual(mediafile.genre, u'the genre') mediafile.genres += [u'another'] mediafile.save() mediafile = MediaFile(mediafile.path) assertCountEqual(self, mediafile.genres, [u'the genre', u'another']) class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin, _common.TempDirMixin): """Test writing and reading tags. Subclasses must set ``extension`` and ``audio_properties``. The basic tests for all audio formats encompass three files provided in our `rsrc` folder: `full.*`, `empty.*`, and `unparseable.*`. Respectively, they should contain a full slate of common fields listed in `full_initial_tags` below; no fields contents at all; and an unparseable release date field. To add support for a new file format to MediaFile, add these three files and then create a `ReadWriteTestBase` subclass by copying n' pasting one of the existing subclasses below. You will want to update the `format` field in that subclass, and you will probably need to fiddle with the `bitrate` and other format-specific fields. You can also add image tests (using an additional `image.*` fixture file) by including one of the image-related mixins. """ full_initial_tags = { 'title': u'full', 'artist': u'the artist', 'album': u'the album', 'genre': u'the genre', 'composer': u'the composer', 'grouping': u'the grouping', 'year': 2001, 'month': None, 'day': None, 'date': datetime.date(2001, 1, 1), 'track': 2, 'tracktotal': 3, 'disc': 4, 'disctotal': 5, 'lyrics': u'the lyrics', 'comments': u'the comments', 'bpm': 6, 'comp': True, 'mb_trackid': '8b882575-08a5-4452-a7a7-cbb8a1531f9e', 'mb_albumid': '9e873859-8aa4-4790-b985-5a953e8ef628', 'mb_artistid': '7cf0ea9d-86b9-4dad-ba9e-2355a64899ea', 'art': None, 'label': u'the label', } tag_fields = [ 'title', 'artist', 'album', 'genre', 'lyricist', 'composer', 'composer_sort', 'arranger', 'grouping', 'year', 'month', 'day', 'date', 'track', 'tracktotal', 'disc', 'disctotal', 'lyrics', 'comments', 'bpm', 'comp', 'mb_trackid', 'mb_albumid', 'mb_artistid', 'art', 'label', 'rg_track_peak', 'rg_track_gain', 'rg_album_peak', 'rg_album_gain', 'r128_track_gain', 'r128_album_gain', 'albumartist', 'mb_albumartistid', 'artist_sort', 'albumartist_sort', 'acoustid_fingerprint', 'acoustid_id', 'mb_releasegroupid', 'asin', 'catalognum', 'disctitle', 'script', 'language', 'country', 'albumstatus', 'media', 'albumdisambig', 'artist_credit', 'albumartist_credit', 'original_year', 'original_month', 'original_day', 'original_date', 'initial_key', ] def setUp(self): self.create_temp_dir() def tearDown(self): self.remove_temp_dir() def test_read_nonexisting(self): mediafile = self._mediafile_fixture('full') os.remove(mediafile.path) self.assertRaises(UnreadableFileError, MediaFile, mediafile.path) def test_save_nonexisting(self): mediafile = self._mediafile_fixture('full') os.remove(mediafile.path) try: mediafile.save() except UnreadableFileError: pass def test_delete_nonexisting(self): mediafile = self._mediafile_fixture('full') os.remove(mediafile.path) try: mediafile.delete() except UnreadableFileError: pass def test_read_audio_properties(self): mediafile = self._mediafile_fixture('full') for key, value in self.audio_properties.items(): if isinstance(value, float): self.assertAlmostEqual(getattr(mediafile, key), value, delta=0.1) else: self.assertEqual(getattr(mediafile, key), value) def test_read_full(self): mediafile = self._mediafile_fixture('full') self.assertTags(mediafile, self.full_initial_tags) def test_read_empty(self): mediafile = self._mediafile_fixture('empty') for field in self.tag_fields: self.assertIsNone(getattr(mediafile, field)) def test_write_empty(self): mediafile = self._mediafile_fixture('empty') tags = self._generate_tags() for key, value in tags.items(): setattr(mediafile, key, value) mediafile.save() mediafile = MediaFile(mediafile.path) self.assertTags(mediafile, tags) def test_update_empty(self): mediafile = self._mediafile_fixture('empty') tags = self._generate_tags() mediafile.update(tags) mediafile.save() mediafile = MediaFile(mediafile.path) self.assertTags(mediafile, tags) def test_overwrite_full(self): mediafile = self._mediafile_fixture('full') tags = self._generate_tags() for key, value in tags.items(): setattr(mediafile, key, value) mediafile.save() # Make sure the tags are already set when writing a second time for key, value in tags.items(): setattr(mediafile, key, value) mediafile.save() mediafile = MediaFile(mediafile.path) self.assertTags(mediafile, tags) def test_update_full(self): mediafile = self._mediafile_fixture('full') tags = self._generate_tags() mediafile.update(tags) mediafile.save() # Make sure the tags are already set when writing a second time mediafile.update(tags) mediafile.save() mediafile = MediaFile(mediafile.path) self.assertTags(mediafile, tags) def test_write_date_components(self): mediafile = self._mediafile_fixture('full') mediafile.year = 2001 mediafile.month = 1 mediafile.day = 2 mediafile.original_year = 1999 mediafile.original_month = 12 mediafile.original_day = 30 mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(mediafile.year, 2001) self.assertEqual(mediafile.month, 1) self.assertEqual(mediafile.day, 2) self.assertEqual(mediafile.date, datetime.date(2001, 1, 2)) self.assertEqual(mediafile.original_year, 1999) self.assertEqual(mediafile.original_month, 12) self.assertEqual(mediafile.original_day, 30) self.assertEqual(mediafile.original_date, datetime.date(1999, 12, 30)) def test_write_incomplete_date_components(self): mediafile = self._mediafile_fixture('empty') mediafile.year = 2001 mediafile.month = None mediafile.day = 2 mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(mediafile.year, 2001) self.assertIsNone(mediafile.month) self.assertIsNone(mediafile.day) self.assertEqual(mediafile.date, datetime.date(2001, 1, 1)) def test_write_dates(self): mediafile = self._mediafile_fixture('full') mediafile.date = datetime.date(2001, 1, 2) mediafile.original_date = datetime.date(1999, 12, 30) mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(mediafile.year, 2001) self.assertEqual(mediafile.month, 1) self.assertEqual(mediafile.day, 2) self.assertEqual(mediafile.date, datetime.date(2001, 1, 2)) self.assertEqual(mediafile.original_year, 1999) self.assertEqual(mediafile.original_month, 12) self.assertEqual(mediafile.original_day, 30) self.assertEqual(mediafile.original_date, datetime.date(1999, 12, 30)) def test_write_packed(self): mediafile = self._mediafile_fixture('empty') mediafile.tracktotal = 2 mediafile.track = 1 mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(mediafile.track, 1) self.assertEqual(mediafile.tracktotal, 2) def test_write_counters_without_total(self): mediafile = self._mediafile_fixture('full') self.assertEqual(mediafile.track, 2) self.assertEqual(mediafile.tracktotal, 3) self.assertEqual(mediafile.disc, 4) self.assertEqual(mediafile.disctotal, 5) mediafile.track = 10 delattr(mediafile, 'tracktotal') mediafile.disc = 10 delattr(mediafile, 'disctotal') mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(mediafile.track, 10) self.assertEqual(mediafile.tracktotal, None) self.assertEqual(mediafile.disc, 10) self.assertEqual(mediafile.disctotal, None) def test_unparseable_date(self): """The `unparseable.*` fixture should not crash but should return None for all parts of the release date. """ mediafile = self._mediafile_fixture('unparseable') self.assertIsNone(mediafile.date) self.assertIsNone(mediafile.year) self.assertIsNone(mediafile.month) self.assertIsNone(mediafile.day) def test_delete_tag(self): mediafile = self._mediafile_fixture('full') keys = self.full_initial_tags.keys() for key in set(keys) - set(['art', 'month', 'day']): self.assertIsNotNone(getattr(mediafile, key)) for key in keys: delattr(mediafile, key) mediafile.save() mediafile = MediaFile(mediafile.path) for key in keys: self.assertIsNone(getattr(mediafile, key)) def test_delete_packed_total(self): mediafile = self._mediafile_fixture('full') delattr(mediafile, 'tracktotal') delattr(mediafile, 'disctotal') mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(mediafile.track, self.full_initial_tags['track']) self.assertEqual(mediafile.disc, self.full_initial_tags['disc']) def test_delete_partial_date(self): mediafile = self._mediafile_fixture('empty') mediafile.date = datetime.date(2001, 12, 3) mediafile.save() mediafile = MediaFile(mediafile.path) self.assertIsNotNone(mediafile.date) self.assertIsNotNone(mediafile.year) self.assertIsNotNone(mediafile.month) self.assertIsNotNone(mediafile.day) delattr(mediafile, 'month') mediafile.save() mediafile = MediaFile(mediafile.path) self.assertIsNotNone(mediafile.date) self.assertIsNotNone(mediafile.year) self.assertIsNone(mediafile.month) self.assertIsNone(mediafile.day) def test_delete_year(self): mediafile = self._mediafile_fixture('full') self.assertIsNotNone(mediafile.date) self.assertIsNotNone(mediafile.year) delattr(mediafile, 'year') mediafile.save() mediafile = MediaFile(mediafile.path) self.assertIsNone(mediafile.date) self.assertIsNone(mediafile.year) def assertTags(self, mediafile, tags): # noqa errors = [] for key, value in tags.items(): try: value2 = getattr(mediafile, key) except AttributeError: errors.append(u'Tag %s does not exist' % key) else: if value2 != value: errors.append(u'Tag %s: %r != %r' % (key, value2, value)) if any(errors): errors = [u'Tags did not match'] + errors self.fail('\n '.join(errors)) def _mediafile_fixture(self, name): name = name + '.' + self.extension if not isinstance(name, bytes): name = name.encode('utf8') src = os.path.join(_common.RSRC, name) target = os.path.join(self.temp_dir, name) shutil.copy(src, target) return MediaFile(target) def _generate_tags(self, base=None): """Return dictionary of tags, mapping tag names to values. """ tags = {} for key in self.tag_fields: if key.startswith('rg_'): # ReplayGain is float tags[key] = 1.0 elif key.startswith('r128_'): # R128 is int tags[key] = -1 else: tags[key] = 'value\u2010%s' % key for key in ['disc', 'disctotal', 'track', 'tracktotal', 'bpm']: tags[key] = 1 tags['art'] = self.jpg_data tags['comp'] = True date = datetime.date(2001, 4, 3) tags['date'] = date tags['year'] = date.year tags['month'] = date.month tags['day'] = date.day original_date = datetime.date(1999, 5, 6) tags['original_date'] = original_date tags['original_year'] = original_date.year tags['original_month'] = original_date.month tags['original_day'] = original_date.day return tags class PartialTestMixin(object): tags_without_total = { 'track': 2, 'tracktotal': 0, 'disc': 4, 'disctotal': 0, } def test_read_track_without_total(self): mediafile = self._mediafile_fixture('partial') self.assertEqual(mediafile.track, 2) self.assertIsNone(mediafile.tracktotal) self.assertEqual(mediafile.disc, 4) self.assertIsNone(mediafile.disctotal) class MP3Test(ReadWriteTestBase, PartialTestMixin, ExtendedImageStructureTestMixin, unittest.TestCase): extension = 'mp3' audio_properties = { 'length': 1.0, 'bitrate': 80000, 'format': 'MP3', 'samplerate': 44100, 'bitdepth': 0, 'channels': 1, } def test_unknown_apic_type(self): mediafile = self._mediafile_fixture('image_unknown_type') self.assertEqual(mediafile.images[0].type, ImageType.other) class MP4Test(ReadWriteTestBase, PartialTestMixin, ImageStructureTestMixin, unittest.TestCase): extension = 'm4a' audio_properties = { 'length': 1.0, 'bitrate': 64000, 'format': 'AAC', 'samplerate': 44100, 'bitdepth': 16, 'channels': 2, } def test_add_tiff_image_fails(self): mediafile = self._mediafile_fixture('empty') with self.assertRaises(ValueError): mediafile.images = [Image(data=self.tiff_data)] def test_guess_cover(self): # There is no metadata associated with images, we pick one at random pass class AlacTest(ReadWriteTestBase, unittest.TestCase): extension = 'alac.m4a' audio_properties = { 'length': 1.0, 'bitrate': 21830, # 'format': 'ALAC', 'samplerate': 44100, 'bitdepth': 16, 'channels': 1, } class MusepackTest(ReadWriteTestBase, unittest.TestCase): extension = 'mpc' audio_properties = { 'length': 1.0, 'bitrate': 23458, 'format': u'Musepack', 'samplerate': 44100, 'bitdepth': 0, 'channels': 2, } class WMATest(ReadWriteTestBase, ExtendedImageStructureTestMixin, unittest.TestCase): extension = 'wma' audio_properties = { 'length': 1.0, 'bitrate': 128000, 'format': u'Windows Media', 'samplerate': 44100, 'bitdepth': 0, 'channels': 1, } def test_write_genre_list_get_first(self): # WMA does not preserve list order mediafile = self._mediafile_fixture('empty') mediafile.genres = [u'one', u'two'] mediafile.save() mediafile = MediaFile(mediafile.path) self.assertIn(mediafile.genre, [u'one', u'two']) def test_read_pure_tags(self): mediafile = self._mediafile_fixture('pure') self.assertEqual(mediafile.comments, u'the comments') self.assertEqual(mediafile.title, u'the title') self.assertEqual(mediafile.artist, u'the artist') class OggTest(ReadWriteTestBase, ExtendedImageStructureTestMixin, unittest.TestCase): extension = 'ogg' audio_properties = { 'length': 1.0, 'bitrate': 48000, 'format': u'OGG', 'samplerate': 44100, 'bitdepth': 0, 'channels': 1, } def test_read_date_from_year_tag(self): mediafile = self._mediafile_fixture('year') self.assertEqual(mediafile.year, 2000) self.assertEqual(mediafile.date, datetime.date(2000, 1, 1)) def test_write_date_to_year_tag(self): mediafile = self._mediafile_fixture('empty') mediafile.year = 2000 mediafile.save() mediafile = MediaFile(mediafile.path) self.assertEqual(mediafile.mgfile['YEAR'], [u'2000']) def test_legacy_coverart_tag(self): mediafile = self._mediafile_fixture('coverart') self.assertTrue('coverart' in mediafile.mgfile) self.assertEqual(mediafile.art, self.png_data) mediafile.art = self.png_data mediafile.save() mediafile = MediaFile(mediafile.path) self.assertFalse('coverart' in mediafile.mgfile) def test_date_tag_with_slashes(self): mediafile = self._mediafile_fixture('date_with_slashes') self.assertEqual(mediafile.year, 2005) self.assertEqual(mediafile.month, 6) self.assertEqual(mediafile.day, 5) class FlacTest(ReadWriteTestBase, PartialTestMixin, ExtendedImageStructureTestMixin, unittest.TestCase): extension = 'flac' audio_properties = { 'length': 1.0, 'bitrate': 108688, 'format': u'FLAC', 'samplerate': 44100, 'bitdepth': 16, 'channels': 1, } class ApeTest(ReadWriteTestBase, ExtendedImageStructureTestMixin, unittest.TestCase): extension = 'ape' audio_properties = { 'length': 1.0, 'bitrate': 112040, 'format': u'APE', 'samplerate': 44100, 'bitdepth': 16, 'channels': 1, } class WavpackTest(ReadWriteTestBase, unittest.TestCase): extension = 'wv' audio_properties = { 'length': 1.0, 'bitrate': 108744, 'format': u'WavPack', 'samplerate': 44100, 'bitdepth': 0, 'channels': 1, } class OpusTest(ReadWriteTestBase, unittest.TestCase): extension = 'opus' audio_properties = { 'length': 1.0, 'bitrate': 57984, 'format': u'Opus', 'samplerate': 48000, 'bitdepth': 0, 'channels': 1, } class AIFFTest(ReadWriteTestBase, unittest.TestCase): extension = 'aiff' audio_properties = { 'length': 1.0, 'bitrate': 705600, 'format': u'AIFF', 'samplerate': 44100, 'bitdepth': 0, 'channels': 1, } # Check whether we have a Mutagen version with DSF support. We can # remove this once we require a version that includes the feature. try: import mutagen.dsf # noqa except ImportError: HAVE_DSF = False else: HAVE_DSF = True @unittest.skipIf(not HAVE_DSF, "Mutagen does not have DSF support") class DSFTest(ReadWriteTestBase, unittest.TestCase): extension = 'dsf' audio_properties = { 'length': 0.01, 'bitrate': 11289600, 'format': u'DSD Stream File', 'samplerate': 5644800, 'bitdepth': 1, 'channels': 2, } class MediaFieldTest(unittest.TestCase): def test_properties_from_fields(self): path = os.path.join(_common.RSRC, b'full.mp3') mediafile = MediaFile(path) for field in MediaFile.fields(): self.assertTrue(hasattr(mediafile, field)) def test_properties_from_readable_fields(self): path = os.path.join(_common.RSRC, b'full.mp3') mediafile = MediaFile(path) for field in MediaFile.readable_fields(): self.assertTrue(hasattr(mediafile, field)) def test_known_fields(self): fields = list(ReadWriteTestBase.tag_fields) fields.extend(('encoder', 'images', 'genres', 'albumtype')) assertCountEqual(self, MediaFile.fields(), fields) def test_fields_in_readable_fields(self): readable = MediaFile.readable_fields() for field in MediaFile.fields(): self.assertIn(field, readable) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_mediafile_edge.py0000644000076500000240000003367213206372465021106 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Specific, edge-case tests for the MediaFile metadata layer. """ from __future__ import division, absolute_import, print_function import os import shutil import unittest import mutagen.id3 from test import _common from beets import mediafile import six _sc = mediafile._safe_cast class EdgeTest(unittest.TestCase): def test_emptylist(self): # Some files have an ID3 frame that has a list with no elements. # This is very hard to produce, so this is just the first 8192 # bytes of a file found "in the wild". emptylist = mediafile.MediaFile( os.path.join(_common.RSRC, b'emptylist.mp3') ) genre = emptylist.genre self.assertEqual(genre, None) def test_release_time_with_space(self): # Ensures that release times delimited by spaces are ignored. # Amie Street produces such files. space_time = mediafile.MediaFile( os.path.join(_common.RSRC, b'space_time.mp3') ) self.assertEqual(space_time.year, 2009) self.assertEqual(space_time.month, 9) self.assertEqual(space_time.day, 4) def test_release_time_with_t(self): # Ensures that release times delimited by Ts are ignored. # The iTunes Store produces such files. t_time = mediafile.MediaFile( os.path.join(_common.RSRC, b't_time.m4a') ) self.assertEqual(t_time.year, 1987) self.assertEqual(t_time.month, 3) self.assertEqual(t_time.day, 31) def test_tempo_with_bpm(self): # Some files have a string like "128 BPM" in the tempo field # rather than just a number. f = mediafile.MediaFile(os.path.join(_common.RSRC, b'bpm.mp3')) self.assertEqual(f.bpm, 128) def test_discc_alternate_field(self): # Different taggers use different vorbis comments to reflect # the disc and disc count fields: ensure that the alternative # style works. f = mediafile.MediaFile(os.path.join(_common.RSRC, b'discc.ogg')) self.assertEqual(f.disc, 4) self.assertEqual(f.disctotal, 5) def test_old_ape_version_bitrate(self): media_file = os.path.join(_common.RSRC, b'oldape.ape') f = mediafile.MediaFile(media_file) self.assertEqual(f.bitrate, 0) def test_only_magic_bytes_jpeg(self): # Some jpeg files can only be recognized by their magic bytes and as # such aren't recognized by imghdr. Ensure that this still works thanks # to our own follow up mimetype detection based on # https://github.com/file/file/blob/master/magic/Magdir/jpeg#L12 magic_bytes_file = os.path.join(_common.RSRC, b'only-magic-bytes.jpg') with open(magic_bytes_file, 'rb') as f: jpg_data = f.read() self.assertEqual( mediafile._imghdr_what_wrapper(jpg_data), 'jpeg') def test_soundcheck_non_ascii(self): # Make sure we don't crash when the iTunes SoundCheck field contains # non-ASCII binary data. f = mediafile.MediaFile(os.path.join(_common.RSRC, b'soundcheck-nonascii.m4a')) self.assertEqual(f.rg_track_gain, 0.0) class InvalidValueToleranceTest(unittest.TestCase): def test_safe_cast_string_to_int(self): self.assertEqual(_sc(int, u'something'), 0) def test_safe_cast_string_to_int_with_no_numbers(self): self.assertEqual(_sc(int, u'-'), 0) def test_safe_cast_int_string_to_int(self): self.assertEqual(_sc(int, u'20'), 20) def test_safe_cast_string_to_bool(self): self.assertEqual(_sc(bool, u'whatever'), False) def test_safe_cast_intstring_to_bool(self): self.assertEqual(_sc(bool, u'5'), True) def test_safe_cast_string_to_float(self): self.assertAlmostEqual(_sc(float, u'1.234'), 1.234) def test_safe_cast_int_to_float(self): self.assertAlmostEqual(_sc(float, 2), 2.0) def test_safe_cast_string_with_cruft_to_float(self): self.assertAlmostEqual(_sc(float, u'1.234stuff'), 1.234) def test_safe_cast_negative_string_to_float(self): self.assertAlmostEqual(_sc(float, u'-1.234'), -1.234) def test_safe_cast_special_chars_to_unicode(self): us = _sc(six.text_type, 'caf\xc3\xa9') self.assertTrue(isinstance(us, six.text_type)) self.assertTrue(us.startswith(u'caf')) def test_safe_cast_float_with_no_numbers(self): v = _sc(float, u'+') self.assertEqual(v, 0.0) def test_safe_cast_float_with_dot_only(self): v = _sc(float, u'.') self.assertEqual(v, 0.0) def test_safe_cast_float_with_multiple_dots(self): v = _sc(float, u'1.0.0') self.assertEqual(v, 1.0) class SafetyTest(unittest.TestCase, _common.TempDirMixin): def setUp(self): self.create_temp_dir() def tearDown(self): self.remove_temp_dir() def _exccheck(self, fn, exc, data=''): fn = os.path.join(self.temp_dir, fn) with open(fn, 'w') as f: f.write(data) try: self.assertRaises(exc, mediafile.MediaFile, fn) finally: os.unlink(fn) # delete the temporary file def test_corrupt_mp3_raises_unreadablefileerror(self): # Make sure we catch Mutagen reading errors appropriately. self._exccheck(b'corrupt.mp3', mediafile.UnreadableFileError) def test_corrupt_mp4_raises_unreadablefileerror(self): self._exccheck(b'corrupt.m4a', mediafile.UnreadableFileError) def test_corrupt_flac_raises_unreadablefileerror(self): self._exccheck(b'corrupt.flac', mediafile.UnreadableFileError) def test_corrupt_ogg_raises_unreadablefileerror(self): self._exccheck(b'corrupt.ogg', mediafile.UnreadableFileError) def test_invalid_ogg_header_raises_unreadablefileerror(self): self._exccheck(b'corrupt.ogg', mediafile.UnreadableFileError, 'OggS\x01vorbis') def test_corrupt_monkeys_raises_unreadablefileerror(self): self._exccheck(b'corrupt.ape', mediafile.UnreadableFileError) def test_invalid_extension_raises_filetypeerror(self): self._exccheck(b'something.unknown', mediafile.FileTypeError) def test_magic_xml_raises_unreadablefileerror(self): self._exccheck(b'nothing.xml', mediafile.UnreadableFileError, "ftyp") @unittest.skipUnless(_common.HAVE_SYMLINK, u'platform lacks symlink') def test_broken_symlink(self): fn = os.path.join(_common.RSRC, b'brokenlink') os.symlink('does_not_exist', fn) try: self.assertRaises(mediafile.UnreadableFileError, mediafile.MediaFile, fn) finally: os.unlink(fn) class SideEffectsTest(unittest.TestCase): def setUp(self): self.empty = os.path.join(_common.RSRC, b'empty.mp3') def test_opening_tagless_file_leaves_untouched(self): old_mtime = os.stat(self.empty).st_mtime mediafile.MediaFile(self.empty) new_mtime = os.stat(self.empty).st_mtime self.assertEqual(old_mtime, new_mtime) class MP4EncodingTest(unittest.TestCase, _common.TempDirMixin): def setUp(self): self.create_temp_dir() src = os.path.join(_common.RSRC, b'full.m4a') self.path = os.path.join(self.temp_dir, b'test.m4a') shutil.copy(src, self.path) self.mf = mediafile.MediaFile(self.path) def tearDown(self): self.remove_temp_dir() def test_unicode_label_in_m4a(self): self.mf.label = u'foo\xe8bar' self.mf.save() new_mf = mediafile.MediaFile(self.path) self.assertEqual(new_mf.label, u'foo\xe8bar') class MP3EncodingTest(unittest.TestCase, _common.TempDirMixin): def setUp(self): self.create_temp_dir() src = os.path.join(_common.RSRC, b'full.mp3') self.path = os.path.join(self.temp_dir, b'test.mp3') shutil.copy(src, self.path) self.mf = mediafile.MediaFile(self.path) def test_comment_with_latin1_encoding(self): # Set up the test file with a Latin1-encoded COMM frame. The encoding # indices defined by MP3 are listed here: # http://id3.org/id3v2.4.0-structure self.mf.mgfile['COMM::eng'].encoding = 0 # Try to store non-Latin1 text. self.mf.comments = u'\u2028' self.mf.save() class ZeroLengthMediaFile(mediafile.MediaFile): @property def length(self): return 0.0 class MissingAudioDataTest(unittest.TestCase): def setUp(self): super(MissingAudioDataTest, self).setUp() path = os.path.join(_common.RSRC, b'full.mp3') self.mf = ZeroLengthMediaFile(path) def test_bitrate_with_zero_length(self): del self.mf.mgfile.info.bitrate # Not available directly. self.assertEqual(self.mf.bitrate, 0) class TypeTest(unittest.TestCase): def setUp(self): super(TypeTest, self).setUp() path = os.path.join(_common.RSRC, b'full.mp3') self.mf = mediafile.MediaFile(path) def test_year_integer_in_string(self): self.mf.year = u'2009' self.assertEqual(self.mf.year, 2009) def test_set_replaygain_gain_to_none(self): self.mf.rg_track_gain = None self.assertEqual(self.mf.rg_track_gain, 0.0) def test_set_replaygain_peak_to_none(self): self.mf.rg_track_peak = None self.assertEqual(self.mf.rg_track_peak, 0.0) def test_set_year_to_none(self): self.mf.year = None self.assertIsNone(self.mf.year) def test_set_track_to_none(self): self.mf.track = None self.assertEqual(self.mf.track, 0) def test_set_date_to_none(self): self.mf.date = None self.assertIsNone(self.mf.date) self.assertIsNone(self.mf.year) self.assertIsNone(self.mf.month) self.assertIsNone(self.mf.day) class SoundCheckTest(unittest.TestCase): def test_round_trip(self): data = mediafile._sc_encode(1.0, 1.0) gain, peak = mediafile._sc_decode(data) self.assertEqual(gain, 1.0) self.assertEqual(peak, 1.0) def test_decode_zero(self): data = b' 80000000 80000000 00000000 00000000 00000000 00000000 ' \ b'00000000 00000000 00000000 00000000' gain, peak = mediafile._sc_decode(data) self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) def test_malformatted(self): gain, peak = mediafile._sc_decode(b'foo') self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) def test_special_characters(self): gain, peak = mediafile._sc_decode(u'caf\xe9'.encode('utf-8')) self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) def test_decode_handles_unicode(self): # Most of the time, we expect to decode the raw bytes. But some formats # might give us text strings, which we need to handle. gain, peak = mediafile._sc_decode(u'caf\xe9') self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) class ID3v23Test(unittest.TestCase, _common.TempDirMixin): def _make_test(self, ext=b'mp3', id3v23=False): self.create_temp_dir() src = os.path.join(_common.RSRC, b'full.' + ext) self.path = os.path.join(self.temp_dir, b'test.' + ext) shutil.copy(src, self.path) return mediafile.MediaFile(self.path, id3v23=id3v23) def _delete_test(self): self.remove_temp_dir() def test_v24_year_tag(self): mf = self._make_test(id3v23=False) try: mf.year = 2013 mf.save() frame = mf.mgfile['TDRC'] self.assertTrue('2013' in six.text_type(frame)) self.assertTrue('TYER' not in mf.mgfile) finally: self._delete_test() def test_v23_year_tag(self): mf = self._make_test(id3v23=True) try: mf.year = 2013 mf.save() frame = mf.mgfile['TYER'] self.assertTrue('2013' in six.text_type(frame)) self.assertTrue('TDRC' not in mf.mgfile) finally: self._delete_test() def test_v23_on_non_mp3_is_noop(self): mf = self._make_test(b'm4a', id3v23=True) try: mf.year = 2013 mf.save() finally: self._delete_test() def test_image_encoding(self): """For compatibility with OS X/iTunes. See https://github.com/beetbox/beets/issues/899#issuecomment-62437773 """ for v23 in [True, False]: mf = self._make_test(id3v23=v23) try: mf.images = [ mediafile.Image(b'data', desc=u""), mediafile.Image(b'data', desc=u"foo"), mediafile.Image(b'data', desc=u"\u0185"), ] mf.save() apic_frames = mf.mgfile.tags.getall('APIC') encodings = dict([(f.desc, f.encoding) for f in apic_frames]) self.assertEqual(encodings, { u"": mutagen.id3.Encoding.LATIN1, u"foo": mutagen.id3.Encoding.LATIN1, u"\u0185": mutagen.id3.Encoding.UTF16, }) finally: self._delete_test() def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_spotify.py0000644000076500000240000000674513025125203017662 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- """Tests for the 'spotify' plugin""" from __future__ import division, absolute_import, print_function import os import responses import unittest from test import _common from beets import config from beets.library import Item from beetsplug import spotify from test.helper import TestHelper from six.moves.urllib.parse import parse_qs, urlparse class ArgumentsMock(object): def __init__(self, mode, show_failures): self.mode = mode self.show_failures = show_failures self.verbose = 1 def _params(url): """Get the query parameters from a URL.""" return parse_qs(urlparse(url).query) class SpotifyPluginTest(_common.TestCase, TestHelper): def setUp(self): config.clear() self.setup_beets() self.spotify = spotify.SpotifyPlugin() opts = ArgumentsMock("list", False) self.spotify.parse_opts(opts) def tearDown(self): self.teardown_beets() def test_args(self): opts = ArgumentsMock("fail", True) self.assertEqual(False, self.spotify.parse_opts(opts)) opts = ArgumentsMock("list", False) self.assertEqual(True, self.spotify.parse_opts(opts)) def test_empty_query(self): self.assertEqual(None, self.spotify.query_spotify(self.lib, u"1=2")) @responses.activate def test_missing_request(self): json_file = os.path.join(_common.RSRC, b'spotify', b'missing_request.json') with open(json_file, 'rb') as f: response_body = f.read() responses.add(responses.GET, 'https://api.spotify.com/v1/search', body=response_body, status=200, content_type='application/json') item = Item( mb_trackid=u'01234', album=u'lkajsdflakjsd', albumartist=u'ujydfsuihse', title=u'duifhjslkef', length=10 ) item.add(self.lib) self.assertEqual([], self.spotify.query_spotify(self.lib, u"")) params = _params(responses.calls[0].request.url) self.assertEqual( params['q'], [u'duifhjslkef album:lkajsdflakjsd artist:ujydfsuihse'], ) self.assertEqual(params['type'], [u'track']) @responses.activate def test_track_request(self): json_file = os.path.join(_common.RSRC, b'spotify', b'track_request.json') with open(json_file, 'rb') as f: response_body = f.read() responses.add(responses.GET, 'https://api.spotify.com/v1/search', body=response_body, status=200, content_type='application/json') item = Item( mb_trackid=u'01234', album=u'Despicable Me 2', albumartist=u'Pharrell Williams', title=u'Happy', length=10 ) item.add(self.lib) results = self.spotify.query_spotify(self.lib, u"Happy") self.assertEqual(1, len(results)) self.assertEqual(u"6NPVjNh8Jhru9xOmyQigds", results[0]['id']) self.spotify.output_results(results) params = _params(responses.calls[0].request.url) self.assertEqual( params['q'], [u'Happy album:Despicable Me 2 artist:Pharrell Williams'], ) self.assertEqual(params['type'], [u'track']) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_template.py0000644000076500000240000002521313122272074017776 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for template engine. """ from __future__ import division, absolute_import, print_function import unittest import six from beets.util import functemplate def _normexpr(expr): """Normalize an Expression object's parts, collapsing multiple adjacent text blocks and removing empty text blocks. Generates a sequence of parts. """ textbuf = [] for part in expr.parts: if isinstance(part, six.string_types): textbuf.append(part) else: if textbuf: text = u''.join(textbuf) if text: yield text textbuf = [] yield part if textbuf: text = u''.join(textbuf) if text: yield text def _normparse(text): """Parse a template and then normalize the resulting Expression.""" return _normexpr(functemplate._parse(text)) class ParseTest(unittest.TestCase): def test_empty_string(self): self.assertEqual(list(_normparse(u'')), []) def _assert_symbol(self, obj, ident): """Assert that an object is a Symbol with the given identifier. """ self.assertTrue(isinstance(obj, functemplate.Symbol), u"not a Symbol: %s" % repr(obj)) self.assertEqual(obj.ident, ident, u"wrong identifier: %s vs. %s" % (repr(obj.ident), repr(ident))) def _assert_call(self, obj, ident, numargs): """Assert that an object is a Call with the given identifier and argument count. """ self.assertTrue(isinstance(obj, functemplate.Call), u"not a Call: %s" % repr(obj)) self.assertEqual(obj.ident, ident, u"wrong identifier: %s vs. %s" % (repr(obj.ident), repr(ident))) self.assertEqual(len(obj.args), numargs, u"wrong argument count in %s: %i vs. %i" % (repr(obj.ident), len(obj.args), numargs)) def test_plain_text(self): self.assertEqual(list(_normparse(u'hello world')), [u'hello world']) def test_escaped_character_only(self): self.assertEqual(list(_normparse(u'$$')), [u'$']) def test_escaped_character_in_text(self): self.assertEqual(list(_normparse(u'a $$ b')), [u'a $ b']) def test_escaped_character_at_start(self): self.assertEqual(list(_normparse(u'$$ hello')), [u'$ hello']) def test_escaped_character_at_end(self): self.assertEqual(list(_normparse(u'hello $$')), [u'hello $']) def test_escaped_function_delim(self): self.assertEqual(list(_normparse(u'a $% b')), [u'a % b']) def test_escaped_sep(self): self.assertEqual(list(_normparse(u'a $, b')), [u'a , b']) def test_escaped_close_brace(self): self.assertEqual(list(_normparse(u'a $} b')), [u'a } b']) def test_bare_value_delim_kept_intact(self): self.assertEqual(list(_normparse(u'a $ b')), [u'a $ b']) def test_bare_function_delim_kept_intact(self): self.assertEqual(list(_normparse(u'a % b')), [u'a % b']) def test_bare_opener_kept_intact(self): self.assertEqual(list(_normparse(u'a { b')), [u'a { b']) def test_bare_closer_kept_intact(self): self.assertEqual(list(_normparse(u'a } b')), [u'a } b']) def test_bare_sep_kept_intact(self): self.assertEqual(list(_normparse(u'a , b')), [u'a , b']) def test_symbol_alone(self): parts = list(_normparse(u'$foo')) self.assertEqual(len(parts), 1) self._assert_symbol(parts[0], u"foo") def test_symbol_in_text(self): parts = list(_normparse(u'hello $foo world')) self.assertEqual(len(parts), 3) self.assertEqual(parts[0], u'hello ') self._assert_symbol(parts[1], u"foo") self.assertEqual(parts[2], u' world') def test_symbol_with_braces(self): parts = list(_normparse(u'hello${foo}world')) self.assertEqual(len(parts), 3) self.assertEqual(parts[0], u'hello') self._assert_symbol(parts[1], u"foo") self.assertEqual(parts[2], u'world') def test_unclosed_braces_symbol(self): self.assertEqual(list(_normparse(u'a ${ b')), [u'a ${ b']) def test_empty_braces_symbol(self): self.assertEqual(list(_normparse(u'a ${} b')), [u'a ${} b']) def test_call_without_args_at_end(self): self.assertEqual(list(_normparse(u'foo %bar')), [u'foo %bar']) def test_call_without_args(self): self.assertEqual(list(_normparse(u'foo %bar baz')), [u'foo %bar baz']) def test_call_with_unclosed_args(self): self.assertEqual(list(_normparse(u'foo %bar{ baz')), [u'foo %bar{ baz']) def test_call_with_unclosed_multiple_args(self): self.assertEqual(list(_normparse(u'foo %bar{bar,bar baz')), [u'foo %bar{bar,bar baz']) def test_call_empty_arg(self): parts = list(_normparse(u'%foo{}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), []) def test_call_single_arg(self): parts = list(_normparse(u'%foo{bar}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), [u'bar']) def test_call_two_args(self): parts = list(_normparse(u'%foo{bar,baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 2) self.assertEqual(list(_normexpr(parts[0].args[0])), [u'bar']) self.assertEqual(list(_normexpr(parts[0].args[1])), [u'baz']) def test_call_with_escaped_sep(self): parts = list(_normparse(u'%foo{bar$,baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), [u'bar,baz']) def test_call_with_escaped_close(self): parts = list(_normparse(u'%foo{bar$}baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), [u'bar}baz']) def test_call_with_symbol_argument(self): parts = list(_normparse(u'%foo{$bar,baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 2) arg_parts = list(_normexpr(parts[0].args[0])) self.assertEqual(len(arg_parts), 1) self._assert_symbol(arg_parts[0], u"bar") self.assertEqual(list(_normexpr(parts[0].args[1])), [u"baz"]) def test_call_with_nested_call_argument(self): parts = list(_normparse(u'%foo{%bar{},baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 2) arg_parts = list(_normexpr(parts[0].args[0])) self.assertEqual(len(arg_parts), 1) self._assert_call(arg_parts[0], u"bar", 1) self.assertEqual(list(_normexpr(parts[0].args[1])), [u"baz"]) def test_nested_call_with_argument(self): parts = list(_normparse(u'%foo{%bar{baz}}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 1) arg_parts = list(_normexpr(parts[0].args[0])) self.assertEqual(len(arg_parts), 1) self._assert_call(arg_parts[0], u"bar", 1) self.assertEqual(list(_normexpr(arg_parts[0].args[0])), [u'baz']) def test_sep_before_call_two_args(self): parts = list(_normparse(u'hello, %foo{bar,baz}')) self.assertEqual(len(parts), 2) self.assertEqual(parts[0], u'hello, ') self._assert_call(parts[1], u"foo", 2) self.assertEqual(list(_normexpr(parts[1].args[0])), [u'bar']) self.assertEqual(list(_normexpr(parts[1].args[1])), [u'baz']) def test_sep_with_symbols(self): parts = list(_normparse(u'hello,$foo,$bar')) self.assertEqual(len(parts), 4) self.assertEqual(parts[0], u'hello,') self._assert_symbol(parts[1], u"foo") self.assertEqual(parts[2], u',') self._assert_symbol(parts[3], u"bar") def test_newline_at_end(self): parts = list(_normparse(u'foo\n')) self.assertEqual(len(parts), 1) self.assertEqual(parts[0], u'foo\n') class EvalTest(unittest.TestCase): def _eval(self, template): values = { u'foo': u'bar', u'baz': u'BaR', } functions = { u'lower': six.text_type.lower, u'len': len, } return functemplate.Template(template).substitute(values, functions) def test_plain_text(self): self.assertEqual(self._eval(u"foo"), u"foo") def test_subtitute_value(self): self.assertEqual(self._eval(u"$foo"), u"bar") def test_subtitute_value_in_text(self): self.assertEqual(self._eval(u"hello $foo world"), u"hello bar world") def test_not_subtitute_undefined_value(self): self.assertEqual(self._eval(u"$bar"), u"$bar") def test_function_call(self): self.assertEqual(self._eval(u"%lower{FOO}"), u"foo") def test_function_call_with_text(self): self.assertEqual(self._eval(u"A %lower{FOO} B"), u"A foo B") def test_nested_function_call(self): self.assertEqual(self._eval(u"%lower{%lower{FOO}}"), u"foo") def test_symbol_in_argument(self): self.assertEqual(self._eval(u"%lower{$baz}"), u"bar") def test_function_call_exception(self): res = self._eval(u"%lower{a,b,c,d,e}") self.assertTrue(isinstance(res, six.string_types)) def test_function_returning_integer(self): self.assertEqual(self._eval(u"%len{foo}"), u"3") def test_not_subtitute_undefined_func(self): self.assertEqual(self._eval(u"%bar{}"), u"%bar{}") def test_not_subtitute_func_with_no_args(self): self.assertEqual(self._eval(u"%lower"), u"%lower") def test_function_call_with_empty_arg(self): self.assertEqual(self._eval(u"%len{}"), u"0") def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_art.py0000644000076500000240000006211713164763003016760 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the album art fetchers.""" from __future__ import division, absolute_import, print_function import os import shutil import unittest import responses from mock import patch from test import _common from beetsplug import fetchart from beets.autotag import AlbumInfo, AlbumMatch from beets import config from beets import library from beets import importer from beets import logging from beets import util from beets.util.artresizer import ArtResizer, WEBPROXY from beets.util import confit logger = logging.getLogger('beets.test_art') class Settings(): """Used to pass settings to the ArtSources when the plugin isn't fully instantiated. """ def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) class UseThePlugin(_common.TestCase): def setUp(self): super(UseThePlugin, self).setUp() self.plugin = fetchart.FetchArtPlugin() class FetchImageHelper(_common.TestCase): """Helper mixin for mocking requests when fetching images with remote art sources. """ @responses.activate def run(self, *args, **kwargs): super(FetchImageHelper, self).run(*args, **kwargs) IMAGEHEADER = {'image/jpeg': b'\x00' * 6 + b'JFIF', 'image/png': b'\211PNG\r\n\032\n', } def mock_response(self, url, content_type='image/jpeg', file_type=None): if file_type is None: file_type = content_type responses.add(responses.GET, url, content_type=content_type, # imghdr reads 32 bytes body=self.IMAGEHEADER.get( file_type, b'').ljust(32, b'\x00')) class FetchImageTest(FetchImageHelper, UseThePlugin): URL = 'http://example.com/test.jpg' def setUp(self): super(FetchImageTest, self).setUp() self.dpath = os.path.join(self.temp_dir, b'arttest') self.source = fetchart.RemoteArtSource(logger, self.plugin.config) self.settings = Settings(maxwidth=0) self.candidate = fetchart.Candidate(logger, url=self.URL) def test_invalid_type_returns_none(self): self.mock_response(self.URL, 'image/watercolour') self.source.fetch_image(self.candidate, self.settings) self.assertEqual(self.candidate.path, None) def test_jpeg_type_returns_path(self): self.mock_response(self.URL, 'image/jpeg') self.source.fetch_image(self.candidate, self.settings) self.assertNotEqual(self.candidate.path, None) def test_extension_set_by_content_type(self): self.mock_response(self.URL, 'image/png') self.source.fetch_image(self.candidate, self.settings) self.assertEqual(os.path.splitext(self.candidate.path)[1], b'.png') self.assertExists(self.candidate.path) def test_does_not_rely_on_server_content_type(self): self.mock_response(self.URL, 'image/jpeg', 'image/png') self.source.fetch_image(self.candidate, self.settings) self.assertEqual(os.path.splitext(self.candidate.path)[1], b'.png') self.assertExists(self.candidate.path) class FSArtTest(UseThePlugin): def setUp(self): super(FSArtTest, self).setUp() self.dpath = os.path.join(self.temp_dir, b'arttest') os.mkdir(self.dpath) self.source = fetchart.FileSystem(logger, self.plugin.config) self.settings = Settings(cautious=False, cover_names=('art',)) def test_finds_jpg_in_directory(self): _common.touch(os.path.join(self.dpath, b'a.jpg')) candidate = next(self.source.get(None, self.settings, [self.dpath])) self.assertEqual(candidate.path, os.path.join(self.dpath, b'a.jpg')) def test_appropriately_named_file_takes_precedence(self): _common.touch(os.path.join(self.dpath, b'a.jpg')) _common.touch(os.path.join(self.dpath, b'art.jpg')) candidate = next(self.source.get(None, self.settings, [self.dpath])) self.assertEqual(candidate.path, os.path.join(self.dpath, b'art.jpg')) def test_non_image_file_not_identified(self): _common.touch(os.path.join(self.dpath, b'a.txt')) with self.assertRaises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) def test_cautious_skips_fallback(self): _common.touch(os.path.join(self.dpath, b'a.jpg')) self.settings.cautious = True with self.assertRaises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) def test_empty_dir(self): with self.assertRaises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) def test_precedence_amongst_correct_files(self): images = [b'front-cover.jpg', b'front.jpg', b'back.jpg'] paths = [os.path.join(self.dpath, i) for i in images] for p in paths: _common.touch(p) self.settings.cover_names = ['cover', 'front', 'back'] candidates = [candidate.path for candidate in self.source.get(None, self.settings, [self.dpath])] self.assertEqual(candidates, paths) class CombinedTest(FetchImageHelper, UseThePlugin): ASIN = 'xxxx' MBID = 'releaseid' AMAZON_URL = 'http://images.amazon.com/images/P/{0}.01.LZZZZZZZ.jpg' \ .format(ASIN) AAO_URL = 'http://www.albumart.org/index_detail.php?asin={0}' \ .format(ASIN) CAA_URL = 'coverartarchive.org/release/{0}/front' \ .format(MBID) def setUp(self): super(CombinedTest, self).setUp() self.dpath = os.path.join(self.temp_dir, b'arttest') os.mkdir(self.dpath) def test_main_interface_returns_amazon_art(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) candidate = self.plugin.art_for_album(album, None) self.assertIsNotNone(candidate) def test_main_interface_returns_none_for_missing_asin_and_path(self): album = _common.Bag() candidate = self.plugin.art_for_album(album, None) self.assertIsNone(candidate) def test_main_interface_gives_precedence_to_fs_art(self): _common.touch(os.path.join(self.dpath, b'art.jpg')) self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) candidate = self.plugin.art_for_album(album, [self.dpath]) self.assertIsNotNone(candidate) self.assertEqual(candidate.path, os.path.join(self.dpath, b'art.jpg')) def test_main_interface_falls_back_to_amazon(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) candidate = self.plugin.art_for_album(album, [self.dpath]) self.assertIsNotNone(candidate) self.assertFalse(candidate.path.startswith(self.dpath)) def test_main_interface_tries_amazon_before_aao(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) self.plugin.art_for_album(album, [self.dpath]) self.assertEqual(len(responses.calls), 1) self.assertEqual(responses.calls[0].request.url, self.AMAZON_URL) def test_main_interface_falls_back_to_aao(self): self.mock_response(self.AMAZON_URL, content_type='text/html') album = _common.Bag(asin=self.ASIN) self.plugin.art_for_album(album, [self.dpath]) self.assertEqual(responses.calls[-1].request.url, self.AAO_URL) def test_main_interface_uses_caa_when_mbid_available(self): self.mock_response("http://" + self.CAA_URL) self.mock_response("https://" + self.CAA_URL) album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) candidate = self.plugin.art_for_album(album, None) self.assertIsNotNone(candidate) self.assertEqual(len(responses.calls), 1) if util.SNI_SUPPORTED: url = "https://" + self.CAA_URL else: url = "http://" + self.CAA_URL self.assertEqual(responses.calls[0].request.url, url) def test_local_only_does_not_access_network(self): album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) self.plugin.art_for_album(album, None, local_only=True) self.assertEqual(len(responses.calls), 0) def test_local_only_gets_fs_image(self): _common.touch(os.path.join(self.dpath, b'art.jpg')) album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) candidate = self.plugin.art_for_album(album, [self.dpath], local_only=True) self.assertIsNotNone(candidate) self.assertEqual(candidate.path, os.path.join(self.dpath, b'art.jpg')) self.assertEqual(len(responses.calls), 0) class AAOTest(UseThePlugin): ASIN = 'xxxx' AAO_URL = 'http://www.albumart.org/index_detail.php?asin={0}'.format(ASIN) def setUp(self): super(AAOTest, self).setUp() self.source = fetchart.AlbumArtOrg(logger, self.plugin.config) self.settings = Settings() @responses.activate def run(self, *args, **kwargs): super(AAOTest, self).run(*args, **kwargs) def mock_response(self, url, body): responses.add(responses.GET, url, body=body, content_type='text/html', match_querystring=True) def test_aao_scraper_finds_image(self): body = """
\"View """ self.mock_response(self.AAO_URL, body) album = _common.Bag(asin=self.ASIN) candidate = next(self.source.get(album, self.settings, [])) self.assertEqual(candidate.url, 'TARGET_URL') def test_aao_scraper_returns_no_result_when_no_image_present(self): self.mock_response(self.AAO_URL, 'blah blah') album = _common.Bag(asin=self.ASIN) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) class GoogleImageTest(UseThePlugin): def setUp(self): super(GoogleImageTest, self).setUp() self.source = fetchart.GoogleImages(logger, self.plugin.config) self.settings = Settings() @responses.activate def run(self, *args, **kwargs): super(GoogleImageTest, self).run(*args, **kwargs) def mock_response(self, url, json): responses.add(responses.GET, url, body=json, content_type='application/json') def test_google_art_finds_image(self): album = _common.Bag(albumartist="some artist", album="some album") json = '{"items": [{"link": "url_to_the_image"}]}' self.mock_response(fetchart.GoogleImages.URL, json) candidate = next(self.source.get(album, self.settings, [])) self.assertEqual(candidate.url, 'url_to_the_image') def test_google_art_returns_no_result_when_error_received(self): album = _common.Bag(albumartist="some artist", album="some album") json = '{"error": {"errors": [{"reason": "some reason"}]}}' self.mock_response(fetchart.GoogleImages.URL, json) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) def test_google_art_returns_no_result_with_malformed_response(self): album = _common.Bag(albumartist="some artist", album="some album") json = """bla blup""" self.mock_response(fetchart.GoogleImages.URL, json) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) class FanartTVTest(UseThePlugin): RESPONSE_MULTIPLE = u"""{ "name": "artistname", "mbid_id": "artistid", "albums": { "thereleasegroupid": { "albumcover": [ { "id": "24", "url": "http://example.com/1.jpg", "likes": "0" }, { "id": "42", "url": "http://example.com/2.jpg", "likes": "0" }, { "id": "23", "url": "http://example.com/3.jpg", "likes": "0" } ], "cdart": [ { "id": "123", "url": "http://example.com/4.jpg", "likes": "0", "disc": "1", "size": "1000" } ] } } }""" RESPONSE_NO_ART = u"""{ "name": "artistname", "mbid_id": "artistid", "albums": { "thereleasegroupid": { "cdart": [ { "id": "123", "url": "http://example.com/4.jpg", "likes": "0", "disc": "1", "size": "1000" } ] } } }""" RESPONSE_ERROR = u"""{ "status": "error", "error message": "the error message" }""" RESPONSE_MALFORMED = u"bla blup" def setUp(self): super(FanartTVTest, self).setUp() self.source = fetchart.FanartTV(logger, self.plugin.config) self.settings = Settings() @responses.activate def run(self, *args, **kwargs): super(FanartTVTest, self).run(*args, **kwargs) def mock_response(self, url, json): responses.add(responses.GET, url, body=json, content_type='application/json') def test_fanarttv_finds_image(self): album = _common.Bag(mb_releasegroupid=u'thereleasegroupid') self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid', self.RESPONSE_MULTIPLE) candidate = next(self.source.get(album, self.settings, [])) self.assertEqual(candidate.url, 'http://example.com/1.jpg') def test_fanarttv_returns_no_result_when_error_received(self): album = _common.Bag(mb_releasegroupid=u'thereleasegroupid') self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid', self.RESPONSE_ERROR) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) def test_fanarttv_returns_no_result_with_malformed_response(self): album = _common.Bag(mb_releasegroupid=u'thereleasegroupid') self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid', self.RESPONSE_MALFORMED) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) def test_fanarttv_only_other_images(self): # The source used to fail when there were images present, but no cover album = _common.Bag(mb_releasegroupid=u'thereleasegroupid') self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid', self.RESPONSE_NO_ART) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) @_common.slow_test() class ArtImporterTest(UseThePlugin): def setUp(self): super(ArtImporterTest, self).setUp() # Mock the album art fetcher to always return our test file. self.art_file = os.path.join(self.temp_dir, b'tmpcover.jpg') _common.touch(self.art_file) self.old_afa = self.plugin.art_for_album self.afa_response = fetchart.Candidate(logger, path=self.art_file) def art_for_album(i, p, local_only=False): return self.afa_response self.plugin.art_for_album = art_for_album # Test library. self.libpath = os.path.join(self.temp_dir, b'tmplib.blb') self.libdir = os.path.join(self.temp_dir, b'tmplib') os.mkdir(self.libdir) os.mkdir(os.path.join(self.libdir, b'album')) itempath = os.path.join(self.libdir, b'album', b'test.mp3') shutil.copyfile(os.path.join(_common.RSRC, b'full.mp3'), itempath) self.lib = library.Library(self.libpath) self.i = _common.item() self.i.path = itempath self.album = self.lib.add_album([self.i]) self.lib._connection().commit() # The import configuration. self.session = _common.import_session(self.lib) # Import task for the coroutine. self.task = importer.ImportTask(None, None, [self.i]) self.task.is_album = True self.task.album = self.album info = AlbumInfo( album=u'some album', album_id=u'albumid', artist=u'some artist', artist_id=u'artistid', tracks=[], ) self.task.set_choice(AlbumMatch(0, info, {}, set(), set())) def tearDown(self): self.lib._connection().close() super(ArtImporterTest, self).tearDown() self.plugin.art_for_album = self.old_afa def _fetch_art(self, should_exist): """Execute the fetch_art coroutine for the task and return the album's resulting artpath. ``should_exist`` specifies whether to assert that art path was set (to the correct value) or or that the path was not set. """ # Execute the two relevant parts of the importer. self.plugin.fetch_art(self.session, self.task) self.plugin.assign_art(self.session, self.task) artpath = self.lib.albums()[0].artpath if should_exist: self.assertEqual( artpath, os.path.join(os.path.dirname(self.i.path), b'cover.jpg') ) self.assertExists(artpath) else: self.assertEqual(artpath, None) return artpath def test_fetch_art(self): assert not self.lib.albums()[0].artpath self._fetch_art(True) def test_art_not_found(self): self.afa_response = None self._fetch_art(False) def test_no_art_for_singleton(self): self.task.is_album = False self._fetch_art(False) def test_leave_original_file_in_place(self): self._fetch_art(True) self.assertExists(self.art_file) def test_delete_original_file(self): self.plugin.src_removed = True self._fetch_art(True) self.assertNotExists(self.art_file) def test_do_not_delete_original_if_already_in_place(self): artdest = os.path.join(os.path.dirname(self.i.path), b'cover.jpg') shutil.copyfile(self.art_file, artdest) self.afa_response = fetchart.Candidate(logger, path=artdest) self._fetch_art(True) def test_fetch_art_if_imported_file_deleted(self): # See #1126. Test the following scenario: # - Album art imported, `album.artpath` set. # - Imported album art file subsequently deleted (by user or other # program). # `fetchart` should import album art again instead of printing the # message " has album art". self._fetch_art(True) util.remove(self.album.artpath) self.plugin.batch_fetch_art(self.lib, self.lib.albums(), force=False, quiet=False) self.assertExists(self.album.artpath) class ArtForAlbumTest(UseThePlugin): """ Tests that fetchart.art_for_album respects the size configuration (e.g., minwidth, enforce_ratio) """ IMG_225x225 = os.path.join(_common.RSRC, b'abbey.jpg') IMG_348x348 = os.path.join(_common.RSRC, b'abbey-different.jpg') IMG_500x490 = os.path.join(_common.RSRC, b'abbey-similar.jpg') def setUp(self): super(ArtForAlbumTest, self).setUp() self.old_fs_source_get = fetchart.FileSystem.get def fs_source_get(_self, album, settings, paths): if paths: yield fetchart.Candidate(logger, path=self.image_file) fetchart.FileSystem.get = fs_source_get self.album = _common.Bag() def tearDown(self): fetchart.FileSystem.get = self.old_fs_source_get super(ArtForAlbumTest, self).tearDown() def _assertImageIsValidArt(self, image_file, should_exist): # noqa self.assertExists(image_file) self.image_file = image_file candidate = self.plugin.art_for_album(self.album, [''], True) if should_exist: self.assertNotEqual(candidate, None) self.assertEqual(candidate.path, self.image_file) self.assertExists(candidate.path) else: self.assertIsNone(candidate) def _assertImageResized(self, image_file, should_resize): # noqa self.image_file = image_file with patch.object(ArtResizer.shared, 'resize') as mock_resize: self.plugin.art_for_album(self.album, [''], True) self.assertEqual(mock_resize.called, should_resize) def _require_backend(self): """Skip the test if the art resizer doesn't have ImageMagick or PIL (so comparisons and measurements are unavailable). """ if ArtResizer.shared.method[0] == WEBPROXY: self.skipTest(u"ArtResizer has no local imaging backend available") def test_respect_minwidth(self): self._require_backend() self.plugin.minwidth = 300 self._assertImageIsValidArt(self.IMG_225x225, False) self._assertImageIsValidArt(self.IMG_348x348, True) def test_respect_enforce_ratio_yes(self): self._require_backend() self.plugin.enforce_ratio = True self._assertImageIsValidArt(self.IMG_500x490, False) self._assertImageIsValidArt(self.IMG_225x225, True) def test_respect_enforce_ratio_no(self): self.plugin.enforce_ratio = False self._assertImageIsValidArt(self.IMG_500x490, True) def test_respect_enforce_ratio_px_above(self): self._require_backend() self.plugin.enforce_ratio = True self.plugin.margin_px = 5 self._assertImageIsValidArt(self.IMG_500x490, False) def test_respect_enforce_ratio_px_below(self): self._require_backend() self.plugin.enforce_ratio = True self.plugin.margin_px = 15 self._assertImageIsValidArt(self.IMG_500x490, True) def test_respect_enforce_ratio_percent_above(self): self._require_backend() self.plugin.enforce_ratio = True self.plugin.margin_percent = (500 - 490) / 500 * 0.5 self._assertImageIsValidArt(self.IMG_500x490, False) def test_respect_enforce_ratio_percent_below(self): self._require_backend() self.plugin.enforce_ratio = True self.plugin.margin_percent = (500 - 490) / 500 * 1.5 self._assertImageIsValidArt(self.IMG_500x490, True) def test_resize_if_necessary(self): self._require_backend() self.plugin.maxwidth = 300 self._assertImageResized(self.IMG_225x225, False) self._assertImageResized(self.IMG_348x348, True) class DeprecatedConfigTest(_common.TestCase): """While refactoring the plugin, the remote_priority option was deprecated, and a new codepath should translate its effect. Check that it actually does so. """ # If we subclassed UseThePlugin, the configuration change would either be # overwritten by _common.TestCase or be set after constructing the # plugin object def setUp(self): super(DeprecatedConfigTest, self).setUp() config['fetchart']['remote_priority'] = True self.plugin = fetchart.FetchArtPlugin() def test_moves_filesystem_to_end(self): self.assertEqual(type(self.plugin.sources[-1]), fetchart.FileSystem) class EnforceRatioConfigTest(_common.TestCase): """Throw some data at the regexes.""" def _load_with_config(self, values, should_raise): if should_raise: for v in values: config['fetchart']['enforce_ratio'] = v with self.assertRaises(confit.ConfigValueError): fetchart.FetchArtPlugin() else: for v in values: config['fetchart']['enforce_ratio'] = v fetchart.FetchArtPlugin() def test_px(self): self._load_with_config(u'0px 4px 12px 123px'.split(), False) self._load_with_config(u'00px stuff5px'.split(), True) def test_percent(self): self._load_with_config(u'0% 0.00% 5.1% 5% 100%'.split(), False) self._load_with_config(u'00% 1.234% foo5% 100.1%'.split(), True) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/test/test_mb.py0000644000076500000240000004457213035502157016573 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for MusicBrainz API wrapper. """ from __future__ import division, absolute_import, print_function from test import _common from beets.autotag import mb from beets import config import unittest import mock class MBAlbumInfoTest(_common.TestCase): def _make_release(self, date_str='2009', tracks=None, track_length=None, track_artist=False): release = { 'title': 'ALBUM TITLE', 'id': 'ALBUM ID', 'asin': 'ALBUM ASIN', 'disambiguation': 'R_DISAMBIGUATION', 'release-group': { 'type': 'Album', 'first-release-date': date_str, 'id': 'RELEASE GROUP ID', 'disambiguation': 'RG_DISAMBIGUATION', }, 'artist-credit': [ { 'artist': { 'name': 'ARTIST NAME', 'id': 'ARTIST ID', 'sort-name': 'ARTIST SORT NAME', }, 'name': 'ARTIST CREDIT', } ], 'date': '3001', 'medium-list': [], 'label-info-list': [{ 'catalog-number': 'CATALOG NUMBER', 'label': {'name': 'LABEL NAME'}, }], 'text-representation': { 'script': 'SCRIPT', 'language': 'LANGUAGE', }, 'country': 'COUNTRY', 'status': 'STATUS', } if tracks: track_list = [] for i, recording in enumerate(tracks): track = { 'recording': recording, 'position': i + 1, 'number': 'A1', } if track_length: # Track lengths are distinct from recording lengths. track['length'] = track_length if track_artist: # Similarly, track artists can differ from recording # artists. track['artist-credit'] = [ { 'artist': { 'name': 'TRACK ARTIST NAME', 'id': 'TRACK ARTIST ID', 'sort-name': 'TRACK ARTIST SORT NAME', }, 'name': 'TRACK ARTIST CREDIT', } ] track_list.append(track) release['medium-list'].append({ 'position': '1', 'track-list': track_list, 'format': 'FORMAT', 'title': 'MEDIUM TITLE', }) return release def _make_track(self, title, tr_id, duration, artist=False): track = { 'title': title, 'id': tr_id, } if duration is not None: track['length'] = duration if artist: track['artist-credit'] = [ { 'artist': { 'name': 'RECORDING ARTIST NAME', 'id': 'RECORDING ARTIST ID', 'sort-name': 'RECORDING ARTIST SORT NAME', }, 'name': 'RECORDING ARTIST CREDIT', } ] return track def test_parse_release_with_year(self): release = self._make_release('1984') d = mb.album_info(release) self.assertEqual(d.album, 'ALBUM TITLE') self.assertEqual(d.album_id, 'ALBUM ID') self.assertEqual(d.artist, 'ARTIST NAME') self.assertEqual(d.artist_id, 'ARTIST ID') self.assertEqual(d.original_year, 1984) self.assertEqual(d.year, 3001) self.assertEqual(d.artist_credit, 'ARTIST CREDIT') def test_parse_release_type(self): release = self._make_release('1984') d = mb.album_info(release) self.assertEqual(d.albumtype, 'album') def test_parse_release_full_date(self): release = self._make_release('1987-03-31') d = mb.album_info(release) self.assertEqual(d.original_year, 1987) self.assertEqual(d.original_month, 3) self.assertEqual(d.original_day, 31) def test_parse_tracks(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(tracks=tracks) d = mb.album_info(release) t = d.tracks self.assertEqual(len(t), 2) self.assertEqual(t[0].title, 'TITLE ONE') self.assertEqual(t[0].track_id, 'ID ONE') self.assertEqual(t[0].length, 100.0) self.assertEqual(t[1].title, 'TITLE TWO') self.assertEqual(t[1].track_id, 'ID TWO') self.assertEqual(t[1].length, 200.0) def test_parse_track_indices(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(tracks=tracks) d = mb.album_info(release) t = d.tracks self.assertEqual(t[0].medium_index, 1) self.assertEqual(t[0].index, 1) self.assertEqual(t[1].medium_index, 2) self.assertEqual(t[1].index, 2) def test_parse_medium_numbers_single_medium(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(tracks=tracks) d = mb.album_info(release) self.assertEqual(d.mediums, 1) t = d.tracks self.assertEqual(t[0].medium, 1) self.assertEqual(t[1].medium, 1) def test_parse_medium_numbers_two_mediums(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(tracks=[tracks[0]]) second_track_list = [{ 'recording': tracks[1], 'position': '1', 'number': 'A1', }] release['medium-list'].append({ 'position': '2', 'track-list': second_track_list, }) d = mb.album_info(release) self.assertEqual(d.mediums, 2) t = d.tracks self.assertEqual(t[0].medium, 1) self.assertEqual(t[0].medium_index, 1) self.assertEqual(t[0].index, 1) self.assertEqual(t[1].medium, 2) self.assertEqual(t[1].medium_index, 1) self.assertEqual(t[1].index, 2) def test_parse_release_year_month_only(self): release = self._make_release('1987-03') d = mb.album_info(release) self.assertEqual(d.original_year, 1987) self.assertEqual(d.original_month, 3) def test_no_durations(self): tracks = [self._make_track('TITLE', 'ID', None)] release = self._make_release(tracks=tracks) d = mb.album_info(release) self.assertEqual(d.tracks[0].length, None) def test_track_length_overrides_recording_length(self): tracks = [self._make_track('TITLE', 'ID', 1.0 * 1000.0)] release = self._make_release(tracks=tracks, track_length=2.0 * 1000.0) d = mb.album_info(release) self.assertEqual(d.tracks[0].length, 2.0) def test_no_release_date(self): release = self._make_release(None) d = mb.album_info(release) self.assertFalse(d.original_year) self.assertFalse(d.original_month) self.assertFalse(d.original_day) def test_various_artists_defaults_false(self): release = self._make_release(None) d = mb.album_info(release) self.assertFalse(d.va) def test_detect_various_artists(self): release = self._make_release(None) release['artist-credit'][0]['artist']['id'] = \ mb.VARIOUS_ARTISTS_ID d = mb.album_info(release) self.assertTrue(d.va) def test_parse_artist_sort_name(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.artist_sort, 'ARTIST SORT NAME') def test_parse_releasegroupid(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.releasegroup_id, 'RELEASE GROUP ID') def test_parse_asin(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.asin, 'ALBUM ASIN') def test_parse_catalognum(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.catalognum, 'CATALOG NUMBER') def test_parse_textrepr(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.script, 'SCRIPT') self.assertEqual(d.language, 'LANGUAGE') def test_parse_country(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.country, 'COUNTRY') def test_parse_status(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.albumstatus, 'STATUS') def test_parse_media(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(None, tracks=tracks) d = mb.album_info(release) self.assertEqual(d.media, 'FORMAT') def test_parse_disambig(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.albumdisambig, 'RG_DISAMBIGUATION, R_DISAMBIGUATION') def test_parse_disctitle(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(None, tracks=tracks) d = mb.album_info(release) t = d.tracks self.assertEqual(t[0].disctitle, 'MEDIUM TITLE') self.assertEqual(t[1].disctitle, 'MEDIUM TITLE') def test_missing_language(self): release = self._make_release(None) del release['text-representation']['language'] d = mb.album_info(release) self.assertEqual(d.language, None) def test_parse_recording_artist(self): tracks = [self._make_track('a', 'b', 1, True)] release = self._make_release(None, tracks=tracks) track = mb.album_info(release).tracks[0] self.assertEqual(track.artist, 'RECORDING ARTIST NAME') self.assertEqual(track.artist_id, 'RECORDING ARTIST ID') self.assertEqual(track.artist_sort, 'RECORDING ARTIST SORT NAME') self.assertEqual(track.artist_credit, 'RECORDING ARTIST CREDIT') def test_track_artist_overrides_recording_artist(self): tracks = [self._make_track('a', 'b', 1, True)] release = self._make_release(None, tracks=tracks, track_artist=True) track = mb.album_info(release).tracks[0] self.assertEqual(track.artist, 'TRACK ARTIST NAME') self.assertEqual(track.artist_id, 'TRACK ARTIST ID') self.assertEqual(track.artist_sort, 'TRACK ARTIST SORT NAME') self.assertEqual(track.artist_credit, 'TRACK ARTIST CREDIT') def test_data_source(self): release = self._make_release() d = mb.album_info(release) self.assertEqual(d.data_source, 'MusicBrainz') class ParseIDTest(_common.TestCase): def test_parse_id_correct(self): id_string = "28e32c71-1450-463e-92bf-e0a46446fc11" out = mb._parse_id(id_string) self.assertEqual(out, id_string) def test_parse_id_non_id_returns_none(self): id_string = "blah blah" out = mb._parse_id(id_string) self.assertEqual(out, None) def test_parse_id_url_finds_id(self): id_string = "28e32c71-1450-463e-92bf-e0a46446fc11" id_url = "http://musicbrainz.org/entity/%s" % id_string out = mb._parse_id(id_url) self.assertEqual(out, id_string) class ArtistFlatteningTest(_common.TestCase): def _credit_dict(self, suffix=''): return { 'artist': { 'name': 'NAME' + suffix, 'sort-name': 'SORT' + suffix, }, 'name': 'CREDIT' + suffix, } def _add_alias(self, credit_dict, suffix='', locale='', primary=False): alias = { 'alias': 'ALIAS' + suffix, 'locale': locale, 'sort-name': 'ALIASSORT' + suffix } if primary: alias['primary'] = 'primary' if 'alias-list' not in credit_dict['artist']: credit_dict['artist']['alias-list'] = [] credit_dict['artist']['alias-list'].append(alias) def test_single_artist(self): a, s, c = mb._flatten_artist_credit([self._credit_dict()]) self.assertEqual(a, 'NAME') self.assertEqual(s, 'SORT') self.assertEqual(c, 'CREDIT') def test_two_artists(self): a, s, c = mb._flatten_artist_credit( [self._credit_dict('a'), ' AND ', self._credit_dict('b')] ) self.assertEqual(a, 'NAMEa AND NAMEb') self.assertEqual(s, 'SORTa AND SORTb') self.assertEqual(c, 'CREDITa AND CREDITb') def test_alias(self): credit_dict = self._credit_dict() self._add_alias(credit_dict, suffix='en', locale='en', primary=True) self._add_alias(credit_dict, suffix='en_GB', locale='en_GB', primary=True) self._add_alias(credit_dict, suffix='fr', locale='fr') self._add_alias(credit_dict, suffix='fr_P', locale='fr', primary=True) self._add_alias(credit_dict, suffix='pt_BR', locale='pt_BR') # test no alias config['import']['languages'] = [''] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('NAME', 'SORT', 'CREDIT')) # test en primary config['import']['languages'] = ['en'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT')) # test en_GB en primary config['import']['languages'] = ['en_GB', 'en'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASen_GB', 'ALIASSORTen_GB', 'CREDIT')) # test en en_GB primary config['import']['languages'] = ['en', 'en_GB'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT')) # test fr primary config['import']['languages'] = ['fr'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASfr_P', 'ALIASSORTfr_P', 'CREDIT')) # test for not matching non-primary config['import']['languages'] = ['pt_BR', 'fr'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASfr_P', 'ALIASSORTfr_P', 'CREDIT')) class MBLibraryTest(unittest.TestCase): def test_match_track(self): with mock.patch('musicbrainzngs.search_recordings') as p: p.return_value = { 'recording-list': [{ 'title': 'foo', 'id': 'bar', 'length': 42, }], } ti = list(mb.match_track('hello', 'there'))[0] p.assert_called_with(artist='hello', recording='there', limit=5) self.assertEqual(ti.title, 'foo') self.assertEqual(ti.track_id, 'bar') def test_match_album(self): mbid = 'd2a6f856-b553-40a0-ac54-a321e8e2da99' with mock.patch('musicbrainzngs.search_releases') as sp: sp.return_value = { 'release-list': [{ 'id': mbid, }], } with mock.patch('musicbrainzngs.get_release_by_id') as gp: gp.return_value = { 'release': { 'title': 'hi', 'id': mbid, 'medium-list': [{ 'track-list': [{ 'recording': { 'title': 'foo', 'id': 'bar', 'length': 42, }, 'position': 9, 'number': 'A1', }], 'position': 5, }], 'artist-credit': [{ 'artist': { 'name': 'some-artist', 'id': 'some-id', }, }], 'release-group': { 'id': 'another-id', } } } ai = list(mb.match_album('hello', 'there'))[0] sp.assert_called_with(artist='hello', release='there', limit=5) gp.assert_called_with(mbid, mock.ANY) self.assertEqual(ai.tracks[0].title, 'foo') self.assertEqual(ai.album, 'hi') def test_match_track_empty(self): with mock.patch('musicbrainzngs.search_recordings') as p: til = list(mb.match_track(' ', ' ')) self.assertFalse(p.called) self.assertEqual(til, []) def test_match_album_empty(self): with mock.patch('musicbrainzngs.search_releases') as p: ail = list(mb.match_album(' ', ' ')) self.assertFalse(p.called) self.assertEqual(ail, []) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.4.6/beetsplug/0000755000076500000240000000000013216774613015574 5ustar asampsonstaff00000000000000beets-1.4.6/beetsplug/duplicates.py0000644000076500000240000003166213214005146020275 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Pedro Silva. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """List duplicate tracks or albums. """ from __future__ import division, absolute_import, print_function import shlex from beets.plugins import BeetsPlugin from beets.ui import decargs, print_, Subcommand, UserError from beets.util import command_output, displayable_path, subprocess, \ bytestring_path, MoveOperation from beets.library import Item, Album import six PLUGIN = 'duplicates' class DuplicatesPlugin(BeetsPlugin): """List duplicate tracks or albums """ def __init__(self): super(DuplicatesPlugin, self).__init__() self.config.add({ 'album': False, 'checksum': '', 'copy': '', 'count': False, 'delete': False, 'format': '', 'full': False, 'keys': [], 'merge': False, 'move': '', 'path': False, 'tiebreak': {}, 'strict': False, 'tag': '', }) self._command = Subcommand('duplicates', help=__doc__, aliases=['dup']) self._command.parser.add_option( u'-c', u'--count', dest='count', action='store_true', help=u'show duplicate counts', ) self._command.parser.add_option( u'-C', u'--checksum', dest='checksum', action='store', metavar='PROG', help=u'report duplicates based on arbitrary command', ) self._command.parser.add_option( u'-d', u'--delete', dest='delete', action='store_true', help=u'delete items from library and disk', ) self._command.parser.add_option( u'-F', u'--full', dest='full', action='store_true', help=u'show all versions of duplicate tracks or albums', ) self._command.parser.add_option( u'-s', u'--strict', dest='strict', action='store_true', help=u'report duplicates only if all attributes are set', ) self._command.parser.add_option( u'-k', u'--key', dest='keys', action='append', metavar='KEY', help=u'report duplicates based on keys (use multiple times)', ) self._command.parser.add_option( u'-M', u'--merge', dest='merge', action='store_true', help=u'merge duplicate items', ) self._command.parser.add_option( u'-m', u'--move', dest='move', action='store', metavar='DEST', help=u'move items to dest', ) self._command.parser.add_option( u'-o', u'--copy', dest='copy', action='store', metavar='DEST', help=u'copy items to dest', ) self._command.parser.add_option( u'-t', u'--tag', dest='tag', action='store', help=u'tag matched items with \'k=v\' attribute', ) self._command.parser.add_all_common_options() def commands(self): def _dup(lib, opts, args): self.config.set_args(opts) album = self.config['album'].get(bool) checksum = self.config['checksum'].get(str) copy = bytestring_path(self.config['copy'].as_str()) count = self.config['count'].get(bool) delete = self.config['delete'].get(bool) fmt = self.config['format'].get(str) full = self.config['full'].get(bool) keys = self.config['keys'].as_str_seq() merge = self.config['merge'].get(bool) move = bytestring_path(self.config['move'].as_str()) path = self.config['path'].get(bool) tiebreak = self.config['tiebreak'].get(dict) strict = self.config['strict'].get(bool) tag = self.config['tag'].get(str) if album: if not keys: keys = ['mb_albumid'] items = lib.albums(decargs(args)) else: if not keys: keys = ['mb_trackid', 'mb_albumid'] items = lib.items(decargs(args)) if path: fmt = u'$path' # Default format string for count mode. if count and not fmt: if album: fmt = u'$albumartist - $album' else: fmt = u'$albumartist - $album - $title' fmt += u': {0}' if checksum: for i in items: k, _ = self._checksum(i, checksum) keys = [k] for obj_id, obj_count, objs in self._duplicates(items, keys=keys, full=full, strict=strict, tiebreak=tiebreak, merge=merge): if obj_id: # Skip empty IDs. for o in objs: self._process_item(o, copy=copy, move=move, delete=delete, tag=tag, fmt=fmt.format(obj_count)) self._command.func = _dup return [self._command] def _process_item(self, item, copy=False, move=False, delete=False, tag=False, fmt=u''): """Process Item `item`. """ print_(format(item, fmt)) if copy: item.move(basedir=copy, operation=MoveOperation.COPY) item.store() if move: item.move(basedir=move) item.store() if delete: item.remove(delete=True) if tag: try: k, v = tag.split('=') except Exception: raise UserError( u"{}: can't parse k=v tag: {}".format(PLUGIN, tag) ) setattr(item, k, v) item.store() def _checksum(self, item, prog): """Run external `prog` on file path associated with `item`, cache output as flexattr on a key that is the name of the program, and return the key, checksum tuple. """ args = [p.format(file=item.path) for p in shlex.split(prog)] key = args[0] checksum = getattr(item, key, False) if not checksum: self._log.debug(u'key {0} on item {1} not cached:' u'computing checksum', key, displayable_path(item.path)) try: checksum = command_output(args) setattr(item, key, checksum) item.store() self._log.debug(u'computed checksum for {0} using {1}', item.title, key) except subprocess.CalledProcessError as e: self._log.debug(u'failed to checksum {0}: {1}', displayable_path(item.path), e) else: self._log.debug(u'key {0} on item {1} cached:' u'not computing checksum', key, displayable_path(item.path)) return key, checksum def _group_by(self, objs, keys, strict): """Return a dictionary with keys arbitrary concatenations of attributes and values lists of objects (Albums or Items) with those keys. If strict, all attributes must be defined for a duplicate match. """ import collections counts = collections.defaultdict(list) for obj in objs: values = [getattr(obj, k, None) for k in keys] values = [v for v in values if v not in (None, '')] if strict and len(values) < len(keys): self._log.debug(u'some keys {0} on item {1} are null or empty:' u' skipping', keys, displayable_path(obj.path)) elif (not strict and not len(values)): self._log.debug(u'all keys {0} on item {1} are null or empty:' u' skipping', keys, displayable_path(obj.path)) else: key = tuple(values) counts[key].append(obj) return counts def _order(self, objs, tiebreak=None): """Return the objects (Items or Albums) sorted by descending order of priority. If provided, the `tiebreak` dict indicates the field to use to prioritize the objects. Otherwise, Items are placed in order of "completeness" (objects with more non-null fields come first) and Albums are ordered by their track count. """ kind = 'items' if all(isinstance(o, Item) for o in objs) else 'albums' if tiebreak and kind in tiebreak.keys(): key = lambda x: tuple(getattr(x, k) for k in tiebreak[kind]) else: if kind == 'items': def truthy(v): # Avoid a Unicode warning by avoiding comparison # between a bytes object and the empty Unicode # string ''. return v is not None and \ (v != '' if isinstance(v, six.text_type) else True) fields = Item.all_keys() key = lambda x: sum(1 for f in fields if truthy(getattr(x, f))) else: key = lambda x: len(x.items()) return sorted(objs, key=key, reverse=True) def _merge_items(self, objs): """Merge Item objs by copying missing fields from items in the tail to the head item. Return same number of items, with the head item modified. """ fields = Item.all_keys() for f in fields: for o in objs[1:]: if getattr(objs[0], f, None) in (None, ''): value = getattr(o, f, None) if value: self._log.debug(u'key {0} on item {1} is null ' u'or empty: setting from item {2}', f, displayable_path(objs[0].path), displayable_path(o.path)) setattr(objs[0], f, value) objs[0].store() break return objs def _merge_albums(self, objs): """Merge Album objs by copying missing items from albums in the tail to the head album. Return same number of albums, with the head album modified.""" ids = [i.mb_trackid for i in objs[0].items()] for o in objs[1:]: for i in o.items(): if i.mb_trackid not in ids: missing = Item.from_path(i.path) missing.album_id = objs[0].id missing.add(i._db) self._log.debug(u'item {0} missing from album {1}:' u' merging from {2} into {3}', missing, objs[0], displayable_path(o.path), displayable_path(missing.destination())) missing.move(operation=MoveOperation.COPY) return objs def _merge(self, objs): """Merge duplicate items. See ``_merge_items`` and ``_merge_albums`` for the relevant strategies. """ kind = Item if all(isinstance(o, Item) for o in objs) else Album if kind is Item: objs = self._merge_items(objs) else: objs = self._merge_albums(objs) return objs def _duplicates(self, objs, keys, full, strict, tiebreak, merge): """Generate triples of keys, duplicate counts, and constituent objects. """ offset = 0 if full else 1 for k, objs in self._group_by(objs, keys, strict).items(): if len(objs) > 1: objs = self._order(objs, tiebreak) if merge: objs = self._merge(objs) yield (k, len(objs) - offset, objs[offset:]) beets-1.4.6/beetsplug/replaygain.py0000644000076500000240000011062313175430622020275 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Fabrice Laporte, Yevgeny Bezman, and Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function import subprocess import os import collections import sys import warnings import re from six.moves import zip from beets import ui from beets.plugins import BeetsPlugin from beets.util import syspath, command_output, displayable_path, py3_path # Utilities. class ReplayGainError(Exception): """Raised when a local (to a track or an album) error occurs in one of the backends. """ class FatalReplayGainError(Exception): """Raised when a fatal error occurs in one of the backends. """ class FatalGstreamerPluginReplayGainError(FatalReplayGainError): """Raised when a fatal error occurs in the GStreamerBackend when loading the required plugins.""" def call(args): """Execute the command and return its output or raise a ReplayGainError on failure. """ try: return command_output(args) except subprocess.CalledProcessError as e: raise ReplayGainError( u"{0} exited with status {1}".format(args[0], e.returncode) ) except UnicodeEncodeError: # Due to a bug in Python 2's subprocess on Windows, Unicode # filenames can fail to encode on that platform. See: # https://github.com/google-code-export/beets/issues/499 raise ReplayGainError(u"argument encoding failed") # Backend base and plumbing classes. Gain = collections.namedtuple("Gain", "gain peak") AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains") class Backend(object): """An abstract class representing engine for calculating RG values. """ def __init__(self, config, log): """Initialize the backend with the configuration view for the plugin. """ self._log = log def compute_track_gain(self, items): raise NotImplementedError() def compute_album_gain(self, album): # TODO: implement album gain in terms of track gain of the # individual tracks which can be used for any backend. raise NotImplementedError() # bsg1770gain backend class Bs1770gainBackend(Backend): """bs1770gain is a loudness scanner compliant with ITU-R BS.1770 and its flavors EBU R128, ATSC A/85 and Replaygain 2.0. """ def __init__(self, config, log): super(Bs1770gainBackend, self).__init__(config, log) config.add({ 'chunk_at': 5000, 'method': 'replaygain', }) self.chunk_at = config['chunk_at'].as_number() self.method = '--' + config['method'].as_str() cmd = 'bs1770gain' try: call([cmd, self.method]) self.command = cmd except OSError: raise FatalReplayGainError( u'Is bs1770gain installed? Is your method in config correct?' ) if not self.command: raise FatalReplayGainError( u'no replaygain command found: install bs1770gain' ) def compute_track_gain(self, items): """Computes the track gain of the given tracks, returns a list of TrackGain objects. """ output = self.compute_gain(items, False) return output def compute_album_gain(self, album): """Computes the album gain of the given album, returns an AlbumGain object. """ # TODO: What should be done when not all tracks in the album are # supported? supported_items = album.items() output = self.compute_gain(supported_items, True) if not output: raise ReplayGainError(u'no output from bs1770gain') return AlbumGain(output[-1], output[:-1]) def isplitter(self, items, chunk_at): """Break an iterable into chunks of at most size `chunk_at`, generating lists for each chunk. """ iterable = iter(items) while True: result = [] for i in range(chunk_at): try: a = next(iterable) except StopIteration: break else: result.append(a) if result: yield result else: break def compute_gain(self, items, is_album): """Computes the track or album gain of a list of items, returns a list of TrackGain objects. When computing album gain, the last TrackGain object returned is the album gain """ if len(items) == 0: return [] albumgaintot = 0.0 albumpeaktot = 0.0 returnchunks = [] # In the case of very large sets of music, we break the tracks # into smaller chunks and process them one at a time. This # avoids running out of memory. if len(items) > self.chunk_at: i = 0 for chunk in self.isplitter(items, self.chunk_at): i += 1 returnchunk = self.compute_chunk_gain(chunk, is_album) albumgaintot += returnchunk[-1].gain albumpeaktot += returnchunk[-1].peak returnchunks = returnchunks + returnchunk[0:-1] returnchunks.append(Gain(albumgaintot / i, albumpeaktot / i)) return returnchunks else: return self.compute_chunk_gain(items, is_album) def compute_chunk_gain(self, items, is_album): """Compute ReplayGain values and return a list of results dictionaries as given by `parse_tool_output`. """ # Construct shell command. cmd = [self.command] cmd += [self.method] cmd += ['-p'] # Workaround for Windows: the underlying tool fails on paths # with the \\?\ prefix, so we don't use it here. This # prevents the backend from working with long paths. args = cmd + [syspath(i.path, prefix=False) for i in items] # Invoke the command. self._log.debug( u'executing {0}', u' '.join(map(displayable_path, args)) ) output = call(args) self._log.debug(u'analysis finished: {0}', output) results = self.parse_tool_output(output, len(items) + is_album) self._log.debug(u'{0} items, {1} results', len(items), len(results)) return results def parse_tool_output(self, text, num_lines): """Given the output from bs1770gain, parse the text and return a list of dictionaries containing information about each analyzed file. """ out = [] data = text.decode('utf-8', errors='ignore') regex = re.compile( u'(\\s{2,2}\\[\\d+\\/\\d+\\].*?|\\[ALBUM\\].*?)' '(?=\\s{2,2}\\[\\d+\\/\\d+\\]|\\s{2,2}\\[ALBUM\\]' ':|done\\.\\s)', re.DOTALL | re.UNICODE) results = re.findall(regex, data) for parts in results[0:num_lines]: part = parts.split(u'\n') if len(part) == 0: self._log.debug(u'bad tool output: {0!r}', text) raise ReplayGainError(u'bs1770gain failed') try: song = { 'file': part[0], 'gain': float((part[1].split('/'))[1].split('LU')[0]), 'peak': float(part[2].split('/')[1]), } except IndexError: self._log.info(u'bs1770gain reports (faulty file?): {}', parts) continue out.append(Gain(song['gain'], song['peak'])) return out # mpgain/aacgain CLI tool backend. class CommandBackend(Backend): def __init__(self, config, log): super(CommandBackend, self).__init__(config, log) config.add({ 'command': u"", 'noclip': True, }) self.command = config["command"].as_str() if self.command: # Explicit executable path. if not os.path.isfile(self.command): raise FatalReplayGainError( u'replaygain command does not exist: {0}'.format( self.command) ) else: # Check whether the program is in $PATH. for cmd in ('mp3gain', 'aacgain'): try: call([cmd, '-v']) self.command = cmd except OSError: pass if not self.command: raise FatalReplayGainError( u'no replaygain command found: install mp3gain or aacgain' ) self.noclip = config['noclip'].get(bool) target_level = config['targetlevel'].as_number() self.gain_offset = int(target_level - 89) def compute_track_gain(self, items): """Computes the track gain of the given tracks, returns a list of TrackGain objects. """ supported_items = list(filter(self.format_supported, items)) output = self.compute_gain(supported_items, False) return output def compute_album_gain(self, album): """Computes the album gain of the given album, returns an AlbumGain object. """ # TODO: What should be done when not all tracks in the album are # supported? supported_items = list(filter(self.format_supported, album.items())) if len(supported_items) != len(album.items()): self._log.debug(u'tracks are of unsupported format') return AlbumGain(None, []) output = self.compute_gain(supported_items, True) return AlbumGain(output[-1], output[:-1]) def format_supported(self, item): """Checks whether the given item is supported by the selected tool. """ if 'mp3gain' in self.command and item.format != 'MP3': return False elif 'aacgain' in self.command and item.format not in ('MP3', 'AAC'): return False return True def compute_gain(self, items, is_album): """Computes the track or album gain of a list of items, returns a list of TrackGain objects. When computing album gain, the last TrackGain object returned is the album gain """ if len(items) == 0: self._log.debug(u'no supported tracks to analyze') return [] """Compute ReplayGain values and return a list of results dictionaries as given by `parse_tool_output`. """ # Construct shell command. The "-o" option makes the output # easily parseable (tab-delimited). "-s s" forces gain # recalculation even if tags are already present and disables # tag-writing; this turns the mp3gain/aacgain tool into a gain # calculator rather than a tag manipulator because we take care # of changing tags ourselves. cmd = [self.command, '-o', '-s', 's'] if self.noclip: # Adjust to avoid clipping. cmd = cmd + ['-k'] else: # Disable clipping warning. cmd = cmd + ['-c'] cmd = cmd + ['-d', str(self.gain_offset)] cmd = cmd + [syspath(i.path) for i in items] self._log.debug(u'analyzing {0} files', len(items)) self._log.debug(u"executing {0}", " ".join(map(displayable_path, cmd))) output = call(cmd) self._log.debug(u'analysis finished') return self.parse_tool_output(output, len(items) + (1 if is_album else 0)) def parse_tool_output(self, text, num_lines): """Given the tab-delimited output from an invocation of mp3gain or aacgain, parse the text and return a list of dictionaries containing information about each analyzed file. """ out = [] for line in text.split(b'\n')[1:num_lines + 1]: parts = line.split(b'\t') if len(parts) != 6 or parts[0] == b'File': self._log.debug(u'bad tool output: {0}', text) raise ReplayGainError(u'mp3gain failed') d = { 'file': parts[0], 'mp3gain': int(parts[1]), 'gain': float(parts[2]), 'peak': float(parts[3]) / (1 << 15), 'maxgain': int(parts[4]), 'mingain': int(parts[5]), } out.append(Gain(d['gain'], d['peak'])) return out # GStreamer-based backend. class GStreamerBackend(Backend): def __init__(self, config, log): super(GStreamerBackend, self).__init__(config, log) self._import_gst() # Initialized a GStreamer pipeline of the form filesrc -> # decodebin -> audioconvert -> audioresample -> rganalysis -> # fakesink The connection between decodebin and audioconvert is # handled dynamically after decodebin figures out the type of # the input file. self._src = self.Gst.ElementFactory.make("filesrc", "src") self._decbin = self.Gst.ElementFactory.make("decodebin", "decbin") self._conv = self.Gst.ElementFactory.make("audioconvert", "conv") self._res = self.Gst.ElementFactory.make("audioresample", "res") self._rg = self.Gst.ElementFactory.make("rganalysis", "rg") if self._src is None or self._decbin is None or self._conv is None \ or self._res is None or self._rg is None: raise FatalGstreamerPluginReplayGainError( u"Failed to load required GStreamer plugins" ) # We check which files need gain ourselves, so all files given # to rganalsys should have their gain computed, even if it # already exists. self._rg.set_property("forced", True) self._rg.set_property("reference-level", config["targetlevel"].as_number()) self._sink = self.Gst.ElementFactory.make("fakesink", "sink") self._pipe = self.Gst.Pipeline() self._pipe.add(self._src) self._pipe.add(self._decbin) self._pipe.add(self._conv) self._pipe.add(self._res) self._pipe.add(self._rg) self._pipe.add(self._sink) self._src.link(self._decbin) self._conv.link(self._res) self._res.link(self._rg) self._rg.link(self._sink) self._bus = self._pipe.get_bus() self._bus.add_signal_watch() self._bus.connect("message::eos", self._on_eos) self._bus.connect("message::error", self._on_error) self._bus.connect("message::tag", self._on_tag) # Needed for handling the dynamic connection between decodebin # and audioconvert self._decbin.connect("pad-added", self._on_pad_added) self._decbin.connect("pad-removed", self._on_pad_removed) self._main_loop = self.GLib.MainLoop() self._files = [] def _import_gst(self): """Import the necessary GObject-related modules and assign `Gst` and `GObject` fields on this object. """ try: import gi except ImportError: raise FatalReplayGainError( u"Failed to load GStreamer: python-gi not found" ) try: gi.require_version('Gst', '1.0') except ValueError as e: raise FatalReplayGainError( u"Failed to load GStreamer 1.0: {0}".format(e) ) from gi.repository import GObject, Gst, GLib # Calling GObject.threads_init() is not needed for # PyGObject 3.10.2+ with warnings.catch_warnings(): warnings.simplefilter("ignore") GObject.threads_init() Gst.init([sys.argv[0]]) self.GObject = GObject self.GLib = GLib self.Gst = Gst def compute(self, files, album): self._error = None self._files = list(files) if len(self._files) == 0: return self._file_tags = collections.defaultdict(dict) if album: self._rg.set_property("num-tracks", len(self._files)) if self._set_first_file(): self._main_loop.run() if self._error is not None: raise self._error def compute_track_gain(self, items): self.compute(items, False) if len(self._file_tags) != len(items): raise ReplayGainError(u"Some tracks did not receive tags") ret = [] for item in items: ret.append(Gain(self._file_tags[item]["TRACK_GAIN"], self._file_tags[item]["TRACK_PEAK"])) return ret def compute_album_gain(self, album): items = list(album.items()) self.compute(items, True) if len(self._file_tags) != len(items): raise ReplayGainError(u"Some items in album did not receive tags") # Collect track gains. track_gains = [] for item in items: try: gain = self._file_tags[item]["TRACK_GAIN"] peak = self._file_tags[item]["TRACK_PEAK"] except KeyError: raise ReplayGainError(u"results missing for track") track_gains.append(Gain(gain, peak)) # Get album gain information from the last track. last_tags = self._file_tags[items[-1]] try: gain = last_tags["ALBUM_GAIN"] peak = last_tags["ALBUM_PEAK"] except KeyError: raise ReplayGainError(u"results missing for album") return AlbumGain(Gain(gain, peak), track_gains) def close(self): self._bus.remove_signal_watch() def _on_eos(self, bus, message): # A file finished playing in all elements of the pipeline. The # RG tags have already been propagated. If we don't have a next # file, we stop processing. if not self._set_next_file(): self._pipe.set_state(self.Gst.State.NULL) self._main_loop.quit() def _on_error(self, bus, message): self._pipe.set_state(self.Gst.State.NULL) self._main_loop.quit() err, debug = message.parse_error() f = self._src.get_property("location") # A GStreamer error, either an unsupported format or a bug. self._error = ReplayGainError( u"Error {0!r} - {1!r} on file {2!r}".format(err, debug, f) ) def _on_tag(self, bus, message): tags = message.parse_tag() def handle_tag(taglist, tag, userdata): # The rganalysis element provides both the existing tags for # files and the new computes tags. In order to ensure we # store the computed tags, we overwrite the RG values of # received a second time. if tag == self.Gst.TAG_TRACK_GAIN: self._file_tags[self._file]["TRACK_GAIN"] = \ taglist.get_double(tag)[1] elif tag == self.Gst.TAG_TRACK_PEAK: self._file_tags[self._file]["TRACK_PEAK"] = \ taglist.get_double(tag)[1] elif tag == self.Gst.TAG_ALBUM_GAIN: self._file_tags[self._file]["ALBUM_GAIN"] = \ taglist.get_double(tag)[1] elif tag == self.Gst.TAG_ALBUM_PEAK: self._file_tags[self._file]["ALBUM_PEAK"] = \ taglist.get_double(tag)[1] elif tag == self.Gst.TAG_REFERENCE_LEVEL: self._file_tags[self._file]["REFERENCE_LEVEL"] = \ taglist.get_double(tag)[1] tags.foreach(handle_tag, None) def _set_first_file(self): if len(self._files) == 0: return False self._file = self._files.pop(0) self._pipe.set_state(self.Gst.State.NULL) self._src.set_property("location", py3_path(syspath(self._file.path))) self._pipe.set_state(self.Gst.State.PLAYING) return True def _set_file(self): """Initialize the filesrc element with the next file to be analyzed. """ # No more files, we're done if len(self._files) == 0: return False self._file = self._files.pop(0) # Disconnect the decodebin element from the pipeline, set its # state to READY to to clear it. self._decbin.unlink(self._conv) self._decbin.set_state(self.Gst.State.READY) # Set a new file on the filesrc element, can only be done in the # READY state self._src.set_state(self.Gst.State.READY) self._src.set_property("location", py3_path(syspath(self._file.path))) # Ensure the filesrc element received the paused state of the # pipeline in a blocking manner self._src.sync_state_with_parent() self._src.get_state(self.Gst.CLOCK_TIME_NONE) # Ensure the decodebin element receives the paused state of the # pipeline in a blocking manner self._decbin.sync_state_with_parent() self._decbin.get_state(self.Gst.CLOCK_TIME_NONE) self._decbin.link(self._conv) self._pipe.set_state(self.Gst.State.READY) self._pipe.set_state(self.Gst.State.PLAYING) return True def _set_next_file(self): """Set the next file to be analyzed while keeping the pipeline in the PAUSED state so that the rganalysis element can correctly handle album gain. """ # A blocking pause self._pipe.set_state(self.Gst.State.PAUSED) self._pipe.get_state(self.Gst.CLOCK_TIME_NONE) # Try setting the next file ret = self._set_file() if ret: # Seek to the beginning in order to clear the EOS state of the # various elements of the pipeline self._pipe.seek_simple(self.Gst.Format.TIME, self.Gst.SeekFlags.FLUSH, 0) self._pipe.set_state(self.Gst.State.PLAYING) return ret def _on_pad_added(self, decbin, pad): sink_pad = self._conv.get_compatible_pad(pad, None) assert(sink_pad is not None) pad.link(sink_pad) def _on_pad_removed(self, decbin, pad): # Called when the decodebin element is disconnected from the # rest of the pipeline while switching input files peer = pad.get_peer() assert(peer is None) class AudioToolsBackend(Backend): """ReplayGain backend that uses `Python Audio Tools `_ and its capabilities to read more file formats and compute ReplayGain values using it replaygain module. """ def __init__(self, config, log): super(AudioToolsBackend, self).__init__(config, log) self._import_audiotools() def _import_audiotools(self): """Check whether it's possible to import the necessary modules. There is no check on the file formats at runtime. :raises :exc:`ReplayGainError`: if the modules cannot be imported """ try: import audiotools import audiotools.replaygain except ImportError: raise FatalReplayGainError( u"Failed to load audiotools: audiotools not found" ) self._mod_audiotools = audiotools self._mod_replaygain = audiotools.replaygain def open_audio_file(self, item): """Open the file to read the PCM stream from the using ``item.path``. :return: the audiofile instance :rtype: :class:`audiotools.AudioFile` :raises :exc:`ReplayGainError`: if the file is not found or the file format is not supported """ try: audiofile = self._mod_audiotools.open(item.path) except IOError: raise ReplayGainError( u"File {} was not found".format(item.path) ) except self._mod_audiotools.UnsupportedFile: raise ReplayGainError( u"Unsupported file type {}".format(item.format) ) return audiofile def init_replaygain(self, audiofile, item): """Return an initialized :class:`audiotools.replaygain.ReplayGain` instance, which requires the sample rate of the song(s) on which the ReplayGain values will be computed. The item is passed in case the sample rate is invalid to log the stored item sample rate. :return: initialized replagain object :rtype: :class:`audiotools.replaygain.ReplayGain` :raises: :exc:`ReplayGainError` if the sample rate is invalid """ try: rg = self._mod_replaygain.ReplayGain(audiofile.sample_rate()) except ValueError: raise ReplayGainError( u"Unsupported sample rate {}".format(item.samplerate)) return return rg def compute_track_gain(self, items): """Compute ReplayGain values for the requested items. :return list: list of :class:`Gain` objects """ return [self._compute_track_gain(item) for item in items] def _title_gain(self, rg, audiofile): """Get the gain result pair from PyAudioTools using the `ReplayGain` instance `rg` for the given `audiofile`. Wraps `rg.title_gain(audiofile.to_pcm())` and throws a `ReplayGainError` when the library fails. """ try: # The method needs an audiotools.PCMReader instance that can # be obtained from an audiofile instance. return rg.title_gain(audiofile.to_pcm()) except ValueError as exc: # `audiotools.replaygain` can raise a `ValueError` if the sample # rate is incorrect. self._log.debug(u'error in rg.title_gain() call: {}', exc) raise ReplayGainError(u'audiotools audio data error') def _compute_track_gain(self, item): """Compute ReplayGain value for the requested item. :rtype: :class:`Gain` """ audiofile = self.open_audio_file(item) rg = self.init_replaygain(audiofile, item) # Each call to title_gain on a ReplayGain object returns peak and gain # of the track. rg_track_gain, rg_track_peak = self._title_gain(rg, audiofile) self._log.debug(u'ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}', item.artist, item.title, rg_track_gain, rg_track_peak) return Gain(gain=rg_track_gain, peak=rg_track_peak) def compute_album_gain(self, album): """Compute ReplayGain values for the requested album and its items. :rtype: :class:`AlbumGain` """ self._log.debug(u'Analysing album {0}', album) # The first item is taken and opened to get the sample rate to # initialize the replaygain object. The object is used for all the # tracks in the album to get the album values. item = list(album.items())[0] audiofile = self.open_audio_file(item) rg = self.init_replaygain(audiofile, item) track_gains = [] for item in album.items(): audiofile = self.open_audio_file(item) rg_track_gain, rg_track_peak = self._title_gain(rg, audiofile) track_gains.append( Gain(gain=rg_track_gain, peak=rg_track_peak) ) self._log.debug(u'ReplayGain for track {0}: {1:.2f}, {2:.2f}', item, rg_track_gain, rg_track_peak) # After getting the values for all tracks, it's possible to get the # album values. rg_album_gain, rg_album_peak = rg.album_gain() self._log.debug(u'ReplayGain for album {0}: {1:.2f}, {2:.2f}', album, rg_album_gain, rg_album_peak) return AlbumGain( Gain(gain=rg_album_gain, peak=rg_album_peak), track_gains=track_gains ) # Main plugin logic. class ReplayGainPlugin(BeetsPlugin): """Provides ReplayGain analysis. """ backends = { "command": CommandBackend, "gstreamer": GStreamerBackend, "audiotools": AudioToolsBackend, "bs1770gain": Bs1770gainBackend, } def __init__(self): super(ReplayGainPlugin, self).__init__() # default backend is 'command' for backward-compatibility. self.config.add({ 'overwrite': False, 'auto': True, 'backend': u'command', 'targetlevel': 89, 'r128': ['Opus'], }) self.overwrite = self.config['overwrite'].get(bool) backend_name = self.config['backend'].as_str() if backend_name not in self.backends: raise ui.UserError( u"Selected ReplayGain backend {0} is not supported. " u"Please select one of: {1}".format( backend_name, u', '.join(self.backends.keys()) ) ) # On-import analysis. if self.config['auto']: self.import_stages = [self.imported] # Formats to use R128. self.r128_whitelist = self.config['r128'].as_str_seq() try: self.backend_instance = self.backends[backend_name]( self.config, self._log ) except (ReplayGainError, FatalReplayGainError) as e: raise ui.UserError( u'replaygain initialization failed: {0}'.format(e)) self.r128_backend_instance = '' def should_use_r128(self, item): """Checks the plugin setting to decide whether the calculation should be done using the EBU R128 standard and use R128_ tags instead. """ return item.format in self.r128_whitelist def track_requires_gain(self, item): return self.overwrite or \ (self.should_use_r128(item) and not item.r128_track_gain) or \ (not self.should_use_r128(item) and (not item.rg_track_gain or not item.rg_track_peak)) def album_requires_gain(self, album): # Skip calculating gain only when *all* files don't need # recalculation. This way, if any file among an album's tracks # needs recalculation, we still get an accurate album gain # value. return self.overwrite or \ any([self.should_use_r128(item) and (not item.r128_track_gain or not item.r128_album_gain) for item in album.items()]) or \ any([not self.should_use_r128(item) and (not item.rg_album_gain or not item.rg_album_peak) for item in album.items()]) def store_track_gain(self, item, track_gain): item.rg_track_gain = track_gain.gain item.rg_track_peak = track_gain.peak item.store() self._log.debug(u'applied track gain {0}, peak {1}', item.rg_track_gain, item.rg_track_peak) def store_track_r128_gain(self, item, track_gain): item.r128_track_gain = int(round(track_gain.gain * pow(2, 8))) item.store() self._log.debug(u'applied track gain {0}', item.r128_track_gain) def store_album_gain(self, album, album_gain): album.rg_album_gain = album_gain.gain album.rg_album_peak = album_gain.peak album.store() self._log.debug(u'applied album gain {0}, peak {1}', album.rg_album_gain, album.rg_album_peak) def store_album_r128_gain(self, album, album_gain): album.r128_album_gain = int(round(album_gain.gain * pow(2, 8))) album.store() self._log.debug(u'applied album gain {0}', album.r128_album_gain) def handle_album(self, album, write): """Compute album and track replay gain store it in all of the album's items. If ``write`` is truthy then ``item.write()`` is called for each item. If replay gain information is already present in all items, nothing is done. """ if not self.album_requires_gain(album): self._log.info(u'Skipping album {0}', album) return self._log.info(u'analyzing {0}', album) if (any([self.should_use_r128(item) for item in album.items()]) and not all(([self.should_use_r128(item) for item in album.items()]))): raise ReplayGainError( u"Mix of ReplayGain and EBU R128 detected" u" for some tracks in album {0}".format(album) ) if any([self.should_use_r128(item) for item in album.items()]): if self.r128_backend_instance == '': self.init_r128_backend() backend_instance = self.r128_backend_instance store_track_gain = self.store_track_r128_gain store_album_gain = self.store_album_r128_gain else: backend_instance = self.backend_instance store_track_gain = self.store_track_gain store_album_gain = self.store_album_gain try: album_gain = backend_instance.compute_album_gain(album) if len(album_gain.track_gains) != len(album.items()): raise ReplayGainError( u"ReplayGain backend failed " u"for some tracks in album {0}".format(album) ) store_album_gain(album, album_gain.album_gain) for item, track_gain in zip(album.items(), album_gain.track_gains): store_track_gain(item, track_gain) if write: item.try_write() except ReplayGainError as e: self._log.info(u"ReplayGain error: {0}", e) except FatalReplayGainError as e: raise ui.UserError( u"Fatal replay gain error: {0}".format(e)) def handle_track(self, item, write): """Compute track replay gain and store it in the item. If ``write`` is truthy then ``item.write()`` is called to write the data to disk. If replay gain information is already present in the item, nothing is done. """ if not self.track_requires_gain(item): self._log.info(u'Skipping track {0}', item) return self._log.info(u'analyzing {0}', item) if self.should_use_r128(item): if self.r128_backend_instance == '': self.init_r128_backend() backend_instance = self.r128_backend_instance store_track_gain = self.store_track_r128_gain else: backend_instance = self.backend_instance store_track_gain = self.store_track_gain try: track_gains = backend_instance.compute_track_gain([item]) if len(track_gains) != 1: raise ReplayGainError( u"ReplayGain backend failed for track {0}".format(item) ) store_track_gain(item, track_gains[0]) if write: item.try_write() except ReplayGainError as e: self._log.info(u"ReplayGain error: {0}", e) except FatalReplayGainError as e: raise ui.UserError( u"Fatal replay gain error: {0}".format(e)) def init_r128_backend(self): backend_name = 'bs1770gain' try: self.r128_backend_instance = self.backends[backend_name]( self.config, self._log ) except (ReplayGainError, FatalReplayGainError) as e: raise ui.UserError( u'replaygain initialization failed: {0}'.format(e)) self.r128_backend_instance.method = '--ebu' def imported(self, session, task): """Add replay gain info to items or albums of ``task``. """ if task.is_album: self.handle_album(task.album, False) else: self.handle_track(task.item, False) def commands(self): """Return the "replaygain" ui subcommand. """ def func(lib, opts, args): write = ui.should_write() if opts.album: for album in lib.albums(ui.decargs(args)): self.handle_album(album, write) else: for item in lib.items(ui.decargs(args)): self.handle_track(item, write) cmd = ui.Subcommand('replaygain', help=u'analyze for ReplayGain') cmd.parser.add_album_option() cmd.func = func return [cmd] beets-1.4.6/beetsplug/fuzzy.py0000644000076500000240000000304313025125202017313 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Philippe Mongeau. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Provides a fuzzy matching query. """ from __future__ import division, absolute_import, print_function from beets.plugins import BeetsPlugin from beets.dbcore.query import StringFieldQuery from beets import config import difflib class FuzzyQuery(StringFieldQuery): @classmethod def string_match(cls, pattern, val): # smartcase if pattern.islower(): val = val.lower() query_matcher = difflib.SequenceMatcher(None, pattern, val) threshold = config['fuzzy']['threshold'].as_number() return query_matcher.quick_ratio() >= threshold class FuzzyPlugin(BeetsPlugin): def __init__(self): super(FuzzyPlugin, self).__init__() self.config.add({ 'prefix': '~', 'threshold': 0.7, }) def queries(self): prefix = self.config['prefix'].as_str() return {prefix: FuzzyQuery} beets-1.4.6/beetsplug/missing.py0000644000076500000240000001767213120341454017617 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Pedro Silva. # Copyright 2017, Quentin Young. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """List missing tracks. """ from __future__ import division, absolute_import, print_function import musicbrainzngs from musicbrainzngs.musicbrainz import MusicBrainzError from collections import defaultdict from beets.autotag import hooks from beets.library import Item from beets.plugins import BeetsPlugin from beets.ui import decargs, print_, Subcommand from beets import config from beets.dbcore import types def _missing_count(album): """Return number of missing items in `album`. """ return (album.albumtotal or 0) - len(album.items()) def _item(track_info, album_info, album_id): """Build and return `item` from `track_info` and `album info` objects. `item` is missing what fields cannot be obtained from MusicBrainz alone (encoder, rg_track_gain, rg_track_peak, rg_album_gain, rg_album_peak, original_year, original_month, original_day, length, bitrate, format, samplerate, bitdepth, channels, mtime.) """ t = track_info a = album_info return Item(**{ 'album_id': album_id, 'album': a.album, 'albumartist': a.artist, 'albumartist_credit': a.artist_credit, 'albumartist_sort': a.artist_sort, 'albumdisambig': a.albumdisambig, 'albumstatus': a.albumstatus, 'albumtype': a.albumtype, 'artist': t.artist, 'artist_credit': t.artist_credit, 'artist_sort': t.artist_sort, 'asin': a.asin, 'catalognum': a.catalognum, 'comp': a.va, 'country': a.country, 'day': a.day, 'disc': t.medium, 'disctitle': t.disctitle, 'disctotal': a.mediums, 'label': a.label, 'language': a.language, 'length': t.length, 'mb_albumid': a.album_id, 'mb_artistid': t.artist_id, 'mb_releasegroupid': a.releasegroup_id, 'mb_trackid': t.track_id, 'media': t.media, 'month': a.month, 'script': a.script, 'title': t.title, 'track': t.index, 'tracktotal': len(a.tracks), 'year': a.year, }) class MissingPlugin(BeetsPlugin): """List missing tracks """ album_types = { 'missing': types.INTEGER, } def __init__(self): super(MissingPlugin, self).__init__() self.config.add({ 'count': False, 'total': False, 'album': False, }) self.album_template_fields['missing'] = _missing_count self._command = Subcommand('missing', help=__doc__, aliases=['miss']) self._command.parser.add_option( u'-c', u'--count', dest='count', action='store_true', help=u'count missing tracks per album') self._command.parser.add_option( u'-t', u'--total', dest='total', action='store_true', help=u'count total of missing tracks') self._command.parser.add_option( u'-a', u'--album', dest='album', action='store_true', help=u'show missing albums for artist instead of tracks') self._command.parser.add_format_option() def commands(self): def _miss(lib, opts, args): self.config.set_args(opts) albms = self.config['album'].get() helper = self._missing_albums if albms else self._missing_tracks helper(lib, decargs(args)) self._command.func = _miss return [self._command] def _missing_tracks(self, lib, query): """Print a listing of tracks missing from each album in the library matching query. """ albums = lib.albums(query) count = self.config['count'].get() total = self.config['total'].get() fmt = config['format_album' if count else 'format_item'].get() if total: print(sum([_missing_count(a) for a in albums])) return # Default format string for count mode. if count: fmt += ': $missing' for album in albums: if count: if _missing_count(album): print_(format(album, fmt)) else: for item in self._missing(album): print_(format(item, fmt)) def _missing_albums(self, lib, query): """Print a listing of albums missing from each artist in the library matching query. """ total = self.config['total'].get() albums = lib.albums(query) # build dict mapping artist to list of their albums in library albums_by_artist = defaultdict(list) for alb in albums: artist = (alb['albumartist'], alb['mb_albumartistid']) albums_by_artist[artist].append(alb) total_missing = 0 # build dict mapping artist to list of all albums for artist, albums in albums_by_artist.items(): if artist[1] is None or artist[1] == "": albs_no_mbid = [u"'" + a['album'] + u"'" for a in albums] self._log.info( u"No musicbrainz ID for artist '{}' found in album(s) {}; " "skipping", artist[0], u", ".join(albs_no_mbid) ) continue try: resp = musicbrainzngs.browse_release_groups(artist=artist[1]) release_groups = resp['release-group-list'] except MusicBrainzError as err: self._log.info( u"Couldn't fetch info for artist '{}' ({}) - '{}'", artist[0], artist[1], err ) continue missing = [] present = [] for rg in release_groups: missing.append(rg) for alb in albums: if alb['mb_releasegroupid'] == rg['id']: missing.remove(rg) present.append(rg) break total_missing += len(missing) if total: continue missing_titles = {rg['title'] for rg in missing} for release_title in missing_titles: print_(u"{} - {}".format(artist[0], release_title)) if total: print(total_missing) def _missing(self, album): """Query MusicBrainz to determine items missing from `album`. """ item_mbids = [x.mb_trackid for x in album.items()] if len([i for i in album.items()]) < album.albumtotal: # fetch missing items # TODO: Implement caching that without breaking other stuff album_info = hooks.album_for_mbid(album.mb_albumid) for track_info in getattr(album_info, 'tracks', []): if track_info.track_id not in item_mbids: item = _item(track_info, album_info, album.id) self._log.debug(u'track {0} in album {1}', track_info.track_id, album_info.album_id) yield item beets-1.4.6/beetsplug/bucket.py0000644000076500000240000001776213025125202017416 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Provides the %bucket{} function for path formatting. """ from __future__ import division, absolute_import, print_function from datetime import datetime import re import string from six.moves import zip from itertools import tee from beets import plugins, ui ASCII_DIGITS = string.digits + string.ascii_lowercase class BucketError(Exception): pass def pairwise(iterable): "s -> (s0,s1), (s1,s2), (s2, s3), ..." a, b = tee(iterable) next(b, None) return zip(a, b) def span_from_str(span_str): """Build a span dict from the span string representation. """ def normalize_year(d, yearfrom): """Convert string to a 4 digits year """ if yearfrom < 100: raise BucketError(u"%d must be expressed on 4 digits" % yearfrom) # if two digits only, pick closest year that ends by these two # digits starting from yearfrom if d < 100: if (d % 100) < (yearfrom % 100): d = (yearfrom - yearfrom % 100) + 100 + d else: d = (yearfrom - yearfrom % 100) + d return d years = [int(x) for x in re.findall('\d+', span_str)] if not years: raise ui.UserError(u"invalid range defined for year bucket '%s': no " u"year found" % span_str) try: years = [normalize_year(x, years[0]) for x in years] except BucketError as exc: raise ui.UserError(u"invalid range defined for year bucket '%s': %s" % (span_str, exc)) res = {'from': years[0], 'str': span_str} if len(years) > 1: res['to'] = years[-1] return res def complete_year_spans(spans): """Set the `to` value of spans if empty and sort them chronologically. """ spans.sort(key=lambda x: x['from']) for (x, y) in pairwise(spans): if 'to' not in x: x['to'] = y['from'] - 1 if spans and 'to' not in spans[-1]: spans[-1]['to'] = datetime.now().year def extend_year_spans(spans, spanlen, start=1900, end=2014): """Add new spans to given spans list so that every year of [start,end] belongs to a span. """ extended_spans = spans[:] for (x, y) in pairwise(spans): # if a gap between two spans, fill the gap with as much spans of # spanlen length as necessary for span_from in range(x['to'] + 1, y['from'], spanlen): extended_spans.append({'from': span_from}) # Create spans prior to declared ones for span_from in range(spans[0]['from'] - spanlen, start, -spanlen): extended_spans.append({'from': span_from}) # Create spans after the declared ones for span_from in range(spans[-1]['to'] + 1, end, spanlen): extended_spans.append({'from': span_from}) complete_year_spans(extended_spans) return extended_spans def build_year_spans(year_spans_str): """Build a chronologically ordered list of spans dict from unordered spans stringlist. """ spans = [] for elem in year_spans_str: spans.append(span_from_str(elem)) complete_year_spans(spans) return spans def str2fmt(s): """Deduces formatting syntax from a span string. """ regex = re.compile(r"(?P\D*)(?P\d+)(?P\D*)" r"(?P\d*)(?P\D*)") m = re.match(regex, s) res = {'fromnchars': len(m.group('fromyear')), 'tonchars': len(m.group('toyear'))} res['fmt'] = "%s%%s%s%s%s" % (m.group('bef'), m.group('sep'), '%s' if res['tonchars'] else '', m.group('after')) return res def format_span(fmt, yearfrom, yearto, fromnchars, tonchars): """Return a span string representation. """ args = (str(yearfrom)[-fromnchars:]) if tonchars: args = (str(yearfrom)[-fromnchars:], str(yearto)[-tonchars:]) return fmt % args def extract_modes(spans): """Extract the most common spans lengths and representation formats """ rangelen = sorted([x['to'] - x['from'] + 1 for x in spans]) deflen = sorted(rangelen, key=rangelen.count)[-1] reprs = [str2fmt(x['str']) for x in spans] deffmt = sorted(reprs, key=reprs.count)[-1] return deflen, deffmt def build_alpha_spans(alpha_spans_str, alpha_regexs): """Extract alphanumerics from string and return sorted list of chars [from...to] """ spans = [] for elem in alpha_spans_str: if elem in alpha_regexs: spans.append(re.compile(alpha_regexs[elem])) else: bucket = sorted([x for x in elem.lower() if x.isalnum()]) if bucket: begin_index = ASCII_DIGITS.index(bucket[0]) end_index = ASCII_DIGITS.index(bucket[-1]) else: raise ui.UserError(u"invalid range defined for alpha bucket " u"'%s': no alphanumeric character found" % elem) spans.append( re.compile( "^[" + ASCII_DIGITS[begin_index:end_index + 1] + ASCII_DIGITS[begin_index:end_index + 1].upper() + "]" ) ) return spans class BucketPlugin(plugins.BeetsPlugin): def __init__(self): super(BucketPlugin, self).__init__() self.template_funcs['bucket'] = self._tmpl_bucket self.config.add({ 'bucket_year': [], 'bucket_alpha': [], 'bucket_alpha_regex': {}, 'extrapolate': False }) self.setup() def setup(self): """Setup plugin from config options """ self.year_spans = build_year_spans(self.config['bucket_year'].get()) if self.year_spans and self.config['extrapolate']: [self.ys_len_mode, self.ys_repr_mode] = extract_modes(self.year_spans) self.year_spans = extend_year_spans(self.year_spans, self.ys_len_mode) self.alpha_spans = build_alpha_spans( self.config['bucket_alpha'].get(), self.config['bucket_alpha_regex'].get() ) def find_bucket_year(self, year): """Return bucket that matches given year or return the year if no matching bucket. """ for ys in self.year_spans: if ys['from'] <= int(year) <= ys['to']: if 'str' in ys: return ys['str'] else: return format_span(self.ys_repr_mode['fmt'], ys['from'], ys['to'], self.ys_repr_mode['fromnchars'], self.ys_repr_mode['tonchars']) return year def find_bucket_alpha(self, s): """Return alpha-range bucket that matches given string or return the string initial if no matching bucket. """ for (i, span) in enumerate(self.alpha_spans): if span.match(s): return self.config['bucket_alpha'].get()[i] return s[0].upper() def _tmpl_bucket(self, text, field=None): if not field and len(text) == 4 and text.isdigit(): field = 'year' if field == 'year': func = self.find_bucket_year else: func = self.find_bucket_alpha return func(text) beets-1.4.6/beetsplug/discogs.py0000644000076500000240000005310513120341454017570 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds Discogs album search support to the autotagger. Requires the discogs-client library. """ from __future__ import division, absolute_import, print_function import beets.ui from beets import config from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance from beets.plugins import BeetsPlugin from beets.util import confit from discogs_client import Release, Client from discogs_client.exceptions import DiscogsAPIError from requests.exceptions import ConnectionError from six.moves import http_client import beets import re import time import json import socket import os import traceback from string import ascii_lowercase USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__) # Exceptions that discogs_client should really handle but does not. CONNECTION_ERRORS = (ConnectionError, socket.error, http_client.HTTPException, ValueError, # JSON decoding raises a ValueError. DiscogsAPIError) class DiscogsPlugin(BeetsPlugin): def __init__(self): super(DiscogsPlugin, self).__init__() self.config.add({ 'apikey': 'rAzVUQYRaoFjeBjyWuWZ', 'apisecret': 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy', 'tokenfile': 'discogs_token.json', 'source_weight': 0.5, 'user_token': '', }) self.config['apikey'].redact = True self.config['apisecret'].redact = True self.config['user_token'].redact = True self.discogs_client = None self.register_listener('import_begin', self.setup) def setup(self, session=None): """Create the `discogs_client` field. Authenticate if necessary. """ c_key = self.config['apikey'].as_str() c_secret = self.config['apisecret'].as_str() # Try using a configured user token (bypassing OAuth login). user_token = self.config['user_token'].as_str() if user_token: self.discogs_client = Client(USER_AGENT, user_token=user_token) return # Get the OAuth token from a file or log in. try: with open(self._tokenfile()) as f: tokendata = json.load(f) except IOError: # No token yet. Generate one. token, secret = self.authenticate(c_key, c_secret) else: token = tokendata['token'] secret = tokendata['secret'] self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret) def reset_auth(self): """Delete token file & redo the auth steps. """ os.remove(self._tokenfile()) self.setup() def _tokenfile(self): """Get the path to the JSON file for storing the OAuth token. """ return self.config['tokenfile'].get(confit.Filename(in_app_dir=True)) def authenticate(self, c_key, c_secret): # Get the link for the OAuth page. auth_client = Client(USER_AGENT, c_key, c_secret) try: _, _, url = auth_client.get_authorize_url() except CONNECTION_ERRORS as e: self._log.debug(u'connection error: {0}', e) raise beets.ui.UserError(u'communication with Discogs failed') beets.ui.print_(u"To authenticate with Discogs, visit:") beets.ui.print_(url) # Ask for the code and validate it. code = beets.ui.input_(u"Enter the code:") try: token, secret = auth_client.get_access_token(code) except DiscogsAPIError: raise beets.ui.UserError(u'Discogs authorization failed') except CONNECTION_ERRORS as e: self._log.debug(u'connection error: {0}', e) raise beets.ui.UserError(u'Discogs token request failed') # Save the token for later use. self._log.debug(u'Discogs token {0}, secret {1}', token, secret) with open(self._tokenfile(), 'w') as f: json.dump({'token': token, 'secret': secret}, f) return token, secret def album_distance(self, items, album_info, mapping): """Returns the album distance. """ dist = Distance() if album_info.data_source == 'Discogs': dist.add('source', self.config['source_weight'].as_number()) return dist def candidates(self, items, artist, album, va_likely): """Returns a list of AlbumInfo objects for discogs search results matching an album and artist (if not various). """ if not self.discogs_client: return if va_likely: query = album else: query = '%s %s' % (artist, album) try: return self.get_albums(query) except DiscogsAPIError as e: self._log.debug(u'API Error: {0} (query: {1})', e, query) if e.status_code == 401: self.reset_auth() return self.candidates(items, artist, album, va_likely) else: return [] except CONNECTION_ERRORS: self._log.debug(u'Connection error in album search', exc_info=True) return [] def album_for_id(self, album_id): """Fetches an album by its Discogs ID and returns an AlbumInfo object or None if the album is not found. """ if not self.discogs_client: return self._log.debug(u'Searching for release {0}', album_id) # Discogs-IDs are simple integers. We only look for those at the end # of an input string as to avoid confusion with other metadata plugins. # An optional bracket can follow the integer, as this is how discogs # displays the release ID on its webpage. match = re.search(r'(^|\[*r|discogs\.com/.+/release/)(\d+)($|\])', album_id) if not match: return None result = Release(self.discogs_client, {'id': int(match.group(2))}) # Try to obtain title to verify that we indeed have a valid Release try: getattr(result, 'title') except DiscogsAPIError as e: if e.status_code != 404: self._log.debug(u'API Error: {0} (query: {1})', e, result._uri) if e.status_code == 401: self.reset_auth() return self.album_for_id(album_id) return None except CONNECTION_ERRORS: self._log.debug(u'Connection error in album lookup', exc_info=True) return None return self.get_album_info(result) def get_albums(self, query): """Returns a list of AlbumInfo objects for a discogs search query. """ # Strip non-word characters from query. Things like "!" and "-" can # cause a query to return no results, even if they match the artist or # album title. Use `re.UNICODE` flag to avoid stripping non-english # word characters. # FIXME: Encode as ASCII to work around a bug: # https://github.com/beetbox/beets/issues/1051 # When the library is fixed, we should encode as UTF-8. query = re.sub(r'(?u)\W+', ' ', query).encode('ascii', "replace") # Strip medium information from query, Things like "CD1" and "disk 1" # can also negate an otherwise positive result. query = re.sub(br'(?i)\b(CD|disc)\s*\d+', b'', query) try: releases = self.discogs_client.search(query, type='release').page(1) except CONNECTION_ERRORS: self._log.debug(u"Communication error while searching for {0!r}", query, exc_info=True) return [] return [album for album in map(self.get_album_info, releases[:5]) if album] def get_album_info(self, result): """Returns an AlbumInfo object for a discogs Release object. """ # Explicitly reload the `Release` fields, as they might not be yet # present if the result is from a `discogs_client.search()`. if not result.data.get('artists'): result.refresh() # Sanity check for required fields. The list of required fields is # defined at Guideline 1.3.1.a, but in practice some releases might be # lacking some of these fields. This function expects at least: # `artists` (>0), `title`, `id`, `tracklist` (>0) # https://www.discogs.com/help/doc/submission-guidelines-general-rules if not all([result.data.get(k) for k in ['artists', 'title', 'id', 'tracklist']]): self._log.warn(u"Release does not contain the required fields") return None artist, artist_id = self.get_artist([a.data for a in result.artists]) album = re.sub(r' +', ' ', result.title) album_id = result.data['id'] # Use `.data` to access the tracklist directly instead of the # convenient `.tracklist` property, which will strip out useful artist # information and leave us with skeleton `Artist` objects that will # each make an API call just to get the same data back. tracks = self.get_tracks(result.data['tracklist']) # Extract information for the optional AlbumInfo fields, if possible. va = result.data['artists'][0].get('name', '').lower() == 'various' year = result.data.get('year') mediums = len(set(t.medium for t in tracks)) country = result.data.get('country') data_url = result.data.get('uri') # Extract information for the optional AlbumInfo fields that are # contained on nested discogs fields. albumtype = media = label = catalogno = None if result.data.get('formats'): albumtype = ', '.join( result.data['formats'][0].get('descriptions', [])) or None media = result.data['formats'][0]['name'] if result.data.get('labels'): label = result.data['labels'][0].get('name') catalogno = result.data['labels'][0].get('catno') # Additional cleanups (various artists name, catalog number, media). if va: artist = config['va_name'].as_str() if catalogno == 'none': catalogno = None # Explicitly set the `media` for the tracks, since it is expected by # `autotag.apply_metadata`, and set `medium_total`. for track in tracks: track.media = media track.medium_total = mediums return AlbumInfo(album, album_id, artist, artist_id, tracks, asin=None, albumtype=albumtype, va=va, year=year, month=None, day=None, label=label, mediums=mediums, artist_sort=None, releasegroup_id=None, catalognum=catalogno, script=None, language=None, country=country, albumstatus=None, media=media, albumdisambig=None, artist_credit=None, original_year=None, original_month=None, original_day=None, data_source='Discogs', data_url=data_url) def get_artist(self, artists): """Returns an artist string (all artists) and an artist_id (the main artist) for a list of discogs album or track artists. """ artist_id = None bits = [] for i, artist in enumerate(artists): if not artist_id: artist_id = artist['id'] name = artist['name'] # Strip disambiguation number. name = re.sub(r' \(\d+\)$', '', name) # Move articles to the front. name = re.sub(r'(?i)^(.*?), (a|an|the)$', r'\2 \1', name) bits.append(name) if artist['join'] and i < len(artists) - 1: bits.append(artist['join']) artist = ' '.join(bits).replace(' ,', ',') or None return artist, artist_id def get_tracks(self, tracklist): """Returns a list of TrackInfo objects for a discogs tracklist. """ try: clean_tracklist = self.coalesce_tracks(tracklist) except Exception as exc: # FIXME: this is an extra precaution for making sure there are no # side effects after #2222. It should be removed after further # testing. self._log.debug(u'{}', traceback.format_exc()) self._log.error(u'uncaught exception in coalesce_tracks: {}', exc) clean_tracklist = tracklist tracks = [] index_tracks = {} index = 0 for track in clean_tracklist: # Only real tracks have `position`. Otherwise, it's an index track. if track['position']: index += 1 track_info = self.get_track_info(track, index) track_info.track_alt = track['position'] tracks.append(track_info) else: index_tracks[index + 1] = track['title'] # Fix up medium and medium_index for each track. Discogs position is # unreliable, but tracks are in order. medium = None medium_count, index_count, side_count = 0, 0, 0 sides_per_medium = 1 # If a medium has two sides (ie. vinyl or cassette), each pair of # consecutive sides should belong to the same medium. if all([track.medium is not None for track in tracks]): m = sorted(set([track.medium.lower() for track in tracks])) # If all track.medium are single consecutive letters, assume it is # a 2-sided medium. if ''.join(m) in ascii_lowercase: sides_per_medium = 2 side_count = 1 # Force for first item, where medium == None for track in tracks: # Handle special case where a different medium does not indicate a # new disc, when there is no medium_index and the ordinal of medium # is not sequential. For example, I, II, III, IV, V. Assume these # are the track index, not the medium. medium_is_index = track.medium and not track.medium_index and ( len(track.medium) != 1 or ord(track.medium) - 64 != medium_count + 1 ) if not medium_is_index and medium != track.medium: if side_count < (sides_per_medium - 1): # Increment side count: side changed, but not medium. side_count += 1 medium = track.medium else: # Increment medium_count and reset index_count and side # count when medium changes. medium = track.medium medium_count += 1 index_count = 0 side_count = 0 index_count += 1 medium_count = 1 if medium_count == 0 else medium_count track.medium, track.medium_index = medium_count, index_count # Get `disctitle` from Discogs index tracks. Assume that an index track # before the first track of each medium is a disc title. for track in tracks: if track.medium_index == 1: if track.index in index_tracks: disctitle = index_tracks[track.index] else: disctitle = None track.disctitle = disctitle return tracks def coalesce_tracks(self, raw_tracklist): """Pre-process a tracklist, merging subtracks into a single track. The title for the merged track is the one from the previous index track, if present; otherwise it is a combination of the subtracks titles. """ def add_merged_subtracks(tracklist, subtracks): """Modify `tracklist` in place, merging a list of `subtracks` into a single track into `tracklist`.""" # Calculate position based on first subtrack, without subindex. idx, medium_idx, sub_idx = \ self.get_track_index(subtracks[0]['position']) position = '%s%s' % (idx or '', medium_idx or '') if tracklist and not tracklist[-1]['position']: # Assume the previous index track contains the track title. if sub_idx: # "Convert" the track title to a real track, discarding the # subtracks assuming they are logical divisions of a # physical track (12.2.9 Subtracks). tracklist[-1]['position'] = position else: # Promote the subtracks to real tracks, discarding the # index track, assuming the subtracks are physical tracks. index_track = tracklist.pop() # Fix artists when they are specified on the index track. if index_track.get('artists'): for subtrack in subtracks: if not subtrack.get('artists'): subtrack['artists'] = index_track['artists'] tracklist.extend(subtracks) else: # Merge the subtracks, pick a title, and append the new track. track = subtracks[0].copy() track['title'] = ' / '.join([t['title'] for t in subtracks]) tracklist.append(track) # Pre-process the tracklist, trying to identify subtracks. subtracks = [] tracklist = [] prev_subindex = '' for track in raw_tracklist: # Regular subtrack (track with subindex). if track['position']: _, _, subindex = self.get_track_index(track['position']) if subindex: if subindex.rjust(len(raw_tracklist)) > prev_subindex: # Subtrack still part of the current main track. subtracks.append(track) else: # Subtrack part of a new group (..., 1.3, *2.1*, ...). add_merged_subtracks(tracklist, subtracks) subtracks = [track] prev_subindex = subindex.rjust(len(raw_tracklist)) continue # Index track with nested sub_tracks. if not track['position'] and 'sub_tracks' in track: # Append the index track, assuming it contains the track title. tracklist.append(track) add_merged_subtracks(tracklist, track['sub_tracks']) continue # Regular track or index track without nested sub_tracks. if subtracks: add_merged_subtracks(tracklist, subtracks) subtracks = [] prev_subindex = '' tracklist.append(track) # Merge and add the remaining subtracks, if any. if subtracks: add_merged_subtracks(tracklist, subtracks) return tracklist def get_track_info(self, track, index): """Returns a TrackInfo object for a discogs track. """ title = track['title'] track_id = None medium, medium_index, _ = self.get_track_index(track['position']) artist, artist_id = self.get_artist(track.get('artists', [])) length = self.get_track_length(track['duration']) return TrackInfo(title, track_id, artist, artist_id, length, index, medium, medium_index, artist_sort=None, disctitle=None, artist_credit=None) def get_track_index(self, position): """Returns the medium, medium index and subtrack index for a discogs track position.""" # Match the standard Discogs positions (12.2.9), which can have several # forms (1, 1-1, A1, A1.1, A1a, ...). match = re.match( r'^(.*?)' # medium: everything before medium_index. r'(\d*?)' # medium_index: a number at the end of # `position`, except if followed by a subtrack # index. # subtrack_index: can only be matched if medium # or medium_index have been matched, and can be r'((?<=\w)\.[\w]+' # - a dot followed by a string (A.1, 2.A) r'|(?<=\d)[A-Z]+' # - a string that follows a number (1A, B2a) r')?' r'$', position.upper() ) if match: medium, index, subindex = match.groups() if subindex and subindex.startswith('.'): subindex = subindex[1:] else: self._log.debug(u'Invalid position: {0}', position) medium = index = subindex = None return medium or None, index or None, subindex or None def get_track_length(self, duration): """Returns the track length in seconds for a discogs duration. """ try: length = time.strptime(duration, '%M:%S') except ValueError: return None return length.tm_min * 60 + length.tm_sec beets-1.4.6/beetsplug/acousticbrainz.py0000644000076500000240000002540313214005142021150 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2015-2016, Ohm Patel. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Fetch various AcousticBrainz metadata using MBID. """ from __future__ import division, absolute_import, print_function import requests from collections import defaultdict from beets import plugins, ui ACOUSTIC_BASE = "https://acousticbrainz.org/" LEVELS = ["/low-level", "/high-level"] ABSCHEME = { 'highlevel': { 'danceability': { 'all': { 'danceable': 'danceable' } }, 'gender': { 'value': 'gender' }, 'genre_rosamerica': { 'value': 'genre_rosamerica' }, 'mood_acoustic': { 'all': { 'acoustic': 'mood_acoustic' } }, 'mood_aggressive': { 'all': { 'aggressive': 'mood_aggressive' } }, 'mood_electronic': { 'all': { 'electronic': 'mood_electronic' } }, 'mood_happy': { 'all': { 'happy': 'mood_happy' } }, 'mood_party': { 'all': { 'party': 'mood_party' } }, 'mood_relaxed': { 'all': { 'relaxed': 'mood_relaxed' } }, 'mood_sad': { 'all': { 'sad': 'mood_sad' } }, 'ismir04_rhythm': { 'value': 'rhythm' }, 'tonal_atonal': { 'all': { 'tonal': 'tonal' } }, 'voice_instrumental': { 'value': 'voice_instrumental' }, }, 'lowlevel': { 'average_loudness': 'average_loudness' }, 'rhythm': { 'bpm': 'bpm' }, 'tonal': { 'chords_changes_rate': 'chords_changes_rate', 'chords_key': 'chords_key', 'chords_number_rate': 'chords_number_rate', 'chords_scale': 'chords_scale', 'key_key': ('initial_key', 0), 'key_scale': ('initial_key', 1), 'key_strength': 'key_strength' } } class AcousticPlugin(plugins.BeetsPlugin): def __init__(self): super(AcousticPlugin, self).__init__() self.config.add({ 'auto': True, 'force': False, 'tags': [] }) if self.config['auto']: self.register_listener('import_task_files', self.import_task_files) def commands(self): cmd = ui.Subcommand('acousticbrainz', help=u"fetch metadata from AcousticBrainz") cmd.parser.add_option( u'-f', u'--force', dest='force_refetch', action='store_true', default=False, help=u're-download data when already present' ) def func(lib, opts, args): items = lib.items(ui.decargs(args)) self._fetch_info(items, ui.should_write(), opts.force_refetch or self.config['force']) cmd.func = func return [cmd] def import_task_files(self, session, task): """Function is called upon beet import. """ self._fetch_info(task.imported_items(), False, True) def _get_data(self, mbid): data = {} for url in _generate_urls(mbid): self._log.debug(u'fetching URL: {}', url) try: res = requests.get(url) except requests.RequestException as exc: self._log.info(u'request error: {}', exc) return {} if res.status_code == 404: self._log.info(u'recording ID {} not found', mbid) return {} try: data.update(res.json()) except ValueError: self._log.debug(u'Invalid Response: {}', res.text) return {} return data def _fetch_info(self, items, write, force): """Fetch additional information from AcousticBrainz for the `item`s. """ tags = self.config['tags'].as_str_seq() for item in items: # If we're not forcing re-downloading for all tracks, check # whether the data is already present. We use one # representative field name to check for previously fetched # data. if not force: mood_str = item.get('mood_acoustic', u'') if mood_str: self._log.info(u'data already present for: {}', item) continue # We can only fetch data for tracks with MBIDs. if not item.mb_trackid: continue self._log.info(u'getting data for: {}', item) data = self._get_data(item.mb_trackid) if data: for attr, val in self._map_data_to_scheme(data, ABSCHEME): if not tags or attr in tags: self._log.debug(u'attribute {} of {} set to {}', attr, item, val) setattr(item, attr, val) else: self._log.debug(u'skipping attribute {} of {}' u' (value {}) due to config', attr, item, val) item.store() if write: item.try_write() def _map_data_to_scheme(self, data, scheme): """Given `data` as a structure of nested dictionaries, and `scheme` as a structure of nested dictionaries , `yield` tuples `(attr, val)` where `attr` and `val` are corresponding leaf nodes in `scheme` and `data`. As its name indicates, `scheme` defines how the data is structured, so this function tries to find leaf nodes in `data` that correspond to the leafs nodes of `scheme`, and not the other way around. Leaf nodes of `data` that do not exist in the `scheme` do not matter. If a leaf node of `scheme` is not present in `data`, no value is yielded for that attribute and a simple warning is issued. Finally, to account for attributes of which the value is split between several leaf nodes in `data`, leaf nodes of `scheme` can be tuples `(attr, order)` where `attr` is the attribute to which the leaf node belongs, and `order` is the place at which it should appear in the value. The different `value`s belonging to the same `attr` are simply joined with `' '`. This is hardcoded and not very flexible, but it gets the job done. For example: >>> scheme = { 'key1': 'attribute', 'key group': { 'subkey1': 'subattribute', 'subkey2': ('composite attribute', 0) }, 'key2': ('composite attribute', 1) } >>> data = { 'key1': 'value', 'key group': { 'subkey1': 'subvalue', 'subkey2': 'part 1 of composite attr' }, 'key2': 'part 2' } >>> print(list(_map_data_to_scheme(data, scheme))) [('subattribute', 'subvalue'), ('attribute', 'value'), ('composite attribute', 'part 1 of composite attr part 2')] """ # First, we traverse `scheme` and `data`, `yield`ing all the non # composites attributes straight away and populating the dictionary # `composites` with the composite attributes. # When we are finished traversing `scheme`, `composites` should # map each composite attribute to an ordered list of the values # belonging to the attribute, for example: # `composites = {'initial_key': ['B', 'minor']}`. # The recursive traversal. composites = defaultdict(list) for attr, val in self._data_to_scheme_child(data, scheme, composites): yield attr, val # When composites has been populated, yield the composite attributes # by joining their parts. for composite_attr, value_parts in composites.items(): yield composite_attr, ' '.join(value_parts) def _data_to_scheme_child(self, subdata, subscheme, composites): """The recursive business logic of :meth:`_map_data_to_scheme`: Traverse two structures of nested dictionaries in parallel and `yield` tuples of corresponding leaf nodes. If a leaf node belongs to a composite attribute (is a `tuple`), populate `composites` rather than yielding straight away. All the child functions for a single traversal share the same `composites` instance, which is passed along. """ for k, v in subscheme.items(): if k in subdata: if type(v) == dict: for attr, val in self._data_to_scheme_child(subdata[k], v, composites): yield attr, val elif type(v) == tuple: composite_attribute, part_number = v attribute_parts = composites[composite_attribute] # Parts are not guaranteed to be inserted in order while len(attribute_parts) <= part_number: attribute_parts.append('') attribute_parts[part_number] = subdata[k] else: yield v, subdata[k] else: self._log.warning(u'Acousticbrainz did not provide info' u'about {}', k) self._log.debug(u'Data {} could not be mapped to scheme {} ' u'because key {} was not found', subdata, v, k) def _generate_urls(mbid): """Generates AcousticBrainz end point urls for given `mbid`. """ for level in LEVELS: yield ACOUSTIC_BASE + mbid + level beets-1.4.6/beetsplug/fetchart.py0000644000076500000240000010417613206372465017755 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Fetches album art. """ from __future__ import division, absolute_import, print_function from contextlib import closing import os import re from tempfile import NamedTemporaryFile import requests from beets import plugins from beets import importer from beets import ui from beets import util from beets import config from beets.mediafile import image_mime_type from beets.util.artresizer import ArtResizer from beets.util import confit from beets.util import syspath, bytestring_path, py3_path import six try: import itunes HAVE_ITUNES = True except ImportError: HAVE_ITUNES = False CONTENT_TYPES = { 'image/jpeg': [b'jpg', b'jpeg'], 'image/png': [b'png'] } IMAGE_EXTENSIONS = [ext for exts in CONTENT_TYPES.values() for ext in exts] class Candidate(object): """Holds information about a matching artwork, deals with validation of dimension restrictions and resizing. """ CANDIDATE_BAD = 0 CANDIDATE_EXACT = 1 CANDIDATE_DOWNSCALE = 2 MATCH_EXACT = 0 MATCH_FALLBACK = 1 def __init__(self, log, path=None, url=None, source=u'', match=None, size=None): self._log = log self.path = path self.url = url self.source = source self.check = None self.match = match self.size = size def _validate(self, plugin): """Determine whether the candidate artwork is valid based on its dimensions (width and ratio). Return `CANDIDATE_BAD` if the file is unusable. Return `CANDIDATE_EXACT` if the file is usable as-is. Return `CANDIDATE_DOWNSCALE` if the file must be resized. """ if not self.path: return self.CANDIDATE_BAD if not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth): return self.CANDIDATE_EXACT # get_size returns None if no local imaging backend is available if not self.size: self.size = ArtResizer.shared.get_size(self.path) self._log.debug(u'image size: {}', self.size) if not self.size: self._log.warning(u'Could not get size of image (please see ' u'documentation for dependencies). ' u'The configuration options `minwidth` and ' u'`enforce_ratio` may be violated.') return self.CANDIDATE_EXACT short_edge = min(self.size) long_edge = max(self.size) # Check minimum size. if plugin.minwidth and self.size[0] < plugin.minwidth: self._log.debug(u'image too small ({} < {})', self.size[0], plugin.minwidth) return self.CANDIDATE_BAD # Check aspect ratio. edge_diff = long_edge - short_edge if plugin.enforce_ratio: if plugin.margin_px: if edge_diff > plugin.margin_px: self._log.debug(u'image is not close enough to being ' u'square, ({} - {} > {})', long_edge, short_edge, plugin.margin_px) return self.CANDIDATE_BAD elif plugin.margin_percent: margin_px = plugin.margin_percent * long_edge if edge_diff > margin_px: self._log.debug(u'image is not close enough to being ' u'square, ({} - {} > {})', long_edge, short_edge, margin_px) return self.CANDIDATE_BAD elif edge_diff: # also reached for margin_px == 0 and margin_percent == 0.0 self._log.debug(u'image is not square ({} != {})', self.size[0], self.size[1]) return self.CANDIDATE_BAD # Check maximum size. if plugin.maxwidth and self.size[0] > plugin.maxwidth: self._log.debug(u'image needs resizing ({} > {})', self.size[0], plugin.maxwidth) return self.CANDIDATE_DOWNSCALE return self.CANDIDATE_EXACT def validate(self, plugin): self.check = self._validate(plugin) return self.check def resize(self, plugin): if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE: self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path) def _logged_get(log, *args, **kwargs): """Like `requests.get`, but logs the effective URL to the specified `log` at the `DEBUG` level. Use the optional `message` parameter to specify what to log before the URL. By default, the string is "getting URL". Also sets the User-Agent header to indicate beets. """ # Use some arguments with the `send` call but most with the # `Request` construction. This is a cheap, magic-filled way to # emulate `requests.get` or, more pertinently, # `requests.Session.request`. req_kwargs = kwargs send_kwargs = {} for arg in ('stream', 'verify', 'proxies', 'cert', 'timeout'): if arg in kwargs: send_kwargs[arg] = req_kwargs.pop(arg) # Our special logging message parameter. if 'message' in kwargs: message = kwargs.pop('message') else: message = 'getting URL' req = requests.Request('GET', *args, **req_kwargs) with requests.Session() as s: s.headers = {'User-Agent': 'beets'} prepped = s.prepare_request(req) log.debug('{}: {}', message, prepped.url) return s.send(prepped, **send_kwargs) class RequestMixin(object): """Adds a Requests wrapper to the class that uses the logger, which must be named `self._log`. """ def request(self, *args, **kwargs): """Like `requests.get`, but uses the logger `self._log`. See also `_logged_get`. """ return _logged_get(self._log, *args, **kwargs) # ART SOURCES ################################################################ class ArtSource(RequestMixin): def __init__(self, log, config): self._log = log self._config = config def get(self, album, plugin, paths): raise NotImplementedError() def _candidate(self, **kwargs): return Candidate(source=self, log=self._log, **kwargs) def fetch_image(self, candidate, plugin): raise NotImplementedError() class LocalArtSource(ArtSource): IS_LOCAL = True LOC_STR = u'local' def fetch_image(self, candidate, plugin): pass class RemoteArtSource(ArtSource): IS_LOCAL = False LOC_STR = u'remote' def fetch_image(self, candidate, plugin): """Downloads an image from a URL and checks whether it seems to actually be an image. If so, returns a path to the downloaded image. Otherwise, returns None. """ if plugin.maxwidth: candidate.url = ArtResizer.shared.proxy_url(plugin.maxwidth, candidate.url) try: with closing(self.request(candidate.url, stream=True, message=u'downloading image')) as resp: ct = resp.headers.get('Content-Type', None) # Download the image to a temporary file. As some servers # (notably fanart.tv) have proven to return wrong Content-Types # when images were uploaded with a bad file extension, do not # rely on it. Instead validate the type using the file magic # and only then determine the extension. data = resp.iter_content(chunk_size=1024) header = b'' for chunk in data: header += chunk if len(header) >= 32: # The imghdr module will only read 32 bytes, and our # own additions in mediafile even less. break else: # server didn't return enough data, i.e. corrupt image return real_ct = image_mime_type(header) if real_ct is None: # detection by file magic failed, fall back to the # server-supplied Content-Type # Is our type detection failsafe enough to drop this? real_ct = ct if real_ct not in CONTENT_TYPES: self._log.debug(u'not a supported image: {}', real_ct or u'unknown content type') return ext = b'.' + CONTENT_TYPES[real_ct][0] if real_ct != ct: self._log.warning(u'Server specified {}, but returned a ' u'{} image. Correcting the extension ' u'to {}', ct, real_ct, ext) suffix = py3_path(ext) with NamedTemporaryFile(suffix=suffix, delete=False) as fh: # write the first already loaded part of the image fh.write(header) # download the remaining part of the image for chunk in data: fh.write(chunk) self._log.debug(u'downloaded art to: {0}', util.displayable_path(fh.name)) candidate.path = util.bytestring_path(fh.name) return except (IOError, requests.RequestException, TypeError) as exc: # Handling TypeError works around a urllib3 bug: # https://github.com/shazow/urllib3/issues/556 self._log.debug(u'error fetching art: {}', exc) return class CoverArtArchive(RemoteArtSource): NAME = u"Cover Art Archive" if util.SNI_SUPPORTED: URL = 'https://coverartarchive.org/release/{mbid}/front' GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}/front' else: URL = 'http://coverartarchive.org/release/{mbid}/front' GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front' def get(self, album, plugin, paths): """Return the Cover Art Archive and Cover Art Archive release group URLs using album MusicBrainz release ID and release group ID. """ if album.mb_albumid: yield self._candidate(url=self.URL.format(mbid=album.mb_albumid), match=Candidate.MATCH_EXACT) if album.mb_releasegroupid: yield self._candidate( url=self.GROUP_URL.format(mbid=album.mb_releasegroupid), match=Candidate.MATCH_FALLBACK) class Amazon(RemoteArtSource): NAME = u"Amazon" URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' INDICES = (1, 2) def get(self, album, plugin, paths): """Generate URLs using Amazon ID (ASIN) string. """ if album.asin: for index in self.INDICES: yield self._candidate(url=self.URL % (album.asin, index), match=Candidate.MATCH_EXACT) class AlbumArtOrg(RemoteArtSource): NAME = u"AlbumArt.org scraper" URL = 'http://www.albumart.org/index_detail.php' PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"' def get(self, album, plugin, paths): """Return art URL from AlbumArt.org using album ASIN. """ if not album.asin: return # Get the page from albumart.org. try: resp = self.request(self.URL, params={'asin': album.asin}) self._log.debug(u'scraped art URL: {0}', resp.url) except requests.RequestException: self._log.debug(u'error scraping art page') return # Search the page for the image URL. m = re.search(self.PAT, resp.text) if m: image_url = m.group(1) yield self._candidate(url=image_url, match=Candidate.MATCH_EXACT) else: self._log.debug(u'no image found on page') class GoogleImages(RemoteArtSource): NAME = u"Google Images" URL = u'https://www.googleapis.com/customsearch/v1' def __init__(self, *args, **kwargs): super(GoogleImages, self).__init__(*args, **kwargs) self.key = self._config['google_key'].get(), self.cx = self._config['google_engine'].get(), def get(self, album, plugin, paths): """Return art URL from google custom search engine given an album title and interpreter. """ if not (album.albumartist and album.album): return search_string = (album.albumartist + ',' + album.album).encode('utf-8') response = self.request(self.URL, params={ 'key': self.key, 'cx': self.cx, 'q': search_string, 'searchType': 'image' }) # Get results using JSON. try: data = response.json() except ValueError: self._log.debug(u'google: error loading response: {}' .format(response.text)) return if 'error' in data: reason = data['error']['errors'][0]['reason'] self._log.debug(u'google fetchart error: {0}', reason) return if 'items' in data.keys(): for item in data['items']: yield self._candidate(url=item['link'], match=Candidate.MATCH_EXACT) class FanartTV(RemoteArtSource): """Art from fanart.tv requested using their API""" NAME = u"fanart.tv" API_URL = 'https://webservice.fanart.tv/v3/' API_ALBUMS = API_URL + 'music/albums/' PROJECT_KEY = '61a7d0ab4e67162b7a0c7c35915cd48e' def __init__(self, *args, **kwargs): super(FanartTV, self).__init__(*args, **kwargs) self.client_key = self._config['fanarttv_key'].get() def get(self, album, plugin, paths): if not album.mb_releasegroupid: return response = self.request( self.API_ALBUMS + album.mb_releasegroupid, headers={'api-key': self.PROJECT_KEY, 'client-key': self.client_key}) try: data = response.json() except ValueError: self._log.debug(u'fanart.tv: error loading response: {}', response.text) return if u'status' in data and data[u'status'] == u'error': if u'not found' in data[u'error message'].lower(): self._log.debug(u'fanart.tv: no image found') elif u'api key' in data[u'error message'].lower(): self._log.warning(u'fanart.tv: Invalid API key given, please ' u'enter a valid one in your config file.') else: self._log.debug(u'fanart.tv: error on request: {}', data[u'error message']) return matches = [] # can there be more than one releasegroupid per response? for mbid, art in data.get(u'albums', dict()).items(): # there might be more art referenced, e.g. cdart, and an albumcover # might not be present, even if the request was succesful if album.mb_releasegroupid == mbid and u'albumcover' in art: matches.extend(art[u'albumcover']) # can this actually occur? else: self._log.debug(u'fanart.tv: unexpected mb_releasegroupid in ' u'response!') matches.sort(key=lambda x: x[u'likes'], reverse=True) for item in matches: # fanart.tv has a strict size requirement for album art to be # uploaded yield self._candidate(url=item[u'url'], match=Candidate.MATCH_EXACT, size=(1000, 1000)) class ITunesStore(RemoteArtSource): NAME = u"iTunes Store" def get(self, album, plugin, paths): """Return art URL from iTunes Store given an album title. """ if not (album.albumartist and album.album): return search_string = (album.albumartist + ' ' + album.album).encode('utf-8') try: # Isolate bugs in the iTunes library while searching. try: results = itunes.search_album(search_string) except Exception as exc: self._log.debug(u'iTunes search failed: {0}', exc) return # Get the first match. if results: itunes_album = results[0] else: self._log.debug(u'iTunes search for {:r} got no results', search_string) return if itunes_album.get_artwork()['100']: small_url = itunes_album.get_artwork()['100'] big_url = small_url.replace('100x100', '1200x1200') yield self._candidate(url=big_url, match=Candidate.MATCH_EXACT) else: self._log.debug(u'album has no artwork in iTunes Store') except IndexError: self._log.debug(u'album not found in iTunes Store') class Wikipedia(RemoteArtSource): NAME = u"Wikipedia (queried through DBpedia)" DBPEDIA_URL = 'https://dbpedia.org/sparql' WIKIPEDIA_URL = 'https://en.wikipedia.org/w/api.php' SPARQL_QUERY = u'''PREFIX rdf: PREFIX dbpprop: PREFIX owl: PREFIX rdfs: PREFIX foaf: SELECT DISTINCT ?pageId ?coverFilename WHERE {{ ?subject owl:wikiPageID ?pageId . ?subject dbpprop:name ?name . ?subject rdfs:label ?label . {{ ?subject dbpprop:artist ?artist }} UNION {{ ?subject owl:artist ?artist }} {{ ?artist foaf:name "{artist}"@en }} UNION {{ ?artist dbpprop:name "{artist}"@en }} ?subject rdf:type . ?subject dbpprop:cover ?coverFilename . FILTER ( regex(?name, "{album}", "i") ) }} Limit 1''' def get(self, album, plugin, paths): if not (album.albumartist and album.album): return # Find the name of the cover art filename on DBpedia cover_filename, page_id = None, None dbpedia_response = self.request( self.DBPEDIA_URL, params={ 'format': 'application/sparql-results+json', 'timeout': 2500, 'query': self.SPARQL_QUERY.format( artist=album.albumartist.title(), album=album.album) }, headers={'content-type': 'application/json'}, ) try: data = dbpedia_response.json() results = data['results']['bindings'] if results: cover_filename = 'File:' + results[0]['coverFilename']['value'] page_id = results[0]['pageId']['value'] else: self._log.debug(u'wikipedia: album not found on dbpedia') except (ValueError, KeyError, IndexError): self._log.debug(u'wikipedia: error scraping dbpedia response: {}', dbpedia_response.text) # Ensure we have a filename before attempting to query wikipedia if not (cover_filename and page_id): return # DBPedia sometimes provides an incomplete cover_filename, indicated # by the filename having a space before the extension, e.g., 'foo .bar' # An additional Wikipedia call can help to find the real filename. # This may be removed once the DBPedia issue is resolved, see: # https://github.com/dbpedia/extraction-framework/issues/396 if ' .' in cover_filename and \ '.' not in cover_filename.split(' .')[-1]: self._log.debug( u'wikipedia: dbpedia provided incomplete cover_filename' ) lpart, rpart = cover_filename.rsplit(' .', 1) # Query all the images in the page wikipedia_response = self.request( self.WIKIPEDIA_URL, params={ 'format': 'json', 'action': 'query', 'continue': '', 'prop': 'images', 'pageids': page_id, }, headers={'content-type': 'application/json'}, ) # Try to see if one of the images on the pages matches our # imcomplete cover_filename try: data = wikipedia_response.json() results = data['query']['pages'][page_id]['images'] for result in results: if re.match(re.escape(lpart) + r'.*?\.' + re.escape(rpart), result['title']): cover_filename = result['title'] break except (ValueError, KeyError): self._log.debug( u'wikipedia: failed to retrieve a cover_filename' ) return # Find the absolute url of the cover art on Wikipedia wikipedia_response = self.request( self.WIKIPEDIA_URL, params={ 'format': 'json', 'action': 'query', 'continue': '', 'prop': 'imageinfo', 'iiprop': 'url', 'titles': cover_filename.encode('utf-8'), }, headers={'content-type': 'application/json'}, ) try: data = wikipedia_response.json() results = data['query']['pages'] for _, result in results.items(): image_url = result['imageinfo'][0]['url'] yield self._candidate(url=image_url, match=Candidate.MATCH_EXACT) except (ValueError, KeyError, IndexError): self._log.debug(u'wikipedia: error scraping imageinfo') return class FileSystem(LocalArtSource): NAME = u"Filesystem" @staticmethod def filename_priority(filename, cover_names): """Sort order for image names. Return indexes of cover names found in the image filename. This means that images with lower-numbered and more keywords will have higher priority. """ return [idx for (idx, x) in enumerate(cover_names) if x in filename] def get(self, album, plugin, paths): """Look for album art files in the specified directories. """ if not paths: return cover_names = list(map(util.bytestring_path, plugin.cover_names)) cover_names_str = b'|'.join(cover_names) cover_pat = br''.join([br"(\b|_)(", cover_names_str, br")(\b|_)"]) for path in paths: if not os.path.isdir(syspath(path)): continue # Find all files that look like images in the directory. images = [] for fn in os.listdir(syspath(path)): fn = bytestring_path(fn) for ext in IMAGE_EXTENSIONS: if fn.lower().endswith(b'.' + ext) and \ os.path.isfile(syspath(os.path.join(path, fn))): images.append(fn) # Look for "preferred" filenames. images = sorted(images, key=lambda x: self.filename_priority(x, cover_names)) remaining = [] for fn in images: if re.search(cover_pat, os.path.splitext(fn)[0], re.I): self._log.debug(u'using well-named art file {0}', util.displayable_path(fn)) yield self._candidate(path=os.path.join(path, fn), match=Candidate.MATCH_EXACT) else: remaining.append(fn) # Fall back to any image in the folder. if remaining and not plugin.cautious: self._log.debug(u'using fallback art file {0}', util.displayable_path(remaining[0])) yield self._candidate(path=os.path.join(path, remaining[0]), match=Candidate.MATCH_FALLBACK) # Try each source in turn. SOURCES_ALL = [u'filesystem', u'coverart', u'itunes', u'amazon', u'albumart', u'wikipedia', u'google', u'fanarttv'] ART_SOURCES = { u'filesystem': FileSystem, u'coverart': CoverArtArchive, u'itunes': ITunesStore, u'albumart': AlbumArtOrg, u'amazon': Amazon, u'wikipedia': Wikipedia, u'google': GoogleImages, u'fanarttv': FanartTV, } SOURCE_NAMES = {v: k for k, v in ART_SOURCES.items()} # PLUGIN LOGIC ############################################################### class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): PAT_PX = r"(0|[1-9][0-9]*)px" PAT_PERCENT = r"(100(\.00?)?|[1-9]?[0-9](\.[0-9]{1,2})?)%" def __init__(self): super(FetchArtPlugin, self).__init__() # Holds candidates corresponding to downloaded images between # fetching them and placing them in the filesystem. self.art_candidates = {} self.config.add({ 'auto': True, 'minwidth': 0, 'maxwidth': 0, 'enforce_ratio': False, 'cautious': False, 'cover_names': ['cover', 'front', 'art', 'album', 'folder'], 'sources': ['filesystem', 'coverart', 'itunes', 'amazon', 'albumart'], 'google_key': None, 'google_engine': u'001442825323518660753:hrh5ch1gjzm', 'fanarttv_key': None, 'store_source': False, }) self.config['google_key'].redact = True self.config['fanarttv_key'].redact = True self.minwidth = self.config['minwidth'].get(int) self.maxwidth = self.config['maxwidth'].get(int) # allow both pixel and percentage-based margin specifications self.enforce_ratio = self.config['enforce_ratio'].get( confit.OneOf([bool, confit.String(pattern=self.PAT_PX), confit.String(pattern=self.PAT_PERCENT)])) self.margin_px = None self.margin_percent = None if type(self.enforce_ratio) is six.text_type: if self.enforce_ratio[-1] == u'%': self.margin_percent = float(self.enforce_ratio[:-1]) / 100 elif self.enforce_ratio[-2:] == u'px': self.margin_px = int(self.enforce_ratio[:-2]) else: # shouldn't happen raise confit.ConfigValueError() self.enforce_ratio = True cover_names = self.config['cover_names'].as_str_seq() self.cover_names = list(map(util.bytestring_path, cover_names)) self.cautious = self.config['cautious'].get(bool) self.store_source = self.config['store_source'].get(bool) self.src_removed = (config['import']['delete'].get(bool) or config['import']['move'].get(bool)) if self.config['auto']: # Enable two import hooks when fetching is enabled. self.import_stages = [self.fetch_art] self.register_listener('import_task_files', self.assign_art) available_sources = list(SOURCES_ALL) if not HAVE_ITUNES and u'itunes' in available_sources: available_sources.remove(u'itunes') if not self.config['google_key'].get() and \ u'google' in available_sources: available_sources.remove(u'google') sources_name = plugins.sanitize_choices( self.config['sources'].as_str_seq(), available_sources) if 'remote_priority' in self.config: self._log.warning( u'The `fetch_art.remote_priority` configuration option has ' u'been deprecated. Instead, place `filesystem` at the end of ' u'your `sources` list.') if self.config['remote_priority'].get(bool): try: sources_name.remove(u'filesystem') sources_name.append(u'filesystem') except ValueError: pass self.sources = [ART_SOURCES[s](self._log, self.config) for s in sources_name] # Asynchronous; after music is added to the library. def fetch_art(self, session, task): """Find art for the album being imported.""" if task.is_album: # Only fetch art for full albums. if task.album.artpath and os.path.isfile(task.album.artpath): # Album already has art (probably a re-import); skip it. return if task.choice_flag == importer.action.ASIS: # For as-is imports, don't search Web sources for art. local = True elif task.choice_flag in (importer.action.APPLY, importer.action.RETAG): # Search everywhere for art. local = False else: # For any other choices (e.g., TRACKS), do nothing. return candidate = self.art_for_album(task.album, task.paths, local) if candidate: self.art_candidates[task] = candidate def _set_art(self, album, candidate, delete=False): album.set_art(candidate.path, delete) if self.store_source: # store the source of the chosen artwork in a flexible field self._log.debug( u"Storing art_source for {0.albumartist} - {0.album}", album) album.art_source = SOURCE_NAMES[type(candidate.source)] album.store() # Synchronous; after music files are put in place. def assign_art(self, session, task): """Place the discovered art in the filesystem.""" if task in self.art_candidates: candidate = self.art_candidates.pop(task) self._set_art(task.album, candidate, not self.src_removed) if self.src_removed: task.prune(candidate.path) # Manual album art fetching. def commands(self): cmd = ui.Subcommand('fetchart', help='download album art') cmd.parser.add_option( u'-f', u'--force', dest='force', action='store_true', default=False, help=u're-download art when already present' ) cmd.parser.add_option( u'-q', u'--quiet', dest='quiet', action='store_true', default=False, help=u'shows only quiet art' ) def func(lib, opts, args): self.batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force, opts.quiet) cmd.func = func return [cmd] # Utilities converted from functions to methods on logging overhaul def art_for_album(self, album, paths, local_only=False): """Given an Album object, returns a path to downloaded art for the album (or None if no art is found). If `maxwidth`, then images are resized to this maximum pixel size. If `local_only`, then only local image files from the filesystem are returned; no network requests are made. """ out = None for source in self.sources: if source.IS_LOCAL or not local_only: self._log.debug( u'trying source {0} for album {1.albumartist} - {1.album}', SOURCE_NAMES[type(source)], album, ) # URLs might be invalid at this point, or the image may not # fulfill the requirements for candidate in source.get(album, self, paths): source.fetch_image(candidate, self) if candidate.validate(self): out = candidate self._log.debug( u'using {0.LOC_STR} image {1}'.format( source, util.displayable_path(out.path))) break if out: break if out: out.resize(self) return out def batch_fetch_art(self, lib, albums, force, quiet): """Fetch album art for each of the albums. This implements the manual fetchart CLI command. """ for album in albums: if album.artpath and not force and os.path.isfile(album.artpath): if not quiet: message = ui.colorize('text_highlight_minor', u'has album art') self._log.info(u'{0}: {1}', album, message) else: # In ordinary invocations, look for images on the # filesystem. When forcing, however, always go to the Web # sources. local_paths = None if force else [album.path] candidate = self.art_for_album(album, local_paths) if candidate: self._set_art(album, candidate) message = ui.colorize('text_success', u'found album art') else: message = ui.colorize('text_error', u'no art found') self._log.info(u'{0}: {1}', album, message) beets-1.4.6/beetsplug/web/0000755000076500000240000000000013216774613016351 5ustar asampsonstaff00000000000000beets-1.4.6/beetsplug/web/__init__.py0000644000076500000240000002770613122272074020464 0ustar asampsonstaff00000000000000# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A Web interface to beets.""" from __future__ import division, absolute_import, print_function from beets.plugins import BeetsPlugin from beets import ui from beets import util import beets.library import flask from flask import g from werkzeug.routing import BaseConverter, PathConverter import os import json import base64 # Utilities. def _rep(obj, expand=False): """Get a flat -- i.e., JSON-ish -- representation of a beets Item or Album object. For Albums, `expand` dictates whether tracks are included. """ out = dict(obj) if isinstance(obj, beets.library.Item): if app.config.get('INCLUDE_PATHS', False): out['path'] = util.displayable_path(out['path']) else: del out['path'] # Filter all bytes attributes and convert them to strings. for key, value in out.items(): if isinstance(out[key], bytes): out[key] = base64.b64encode(value).decode('ascii') # Get the size (in bytes) of the backing file. This is useful # for the Tomahawk resolver API. try: out['size'] = os.path.getsize(util.syspath(obj.path)) except OSError: out['size'] = 0 return out elif isinstance(obj, beets.library.Album): del out['artpath'] if expand: out['items'] = [_rep(item) for item in obj.items()] return out def json_generator(items, root, expand=False): """Generator that dumps list of beets Items or Albums as JSON :param root: root key for JSON :param items: list of :class:`Item` or :class:`Album` to dump :param expand: If true every :class:`Album` contains its items in the json representation :returns: generator that yields strings """ yield '{"%s":[' % root first = True for item in items: if first: first = False else: yield ',' yield json.dumps(_rep(item, expand=expand)) yield ']}' def is_expand(): """Returns whether the current request is for an expanded response.""" return flask.request.args.get('expand') is not None def resource(name): """Decorates a function to handle RESTful HTTP requests for a resource. """ def make_responder(retriever): def responder(ids): entities = [retriever(id) for id in ids] entities = [entity for entity in entities if entity] if len(entities) == 1: return flask.jsonify(_rep(entities[0], expand=is_expand())) elif entities: return app.response_class( json_generator(entities, root=name), mimetype='application/json' ) else: return flask.abort(404) responder.__name__ = 'get_{0}'.format(name) return responder return make_responder def resource_query(name): """Decorates a function to handle RESTful HTTP queries for resources. """ def make_responder(query_func): def responder(queries): return app.response_class( json_generator( query_func(queries), root='results', expand=is_expand() ), mimetype='application/json' ) responder.__name__ = 'query_{0}'.format(name) return responder return make_responder def resource_list(name): """Decorates a function to handle RESTful HTTP request for a list of resources. """ def make_responder(list_all): def responder(): return app.response_class( json_generator(list_all(), root=name, expand=is_expand()), mimetype='application/json' ) responder.__name__ = 'all_{0}'.format(name) return responder return make_responder def _get_unique_table_field_values(model, field, sort_field): """ retrieve all unique values belonging to a key from a model """ if field not in model.all_keys() or sort_field not in model.all_keys(): raise KeyError with g.lib.transaction() as tx: rows = tx.query('SELECT DISTINCT "{0}" FROM "{1}" ORDER BY "{2}"' .format(field, model._table, sort_field)) return [row[0] for row in rows] class IdListConverter(BaseConverter): """Converts comma separated lists of ids in urls to integer lists. """ def to_python(self, value): ids = [] for id in value.split(','): try: ids.append(int(id)) except ValueError: pass return ids def to_url(self, value): return ','.join(value) class QueryConverter(PathConverter): """Converts slash separated lists of queries in the url to string list. """ def to_python(self, value): return value.split('/') def to_url(self, value): return ','.join(value) class EverythingConverter(PathConverter): regex = '.*?' # Flask setup. app = flask.Flask(__name__) app.url_map.converters['idlist'] = IdListConverter app.url_map.converters['query'] = QueryConverter app.url_map.converters['everything'] = EverythingConverter @app.before_request def before_request(): g.lib = app.config['lib'] # Items. @app.route('/item/') @resource('items') def get_item(id): return g.lib.get_item(id) @app.route('/item/') @app.route('/item/query/') @resource_list('items') def all_items(): return g.lib.items() @app.route('/item//file') def item_file(item_id): item = g.lib.get_item(item_id) # On Windows under Python 2, Flask wants a Unicode path. On Python 3, it # *always* wants a Unicode path. if os.name == 'nt': item_path = util.syspath(item.path) else: item_path = util.py3_path(item.path) response = flask.send_file( item_path, as_attachment=True, attachment_filename=os.path.basename(util.py3_path(item.path)), ) response.headers['Content-Length'] = os.path.getsize(item_path) return response @app.route('/item/query/') @resource_query('items') def item_query(queries): return g.lib.items(queries) @app.route('/item/path/') def item_at_path(path): query = beets.library.PathQuery('path', path.encode('utf-8')) item = g.lib.items(query).get() if item: return flask.jsonify(_rep(item)) else: return flask.abort(404) @app.route('/item/values/') def item_unique_field_values(key): sort_key = flask.request.args.get('sort_key', key) try: values = _get_unique_table_field_values(beets.library.Item, key, sort_key) except KeyError: return flask.abort(404) return flask.jsonify(values=values) # Albums. @app.route('/album/') @resource('albums') def get_album(id): return g.lib.get_album(id) @app.route('/album/') @app.route('/album/query/') @resource_list('albums') def all_albums(): return g.lib.albums() @app.route('/album/query/') @resource_query('albums') def album_query(queries): return g.lib.albums(queries) @app.route('/album//art') def album_art(album_id): album = g.lib.get_album(album_id) if album.artpath: return flask.send_file(album.artpath) else: return flask.abort(404) @app.route('/album/values/') def album_unique_field_values(key): sort_key = flask.request.args.get('sort_key', key) try: values = _get_unique_table_field_values(beets.library.Album, key, sort_key) except KeyError: return flask.abort(404) return flask.jsonify(values=values) # Artists. @app.route('/artist/') def all_artists(): with g.lib.transaction() as tx: rows = tx.query("SELECT DISTINCT albumartist FROM albums") all_artists = [row[0] for row in rows] return flask.jsonify(artist_names=all_artists) # Library information. @app.route('/stats') def stats(): with g.lib.transaction() as tx: item_rows = tx.query("SELECT COUNT(*) FROM items") album_rows = tx.query("SELECT COUNT(*) FROM albums") return flask.jsonify({ 'items': item_rows[0][0], 'albums': album_rows[0][0], }) # UI. @app.route('/') def home(): return flask.render_template('index.html') # Plugin hook. class WebPlugin(BeetsPlugin): def __init__(self): super(WebPlugin, self).__init__() self.config.add({ 'host': u'127.0.0.1', 'port': 8337, 'cors': '', 'reverse_proxy': False, 'include_paths': False, }) def commands(self): cmd = ui.Subcommand('web', help=u'start a Web interface') cmd.parser.add_option(u'-d', u'--debug', action='store_true', default=False, help=u'debug mode') def func(lib, opts, args): args = ui.decargs(args) if args: self.config['host'] = args.pop(0) if args: self.config['port'] = int(args.pop(0)) app.config['lib'] = lib # Normalizes json output app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False app.config['INCLUDE_PATHS'] = self.config['include_paths'] # Enable CORS if required. if self.config['cors']: self._log.info(u'Enabling CORS with origin: {0}', self.config['cors']) from flask.ext.cors import CORS app.config['CORS_ALLOW_HEADERS'] = "Content-Type" app.config['CORS_RESOURCES'] = { r"/*": {"origins": self.config['cors'].get(str)} } CORS(app) # Allow serving behind a reverse proxy if self.config['reverse_proxy']: app.wsgi_app = ReverseProxied(app.wsgi_app) # Start the web application. app.run(host=self.config['host'].as_str(), port=self.config['port'].get(int), debug=opts.debug, threaded=True) cmd.func = func return [cmd] class ReverseProxied(object): '''Wrap the application in this middleware and configure the front-end server to add these headers, to let you quietly bind this to a URL other than / and to an HTTP scheme that is different than what is used locally. In nginx: location /myprefix { proxy_pass http://192.168.0.1:5001; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Scheme $scheme; proxy_set_header X-Script-Name /myprefix; } From: http://flask.pocoo.org/snippets/35/ :param app: the WSGI application ''' def __init__(self, app): self.app = app def __call__(self, environ, start_response): script_name = environ.get('HTTP_X_SCRIPT_NAME', '') if script_name: environ['SCRIPT_NAME'] = script_name path_info = environ['PATH_INFO'] if path_info.startswith(script_name): environ['PATH_INFO'] = path_info[len(script_name):] scheme = environ.get('HTTP_X_SCHEME', '') if scheme: environ['wsgi.url_scheme'] = scheme return self.app(environ, start_response) beets-1.4.6/beetsplug/web/static/0000755000076500000240000000000013216774613017640 5ustar asampsonstaff00000000000000beets-1.4.6/beetsplug/web/static/underscore.js0000644000076500000240000010330213025125202022324 0ustar asampsonstaff00000000000000// Underscore.js 1.2.2 // (c) 2011 Jeremy Ashkenas, DocumentCloud Inc. // Underscore is freely distributable under the MIT license. // Portions of Underscore are inspired or borrowed from Prototype, // Oliver Steele's Functional, and John Resig's Micro-Templating. // For all details and documentation: // http://documentcloud.github.com/underscore (function() { // Baseline setup // -------------- // Establish the root object, `window` in the browser, or `global` on the server. var root = this; // Save the previous value of the `_` variable. var previousUnderscore = root._; // Establish the object that gets returned to break out of a loop iteration. var breaker = {}; // Save bytes in the minified (but not gzipped) version: var ArrayProto = Array.prototype, ObjProto = Object.prototype, FuncProto = Function.prototype; // Create quick reference variables for speed access to core prototypes. var slice = ArrayProto.slice, unshift = ArrayProto.unshift, toString = ObjProto.toString, hasOwnProperty = ObjProto.hasOwnProperty; // All **ECMAScript 5** native function implementations that we hope to use // are declared here. var nativeForEach = ArrayProto.forEach, nativeMap = ArrayProto.map, nativeReduce = ArrayProto.reduce, nativeReduceRight = ArrayProto.reduceRight, nativeFilter = ArrayProto.filter, nativeEvery = ArrayProto.every, nativeSome = ArrayProto.some, nativeIndexOf = ArrayProto.indexOf, nativeLastIndexOf = ArrayProto.lastIndexOf, nativeIsArray = Array.isArray, nativeKeys = Object.keys, nativeBind = FuncProto.bind; // Create a safe reference to the Underscore object for use below. var _ = function(obj) { return new wrapper(obj); }; // Export the Underscore object for **Node.js** and **"CommonJS"**, with // backwards-compatibility for the old `require()` API. If we're not in // CommonJS, add `_` to the global object. if (typeof exports !== 'undefined') { if (typeof module !== 'undefined' && module.exports) { exports = module.exports = _; } exports._ = _; } else if (typeof define === 'function' && define.amd) { // Register as a named module with AMD. define('underscore', function() { return _; }); } else { // Exported as a string, for Closure Compiler "advanced" mode. root['_'] = _; } // Current version. _.VERSION = '1.2.2'; // Collection Functions // -------------------- // The cornerstone, an `each` implementation, aka `forEach`. // Handles objects with the built-in `forEach`, arrays, and raw objects. // Delegates to **ECMAScript 5**'s native `forEach` if available. var each = _.each = _.forEach = function(obj, iterator, context) { if (obj == null) return; if (nativeForEach && obj.forEach === nativeForEach) { obj.forEach(iterator, context); } else if (obj.length === +obj.length) { for (var i = 0, l = obj.length; i < l; i++) { if (i in obj && iterator.call(context, obj[i], i, obj) === breaker) return; } } else { for (var key in obj) { if (hasOwnProperty.call(obj, key)) { if (iterator.call(context, obj[key], key, obj) === breaker) return; } } } }; // Return the results of applying the iterator to each element. // Delegates to **ECMAScript 5**'s native `map` if available. _.map = function(obj, iterator, context) { var results = []; if (obj == null) return results; if (nativeMap && obj.map === nativeMap) return obj.map(iterator, context); each(obj, function(value, index, list) { results[results.length] = iterator.call(context, value, index, list); }); return results; }; // **Reduce** builds up a single result from a list of values, aka `inject`, // or `foldl`. Delegates to **ECMAScript 5**'s native `reduce` if available. _.reduce = _.foldl = _.inject = function(obj, iterator, memo, context) { var initial = memo !== void 0; if (obj == null) obj = []; if (nativeReduce && obj.reduce === nativeReduce) { if (context) iterator = _.bind(iterator, context); return initial ? obj.reduce(iterator, memo) : obj.reduce(iterator); } each(obj, function(value, index, list) { if (!initial) { memo = value; initial = true; } else { memo = iterator.call(context, memo, value, index, list); } }); if (!initial) throw new TypeError("Reduce of empty array with no initial value"); return memo; }; // The right-associative version of reduce, also known as `foldr`. // Delegates to **ECMAScript 5**'s native `reduceRight` if available. _.reduceRight = _.foldr = function(obj, iterator, memo, context) { if (obj == null) obj = []; if (nativeReduceRight && obj.reduceRight === nativeReduceRight) { if (context) iterator = _.bind(iterator, context); return memo !== void 0 ? obj.reduceRight(iterator, memo) : obj.reduceRight(iterator); } var reversed = (_.isArray(obj) ? obj.slice() : _.toArray(obj)).reverse(); return _.reduce(reversed, iterator, memo, context); }; // Return the first value which passes a truth test. Aliased as `detect`. _.find = _.detect = function(obj, iterator, context) { var result; any(obj, function(value, index, list) { if (iterator.call(context, value, index, list)) { result = value; return true; } }); return result; }; // Return all the elements that pass a truth test. // Delegates to **ECMAScript 5**'s native `filter` if available. // Aliased as `select`. _.filter = _.select = function(obj, iterator, context) { var results = []; if (obj == null) return results; if (nativeFilter && obj.filter === nativeFilter) return obj.filter(iterator, context); each(obj, function(value, index, list) { if (iterator.call(context, value, index, list)) results[results.length] = value; }); return results; }; // Return all the elements for which a truth test fails. _.reject = function(obj, iterator, context) { var results = []; if (obj == null) return results; each(obj, function(value, index, list) { if (!iterator.call(context, value, index, list)) results[results.length] = value; }); return results; }; // Determine whether all of the elements match a truth test. // Delegates to **ECMAScript 5**'s native `every` if available. // Aliased as `all`. _.every = _.all = function(obj, iterator, context) { var result = true; if (obj == null) return result; if (nativeEvery && obj.every === nativeEvery) return obj.every(iterator, context); each(obj, function(value, index, list) { if (!(result = result && iterator.call(context, value, index, list))) return breaker; }); return result; }; // Determine if at least one element in the object matches a truth test. // Delegates to **ECMAScript 5**'s native `some` if available. // Aliased as `any`. var any = _.some = _.any = function(obj, iterator, context) { iterator = iterator || _.identity; var result = false; if (obj == null) return result; if (nativeSome && obj.some === nativeSome) return obj.some(iterator, context); each(obj, function(value, index, list) { if (result || (result = iterator.call(context, value, index, list))) return breaker; }); return !!result; }; // Determine if a given value is included in the array or object using `===`. // Aliased as `contains`. _.include = _.contains = function(obj, target) { var found = false; if (obj == null) return found; if (nativeIndexOf && obj.indexOf === nativeIndexOf) return obj.indexOf(target) != -1; found = any(obj, function(value) { return value === target; }); return found; }; // Invoke a method (with arguments) on every item in a collection. _.invoke = function(obj, method) { var args = slice.call(arguments, 2); return _.map(obj, function(value) { return (method.call ? method || value : value[method]).apply(value, args); }); }; // Convenience version of a common use case of `map`: fetching a property. _.pluck = function(obj, key) { return _.map(obj, function(value){ return value[key]; }); }; // Return the maximum element or (element-based computation). _.max = function(obj, iterator, context) { if (!iterator && _.isArray(obj)) return Math.max.apply(Math, obj); if (!iterator && _.isEmpty(obj)) return -Infinity; var result = {computed : -Infinity}; each(obj, function(value, index, list) { var computed = iterator ? iterator.call(context, value, index, list) : value; computed >= result.computed && (result = {value : value, computed : computed}); }); return result.value; }; // Return the minimum element (or element-based computation). _.min = function(obj, iterator, context) { if (!iterator && _.isArray(obj)) return Math.min.apply(Math, obj); if (!iterator && _.isEmpty(obj)) return Infinity; var result = {computed : Infinity}; each(obj, function(value, index, list) { var computed = iterator ? iterator.call(context, value, index, list) : value; computed < result.computed && (result = {value : value, computed : computed}); }); return result.value; }; // Shuffle an array. _.shuffle = function(obj) { var shuffled = [], rand; each(obj, function(value, index, list) { if (index == 0) { shuffled[0] = value; } else { rand = Math.floor(Math.random() * (index + 1)); shuffled[index] = shuffled[rand]; shuffled[rand] = value; } }); return shuffled; }; // Sort the object's values by a criterion produced by an iterator. _.sortBy = function(obj, iterator, context) { return _.pluck(_.map(obj, function(value, index, list) { return { value : value, criteria : iterator.call(context, value, index, list) }; }).sort(function(left, right) { var a = left.criteria, b = right.criteria; return a < b ? -1 : a > b ? 1 : 0; }), 'value'); }; // Groups the object's values by a criterion. Pass either a string attribute // to group by, or a function that returns the criterion. _.groupBy = function(obj, val) { var result = {}; var iterator = _.isFunction(val) ? val : function(obj) { return obj[val]; }; each(obj, function(value, index) { var key = iterator(value, index); (result[key] || (result[key] = [])).push(value); }); return result; }; // Use a comparator function to figure out at what index an object should // be inserted so as to maintain order. Uses binary search. _.sortedIndex = function(array, obj, iterator) { iterator || (iterator = _.identity); var low = 0, high = array.length; while (low < high) { var mid = (low + high) >> 1; iterator(array[mid]) < iterator(obj) ? low = mid + 1 : high = mid; } return low; }; // Safely convert anything iterable into a real, live array. _.toArray = function(iterable) { if (!iterable) return []; if (iterable.toArray) return iterable.toArray(); if (_.isArray(iterable)) return slice.call(iterable); if (_.isArguments(iterable)) return slice.call(iterable); return _.values(iterable); }; // Return the number of elements in an object. _.size = function(obj) { return _.toArray(obj).length; }; // Array Functions // --------------- // Get the first element of an array. Passing **n** will return the first N // values in the array. Aliased as `head`. The **guard** check allows it to work // with `_.map`. _.first = _.head = function(array, n, guard) { return (n != null) && !guard ? slice.call(array, 0, n) : array[0]; }; // Returns everything but the last entry of the array. Especcialy useful on // the arguments object. Passing **n** will return all the values in // the array, excluding the last N. The **guard** check allows it to work with // `_.map`. _.initial = function(array, n, guard) { return slice.call(array, 0, array.length - ((n == null) || guard ? 1 : n)); }; // Get the last element of an array. Passing **n** will return the last N // values in the array. The **guard** check allows it to work with `_.map`. _.last = function(array, n, guard) { if ((n != null) && !guard) { return slice.call(array, Math.max(array.length - n, 0)); } else { return array[array.length - 1]; } }; // Returns everything but the first entry of the array. Aliased as `tail`. // Especially useful on the arguments object. Passing an **index** will return // the rest of the values in the array from that index onward. The **guard** // check allows it to work with `_.map`. _.rest = _.tail = function(array, index, guard) { return slice.call(array, (index == null) || guard ? 1 : index); }; // Trim out all falsy values from an array. _.compact = function(array) { return _.filter(array, function(value){ return !!value; }); }; // Return a completely flattened version of an array. _.flatten = function(array, shallow) { return _.reduce(array, function(memo, value) { if (_.isArray(value)) return memo.concat(shallow ? value : _.flatten(value)); memo[memo.length] = value; return memo; }, []); }; // Return a version of the array that does not contain the specified value(s). _.without = function(array) { return _.difference(array, slice.call(arguments, 1)); }; // Produce a duplicate-free version of the array. If the array has already // been sorted, you have the option of using a faster algorithm. // Aliased as `unique`. _.uniq = _.unique = function(array, isSorted, iterator) { var initial = iterator ? _.map(array, iterator) : array; var result = []; _.reduce(initial, function(memo, el, i) { if (0 == i || (isSorted === true ? _.last(memo) != el : !_.include(memo, el))) { memo[memo.length] = el; result[result.length] = array[i]; } return memo; }, []); return result; }; // Produce an array that contains the union: each distinct element from all of // the passed-in arrays. _.union = function() { return _.uniq(_.flatten(arguments, true)); }; // Produce an array that contains every item shared between all the // passed-in arrays. (Aliased as "intersect" for back-compat.) _.intersection = _.intersect = function(array) { var rest = slice.call(arguments, 1); return _.filter(_.uniq(array), function(item) { return _.every(rest, function(other) { return _.indexOf(other, item) >= 0; }); }); }; // Take the difference between one array and another. // Only the elements present in just the first array will remain. _.difference = function(array, other) { return _.filter(array, function(value){ return !_.include(other, value); }); }; // Zip together multiple lists into a single array -- elements that share // an index go together. _.zip = function() { var args = slice.call(arguments); var length = _.max(_.pluck(args, 'length')); var results = new Array(length); for (var i = 0; i < length; i++) results[i] = _.pluck(args, "" + i); return results; }; // If the browser doesn't supply us with indexOf (I'm looking at you, **MSIE**), // we need this function. Return the position of the first occurrence of an // item in an array, or -1 if the item is not included in the array. // Delegates to **ECMAScript 5**'s native `indexOf` if available. // If the array is large and already in sort order, pass `true` // for **isSorted** to use binary search. _.indexOf = function(array, item, isSorted) { if (array == null) return -1; var i, l; if (isSorted) { i = _.sortedIndex(array, item); return array[i] === item ? i : -1; } if (nativeIndexOf && array.indexOf === nativeIndexOf) return array.indexOf(item); for (i = 0, l = array.length; i < l; i++) if (array[i] === item) return i; return -1; }; // Delegates to **ECMAScript 5**'s native `lastIndexOf` if available. _.lastIndexOf = function(array, item) { if (array == null) return -1; if (nativeLastIndexOf && array.lastIndexOf === nativeLastIndexOf) return array.lastIndexOf(item); var i = array.length; while (i--) if (array[i] === item) return i; return -1; }; // Generate an integer Array containing an arithmetic progression. A port of // the native Python `range()` function. See // [the Python documentation](http://docs.python.org/library/functions.html#range). _.range = function(start, stop, step) { if (arguments.length <= 1) { stop = start || 0; start = 0; } step = arguments[2] || 1; var len = Math.max(Math.ceil((stop - start) / step), 0); var idx = 0; var range = new Array(len); while(idx < len) { range[idx++] = start; start += step; } return range; }; // Function (ahem) Functions // ------------------ // Reusable constructor function for prototype setting. var ctor = function(){}; // Create a function bound to a given object (assigning `this`, and arguments, // optionally). Binding with arguments is also known as `curry`. // Delegates to **ECMAScript 5**'s native `Function.bind` if available. // We check for `func.bind` first, to fail fast when `func` is undefined. _.bind = function bind(func, context) { var bound, args; if (func.bind === nativeBind && nativeBind) return nativeBind.apply(func, slice.call(arguments, 1)); if (!_.isFunction(func)) throw new TypeError; args = slice.call(arguments, 2); return bound = function() { if (!(this instanceof bound)) return func.apply(context, args.concat(slice.call(arguments))); ctor.prototype = func.prototype; var self = new ctor; var result = func.apply(self, args.concat(slice.call(arguments))); if (Object(result) === result) return result; return self; }; }; // Bind all of an object's methods to that object. Useful for ensuring that // all callbacks defined on an object belong to it. _.bindAll = function(obj) { var funcs = slice.call(arguments, 1); if (funcs.length == 0) funcs = _.functions(obj); each(funcs, function(f) { obj[f] = _.bind(obj[f], obj); }); return obj; }; // Memoize an expensive function by storing its results. _.memoize = function(func, hasher) { var memo = {}; hasher || (hasher = _.identity); return function() { var key = hasher.apply(this, arguments); return hasOwnProperty.call(memo, key) ? memo[key] : (memo[key] = func.apply(this, arguments)); }; }; // Delays a function for the given number of milliseconds, and then calls // it with the arguments supplied. _.delay = function(func, wait) { var args = slice.call(arguments, 2); return setTimeout(function(){ return func.apply(func, args); }, wait); }; // Defers a function, scheduling it to run after the current call stack has // cleared. _.defer = function(func) { return _.delay.apply(_, [func, 1].concat(slice.call(arguments, 1))); }; // Returns a function, that, when invoked, will only be triggered at most once // during a given window of time. _.throttle = function(func, wait) { var context, args, timeout, throttling, more; var whenDone = _.debounce(function(){ more = throttling = false; }, wait); return function() { context = this; args = arguments; var later = function() { timeout = null; if (more) func.apply(context, args); whenDone(); }; if (!timeout) timeout = setTimeout(later, wait); if (throttling) { more = true; } else { func.apply(context, args); } whenDone(); throttling = true; }; }; // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for // N milliseconds. _.debounce = function(func, wait) { var timeout; return function() { var context = this, args = arguments; var later = function() { timeout = null; func.apply(context, args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }; // Returns a function that will be executed at most one time, no matter how // often you call it. Useful for lazy initialization. _.once = function(func) { var ran = false, memo; return function() { if (ran) return memo; ran = true; return memo = func.apply(this, arguments); }; }; // Returns the first function passed as an argument to the second, // allowing you to adjust arguments, run code before and after, and // conditionally execute the original function. _.wrap = function(func, wrapper) { return function() { var args = [func].concat(slice.call(arguments)); return wrapper.apply(this, args); }; }; // Returns a function that is the composition of a list of functions, each // consuming the return value of the function that follows. _.compose = function() { var funcs = slice.call(arguments); return function() { var args = slice.call(arguments); for (var i = funcs.length - 1; i >= 0; i--) { args = [funcs[i].apply(this, args)]; } return args[0]; }; }; // Returns a function that will only be executed after being called N times. _.after = function(times, func) { if (times <= 0) return func(); return function() { if (--times < 1) { return func.apply(this, arguments); } }; }; // Object Functions // ---------------- // Retrieve the names of an object's properties. // Delegates to **ECMAScript 5**'s native `Object.keys` _.keys = nativeKeys || function(obj) { if (obj !== Object(obj)) throw new TypeError('Invalid object'); var keys = []; for (var key in obj) if (hasOwnProperty.call(obj, key)) keys[keys.length] = key; return keys; }; // Retrieve the values of an object's properties. _.values = function(obj) { return _.map(obj, _.identity); }; // Return a sorted list of the function names available on the object. // Aliased as `methods` _.functions = _.methods = function(obj) { var names = []; for (var key in obj) { if (_.isFunction(obj[key])) names.push(key); } return names.sort(); }; // Extend a given object with all the properties in passed-in object(s). _.extend = function(obj) { each(slice.call(arguments, 1), function(source) { for (var prop in source) { if (source[prop] !== void 0) obj[prop] = source[prop]; } }); return obj; }; // Fill in a given object with default properties. _.defaults = function(obj) { each(slice.call(arguments, 1), function(source) { for (var prop in source) { if (obj[prop] == null) obj[prop] = source[prop]; } }); return obj; }; // Create a (shallow-cloned) duplicate of an object. _.clone = function(obj) { if (!_.isObject(obj)) return obj; return _.isArray(obj) ? obj.slice() : _.extend({}, obj); }; // Invokes interceptor with the obj, and then returns obj. // The primary purpose of this method is to "tap into" a method chain, in // order to perform operations on intermediate results within the chain. _.tap = function(obj, interceptor) { interceptor(obj); return obj; }; // Internal recursive comparison function. function eq(a, b, stack) { // Identical objects are equal. `0 === -0`, but they aren't identical. // See the Harmony `egal` proposal: http://wiki.ecmascript.org/doku.php?id=harmony:egal. if (a === b) return a !== 0 || 1 / a == 1 / b; // A strict comparison is necessary because `null == undefined`. if (a == null || b == null) return a === b; // Unwrap any wrapped objects. if (a._chain) a = a._wrapped; if (b._chain) b = b._wrapped; // Invoke a custom `isEqual` method if one is provided. if (_.isFunction(a.isEqual)) return a.isEqual(b); if (_.isFunction(b.isEqual)) return b.isEqual(a); // Compare `[[Class]]` names. var className = toString.call(a); if (className != toString.call(b)) return false; switch (className) { // Strings, numbers, dates, and booleans are compared by value. case '[object String]': // Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is // equivalent to `new String("5")`. return String(a) == String(b); case '[object Number]': a = +a; b = +b; // `NaN`s are equivalent, but non-reflexive. An `egal` comparison is performed for // other numeric values. return a != a ? b != b : (a == 0 ? 1 / a == 1 / b : a == b); case '[object Date]': case '[object Boolean]': // Coerce dates and booleans to numeric primitive values. Dates are compared by their // millisecond representations. Note that invalid dates with millisecond representations // of `NaN` are not equivalent. return +a == +b; // RegExps are compared by their source patterns and flags. case '[object RegExp]': return a.source == b.source && a.global == b.global && a.multiline == b.multiline && a.ignoreCase == b.ignoreCase; } if (typeof a != 'object' || typeof b != 'object') return false; // Assume equality for cyclic structures. The algorithm for detecting cyclic // structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`. var length = stack.length; while (length--) { // Linear search. Performance is inversely proportional to the number of // unique nested structures. if (stack[length] == a) return true; } // Add the first object to the stack of traversed objects. stack.push(a); var size = 0, result = true; // Recursively compare objects and arrays. if (className == '[object Array]') { // Compare array lengths to determine if a deep comparison is necessary. size = a.length; result = size == b.length; if (result) { // Deep compare the contents, ignoring non-numeric properties. while (size--) { // Ensure commutative equality for sparse arrays. if (!(result = size in a == size in b && eq(a[size], b[size], stack))) break; } } } else { // Objects with different constructors are not equivalent. if ("constructor" in a != "constructor" in b || a.constructor != b.constructor) return false; // Deep compare objects. for (var key in a) { if (hasOwnProperty.call(a, key)) { // Count the expected number of properties. size++; // Deep compare each member. if (!(result = hasOwnProperty.call(b, key) && eq(a[key], b[key], stack))) break; } } // Ensure that both objects contain the same number of properties. if (result) { for (key in b) { if (hasOwnProperty.call(b, key) && !(size--)) break; } result = !size; } } // Remove the first object from the stack of traversed objects. stack.pop(); return result; } // Perform a deep comparison to check if two objects are equal. _.isEqual = function(a, b) { return eq(a, b, []); }; // Is a given array, string, or object empty? // An "empty" object has no enumerable own-properties. _.isEmpty = function(obj) { if (_.isArray(obj) || _.isString(obj)) return obj.length === 0; for (var key in obj) if (hasOwnProperty.call(obj, key)) return false; return true; }; // Is a given value a DOM element? _.isElement = function(obj) { return !!(obj && obj.nodeType == 1); }; // Is a given value an array? // Delegates to ECMA5's native Array.isArray _.isArray = nativeIsArray || function(obj) { return toString.call(obj) == '[object Array]'; }; // Is a given variable an object? _.isObject = function(obj) { return obj === Object(obj); }; // Is a given variable an arguments object? if (toString.call(arguments) == '[object Arguments]') { _.isArguments = function(obj) { return toString.call(obj) == '[object Arguments]'; }; } else { _.isArguments = function(obj) { return !!(obj && hasOwnProperty.call(obj, 'callee')); }; } // Is a given value a function? _.isFunction = function(obj) { return toString.call(obj) == '[object Function]'; }; // Is a given value a string? _.isString = function(obj) { return toString.call(obj) == '[object String]'; }; // Is a given value a number? _.isNumber = function(obj) { return toString.call(obj) == '[object Number]'; }; // Is the given value `NaN`? _.isNaN = function(obj) { // `NaN` is the only value for which `===` is not reflexive. return obj !== obj; }; // Is a given value a boolean? _.isBoolean = function(obj) { return obj === true || obj === false || toString.call(obj) == '[object Boolean]'; }; // Is a given value a date? _.isDate = function(obj) { return toString.call(obj) == '[object Date]'; }; // Is the given value a regular expression? _.isRegExp = function(obj) { return toString.call(obj) == '[object RegExp]'; }; // Is a given value equal to null? _.isNull = function(obj) { return obj === null; }; // Is a given variable undefined? _.isUndefined = function(obj) { return obj === void 0; }; // Utility Functions // ----------------- // Run Underscore.js in *noConflict* mode, returning the `_` variable to its // previous owner. Returns a reference to the Underscore object. _.noConflict = function() { root._ = previousUnderscore; return this; }; // Keep the identity function around for default iterators. _.identity = function(value) { return value; }; // Run a function **n** times. _.times = function (n, iterator, context) { for (var i = 0; i < n; i++) iterator.call(context, i); }; // Escape a string for HTML interpolation. _.escape = function(string) { return (''+string).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\//g,'/'); }; // Add your own custom functions to the Underscore object, ensuring that // they're correctly added to the OOP wrapper as well. _.mixin = function(obj) { each(_.functions(obj), function(name){ addToWrapper(name, _[name] = obj[name]); }); }; // Generate a unique integer id (unique within the entire client session). // Useful for temporary DOM ids. var idCounter = 0; _.uniqueId = function(prefix) { var id = idCounter++; return prefix ? prefix + id : id; }; // By default, Underscore uses ERB-style template delimiters, change the // following template settings to use alternative delimiters. _.templateSettings = { evaluate : /<%([\s\S]+?)%>/g, interpolate : /<%=([\s\S]+?)%>/g, escape : /<%-([\s\S]+?)%>/g }; // JavaScript micro-templating, similar to John Resig's implementation. // Underscore templating handles arbitrary delimiters, preserves whitespace, // and correctly escapes quotes within interpolated code. _.template = function(str, data) { var c = _.templateSettings; var tmpl = 'var __p=[],print=function(){__p.push.apply(__p,arguments);};' + 'with(obj||{}){__p.push(\'' + str.replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(c.escape, function(match, code) { return "',_.escape(" + code.replace(/\\'/g, "'") + "),'"; }) .replace(c.interpolate, function(match, code) { return "'," + code.replace(/\\'/g, "'") + ",'"; }) .replace(c.evaluate || null, function(match, code) { return "');" + code.replace(/\\'/g, "'") .replace(/[\r\n\t]/g, ' ') + ";__p.push('"; }) .replace(/\r/g, '\\r') .replace(/\n/g, '\\n') .replace(/\t/g, '\\t') + "');}return __p.join('');"; var func = new Function('obj', '_', tmpl); return data ? func(data, _) : function(data) { return func(data, _) }; }; // The OOP Wrapper // --------------- // If Underscore is called as a function, it returns a wrapped object that // can be used OO-style. This wrapper holds altered versions of all the // underscore functions. Wrapped objects may be chained. var wrapper = function(obj) { this._wrapped = obj; }; // Expose `wrapper.prototype` as `_.prototype` _.prototype = wrapper.prototype; // Helper function to continue chaining intermediate results. var result = function(obj, chain) { return chain ? _(obj).chain() : obj; }; // A method to easily add functions to the OOP wrapper. var addToWrapper = function(name, func) { wrapper.prototype[name] = function() { var args = slice.call(arguments); unshift.call(args, this._wrapped); return result(func.apply(_, args), this._chain); }; }; // Add all of the Underscore functions to the wrapper object. _.mixin(_); // Add all mutator Array functions to the wrapper. each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) { var method = ArrayProto[name]; wrapper.prototype[name] = function() { method.apply(this._wrapped, arguments); return result(this._wrapped, this._chain); }; }); // Add all accessor Array functions to the wrapper. each(['concat', 'join', 'slice'], function(name) { var method = ArrayProto[name]; wrapper.prototype[name] = function() { return result(method.apply(this._wrapped, arguments), this._chain); }; }); // Start chaining a wrapped Underscore object. wrapper.prototype.chain = function() { this._chain = true; return this; }; // Extracts the result from a wrapped and chained object. wrapper.prototype.value = function() { return this._wrapped; }; }).call(this); beets-1.4.6/beetsplug/web/static/backbone.js0000644000076500000240000012313713025125202021727 0ustar asampsonstaff00000000000000// Backbone.js 0.5.3 // (c) 2010 Jeremy Ashkenas, DocumentCloud Inc. // Backbone may be freely distributed under the MIT license. // For all details and documentation: // http://documentcloud.github.com/backbone (function(){ // Initial Setup // ------------- // Save a reference to the global object. var root = this; // Save the previous value of the `Backbone` variable. var previousBackbone = root.Backbone; // The top-level namespace. All public Backbone classes and modules will // be attached to this. Exported for both CommonJS and the browser. var Backbone; if (typeof exports !== 'undefined') { Backbone = exports; } else { Backbone = root.Backbone = {}; } // Current version of the library. Keep in sync with `package.json`. Backbone.VERSION = '0.5.3'; // Require Underscore, if we're on the server, and it's not already present. var _ = root._; if (!_ && (typeof require !== 'undefined')) _ = require('underscore')._; // For Backbone's purposes, jQuery or Zepto owns the `$` variable. var $ = root.jQuery || root.Zepto; // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable // to its previous owner. Returns a reference to this Backbone object. Backbone.noConflict = function() { root.Backbone = previousBackbone; return this; }; // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option will // fake `"PUT"` and `"DELETE"` requests via the `_method` parameter and set a // `X-Http-Method-Override` header. Backbone.emulateHTTP = false; // Turn on `emulateJSON` to support legacy servers that can't deal with direct // `application/json` requests ... will encode the body as // `application/x-www-form-urlencoded` instead and will send the model in a // form param named `model`. Backbone.emulateJSON = false; // Backbone.Events // ----------------- // A module that can be mixed in to *any object* in order to provide it with // custom events. You may `bind` or `unbind` a callback function to an event; // `trigger`-ing an event fires all callbacks in succession. // // var object = {}; // _.extend(object, Backbone.Events); // object.bind('expand', function(){ alert('expanded'); }); // object.trigger('expand'); // Backbone.Events = { // Bind an event, specified by a string name, `ev`, to a `callback` function. // Passing `"all"` will bind the callback to all events fired. bind : function(ev, callback, context) { var calls = this._callbacks || (this._callbacks = {}); var list = calls[ev] || (calls[ev] = []); list.push([callback, context]); return this; }, // Remove one or many callbacks. If `callback` is null, removes all // callbacks for the event. If `ev` is null, removes all bound callbacks // for all events. unbind : function(ev, callback) { var calls; if (!ev) { this._callbacks = {}; } else if (calls = this._callbacks) { if (!callback) { calls[ev] = []; } else { var list = calls[ev]; if (!list) return this; for (var i = 0, l = list.length; i < l; i++) { if (list[i] && callback === list[i][0]) { list[i] = null; break; } } } } return this; }, // Trigger an event, firing all bound callbacks. Callbacks are passed the // same arguments as `trigger` is, apart from the event name. // Listening for `"all"` passes the true event name as the first argument. trigger : function(eventName) { var list, calls, ev, callback, args; var both = 2; if (!(calls = this._callbacks)) return this; while (both--) { ev = both ? eventName : 'all'; if (list = calls[ev]) { for (var i = 0, l = list.length; i < l; i++) { if (!(callback = list[i])) { list.splice(i, 1); i--; l--; } else { args = both ? Array.prototype.slice.call(arguments, 1) : arguments; callback[0].apply(callback[1] || this, args); } } } } return this; } }; // Backbone.Model // -------------- // Create a new model, with defined attributes. A client id (`cid`) // is automatically generated and assigned for you. Backbone.Model = function(attributes, options) { var defaults; attributes || (attributes = {}); if (defaults = this.defaults) { if (_.isFunction(defaults)) defaults = defaults.call(this); attributes = _.extend({}, defaults, attributes); } this.attributes = {}; this._escapedAttributes = {}; this.cid = _.uniqueId('c'); this.set(attributes, {silent : true}); this._changed = false; this._previousAttributes = _.clone(this.attributes); if (options && options.collection) this.collection = options.collection; this.initialize(attributes, options); }; // Attach all inheritable methods to the Model prototype. _.extend(Backbone.Model.prototype, Backbone.Events, { // A snapshot of the model's previous attributes, taken immediately // after the last `"change"` event was fired. _previousAttributes : null, // Has the item been changed since the last `"change"` event? _changed : false, // The default name for the JSON `id` attribute is `"id"`. MongoDB and // CouchDB users may want to set this to `"_id"`. idAttribute : 'id', // Initialize is an empty function by default. Override it with your own // initialization logic. initialize : function(){}, // Return a copy of the model's `attributes` object. toJSON : function() { return _.clone(this.attributes); }, // Get the value of an attribute. get : function(attr) { return this.attributes[attr]; }, // Get the HTML-escaped value of an attribute. escape : function(attr) { var html; if (html = this._escapedAttributes[attr]) return html; var val = this.attributes[attr]; return this._escapedAttributes[attr] = escapeHTML(val == null ? '' : '' + val); }, // Returns `true` if the attribute contains a value that is not null // or undefined. has : function(attr) { return this.attributes[attr] != null; }, // Set a hash of model attributes on the object, firing `"change"` unless you // choose to silence it. set : function(attrs, options) { // Extract attributes and options. options || (options = {}); if (!attrs) return this; if (attrs.attributes) attrs = attrs.attributes; var now = this.attributes, escaped = this._escapedAttributes; // Run validation. if (!options.silent && this.validate && !this._performValidation(attrs, options)) return false; // Check for changes of `id`. if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; // We're about to start triggering change events. var alreadyChanging = this._changing; this._changing = true; // Update attributes. for (var attr in attrs) { var val = attrs[attr]; if (!_.isEqual(now[attr], val)) { now[attr] = val; delete escaped[attr]; this._changed = true; if (!options.silent) this.trigger('change:' + attr, this, val, options); } } // Fire the `"change"` event, if the model has been changed. if (!alreadyChanging && !options.silent && this._changed) this.change(options); this._changing = false; return this; }, // Remove an attribute from the model, firing `"change"` unless you choose // to silence it. `unset` is a noop if the attribute doesn't exist. unset : function(attr, options) { if (!(attr in this.attributes)) return this; options || (options = {}); var value = this.attributes[attr]; // Run validation. var validObj = {}; validObj[attr] = void 0; if (!options.silent && this.validate && !this._performValidation(validObj, options)) return false; // Remove the attribute. delete this.attributes[attr]; delete this._escapedAttributes[attr]; if (attr == this.idAttribute) delete this.id; this._changed = true; if (!options.silent) { this.trigger('change:' + attr, this, void 0, options); this.change(options); } return this; }, // Clear all attributes on the model, firing `"change"` unless you choose // to silence it. clear : function(options) { options || (options = {}); var attr; var old = this.attributes; // Run validation. var validObj = {}; for (attr in old) validObj[attr] = void 0; if (!options.silent && this.validate && !this._performValidation(validObj, options)) return false; this.attributes = {}; this._escapedAttributes = {}; this._changed = true; if (!options.silent) { for (attr in old) { this.trigger('change:' + attr, this, void 0, options); } this.change(options); } return this; }, // Fetch the model from the server. If the server's representation of the // model differs from its current attributes, they will be overriden, // triggering a `"change"` event. fetch : function(options) { options || (options = {}); var model = this; var success = options.success; options.success = function(resp, status, xhr) { if (!model.set(model.parse(resp, xhr), options)) return false; if (success) success(model, resp); }; options.error = wrapError(options.error, model, options); return (this.sync || Backbone.sync).call(this, 'read', this, options); }, // Set a hash of model attributes, and sync the model to the server. // If the server returns an attributes hash that differs, the model's // state will be `set` again. save : function(attrs, options) { options || (options = {}); if (attrs && !this.set(attrs, options)) return false; var model = this; var success = options.success; options.success = function(resp, status, xhr) { if (!model.set(model.parse(resp, xhr), options)) return false; if (success) success(model, resp, xhr); }; options.error = wrapError(options.error, model, options); var method = this.isNew() ? 'create' : 'update'; return (this.sync || Backbone.sync).call(this, method, this, options); }, // Destroy this model on the server if it was already persisted. Upon success, the model is removed // from its collection, if it has one. destroy : function(options) { options || (options = {}); if (this.isNew()) return this.trigger('destroy', this, this.collection, options); var model = this; var success = options.success; options.success = function(resp) { model.trigger('destroy', model, model.collection, options); if (success) success(model, resp); }; options.error = wrapError(options.error, model, options); return (this.sync || Backbone.sync).call(this, 'delete', this, options); }, // Default URL for the model's representation on the server -- if you're // using Backbone's restful methods, override this to change the endpoint // that will be called. url : function() { var base = getUrl(this.collection) || this.urlRoot || urlError(); if (this.isNew()) return base; return base + (base.charAt(base.length - 1) == '/' ? '' : '/') + encodeURIComponent(this.id); }, // **parse** converts a response into the hash of attributes to be `set` on // the model. The default implementation is just to pass the response along. parse : function(resp, xhr) { return resp; }, // Create a new model with identical attributes to this one. clone : function() { return new this.constructor(this); }, // A model is new if it has never been saved to the server, and lacks an id. isNew : function() { return this.id == null; }, // Call this method to manually fire a `change` event for this model. // Calling this will cause all objects observing the model to update. change : function(options) { this.trigger('change', this, options); this._previousAttributes = _.clone(this.attributes); this._changed = false; }, // Determine if the model has changed since the last `"change"` event. // If you specify an attribute name, determine if that attribute has changed. hasChanged : function(attr) { if (attr) return this._previousAttributes[attr] != this.attributes[attr]; return this._changed; }, // Return an object containing all the attributes that have changed, or false // if there are no changed attributes. Useful for determining what parts of a // view need to be updated and/or what attributes need to be persisted to // the server. changedAttributes : function(now) { now || (now = this.attributes); var old = this._previousAttributes; var changed = false; for (var attr in now) { if (!_.isEqual(old[attr], now[attr])) { changed = changed || {}; changed[attr] = now[attr]; } } return changed; }, // Get the previous value of an attribute, recorded at the time the last // `"change"` event was fired. previous : function(attr) { if (!attr || !this._previousAttributes) return null; return this._previousAttributes[attr]; }, // Get all of the attributes of the model at the time of the previous // `"change"` event. previousAttributes : function() { return _.clone(this._previousAttributes); }, // Run validation against a set of incoming attributes, returning `true` // if all is well. If a specific `error` callback has been passed, // call that instead of firing the general `"error"` event. _performValidation : function(attrs, options) { var error = this.validate(attrs); if (error) { if (options.error) { options.error(this, error, options); } else { this.trigger('error', this, error, options); } return false; } return true; } }); // Backbone.Collection // ------------------- // Provides a standard collection class for our sets of models, ordered // or unordered. If a `comparator` is specified, the Collection will maintain // its models in sort order, as they're added and removed. Backbone.Collection = function(models, options) { options || (options = {}); if (options.comparator) this.comparator = options.comparator; _.bindAll(this, '_onModelEvent', '_removeReference'); this._reset(); if (models) this.reset(models, {silent: true}); this.initialize.apply(this, arguments); }; // Define the Collection's inheritable methods. _.extend(Backbone.Collection.prototype, Backbone.Events, { // The default model for a collection is just a **Backbone.Model**. // This should be overridden in most cases. model : Backbone.Model, // Initialize is an empty function by default. Override it with your own // initialization logic. initialize : function(){}, // The JSON representation of a Collection is an array of the // models' attributes. toJSON : function() { return this.map(function(model){ return model.toJSON(); }); }, // Add a model, or list of models to the set. Pass **silent** to avoid // firing the `added` event for every new model. add : function(models, options) { if (_.isArray(models)) { for (var i = 0, l = models.length; i < l; i++) { this._add(models[i], options); } } else { this._add(models, options); } return this; }, // Remove a model, or a list of models from the set. Pass silent to avoid // firing the `removed` event for every model removed. remove : function(models, options) { if (_.isArray(models)) { for (var i = 0, l = models.length; i < l; i++) { this._remove(models[i], options); } } else { this._remove(models, options); } return this; }, // Get a model from the set by id. get : function(id) { if (id == null) return null; return this._byId[id.id != null ? id.id : id]; }, // Get a model from the set by client id. getByCid : function(cid) { return cid && this._byCid[cid.cid || cid]; }, // Get the model at the given index. at: function(index) { return this.models[index]; }, // Force the collection to re-sort itself. You don't need to call this under normal // circumstances, as the set will maintain sort order as each item is added. sort : function(options) { options || (options = {}); if (!this.comparator) throw new Error('Cannot sort a set without a comparator'); this.models = this.sortBy(this.comparator); if (!options.silent) this.trigger('reset', this, options); return this; }, // Pluck an attribute from each model in the collection. pluck : function(attr) { return _.map(this.models, function(model){ return model.get(attr); }); }, // When you have more items than you want to add or remove individually, // you can reset the entire set with a new list of models, without firing // any `added` or `removed` events. Fires `reset` when finished. reset : function(models, options) { models || (models = []); options || (options = {}); this.each(this._removeReference); this._reset(); this.add(models, {silent: true}); if (!options.silent) this.trigger('reset', this, options); return this; }, // Fetch the default set of models for this collection, resetting the // collection when they arrive. If `add: true` is passed, appends the // models to the collection instead of resetting. fetch : function(options) { options || (options = {}); var collection = this; var success = options.success; options.success = function(resp, status, xhr) { collection[options.add ? 'add' : 'reset'](collection.parse(resp, xhr), options); if (success) success(collection, resp); }; options.error = wrapError(options.error, collection, options); return (this.sync || Backbone.sync).call(this, 'read', this, options); }, // Create a new instance of a model in this collection. After the model // has been created on the server, it will be added to the collection. // Returns the model, or 'false' if validation on a new model fails. create : function(model, options) { var coll = this; options || (options = {}); model = this._prepareModel(model, options); if (!model) return false; var success = options.success; options.success = function(nextModel, resp, xhr) { coll.add(nextModel, options); if (success) success(nextModel, resp, xhr); }; model.save(null, options); return model; }, // **parse** converts a response into a list of models to be added to the // collection. The default implementation is just to pass it through. parse : function(resp, xhr) { return resp; }, // Proxy to _'s chain. Can't be proxied the same way the rest of the // underscore methods are proxied because it relies on the underscore // constructor. chain: function () { return _(this.models).chain(); }, // Reset all internal state. Called when the collection is reset. _reset : function(options) { this.length = 0; this.models = []; this._byId = {}; this._byCid = {}; }, // Prepare a model to be added to this collection _prepareModel: function(model, options) { if (!(model instanceof Backbone.Model)) { var attrs = model; model = new this.model(attrs, {collection: this}); if (model.validate && !model._performValidation(attrs, options)) model = false; } else if (!model.collection) { model.collection = this; } return model; }, // Internal implementation of adding a single model to the set, updating // hash indexes for `id` and `cid` lookups. // Returns the model, or 'false' if validation on a new model fails. _add : function(model, options) { options || (options = {}); model = this._prepareModel(model, options); if (!model) return false; var already = this.getByCid(model); if (already) throw new Error(["Can't add the same model to a set twice", already.id]); this._byId[model.id] = model; this._byCid[model.cid] = model; var index = options.at != null ? options.at : this.comparator ? this.sortedIndex(model, this.comparator) : this.length; this.models.splice(index, 0, model); model.bind('all', this._onModelEvent); this.length++; if (!options.silent) model.trigger('add', model, this, options); return model; }, // Internal implementation of removing a single model from the set, updating // hash indexes for `id` and `cid` lookups. _remove : function(model, options) { options || (options = {}); model = this.getByCid(model) || this.get(model); if (!model) return null; delete this._byId[model.id]; delete this._byCid[model.cid]; this.models.splice(this.indexOf(model), 1); this.length--; if (!options.silent) model.trigger('remove', model, this, options); this._removeReference(model); return model; }, // Internal method to remove a model's ties to a collection. _removeReference : function(model) { if (this == model.collection) { delete model.collection; } model.unbind('all', this._onModelEvent); }, // Internal method called every time a model in the set fires an event. // Sets need to update their indexes when models change ids. All other // events simply proxy through. "add" and "remove" events that originate // in other collections are ignored. _onModelEvent : function(ev, model, collection, options) { if ((ev == 'add' || ev == 'remove') && collection != this) return; if (ev == 'destroy') { this._remove(model, options); } if (model && ev === 'change:' + model.idAttribute) { delete this._byId[model.previous(model.idAttribute)]; this._byId[model.id] = model; } this.trigger.apply(this, arguments); } }); // Underscore methods that we want to implement on the Collection. var methods = ['forEach', 'each', 'map', 'reduce', 'reduceRight', 'find', 'detect', 'filter', 'select', 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke', 'max', 'min', 'sortBy', 'sortedIndex', 'toArray', 'size', 'first', 'rest', 'last', 'without', 'indexOf', 'lastIndexOf', 'isEmpty', 'groupBy']; // Mix in each Underscore method as a proxy to `Collection#models`. _.each(methods, function(method) { Backbone.Collection.prototype[method] = function() { return _[method].apply(_, [this.models].concat(_.toArray(arguments))); }; }); // Backbone.Router // ------------------- // Routers map faux-URLs to actions, and fire events when routes are // matched. Creating a new one sets its `routes` hash, if not set statically. Backbone.Router = function(options) { options || (options = {}); if (options.routes) this.routes = options.routes; this._bindRoutes(); this.initialize.apply(this, arguments); }; // Cached regular expressions for matching named param parts and splatted // parts of route strings. var namedParam = /:([\w\d]+)/g; var splatParam = /\*([\w\d]+)/g; var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g; // Set up all inheritable **Backbone.Router** properties and methods. _.extend(Backbone.Router.prototype, Backbone.Events, { // Initialize is an empty function by default. Override it with your own // initialization logic. initialize : function(){}, // Manually bind a single named route to a callback. For example: // // this.route('search/:query/p:num', 'search', function(query, num) { // ... // }); // route : function(route, name, callback) { Backbone.history || (Backbone.history = new Backbone.History); if (!_.isRegExp(route)) route = this._routeToRegExp(route); Backbone.history.route(route, _.bind(function(fragment) { var args = this._extractParameters(route, fragment); callback.apply(this, args); this.trigger.apply(this, ['route:' + name].concat(args)); }, this)); }, // Simple proxy to `Backbone.history` to save a fragment into the history. navigate : function(fragment, triggerRoute) { Backbone.history.navigate(fragment, triggerRoute); }, // Bind all defined routes to `Backbone.history`. We have to reverse the // order of the routes here to support behavior where the most general // routes can be defined at the bottom of the route map. _bindRoutes : function() { if (!this.routes) return; var routes = []; for (var route in this.routes) { routes.unshift([route, this.routes[route]]); } for (var i = 0, l = routes.length; i < l; i++) { this.route(routes[i][0], routes[i][1], this[routes[i][1]]); } }, // Convert a route string into a regular expression, suitable for matching // against the current location hash. _routeToRegExp : function(route) { route = route.replace(escapeRegExp, "\\$&") .replace(namedParam, "([^\/]*)") .replace(splatParam, "(.*?)"); return new RegExp('^' + route + '$'); }, // Given a route, and a URL fragment that it matches, return the array of // extracted parameters. _extractParameters : function(route, fragment) { return route.exec(fragment).slice(1); } }); // Backbone.History // ---------------- // Handles cross-browser history management, based on URL fragments. If the // browser does not support `onhashchange`, falls back to polling. Backbone.History = function() { this.handlers = []; _.bindAll(this, 'checkUrl'); }; // Cached regex for cleaning hashes. var hashStrip = /^#*/; // Cached regex for detecting MSIE. var isExplorer = /msie [\w.]+/; // Has the history handling already been started? var historyStarted = false; // Set up all inheritable **Backbone.History** properties and methods. _.extend(Backbone.History.prototype, { // The default interval to poll for hash changes, if necessary, is // twenty times a second. interval: 50, // Get the cross-browser normalized URL fragment, either from the URL, // the hash, or the override. getFragment : function(fragment, forcePushState) { if (fragment == null) { if (this._hasPushState || forcePushState) { fragment = window.location.pathname; var search = window.location.search; if (search) fragment += search; if (fragment.indexOf(this.options.root) == 0) fragment = fragment.substr(this.options.root.length); } else { fragment = window.location.hash; } } return decodeURIComponent(fragment.replace(hashStrip, '')); }, // Start the hash change handling, returning `true` if the current URL matches // an existing route, and `false` otherwise. start : function(options) { // Figure out the initial configuration. Do we need an iframe? // Is pushState desired ... is it available? if (historyStarted) throw new Error("Backbone.history has already been started"); this.options = _.extend({}, {root: '/'}, this.options, options); this._wantsPushState = !!this.options.pushState; this._hasPushState = !!(this.options.pushState && window.history && window.history.pushState); var fragment = this.getFragment(); var docMode = document.documentMode; var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7)); if (oldIE) { this.iframe = $('