pax_global_header00006660000000000000000000000064125726142360014522gustar00rootroot0000000000000052 comment=26f6ed2395c239000968a51c6ba55f1d1193a2eb mopidy-local-sqlite-1.0.0/000077500000000000000000000000001257261423600154105ustar00rootroot00000000000000mopidy-local-sqlite-1.0.0/.gitignore000066400000000000000000000001001257261423600173670ustar00rootroot00000000000000*.egg-info *.pyc *.swp *~ .coverage .tox/ MANIFEST build/ dist/ mopidy-local-sqlite-1.0.0/.travis.yml000066400000000000000000000005371257261423600175260ustar00rootroot00000000000000sudo: false language: python python: - "2.7_with_system_site_packages" addons: apt: sources: - mopidy-stable packages: - mopidy env: - TOX_ENV=py27 - TOX_ENV=flake8 install: - "pip install tox" script: - "tox -e $TOX_ENV" after_success: - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" mopidy-local-sqlite-1.0.0/AUTHORS000066400000000000000000000001241257261423600164550ustar00rootroot00000000000000- Thomas Kemmer - Stein Magnus Jodal mopidy-local-sqlite-1.0.0/CHANGES.rst000066400000000000000000000104541257261423600172160ustar00rootroot00000000000000v1.0.0 (2015-09-05) ------------------- - Require Mopidy >= 1.1. - The data directory provided by Mopidy v1.1 to each extension is now used to store the SQLite database containing the music metadata. If we can find the old data dir, all files are automatically moved to the new data dir. - Add support for ordering artist browse results based on their ``sortname`` fields. Set ``use_artist_sortname = true`` to enable this, but be aware this may give confusing results if not all artists in the library have proper sortnames. - Return browse results in case-insensitive sort order. Note that this will only work for ASCII characters due to SQLite's ``NOCASE`` limitations. - Remove file system ("Folders") browsing, since this is already handled by the ``file`` backend in Mopidy v1.1. - Deprecate ``search_limit`` config value. v0.10.3 (2015-08-18) -------------------- - Update links to GitHub repository. v0.10.2 (2015-06-27) -------------------- - Fix data directory path. v0.10.1 (2015-06-17) -------------------- - Update ``local.translator`` imports for Mopidy v1.1. - Update build/test environment. v0.10.0 (2015-03-25) -------------------- - Require Mopidy v1.0. - Implement ``Library.get_distinct``. - Lookup album and artist URIs. - ``Track.last_modified`` changed to milliseconds. - Return ``Ref.ARTIST`` for artists when browsing. v0.9.3 (2015-03-06) ------------------- - Fix URI handling when browsing albums via track artists. v0.9.2 (2015-01-14) ------------------- - Return file URIs when browsing directories. - Add `search_limit` config value (default `-1`). v0.9.1 (2014-12-15) ------------------- - Skip invalid search URIs. - Use file system encoding when browsing `Folders`. v0.9.0 (2014-12-05) ------------------- - Move image extraction to `Mopidy-Local-Images`. - Add `max-age` URI parameter. v0.8.1 (2014-12-01) ------------------- - Fix track sort order when browsing non-album URIs. v0.8.0 (2014-10-22) ------------------- - Support file system browsing. - Deprecate ``encodings`` configuration setting. - Add database indexes for `date` and `track_no`. - Refactor browsing implementation and image directory. v0.7.3 (2014-10-15) ------------------- - Improve browse performance. v0.7.2 (2014-10-12) ------------------- - Do not raise exceptions from ``http:app`` factory. - Fix file URI for scanning images. v0.7.1 (2014-10-09) ------------------- - Fix handling of `uris` search parameter. v0.7.0 (2014-10-08) ------------------- - Support for external album art. - Support for browsing by genre and date. - Unified browsing: return albums for composers, genres, etc. - Configurable root directories with refactored URI scheme. - Deprecate ``foreign_keys``, ``hash`` and ``default_image_extension`` confvals. - Depend on Mopidy >= 0.19.4 for ``mopidy.local.ROOT_DIRECTORY_URI``. v0.6.4 (2014-09-11) ------------------- - Fix packaging issue. v0.6.3 (2014-09-11) ------------------- - Add index page for HTTP handler. v0.6.2 (2014-09-09) ------------------- - Catch all exceptions within ``SQLiteLibrary.add()``. - Configurable encoding(s) for generated track names. v0.6.1 (2014-09-06) ------------------- - Handle empty queries in ``schema.search()``. v0.6.0 (2014-09-02) ------------------- - Add HTTP handler for accessing local images. v0.5.0 (2014-08-26) ------------------- - Create `albums`, `artists`, etc. views. _ Support browsing by composer and performer. - Perform ``ANALYZE`` after local scan. v0.4.0 (2014-08-24) ------------------- - Add `uris` parameter to schema.search_tracks(). v0.3.2 (2014-08-22) ------------------- - Fixed exception handling when extracting images. v0.3.1 (2014-08-22) ------------------- - Delete unreferenced image files after local scan. v0.3.0 (2014-08-21) ------------------- - Extract images from local media files (experimental). v0.2.0 (2014-08-20) ------------------- - Support for indexed and full-text search. - Support for local album images (Mopidy v0.20). - Missing track names are generated from the track's URI. - New configuration options for album/artist URI generation. v0.1.1 (2014-08-14) ------------------- - Browsing artists no longer returns composers and performers. - Clean up artists/albums after import. v0.1.0 (2014-08-13) ------------------- - Initial release. mopidy-local-sqlite-1.0.0/LICENSE000066400000000000000000000236761257261423600164330ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS mopidy-local-sqlite-1.0.0/MANIFEST.in000066400000000000000000000003421257261423600171450ustar00rootroot00000000000000include .travis.yml include CHANGES.rst include LICENSE include MANIFEST.in include README.rst include mopidy_local_sqlite/ext.conf include tox.ini recursive-include mopidy_local_sqlite/sql *.sql recursive-include tests *.py mopidy-local-sqlite-1.0.0/README.rst000066400000000000000000000106351257261423600171040ustar00rootroot00000000000000Mopidy-Local-SQLite ======================================================================== Mopidy-Local-SQLite is a Mopidy_ local library extension that uses an SQLite_ database for keeping track of your local media. This extension lets you browse your music collection by album, artist, composer and performer, and provides full-text search capabilities based on SQLite's FTS_ modules. It also notices updates via ``mopidy local scan`` while Mopidy is running, so you can scan your media library periodically from a cron job, for example. Installation ------------------------------------------------------------------------ Mopidy-Local-SQLite can be installed using pip_ by running:: pip install Mopidy-Local-SQLite Configuration ------------------------------------------------------------------------ Before starting Mopidy, you must change your configuration to switch to using Mopidy-Local-SQLite as your preferred local library. It is also recommended to change the default ``scan_flush_threshold``, to improve database access during a local scan:: [local] library = sqlite scan_flush_threshold = 100 Once this has been set you need to re-scan your library to populate the database:: mopidy local scan This extension also provides some configuration settings of its own, but be aware that these are still subject to change:: [local-sqlite] enabled = true # top-level directories for browsing, as directories = Albums local:directory?type=album Artists local:directory?type=artist Composers local:directory?type=artist&role=composer Genres local:directory?type=genre Performers local:directory?type=artist&role=performer Release Years local:directory?type=date&format=%25Y Tracks local:directory?type=track Last Week's Updates local:directory?max-age=604800 Last Month's Updates local:directory?max-age=2592000 # database connection timeout in seconds timeout = 10 # whether to use an album's musicbrainz_id for generating its URI use_album_mbid_uri = true # whether to use an artist's musicbrainz_id for generating its URI; # disabled by default, since some taggers do not handle this well for # multi-artist tracks [https://github.com/sampsyo/beets/issues/907] use_artist_mbid_uri = false # whether to use the sortname field for sorting artist browse results; # set to false to sort according to displayed name only use_artist_sortname = true Project Resources ------------------------------------------------------------------------ .. image:: http://img.shields.io/pypi/v/Mopidy-Local-SQLite.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Local-SQLite/ :alt: Latest PyPI version .. image:: http://img.shields.io/pypi/dm/Mopidy-Local-SQLite.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Local-SQLite/ :alt: Number of PyPI downloads .. image:: http://img.shields.io/travis/mopidy/mopidy-local-sqlite/master.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy-local-sqlite/ :alt: Travis CI build status .. image:: http://img.shields.io/coveralls/mopidy/mopidy-local-sqlite/master.svg?style=flat :target: https://coveralls.io/r/mopidy/mopidy-local-sqlite/ :alt: Test coverage - `Issue Tracker`_ - `Source Code`_ - `Change Log`_ License ------------------------------------------------------------------------ Copyright (c) 2014, 2015 Thomas Kemmer and contributors_. Licensed under the `Apache License, Version 2.0`_. Known Bugs and Limitations ------------------------------------------------------------------------ The database schema does not support multiple artists, composers or performers for a single track or album. Look out for "Ignoring multiple artists" warnings during a local scan to see if you are affected by this. .. _Mopidy: http://www.mopidy.com/ .. _SQLite: http://www.sqlite.org/ .. _FTS: http://www.sqlite.org/fts3.html .. _pip: https://pip.pypa.io/en/latest/ .. _Issue Tracker: https://github.com/mopidy/mopidy-local-sqlite/issues/ .. _Source Code: https://github.com/mopidy/mopidy-local-sqlite/ .. _Change Log: https://github.com/mopidy/mopidy-local-sqlite/blob/master/CHANGES.rst .. _contributors: https://github.com/mopidy/mopidy-local-sqlite/blob/master/AUTHORS .. _Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/000077500000000000000000000000001257261423600214445ustar00rootroot00000000000000mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/__init__.py000066400000000000000000000037611257261423600235640ustar00rootroot00000000000000from __future__ import unicode_literals import logging import os from mopidy import config, ext __version__ = '1.0.0' logger = logging.getLogger(__name__) class Extension(ext.Extension): dist_name = 'Mopidy-Local-SQLite' ext_name = 'local-sqlite' version = __version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['directories'] = config.List() schema['timeout'] = config.Integer(optional=True, minimum=1) schema['use_album_mbid_uri'] = config.Boolean() schema['use_artist_mbid_uri'] = config.Boolean() schema['use_artist_sortname'] = config.Boolean() # no longer used schema['search_limit'] = config.Deprecated() schema['extract_images'] = config.Deprecated() schema['image_dir'] = config.Deprecated() schema['image_base_uri'] = config.Deprecated() schema['album_art_files'] = config.Deprecated() return schema def setup(self, registry): from .library import SQLiteLibrary registry.add('local:library', SQLiteLibrary) @classmethod def get_or_create_data_dir(cls, config): data_dir = cls().get_data_dir(config) migrate_old_data_dir(config, data_dir) return data_dir def migrate_old_data_dir(config, new_dir): # Remove this method when we're confident most users have upgraded away # from Mopidy 1.0. old_dir = os.path.join(config['core']['data_dir'], b'local', b'sqlite') if not os.path.isdir(old_dir): return logger.info('Migrating Mopidy-Local-SQLite to new data dir') for filename in os.listdir(old_dir): old_path = os.path.join(old_dir, filename) new_path = os.path.join(new_dir, filename) logger.info('Moving %r to %r', old_path, new_path) os.rename(old_path, new_path) os.rmdir(old_dir) mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/ext.conf000066400000000000000000000023411257261423600231130ustar00rootroot00000000000000[local-sqlite] enabled = true # top-level directories for browsing, as directories = Albums local:directory?type=album Artists local:directory?type=artist Composers local:directory?type=artist&role=composer Genres local:directory?type=genre Performers local:directory?type=artist&role=performer Release Years local:directory?type=date&format=%25Y Tracks local:directory?type=track Last Week's Updates local:directory?max-age=604800 Last Month's Updates local:directory?max-age=2592000 # database connection timeout in seconds timeout = 10 # whether to use an album's musicbrainz_id for generating its URI use_album_mbid_uri = true # whether to use an artist's musicbrainz_id for generating its URI; # disabled by default, since some taggers do not handle this well for # multi-artist tracks [https://github.com/sampsyo/beets/issues/907] use_artist_mbid_uri = false # whether to use the sortname field for ordering artist browse # results; disabled by default, since this may give confusing results # if not all artists in the library have proper sortnames use_artist_sortname = false mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/library.py000066400000000000000000000223261257261423600234670ustar00rootroot00000000000000from __future__ import unicode_literals import hashlib import logging import operator import os import os.path import sqlite3 import sys from mopidy import local from mopidy.exceptions import ExtensionError from mopidy.local import translator from mopidy.models import Ref, SearchResult import uritools from . import Extension, schema logger = logging.getLogger(__name__) class SQLiteLibrary(local.Library): name = 'sqlite' def __init__(self, config): self._config = ext_config = config[Extension.ext_name] self._data_dir = Extension.get_or_create_data_dir(config) try: self._media_dir = config['local']['media_dir'] except KeyError: raise ExtensionError('Mopidy-Local not enabled') self._directories = [] for line in ext_config['directories']: name, uri = line.rsplit(None, 1) ref = Ref.directory(uri=uri, name=name) self._directories.append(ref) self._dbpath = os.path.join(self._data_dir, b'library.db') self._connection = None def load(self): with self._connect() as connection: version = schema.load(connection) logger.debug('Using SQLite database schema v%s', version) return schema.count_tracks(connection) def lookup(self, uri): if uri.startswith('local:album'): return list(schema.lookup(self._connect(), Ref.ALBUM, uri)) elif uri.startswith('local:artist'): return list(schema.lookup(self._connect(), Ref.ARTIST, uri)) elif uri.startswith('local:track'): return list(schema.lookup(self._connect(), Ref.TRACK, uri)) else: logger.error('Invalid lookup URI %s', uri) return [] def browse(self, uri): try: if uri == self.ROOT_DIRECTORY_URI: return self._directories elif uri.startswith('local:directory'): return self._browse_directory(uri) elif uri.startswith('local:artist'): return self._browse_artist(uri) elif uri.startswith('local:album'): return self._browse_album(uri) else: raise ValueError('Invalid browse URI') except Exception as e: logger.error('Error browsing %s: %s', uri, e) return [] def search(self, query=None, limit=100, offset=0, uris=None, exact=False): q = [] for field, values in (query.items() if query else []): q.extend((field, value) for value in values) filters = [f for uri in uris or [] for f in self._filters(uri) if f] with self._connect() as c: tracks = schema.search_tracks(c, q, limit, offset, exact, filters) uri = uritools.uricompose('local', path='search', query=q) return SearchResult(uri=uri, tracks=tracks) def get_distinct(self, field, query=None): q = [] for key, values in (query.items() if query else []): q.extend((key, value) for value in values) return set(schema.list_distinct(self._connect(), field, q)) def begin(self): return schema.tracks(self._connect()) def add(self, track): try: track = self._validate_track(track) schema.insert_track(self._connect(), track) except Exception as e: logger.warn('Skipped %s: %s', track.uri, e) def remove(self, uri): schema.delete_track(self._connect(), uri) def flush(self): if not self._connection: return False self._connection.commit() return True def close(self): schema.cleanup(self._connection) self._connection.commit() self._connection.close() self._connection = None def clear(self): try: schema.clear(self._connect()) return True except sqlite3.Error as e: logger.error('Error clearing SQLite database: %s', e) return False def _connect(self): if not self._connection: self._connection = sqlite3.connect( self._dbpath, factory=schema.Connection, timeout=self._config['timeout'], check_same_thread=False, ) return self._connection def _browse_album(self, uri, order=('disc_no', 'track_no', 'name')): return schema.browse(self._connect(), Ref.TRACK, order, album=uri) def _browse_artist(self, uri, order=('type', 'name COLLATE NOCASE')): with self._connect() as c: albums = schema.browse(c, Ref.ALBUM, order, albumartist=uri) refs = schema.browse(c, order=order, artist=uri) album_uris, tracks = {ref.uri for ref in albums}, [] for ref in refs: if ref.type == Ref.ALBUM and ref.uri not in album_uris: albums.append(Ref.directory( uri=uritools.uricompose('local', None, 'directory', dict( type=Ref.TRACK, album=ref.uri, artist=uri )), name=ref.name )) elif ref.type == Ref.TRACK: tracks.append(ref) else: logger.debug('Skipped SQLite browse result %s', ref.uri) albums.sort(key=operator.attrgetter('name')) return albums + tracks def _browse_directory(self, uri, order=('type', 'name COLLATE NOCASE')): query = dict(uritools.urisplit(uri).getquerylist()) type = query.pop('type', None) role = query.pop('role', None) # TODO: handle these in schema (generically)? if type == 'date': format = query.get('format', '%Y-%m-%d') return map(_dateref, schema.dates(self._connect(), format=format)) if type == 'genre': return map(_genreref, schema.list_distinct(self._connect(), 'genre')) # noqa # Fix #38: keep sort order of album tracks; this also applies # to composers and performers if type == Ref.TRACK and 'album' in query: order = ('disc_no', 'track_no', 'name') if type == Ref.ARTIST and self._config['use_artist_sortname']: order = ('coalesce(sortname, name) COLLATE NOCASE',) roles = role or ('artist', 'albumartist') # FIXME: re-think 'roles'... refs = [] for ref in schema.browse(self._connect(), type, order, role=roles, **query): # noqa if ref.type == Ref.TRACK or (not query and not role): refs.append(ref) elif ref.type == Ref.ALBUM: refs.append(Ref.directory(uri=uritools.uricompose( 'local', None, 'directory', dict(query, type=Ref.TRACK, album=ref.uri) # noqa ), name=ref.name)) elif ref.type == Ref.ARTIST: refs.append(Ref.directory(uri=uritools.uricompose( 'local', None, 'directory', dict(query, **{role: ref.uri}) ), name=ref.name)) else: logger.warn('Unexpected SQLite browse result: %r', ref) return refs def _validate_artist(self, artist): if not artist.name: raise ValueError('Empty artist name') uri = artist.uri or self._model_uri('artist', artist) return artist.copy(uri=uri) def _validate_album(self, album): if not album.name: raise ValueError('Empty album name') uri = album.uri or self._model_uri('album', album) artists = map(self._validate_artist, album.artists) return album.copy(uri=uri, artists=artists) def _validate_track(self, track, encoding=sys.getfilesystemencoding()): if not track.uri: raise ValueError('Empty track URI') if track.name: name = track.name else: path = translator.local_track_uri_to_path(track.uri, b'') name = os.path.basename(path).decode(encoding, errors='replace') if track.album and track.album.name: album = self._validate_album(track.album) else: album = None return track.copy( name=name, album=album, artists=map(self._validate_artist, track.artists), composers=map(self._validate_artist, track.composers), performers=map(self._validate_artist, track.performers) ) def _filters(self, uri): if uri.startswith('local:directory'): return [dict(uritools.urisplit(uri).getquerylist())] elif uri.startswith('local:artist'): return [{'artist': uri}, {'albumartist': uri}] elif uri.startswith('local:album'): return [{'album': uri}] else: return [] def _model_uri(self, type, model): if model.musicbrainz_id and self._config['use_%s_mbid_uri' % type]: return 'local:%s:mbid:%s' % (type, model.musicbrainz_id) digest = hashlib.md5(str(model)).hexdigest() return 'local:%s:md5:%s' % (type, digest) def _dateref(date): return Ref.directory( uri=uritools.uricompose('local', None, 'directory', {'date': date}), name=date ) def _genreref(genre): return Ref.directory( uri=uritools.uricompose('local', None, 'directory', {'genre': genre}), name=genre ) mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/schema.py000066400000000000000000000326121257261423600232620ustar00rootroot00000000000000from __future__ import unicode_literals import itertools import logging import operator import os import sqlite3 from mopidy.models import Album, Artist, Ref, Track _BROWSE_QUERIES = { None: """ SELECT CASE WHEN album.uri IS NULL THEN '%s' ELSE '%s' END AS type, coalesce(album.uri, track.uri) AS uri, coalesce(album.name, track.name) AS name FROM track LEFT OUTER JOIN album ON track.album = album.uri WHERE %%s GROUP BY coalesce(album.uri, track.uri) ORDER BY %%s """ % (Ref.TRACK, Ref.ALBUM), Ref.ALBUM: """ SELECT '%s' AS type, uri AS uri, name AS name FROM album WHERE %%s ORDER BY %%s """ % Ref.ALBUM, Ref.ARTIST: """ SELECT '%s' AS type, uri AS uri, name AS name FROM artist WHERE %%s ORDER BY %%s """ % Ref.ARTIST, Ref.TRACK: """ SELECT '%s' AS type, uri AS uri, name AS name FROM track WHERE %%s ORDER BY %%s """ % Ref.TRACK } _BROWSE_FILTERS = { None: { 'album': 'track.album = ?', 'albumartist': 'album.artists = ?', 'artist': 'track.artists = ?', 'composer': 'track.composers = ?', 'date': "track.date LIKE ? || '%'", 'genre': 'track.genre = ?', 'performer': 'track.performers = ?', 'max-age': "track.last_modified >= (strftime('%s', 'now') - ?) * 1000", }, Ref.ARTIST: { 'role': { 'albumartist': """EXISTS ( SELECT * FROM album WHERE album.artists = artist.uri )""", 'artist': """EXISTS ( SELECT * FROM track WHERE track.artists = artist.uri )""", 'composer': """EXISTS ( SELECT * FROM track WHERE track.composers = artist.uri )""", 'performer': """EXISTS ( SELECT * FROM track WHERE track.performers = artist.uri )""" }, }, Ref.ALBUM: { 'albumartist': 'artists = ?', 'artist': """? IN ( SELECT artists FROM track WHERE album = album.uri )""", 'composer': """? IN ( SELECT composers FROM track WHERE album = album.uri )""", 'date': """EXISTS ( SELECT * FROM track WHERE album = album.uri AND date LIKE ? || '%' )""", 'genre': """? IN ( SELECT genre FROM track WHERE album = album.uri )""", 'performer': """? IN ( SELECT performers FROM track WHERE album = album.uri )""", 'max-age': """EXISTS ( SELECT * FROM track WHERE album = album.uri AND last_modified >= (strftime('%s', 'now') - ?) * 1000 )""", }, Ref.TRACK: { 'album': 'album = ?', 'albumartist': """? IN ( SELECT artists FROM album WHERE uri = track.album )""", 'artist': 'artists = ?', 'composer': 'composers = ?', 'date': "date LIKE ? || '%'", 'genre': 'genre = ?', 'performer': 'performers = ?', 'max-age': "last_modified >= (strftime('%s', 'now') - ?) * 1000", } } _LOOKUP_QUERIES = { Ref.ALBUM: """ SELECT * FROM tracks WHERE album_uri = ? """, Ref.ARTIST: """ SELECT * FROM tracks WHERE ? IN (artist_uri, albumartist_uri) """, Ref.TRACK: """ SELECT * FROM tracks WHERE uri = ? """ } _SEARCH_SQL = """ SELECT * FROM tracks WHERE docid IN (SELECT docid FROM %s WHERE %s) """ _SEARCH_FILTERS = { 'album': 'album_uri = ?', 'albumartist': 'albumartist_uri = ?', 'artist': 'artist_uri = ?', 'composer': 'composer_uri = ?', 'date': "date LIKE ? || '%'", 'genre': 'genre = ?', 'performer': 'performer_uri = ?', 'max-age': "last_modified >= (strftime('%s', 'now') - ?) * 1000", } _SEARCH_FIELDS = { 'uri', 'track_name', 'album', 'artist', 'composer', 'performer', 'albumartist', 'genre', 'track_no', 'date', 'comment' } schema_version = 6 logger = logging.getLogger(__name__) class Connection(sqlite3.Connection): class Row(sqlite3.Row): def __getattr__(self, name): return self[name] def __init__(self, *args, **kwargs): sqlite3.Connection.__init__(self, *args, **kwargs) self.execute('PRAGMA foreign_keys = ON') self.row_factory = self.Row def load(c): sql_dir = os.path.join(os.path.dirname(__file__), b'sql') user_version = c.execute('PRAGMA user_version').fetchone()[0] while user_version != schema_version: if user_version: logger.info('Upgrading SQLite database schema v%s', user_version) filename = 'upgrade-v%s.sql' % user_version else: logger.info('Creating SQLite database schema v%s', schema_version) filename = 'schema.sql' with open(os.path.join(sql_dir, filename)) as fh: c.executescript(fh.read()) new_version = c.execute('PRAGMA user_version').fetchone()[0] assert new_version != user_version user_version = new_version return user_version def tracks(c): return itertools.imap(_track, c.execute('SELECT * FROM tracks')) def list_distinct(c, field, query=[]): if field not in _SEARCH_FIELDS: raise LookupError('Invalid search field: %s' % field) sql = """ SELECT DISTINCT %s AS field FROM search WHERE field IS NOT NULL """ % field terms = [] params = [] for key, value in query: if key == 'any': terms.append('? IN (%s)' % ','.join(_SEARCH_FIELDS)) elif key in _SEARCH_FIELDS: terms.append('%s = ?' % key) else: raise LookupError('Invalid search field: %s' % key) params.append(value) if terms: sql += ' AND ' + ' AND '.join(terms) logger.debug('SQLite list query %r: %s', params, sql) return itertools.imap(operator.itemgetter(0), c.execute(sql, params)) def dates(c, format='%Y-%m-%d'): return itertools.imap(operator.itemgetter(0), c.execute(""" SELECT DISTINCT strftime(?, date) AS date FROM track WHERE date IS NOT NULL ORDER BY date """, [format])) def lookup(c, type, uri): return itertools.imap(_track, c.execute(_LOOKUP_QUERIES[type], [uri])) def exists(c, uri): rows = c.execute('SELECT EXISTS(SELECT * FROM track WHERE uri = ?)', [uri]) return rows.fetchone()[0] def browse(c, type=None, order=('type', 'name COLLATE NOCASE'), **kwargs): filters, params = _filters(_BROWSE_FILTERS[type], **kwargs) sql = _BROWSE_QUERIES[type] % ( ' AND '.join(filters) or '1', ', '.join(order) ) logger.debug('SQLite browse query %r: %s', params, sql) return [Ref(**row) for row in c.execute(sql, params)] def search_tracks(c, query, limit, offset, exact, filters=[]): if not query: sql, params = ('SELECT * FROM tracks WHERE 1', []) elif exact: sql, params = _indexed_query(query) else: sql, params = _fulltext_query(query) clauses = [] for kwargs in filters: f, p = _filters(_SEARCH_FILTERS, **kwargs) if f: clauses.append('(%s)' % ' AND '.join(f)) params.extend(p) else: logger.debug('Skipped SQLite search filter %r', kwargs) if clauses: sql += ' AND (%s)' % ' OR '.join(clauses) sql += ' LIMIT ? OFFSET ?' params += [limit, offset] logger.debug('SQLite search query %r: %s', params, sql) rows = c.execute(sql, params) return map(_track, rows) def insert_artists(c, artists): if not artists: return None if len(artists) != 1: logger.warn('Ignoring multiple artists: %r', artists) artist = next(iter(artists)) _insert(c, 'artist', { 'uri': artist.uri, 'name': artist.name, 'sortname': artist.sortname, 'musicbrainz_id': artist.musicbrainz_id }) return artist.uri def insert_album(c, album): if not album or not album.name: return None _insert(c, 'album', { 'uri': album.uri, 'name': album.name, 'artists': insert_artists(c, album.artists), 'num_tracks': album.num_tracks, 'num_discs': album.num_discs, 'date': album.date, 'musicbrainz_id': album.musicbrainz_id, 'images': ' '.join(album.images) if album.images else None }) return album.uri def insert_track(c, track): _insert(c, 'track', { 'uri': track.uri, 'name': track.name, 'album': insert_album(c, track.album), 'artists': insert_artists(c, track.artists), 'composers': insert_artists(c, track.composers), 'performers': insert_artists(c, track.performers), 'genre': track.genre, 'track_no': track.track_no, 'disc_no': track.disc_no, 'date': track.date, 'length': track.length, 'bitrate': track.bitrate, 'comment': track.comment, 'musicbrainz_id': track.musicbrainz_id, 'last_modified': track.last_modified }) return track.uri def delete_track(c, uri): c.execute('DELETE FROM track WHERE uri = ?', (uri,)) def count_tracks(c): return c.execute('SELECT count(*) FROM track').fetchone()[0] def cleanup(c): c.execute(""" DELETE FROM album WHERE NOT EXISTS ( SELECT uri FROM track WHERE track.album = album.uri ) """) c.execute(""" DELETE FROM artist WHERE NOT EXISTS ( SELECT uri FROM track WHERE track.artists = artist.uri UNION SELECT uri FROM track WHERE track.composers = artist.uri UNION SELECT uri FROM track WHERE track.performers = artist.uri UNION SELECT uri FROM album WHERE album.artists = artist.uri ) """) c.execute('ANALYZE') def clear(c): c.executescript(""" DELETE FROM track; DELETE FROM album; DELETE FROM artist; VACUUM; """) def _insert(c, table, params): sql = 'INSERT OR REPLACE INTO %s (%s) VALUES (%s)' % ( table, ', '.join(params.keys()), ', '.join(['?'] * len(params)) ) logger.debug('SQLite insert statement: %s %r', sql, params.values()) return c.execute(sql, params.values()) def _filters(mapping, role=None, **kwargs): filters, params = [], [] if role and 'role' in mapping: rolemap = mapping['role'] if isinstance(role, basestring): filters.append(rolemap[role]) else: filters.append(' OR '.join(rolemap[r] for r in role)) for key, value in kwargs.items(): if key in mapping: filters.append(mapping[key]) params.append(value) else: logger.debug('Skipped SQLite filter expression: %s=%r', key, value) return (filters, params) def _indexed_query(query): terms = [] params = [] for field, value in query: if field == 'any': terms.append('? IN (%s)' % ','.join(_SEARCH_FIELDS)) elif field in _SEARCH_FIELDS: terms.append('%s = ?' % field) else: raise LookupError('Invalid search field: %s' % field) params.append(value) return (_SEARCH_SQL % ('search', ' AND '.join(terms)), params) def _fulltext_query(query): terms = [] params = [] for field, value in query: if field == 'any': terms.append(_SEARCH_SQL % ('fts', 'fts MATCH ?')) elif field in _SEARCH_FIELDS: terms.append(_SEARCH_SQL % ('fts', '%s MATCH ?' % field)) else: raise LookupError('Invalid search field: %s' % field) params.append(value) return (' INTERSECT '.join(terms), params) def _track(row): kwargs = { 'uri': row.uri, 'name': row.name, 'genre': row.genre, 'track_no': row.track_no, 'disc_no': row.disc_no, 'date': row.date, 'length': row.length, 'bitrate': row.bitrate, 'comment': row.comment, 'musicbrainz_id': row.musicbrainz_id, 'last_modified': row.last_modified } if row.album_uri is not None: if row.albumartist_uri is not None: albumartists = [Artist( uri=row.albumartist_uri, name=row.albumartist_name, sortname=row.albumartist_sortname, musicbrainz_id=row.albumartist_musicbrainz_id )] else: albumartists = None kwargs['album'] = Album( uri=row.album_uri, name=row.album_name, artists=albumartists, num_tracks=row.album_num_tracks, num_discs=row.album_num_discs, date=row.album_date, musicbrainz_id=row.album_musicbrainz_id, images=row.album_images.split() if row.album_images else None ) if row.artist_uri is not None: kwargs['artists'] = [Artist( uri=row.artist_uri, name=row.artist_name, sortname=row.artist_sortname, musicbrainz_id=row.artist_musicbrainz_id )] if row.composer_uri is not None: kwargs['composers'] = [Artist( uri=row.composer_uri, name=row.composer_name, sortname=row.composer_sortname, musicbrainz_id=row.composer_musicbrainz_id )] if row.performer_uri is not None: kwargs['performers'] = [Artist( uri=row.performer_uri, name=row.performer_name, sortname=row.performer_sortname, musicbrainz_id=row.performer_musicbrainz_id )] return Track(**kwargs) mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/sql/000077500000000000000000000000001257261423600222435ustar00rootroot00000000000000mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/sql/schema.sql000066400000000000000000000176271257261423600242410ustar00rootroot00000000000000-- Mopidy-Local-SQLite schema BEGIN EXCLUSIVE TRANSACTION; PRAGMA user_version = 6; -- schema version CREATE TABLE artist ( uri TEXT PRIMARY KEY, -- artist URI name TEXT NOT NULL, -- artist name sortname TEXT, -- artist name for sorting musicbrainz_id TEXT -- MusicBrainz ID ); CREATE TABLE album ( uri TEXT PRIMARY KEY, -- album URI name TEXT NOT NULL, -- album name artists TEXT, -- (list of Artist) album artists num_tracks INTEGER, -- number of tracks in album num_discs INTEGER, -- number of discs in album date TEXT, -- album release date (YYYY or YYYY-MM-DD) musicbrainz_id TEXT, -- MusicBrainz ID images TEXT, -- (list of strings) album image URIs FOREIGN KEY (artists) REFERENCES artist (uri) ); CREATE TABLE track ( uri TEXT PRIMARY KEY, -- track URI name TEXT NOT NULL, -- track name album TEXT, -- track album artists TEXT, -- (list of Artist) – track artists composers TEXT, -- (list of Artist) – track composers performers TEXT, -- (list of Artist) – track performers genre TEXT, -- track genre track_no INTEGER, -- track number in album disc_no INTEGER, -- disc number in album date TEXT, -- track release date (YYYY or YYYY-MM-DD) length INTEGER, -- track length in milliseconds bitrate INTEGER, -- bitrate in kbit/s comment TEXT, -- track comment musicbrainz_id TEXT, -- MusicBrainz ID last_modified INTEGER, -- Represents last modification time FOREIGN KEY (album) REFERENCES album (uri), FOREIGN KEY (artists) REFERENCES artist (uri), FOREIGN KEY (composers) REFERENCES artist (uri), FOREIGN KEY (performers) REFERENCES artist (uri) ); CREATE INDEX album_name_index ON album (name); CREATE INDEX album_artists_index ON album (artists); CREATE INDEX album_date_index ON album (date); CREATE INDEX artist_name_index ON artist (name); CREATE INDEX track_name_index ON track (name); CREATE INDEX track_album_index ON track (album); CREATE INDEX track_artists_index ON track (artists); CREATE INDEX track_composers_index ON track (composers); CREATE INDEX track_performers_index ON track (performers); CREATE INDEX track_genre_index ON track (genre); CREATE INDEX track_track_no_index ON track (track_no); CREATE INDEX track_date_index ON track (date); CREATE INDEX track_comment_index on track (comment); CREATE INDEX track_last_modified_index on track (last_modified); -- Convenience views CREATE VIEW albums AS SELECT album.uri AS uri, album.name AS name, artist.uri AS artist_uri, artist.name AS artist_name, artist.sortname AS artist_sortname, artist.musicbrainz_id AS artist_musicbrainz_id, album.num_tracks AS num_tracks, album.num_discs AS num_discs, album.date AS date, album.musicbrainz_id AS musicbrainz_id, album.images AS images FROM album LEFT OUTER JOIN artist ON album.artists = artist.uri; CREATE VIEW tracks AS SELECT track.rowid AS docid, track.uri AS uri, track.name AS name, track.genre AS genre, track.track_no AS track_no, track.disc_no AS disc_no, track.date AS date, track.length AS length, track.bitrate AS bitrate, track.comment AS comment, track.musicbrainz_id AS musicbrainz_id, track.last_modified AS last_modified, album.uri AS album_uri, album.name AS album_name, album.num_tracks AS album_num_tracks, album.num_discs AS album_num_discs, album.date AS album_date, album.musicbrainz_id AS album_musicbrainz_id, album.images AS album_images, artist.uri AS artist_uri, artist.name AS artist_name, artist.sortname AS artist_sortname, artist.musicbrainz_id AS artist_musicbrainz_id, composer.uri AS composer_uri, composer.name AS composer_name, composer.sortname AS composer_sortname, composer.musicbrainz_id AS composer_musicbrainz_id, performer.uri AS performer_uri, performer.name AS performer_name, performer.sortname AS performer_sortname, performer.musicbrainz_id AS performer_musicbrainz_id, albumartist.uri AS albumartist_uri, albumartist.name AS albumartist_name, albumartist.sortname AS albumartist_sortname, albumartist.musicbrainz_id AS albumartist_musicbrainz_id FROM track LEFT OUTER JOIN album ON track.album = album.uri LEFT OUTER JOIN artist ON track.artists = artist.uri LEFT OUTER JOIN artist AS composer ON track.composers = composer.uri LEFT OUTER JOIN artist AS performer ON track.performers = performer.uri LEFT OUTER JOIN artist AS albumartist ON album.artists = albumartist.uri; -- Indexed search; column names match Mopidy query fields CREATE VIEW search AS SELECT docid AS docid, uri AS uri, name AS track_name, album_name AS album, artist_name AS artist, composer_name AS composer, performer_name AS performer, albumartist_name AS albumartist, genre AS genre, track_no AS track_no, coalesce(date, album_date) AS date, comment AS comment FROM tracks; -- Full-text search; column names match Mopidy query fields CREATE VIRTUAL TABLE fts USING fts3 ( uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ); CREATE TRIGGER track_after_insert AFTER INSERT ON track BEGIN INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ) SELECT * FROM search WHERE docid = new.rowid; END; CREATE TRIGGER track_after_update AFTER UPDATE ON track BEGIN INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ) SELECT * FROM search WHERE docid = new.rowid; END; CREATE TRIGGER track_before_update BEFORE UPDATE ON track BEGIN DELETE FROM fts WHERE docid = old.rowid; END; CREATE TRIGGER track_before_delete BEFORE DELETE ON track BEGIN DELETE FROM fts WHERE docid = old.rowid; END; END TRANSACTION; mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/sql/upgrade-v1.sql000066400000000000000000000114321257261423600247400ustar00rootroot00000000000000-- Mopidy-Local-SQLite schema upgrade v1 -> v2 BEGIN EXCLUSIVE TRANSACTION; -- update schema CREATE INDEX album_name_index ON album (name); CREATE INDEX album_artists_index ON album (artists); CREATE INDEX artist_name_index ON artist (name); CREATE INDEX track_name_index ON track (name); CREATE INDEX track_album_index ON track (album); CREATE INDEX track_artists_index ON track (artists); CREATE INDEX track_composers_index ON track (composers); CREATE INDEX track_performers_index ON track (performers); CREATE INDEX track_genre_index ON track (genre); CREATE INDEX track_comment_index on track (comment); CREATE VIEW tracks AS SELECT track.rowid AS docid, track.uri AS uri, track.name AS name, track.genre AS genre, track.track_no AS track_no, track.disc_no AS disc_no, track.date AS date, track.length AS length, track.bitrate AS bitrate, track.comment AS comment, track.musicbrainz_id AS musicbrainz_id, track.last_modified AS last_modified, album.uri AS album_uri, album.name AS album_name, album.num_tracks AS album_num_tracks, album.num_discs AS album_num_discs, album.date AS album_date, album.musicbrainz_id AS album_musicbrainz_id, album.images AS album_images, artist.uri AS artist_uri, artist.name AS artist_name, artist.musicbrainz_id AS artist_musicbrainz_id, composer.uri AS composer_uri, composer.name AS composer_name, composer.musicbrainz_id AS composer_musicbrainz_id, performer.uri AS performer_uri, performer.name AS performer_name, performer.musicbrainz_id AS performer_musicbrainz_id, albumartist.uri AS albumartist_uri, albumartist.name AS albumartist_name, albumartist.musicbrainz_id AS albumartist_musicbrainz_id FROM track LEFT OUTER JOIN album ON track.album = album.uri LEFT OUTER JOIN artist ON track.artists = artist.uri LEFT OUTER JOIN artist AS composer ON track.composers = composer.uri LEFT OUTER JOIN artist AS performer ON track.performers = performer.uri LEFT OUTER JOIN artist AS albumartist ON album.artists = albumartist.uri; CREATE VIEW search AS SELECT docid AS docid, uri AS uri, name AS track_name, album_name AS album, artist_name AS artist, composer_name AS composer, performer_name AS performer, albumartist_name AS albumartist, genre AS genre, track_no AS track_no, coalesce(date, album_date) AS date, comment AS comment FROM tracks; CREATE VIRTUAL TABLE fts USING fts3 ( uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ); CREATE TRIGGER track_after_insert AFTER INSERT ON track BEGIN INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ) SELECT * FROM search WHERE docid = new.rowid; END; CREATE TRIGGER track_after_update AFTER UPDATE ON track BEGIN INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ) SELECT * FROM search WHERE docid = new.rowid; END; CREATE TRIGGER track_before_update BEFORE UPDATE ON track BEGIN DELETE FROM fts WHERE docid = old.rowid; END; CREATE TRIGGER track_before_delete BEFORE DELETE ON track BEGIN DELETE FROM fts WHERE docid = old.rowid; END; -- update date INSERT INTO fts ( docid, uri, track_name, album, artist, composer, performer, albumartist, genre, track_no, date, comment ) SELECT * FROM search; PRAGMA user_version = 2; -- update schema version END TRANSACTION; mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/sql/upgrade-v2.sql000066400000000000000000000024211257261423600247370ustar00rootroot00000000000000-- Mopidy-Local-SQLite schema upgrade v2 -> v3 BEGIN EXCLUSIVE TRANSACTION; CREATE VIEW albums AS SELECT album.uri AS uri, album.name AS name, artist.uri AS artist_uri, artist.name AS artist_name, artist.musicbrainz_id AS artist_musicbrainz_id, album.num_tracks AS num_tracks, album.num_discs AS num_discs, album.date AS date, album.musicbrainz_id AS musicbrainz_id, album.images AS images FROM album LEFT OUTER JOIN artist ON album.artists = artist.uri; CREATE VIEW artists AS SELECT uri, name, musicbrainz_id FROM artist WHERE EXISTS (SELECT * FROM album WHERE album.artists = artist.uri) OR EXISTS (SELECT * FROM track WHERE track.artists = artist.uri); CREATE VIEW composers AS SELECT uri, name, musicbrainz_id FROM artist WHERE EXISTS (SELECT * FROM track WHERE track.composers = artist.uri); CREATE VIEW performers AS SELECT uri, name, musicbrainz_id FROM artist WHERE EXISTS (SELECT * FROM track WHERE track.performers = artist.uri); PRAGMA user_version = 3; -- update schema version END TRANSACTION; mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/sql/upgrade-v3.sql000066400000000000000000000006021257261423600247370ustar00rootroot00000000000000-- Mopidy-Local-SQLite schema upgrade v3 -> v4 BEGIN EXCLUSIVE TRANSACTION; CREATE INDEX album_date_index ON album (date); CREATE INDEX track_track_no_index ON track (track_no); CREATE INDEX track_date_index ON track (date); DROP VIEW artists; DROP VIEW composers; DROP VIEW performers; PRAGMA user_version = 4; -- update schema version END TRANSACTION; mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/sql/upgrade-v4.sql000066400000000000000000000003261257261423600247430ustar00rootroot00000000000000-- Mopidy-Local-SQLite schema upgrade v4 -> v5 BEGIN EXCLUSIVE TRANSACTION; CREATE INDEX track_last_modified_index ON track (last_modified); PRAGMA user_version = 5; -- update schema version END TRANSACTION; mopidy-local-sqlite-1.0.0/mopidy_local_sqlite/sql/upgrade-v5.sql000066400000000000000000000064321257261423600247500ustar00rootroot00000000000000-- Mopidy-Local-SQLite schema upgrade v5 -> v6 BEGIN EXCLUSIVE TRANSACTION; ALTER TABLE artist ADD COLUMN sortname TEXT; DROP VIEW albums; DROP VIEW tracks; CREATE VIEW albums AS SELECT album.uri AS uri, album.name AS name, artist.uri AS artist_uri, artist.name AS artist_name, artist.sortname AS artist_sortname, artist.musicbrainz_id AS artist_musicbrainz_id, album.num_tracks AS num_tracks, album.num_discs AS num_discs, album.date AS date, album.musicbrainz_id AS musicbrainz_id, album.images AS images FROM album LEFT OUTER JOIN artist ON album.artists = artist.uri; CREATE VIEW tracks AS SELECT track.rowid AS docid, track.uri AS uri, track.name AS name, track.genre AS genre, track.track_no AS track_no, track.disc_no AS disc_no, track.date AS date, track.length AS length, track.bitrate AS bitrate, track.comment AS comment, track.musicbrainz_id AS musicbrainz_id, track.last_modified AS last_modified, album.uri AS album_uri, album.name AS album_name, album.num_tracks AS album_num_tracks, album.num_discs AS album_num_discs, album.date AS album_date, album.musicbrainz_id AS album_musicbrainz_id, album.images AS album_images, artist.uri AS artist_uri, artist.name AS artist_name, artist.sortname AS artist_sortname, artist.musicbrainz_id AS artist_musicbrainz_id, composer.uri AS composer_uri, composer.name AS composer_name, composer.sortname AS composer_sortname, composer.musicbrainz_id AS composer_musicbrainz_id, performer.uri AS performer_uri, performer.name AS performer_name, performer.sortname AS performer_sortname, performer.musicbrainz_id AS performer_musicbrainz_id, albumartist.uri AS albumartist_uri, albumartist.name AS albumartist_name, albumartist.sortname AS albumartist_sortname, albumartist.musicbrainz_id AS albumartist_musicbrainz_id FROM track LEFT OUTER JOIN album ON track.album = album.uri LEFT OUTER JOIN artist ON track.artists = artist.uri LEFT OUTER JOIN artist AS composer ON track.composers = composer.uri LEFT OUTER JOIN artist AS performer ON track.performers = performer.uri LEFT OUTER JOIN artist AS albumartist ON album.artists = albumartist.uri; PRAGMA user_version = 6; -- update schema version END TRANSACTION; mopidy-local-sqlite-1.0.0/setup.cfg000066400000000000000000000001571257261423600172340ustar00rootroot00000000000000[flake8] application-import-names = mopidy_local_sqlite,tests exclude = .git,.tox,build [wheel] universal = 1 mopidy-local-sqlite-1.0.0/setup.py000066400000000000000000000024721257261423600171270ustar00rootroot00000000000000from __future__ import unicode_literals import re from setuptools import find_packages, setup def get_version(filename): with open(filename) as fh: metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fh.read())) return metadata['version'] setup( name='Mopidy-Local-SQLite', version=get_version('mopidy_local_sqlite/__init__.py'), url='https://github.com/mopidy/mopidy-local-sqlite', license='Apache License, Version 2.0', author='Thomas Kemmer', author_email='tkemmer@computer.org', description='Mopidy SQLite local library extension', long_description=open('README.rst').read(), packages=find_packages(exclude=['tests', 'tests.*']), zip_safe=False, include_package_data=True, install_requires=[ 'setuptools', 'Mopidy >= 1.1', 'Pykka >= 1.1', 'uritools >= 1.0' ], entry_points={ 'mopidy.ext': [ 'local-sqlite = mopidy_local_sqlite:Extension', ], }, classifiers=[ 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2', 'Topic :: Multimedia :: Sound/Audio :: Players', ], ) mopidy-local-sqlite-1.0.0/tests/000077500000000000000000000000001257261423600165525ustar00rootroot00000000000000mopidy-local-sqlite-1.0.0/tests/__init__.py000066400000000000000000000000001257261423600206510ustar00rootroot00000000000000mopidy-local-sqlite-1.0.0/tests/test_extension.py000066400000000000000000000010311257261423600221720ustar00rootroot00000000000000from __future__ import unicode_literals from mopidy_local_sqlite import Extension def test_get_default_config(): ext = Extension() config = ext.get_default_config() assert '[local-sqlite]' in config assert 'enabled = true' in config def test_get_config_schema(): ext = Extension() schema = ext.get_config_schema() assert 'directories' in schema assert 'timeout' in schema assert 'use_album_mbid_uri' in schema assert 'use_artist_mbid_uri' in schema assert 'use_artist_sortname' in schema mopidy-local-sqlite-1.0.0/tests/test_library.py000066400000000000000000000045471257261423600216410ustar00rootroot00000000000000from __future__ import unicode_literals import shutil import tempfile import unittest from mopidy.local import translator from mopidy.models import SearchResult, Track from mopidy_local_sqlite import library class LocalLibraryProviderTest(unittest.TestCase): config = { 'local-sqlite': { 'directories': [], 'encodings': ['utf-8', 'latin-1'], 'timeout': 1.0, 'use_album_mbid_uri': False, 'use_artist_mbid_uri': False, 'search_limit': None } } def setUp(self): self.tempdir = tempfile.mkdtemp() self.library = library.SQLiteLibrary(dict( self.config, core={ 'data_dir': self.tempdir, }, local={ 'media_dir': self.tempdir, 'data_dir': self.tempdir, 'excluded_file_extensions': [] } )) self.library.load() def tearDown(self): shutil.rmtree(self.tempdir) def test_add_noname_ascii(self): name = b'Test.mp3' uri = translator.path_to_local_track_uri(name) track = Track(name=name, uri=uri) self.library.begin() self.library.add(track) self.library.close() self.assertEqual([track], self.library.lookup(uri)) def test_add_noname_utf8(self): name = u'Mi\xf0vikudags.mp3' uri = translator.path_to_local_track_uri(name.encode('utf-8')) track = Track(name=name, uri=uri) self.library.begin() self.library.add(track) self.library.close() self.assertEqual([track], self.library.lookup(uri)) def test_clear(self): self.library.begin() self.library.add(Track(uri='local:track:track.mp3')) self.library.close() self.library.clear() self.assertEqual(self.library.load(), 0) def test_search_uri(self): empty = SearchResult(uri='local:search?') self.assertEqual(empty, self.library.search(uris=None)) self.assertEqual(empty, self.library.search(uris=[])) self.assertEqual(empty, self.library.search(uris=['local:'])) self.assertEqual(empty, self.library.search(uris=['local:directory'])) self.assertEqual(empty, self.library.search(uris=['local:directory:'])) self.assertEqual(empty, self.library.search(uris=['foobar:'])) mopidy-local-sqlite-1.0.0/tests/test_schema.py000066400000000000000000000222301257261423600214220ustar00rootroot00000000000000from __future__ import unicode_literals import sqlite3 import unittest from mopidy.models import Album, Artist, Ref, Track from mopidy_local_sqlite import schema DBPATH = ':memory:' class SchemaTest(unittest.TestCase): artists = [ Artist(uri='local:artist:0', name='artist #0'), Artist(uri='local:artist:1', name='artist #1'), ] albums = [ Album(uri='local:album:0', name='album #0'), Album(uri='local:album:1', name='album #1', artists=[artists[0]]), Album(uri='local:album:2', name='album #2', artists=[artists[1]]) ] tracks = [ Track(uri='local:track:0', name='track #0', date='2015-03-15', genre='Rock'), Track(uri='local:track:1', name='track #1', artists=[artists[0]]), Track(uri='local:track:2', name='track #2', album=albums[0]), Track(uri='local:track:3', name='track #3', album=albums[1]), Track(uri='local:track:4', name='track #4', album=albums[2], composers=[artists[0]], performers=[artists[0]]) ] def setUp(self): self.connection = sqlite3.connect(DBPATH, factory=schema.Connection) schema.load(self.connection) for track in self.tracks: schema.insert_track(self.connection, track) def tearDown(self): self.connection.close() self.connection = None def test_create(self): count = schema.count_tracks(self.connection) self.assertEqual(len(self.tracks), count) tracks = list(schema.tracks(self.connection)) self.assertEqual(len(self.tracks), len(tracks)) def test_list_distinct(self): self.assertItemsEqual( [album.name for album in self.albums], schema.list_distinct(self.connection, 'album') ) self.assertItemsEqual( [artist.name for artist in self.artists[0:2]], schema.list_distinct(self.connection, 'albumartist') ) self.assertItemsEqual( [artist.name for artist in self.artists[0:1]], schema.list_distinct(self.connection, 'artist') ) self.assertItemsEqual( [artist.name for artist in self.artists[0:1]], schema.list_distinct(self.connection, 'composer') ) self.assertItemsEqual( [artist.name for artist in self.artists[0:1]], schema.list_distinct(self.connection, 'performer') ) self.assertItemsEqual( [self.tracks[0].date], schema.list_distinct(self.connection, 'date') ) self.assertItemsEqual( [self.tracks[0].genre], schema.list_distinct(self.connection, 'genre') ) def test_lookup_track(self): with self.connection as c: for track in self.tracks: result = schema.lookup(c, Ref.TRACK, track.uri) self.assertEqual([track], list(result)) def test_lookup_album(self): with self.connection as c: result = schema.lookup(c, Ref.ALBUM, self.albums[0].uri) self.assertEqual([self.tracks[2]], list(result)) result = schema.lookup(c, Ref.ALBUM, self.albums[1].uri) self.assertEqual([self.tracks[3]], list(result)) result = schema.lookup(c, Ref.ALBUM, self.albums[2].uri) self.assertEqual([self.tracks[4]], list(result)) def test_lookup_artist(self): with self.connection as c: result = schema.lookup(c, Ref.ARTIST, self.artists[0].uri) self.assertEqual([self.tracks[1], self.tracks[3]], list(result)) result = schema.lookup(c, Ref.ARTIST, self.artists[1].uri) self.assertEqual([self.tracks[4]], list(result)) def test_indexed_search(self): for results, query, filters in [ ( map(lambda t: t.uri, self.tracks), [], [] ), ( [], [('any', 'none')], [] ), ( [self.tracks[1].uri, self.tracks[3].uri, self.tracks[4].uri], [('any', self.artists[0].name)], [] ), ( [self.tracks[3].uri], [('any', self.artists[0].name)], [{'album': self.albums[1].uri}], ), ( [self.tracks[2].uri], [('album', self.tracks[2].album.name)], [], ), ( [self.tracks[1].uri], [('artist', next(iter(self.tracks[1].artists)).name)], [], ), ( [self.tracks[0].uri], [('track_name', self.tracks[0].name)], [] ), ]: for exact in (True, False): with self.connection as c: tracks = schema.search_tracks(c, query, 10, 0, exact, filters) # noqa self.assertItemsEqual(results, map(lambda t: t.uri, tracks)) def test_fulltext_search(self): for results, query, filters in [ ( map(lambda t: t.uri, self.tracks), [('track_name', 'track')], [] ), ( [self.tracks[1].uri, self.tracks[3].uri], [('track_name', 'track')], [{'artist': self.artists[0].uri}, {'albumartist': self.artists[0].uri}] # noqa ), ]: with self.connection as c: tracks = schema.search_tracks(c, query, 10, 0, False, filters) self.assertItemsEqual(results, map(lambda t: t.uri, tracks)) def test_browse_artists(self): def ref(artist): return Ref.artist(name=artist.name, uri=artist.uri) with self.connection as c: self.assertEqual(map(ref, self.artists), schema.browse( c, Ref.ARTIST )) self.assertEqual(map(ref, self.artists), schema.browse( c, Ref.ARTIST, role=['artist', 'albumartist'] )) self.assertEqual(map(ref, self.artists[0:1]), schema.browse( c, Ref.ARTIST, role='artist' )) self.assertEqual(map(ref, self.artists[0:1]), schema.browse( c, Ref.ARTIST, role='composer' )) self.assertEqual(map(ref, self.artists[0:1]), schema.browse( c, Ref.ARTIST, role='performer' )) self.assertEqual(map(ref, self.artists), schema.browse( c, Ref.ARTIST, role='albumartist' )) def test_browse_albums(self): def ref(album): return Ref.album(name=album.name, uri=album.uri) with self.connection as c: self.assertEqual(map(ref, self.albums), schema.browse( c, Ref.ALBUM )) self.assertEqual(map(ref, []), schema.browse( c, Ref.ALBUM, artist=self.artists[0].uri )) self.assertEqual(map(ref, self.albums[1:2]), schema.browse( c, Ref.ALBUM, albumartist=self.artists[0].uri )) def test_browse_tracks(self): def ref(track): return Ref.track(name=track.name, uri=track.uri) with self.connection as c: self.assertEqual(map(ref, self.tracks), schema.browse( c, Ref.TRACK )) self.assertEqual(map(ref, self.tracks[1:2]), schema.browse( c, Ref.TRACK, artist=self.artists[0].uri )) self.assertEqual(map(ref, self.tracks[2:3]), schema.browse( c, Ref.TRACK, album=self.albums[0].uri )) self.assertEqual(map(ref, self.tracks[3:4]), schema.browse( c, Ref.TRACK, albumartist=self.artists[0].uri )) self.assertEqual(map(ref, self.tracks[4:5]), schema.browse( c, Ref.TRACK, composer=self.artists[0].uri, performer=self.artists[0].uri )) def test_delete(self): c = self.connection schema.delete_track(c, self.tracks[0].uri) schema.cleanup(c) self.assertEqual(3, len(c.execute('SELECT * FROM album').fetchall())) self.assertEqual(2, len(c.execute('SELECT * FROM artist').fetchall())) schema.delete_track(c, self.tracks[1].uri) schema.cleanup(c) self.assertEqual(3, len(c.execute('SELECT * FROM album').fetchall())) self.assertEqual(2, len(c.execute('SELECT * FROM artist').fetchall())) schema.delete_track(c, self.tracks[2].uri) schema.cleanup(c) self.assertEqual(2, len(c.execute('SELECT * FROM album').fetchall())) self.assertEqual(2, len(c.execute('SELECT * FROM artist').fetchall())) schema.delete_track(c, self.tracks[3].uri) schema.cleanup(c) self.assertEqual(1, len(c.execute('SELECT * FROM album').fetchall())) self.assertEqual(2, len(c.execute('SELECT * FROM artist').fetchall())) schema.delete_track(c, self.tracks[4].uri) schema.cleanup(c) self.assertEqual(0, len(c.execute('SELECT * FROM album').fetchall())) self.assertEqual(0, len(c.execute('SELECT * FROM artist').fetchall())) mopidy-local-sqlite-1.0.0/tox.ini000066400000000000000000000005611257261423600167250ustar00rootroot00000000000000[tox] envlist = py27, flake8 [testenv] sitepackages = true deps = mock mopidy==dev pytest pytest-cov pytest-xdist install_command = pip install --allow-unverified=mopidy --pre {opts} {packages} commands = py.test --basetemp={envtmpdir} {posargs} [testenv:flake8] deps = flake8 flake8-import-order skip_install = true commands = flake8