diff --git a/beetsplug/subsonicplaylist.py b/beetsplug/subsonicplaylist.py new file mode 100644 index 000000000..95bdaa886 --- /dev/null +++ b/beetsplug/subsonicplaylist.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2019, Joris Jensen +# +# 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 absolute_import, division, print_function + +import random +import string +import xml.etree.ElementTree as ET +from hashlib import md5 +from urllib.parse import urlencode + +import requests + +from beets.dbcore import AndQuery +from beets.dbcore.query import SubstringQuery +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand + +__author__ = 'https://github.com/MrNuggelz' + + +def filter_to_be_removed(items, keys): + if len(items) > len(keys): + dont_remove = [] + for artist, album, title in keys: + for item in items: + if artist == item['artist'] and \ + album == item['album'] and \ + title == item['title']: + dont_remove.append(item) + return [item for item in items if item not in dont_remove] + else: + def to_be_removed(item): + for artist, album, title in keys: + if artist == item['artist'] and\ + album == item['album'] and\ + title == item['title']: + return False + return True + + return [item for item in items if to_be_removed(item)] + + +class SubsonicPlaylistPlugin(BeetsPlugin): + + def __init__(self): + super(SubsonicPlaylistPlugin, self).__init__() + self.config.add( + { + 'delete': False, + 'playlist_ids': [], + 'playlist_names': [], + 'username': '', + 'password': '' + } + ) + self.config['password'].redact = True + + def update_tags(self, playlist_dict, lib): + with lib.transaction(): + for query, playlist_tag in playlist_dict.items(): + query = AndQuery([SubstringQuery("artist", query[0]), + SubstringQuery("album", query[1]), + SubstringQuery("title", query[2])]) + items = lib.items(query) + if not items: + self._log.warn(u"{} | track not found ({})", playlist_tag, + query) + continue + for item in items: + item.subsonic_playlist = playlist_tag + item.try_sync(write=True, move=False) + + def get_playlist(self, playlist_id): + xml = self.send('getPlaylist', {'id': playlist_id}).text + playlist = ET.fromstring(xml)[0] + if playlist.attrib.get('code', '200') != '200': + alt_error = 'error getting playlist, but no error message found' + self._log.warn(playlist.attrib.get('message', alt_error)) + return + + name = playlist.attrib.get('name', 'undefined') + tracks = [(t.attrib['artist'], t.attrib['album'], t.attrib['title']) + for t in playlist] + return name, tracks + + def commands(self): + def build_playlist(lib, opts, args): + self.config.set_args(opts) + ids = self.config['playlist_ids'].as_str_seq() + if self.config['playlist_names'].as_str_seq(): + playlists = ET.fromstring(self.send('getPlaylists').text)[ + 0] + if playlists.attrib.get('code', '200') != '200': + alt_error = 'error getting playlists,' \ + ' but no error message found' + self._log.warn( + playlists.attrib.get('message', alt_error)) + return + for name in self.config['playlist_names'].as_str_seq(): + for playlist in playlists: + if name == playlist.attrib['name']: + ids.append(playlist.attrib['id']) + + playlist_dict = self.get_playlists(ids) + + # delete old tags + if self.config['delete']: + existing = list(lib.items('subsonic_playlist:";"')) + to_be_removed = filter_to_be_removed( + existing, + playlist_dict.keys()) + for item in to_be_removed: + item['subsonic_playlist'] = '' + with lib.transaction(): + item.try_sync(write=True, move=False) + + self.update_tags(playlist_dict, lib) + + subsonicplaylist_cmds = Subcommand( + 'subsonicplaylist', help=u'import a subsonic playlist' + ) + subsonicplaylist_cmds.parser.add_option( + u'-d', + u'--delete', + action='store_true', + help=u'delete tag from items not in any playlist anymore', + ) + subsonicplaylist_cmds.func = build_playlist + return [subsonicplaylist_cmds] + + def generate_token(self): + salt = ''.join(random.choices(string.ascii_lowercase + string.digits)) + return md5( + (self.config['password'].get() + salt).encode()).hexdigest(), salt + + def send(self, endpoint, params=None): + if params is None: + params = dict() + a, b = self.generate_token() + params['u'] = self.config['username'] + params['t'] = a + params['s'] = b + params['v'] = '1.12.0' + params['c'] = 'beets' + resp = requests.get('{}/rest/{}?{}'.format( + self.config['base_url'].get(), + endpoint, + urlencode(params)) + ) + return resp + + def get_playlists(self, ids): + output = dict() + for playlist_id in ids: + name, tracks = self.get_playlist(playlist_id) + for track in tracks: + if track not in output: + output[track] = ';' + output[track] += name + ';' + return output diff --git a/docs/changelog.rst b/docs/changelog.rst index c09b1b8ab..3f7ec228d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,7 @@ Changelog New features: +* :doc:`/plugins/subsonicplaylist`: import playlist from a subsonic server. * A new :ref:`extra_tags` configuration option allows more tagged metadata to be included in MusicBrainz queries. * A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1247d0ed0..de2f2930c 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -116,6 +116,7 @@ following to your configuration:: smartplaylist sonosupdate spotify + subsonicplaylist subsonicupdate the thumbnails diff --git a/docs/plugins/subsonicplaylist.rst b/docs/plugins/subsonicplaylist.rst new file mode 100644 index 000000000..98c83ebe1 --- /dev/null +++ b/docs/plugins/subsonicplaylist.rst @@ -0,0 +1,43 @@ +Subsonic Playlist Plugin +======================== + +The ``subsonicplaylist`` plugin allows to import playlists from a subsonic server. +This is done by retrieving the track info from the subsonic server, searching +for them in the beets library, and adding the playlist names to the +`subsonic_playlist` tag of the found items. The content of the tag has the format: + + subsonic_playlist: ";first playlist;second playlist;" + +To get all items in a playlist use the query `;playlist name;`. + +Command Line Usage +------------------ + +To use the ``subsonicplaylist`` plugin, enable it in your configuration (see +:ref:`using-plugins`). Then use it by invoking the ``subsonicplaylist`` command. +Next, configure the plugin to connect to your Subsonic server, like this:: + + subsonicplaylist: + base_url: http://subsonic.example.com + username: someUser + password: somePassword + +After this you can import your playlists by invoking the `subsonicplaylist` command. + +By default only the tags of the items found for playlists will be updated. +This means that, if one imported a playlist, then delete one song from it and +imported the playlist again, the deleted song will still have the playlist set +in its `subsonic_playlist` tag. To solve this problem one can use the `-d/--delete` +flag. This resets all `subsonic_playlist` tag before importing playlists. + +Here's an example configuration with all the available options and their default values:: + + subsonicplaylist: + base_url: "https://your.subsonic.server" + delete: no + playlist_ids: [] + playlist_names: [] + username: '' + password: '' + +The `base_url`, `username`, and `password` options are required.