more frontend refactoring
authormarcelveldt <marcelvanderveldt@MacBook-Pro.local>
Sat, 9 Nov 2019 01:33:45 +0000 (02:33 +0100)
committermarcelveldt <marcelvanderveldt@MacBook-Pro.local>
Sat, 9 Nov 2019 01:33:45 +0000 (02:33 +0100)
17 files changed:
frontend/package-lock.json
frontend/package.json
frontend/src/components/ContextMenu.vue
frontend/src/components/ListviewItem.vue
frontend/src/main.js
frontend/src/plugins/server.js
frontend/src/views/Browse.vue
frontend/src/views/Config.vue
music_assistant/database.py
music_assistant/models/musicprovider.py
music_assistant/models/player_queue.py
music_assistant/music_manager.py
music_assistant/musicproviders/qobuz.py
music_assistant/musicproviders/spotify.py
music_assistant/player_manager.py
music_assistant/utils.py
music_assistant/web.py

index f331637d2a5055d8822a8609854531eee41181ee..1665965a4eddb364d94723c5f821851842dd18b0 100644 (file)
         "is-symbol": "^1.0.2"
       }
     },
+    "es6-object-assign": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz",
+      "integrity": "sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw="
+    },
     "escape-html": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
         }
       }
     },
+    "vue-directive-long-press": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/vue-directive-long-press/-/vue-directive-long-press-1.1.0.tgz",
+      "integrity": "sha512-OHhR4N1kuuKaK87SPmqkcm5Y81HjXgv5DfWr96flIkTYveWps1AozKiNrabsIirq+ibSZWFr3IUr0en+Ce3fAA=="
+    },
     "vue-eslint-parser": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-5.0.0.tgz",
       "resolved": "https://registry.npmjs.org/vue-loading-overlay/-/vue-loading-overlay-3.2.0.tgz",
       "integrity": "sha512-QBHa+vwcQ3k3oKp4pucP7RHWHSQvgVWFlDFqSaXLu+kCuEv1PZCoerAo1T04enF5y9yMFCqh7L9ChrWHy7HYvA=="
     },
+    "vue-long-click": {
+      "version": "0.0.4",
+      "resolved": "https://registry.npmjs.org/vue-long-click/-/vue-long-click-0.0.4.tgz",
+      "integrity": "sha512-1/8KMsON6k8ebGqOhBZKU69EWlTLv4+LUluwUxTMNYno9t7ztk8j6rNVwewbp9hULktEoe+jBnxMFBngsPQCaQ==",
+      "requires": {
+        "vue": "^2.5.22"
+      }
+    },
     "vue-observe-visibility": {
       "version": "0.4.6",
       "resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-0.4.6.tgz",
         "vue-resize": "^0.4.5"
       }
     },
+    "vue2-touch-events": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/vue2-touch-events/-/vue2-touch-events-2.0.0.tgz",
+      "integrity": "sha512-t4LyihIieUif96RDMgRllrt/1qRE8Zu0dZoyqOAqAttx/TWynIshBQ8TumCj1xhog5EDaIgsihuVA6hPnssCLQ=="
+    },
+    "vuejs-logger": {
+      "version": "1.5.3",
+      "resolved": "https://registry.npmjs.org/vuejs-logger/-/vuejs-logger-1.5.3.tgz",
+      "integrity": "sha512-jw+AQ+IMJBz18fA4opHsqaU7P7yQNugoGywT6i3DCd1BWqg9eUx03Fr21kayqGcP4dxUwhVkkjuOyeirxLJC8g==",
+      "requires": {
+        "es6-object-assign": "1.1.0"
+      }
+    },
     "vuetify": {
       "version": "2.1.7",
       "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.1.7.tgz",
index ca122b67ae4e19cc55ba01396dc3be660ca4a59b..b663fe6e2dec0aa732fceac5cbbd2d39dde8d4f6 100644 (file)
     "register-service-worker": "^1.6.2",
     "roboto-fontface": "*",
     "vue": "^2.6.10",
+    "vue-directive-long-press": "^1.1.0",
     "vue-i18n": "^8.0.0",
     "vue-loading-overlay": "^3.2.0",
+    "vue-long-click": "0.0.4",
     "vue-observe-visibility": "^0.4.6",
     "vue-router": "^3.1.3",
     "vue-virtual-scroller": "^1.0.0-rc.2",
+    "vue2-touch-events": "^2.0.0",
+    "vuejs-logger": "1.5.3",
     "vuetify": "^2.1.0",
     "vuex": "^3.0.1"
   },
index 490a9907192f3bc144bfc1ad56d873c74ac09b12..0a76d22dd5959cb27e4a652decd7376f75b5ac32 100644 (file)
@@ -31,7 +31,7 @@
           :hideproviders="false"\r
           :hidelibrary="true"\r
           :hidemenu="true"\r
-          :onclickHandler="playlistSelected"\r
+          :onclickHandler="addToPlaylist"\r
         ></listviewItem>\r
       </v-list>\r
     </v-card>\r
@@ -100,7 +100,7 @@ export default Vue.extend({
       if (mediaItem.in_library.length === 0) {\r
         menuItems.push({\r
           label: 'add_library',\r
-          action: 'add_library',\r
+          action: 'toggle_library',\r
           icon: 'favorite_border'\r
         })\r
       }\r
@@ -108,7 +108,7 @@ export default Vue.extend({
       if (mediaItem.in_library.length > 0) {\r
         menuItems.push({\r
           label: 'remove_library',\r
-          action: 'remove_library',\r
+          action: 'toggle_library',\r
           icon: 'favorite'\r
         })\r
       }\r
@@ -169,9 +169,9 @@ export default Vue.extend({
       for (let item of this.curItem.provider_ids) {\r
         trackProviders.push(item.provider)\r
       }\r
-      let playlists = await this.$server.getData('playlists')\r
+      let playlists = await this.$server.getData('library/playlists')\r
       let items = []\r
-      for (var playlist of playlists.items) {\r
+      for (var playlist of playlists['items']) {\r
         if (\r
           playlist.is_editable &&\r
           (!this.curPlaylist || playlist.item_id !== this.curPlaylist.item_id)\r
@@ -189,8 +189,14 @@ export default Vue.extend({
     itemCommand (cmd) {\r
       if (cmd === 'info') {\r
         // show media info\r
+        let endpoint = ''\r
+        if (this.curItem.media_type === 1) endpoint = 'artists'\r
+        if (this.curItem.media_type === 2) endpoint = 'albums'\r
+        if (this.curItem.media_type === 3) endpoint = 'tracks'\r
+        if (this.curItem.media_type === 4) endpoint = 'playlists'\r
+        if (this.curItem.media_type === 5) endpoint = 'radios'\r
         this.$router.push({\r
-          path: '/' + this.curItem.media_type + '/' + this.curItem.item_id,\r
+          path: '/' + endpoint + '/' + this.curItem.item_id,\r
           query: { provider: this.curItem.provider }\r
         })\r
         this.visible = false\r
@@ -202,40 +208,37 @@ export default Vue.extend({
         return this.showPlaylistsMenu()\r
       } else if (cmd === 'remove_playlist') {\r
         // remove track from playlist\r
-        this.playlistAddRemove(\r
+        this.removeFromPlaylist(\r
           this.curItem,\r
           this.curPlaylist.item_id,\r
           'playlist_remove'\r
         )\r
         this.visible = false\r
+      } else if (cmd === 'toggle_library') {\r
+        // add/remove to/from library\r
+        this.$server.toggleLibrary(this.curItem)\r
+        this.visible = false\r
       } else {\r
         // assume play command\r
         this.$server.playItem(this.curItem, cmd)\r
         this.visible = false\r
       }\r
     },\r
-    playlistSelected (playlistobj) {\r
-      this.playlistAddRemove(\r
-        this.curItem,\r
-        playlistobj.item_id,\r
-        'playlist_add'\r
-      )\r
-      this.visible = false\r
+    addToPlaylist (playlistObj) {\r
+      /// add track to playlist\r
+      let endpoint = 'playlists/' + playlistObj.item_id + '/tracks'\r
+      this.$server.putData(endpoint, this.curItem)\r
+        .then(result => {\r
+          this.visible = false\r
+        })\r
     },\r
-    playlistAddRemove (track, playlistId, action = 'playlist_add') {\r
-      /// add or remove track on playlist\r
-      let endpoint = 'track/' + track.item_id\r
-      let params = {\r
-        provider: track.provider,\r
-        action: action,\r
-        action_details: playlistId\r
-      }\r
-      this.$server.getData(endpoint, params)\r
+    removeFromPlaylist (track, playlistId) {\r
+      /// remove track from playlist\r
+      let endpoint = 'playlists/' + playlistId + '/tracks'\r
+      this.$server.deleteData(endpoint, track)\r
         .then(result => {\r
           // reload listing\r
-          if (action === 'playlist_remove') {\r
-            this.$server.$emit('refresh_listing')\r
-          }\r
+          this.$server.$emit('refresh_listing')\r
         })\r
     }\r
   }\r
index eec96b11da101d29e30affcffadabf8fedf63933..03025b447f2bab8b8105d7826e63206a2ccf6d8d 100644 (file)
@@ -1,6 +1,7 @@
 <template>
   <div>
-    <v-list-item ripple @click="itemClicked(item)">
+    <v-list-item ripple @click="itemClicked(item)" v-long-press="500" @click.stop
+      @long-press-start="menuClick(item)" @long-press-start.prevent>
       <v-list-item-avatar tile color="grey" v-if="!hideavatar">
         <img
           :src="$server.getImageUrl(item, 'image', 80)"
@@ -103,7 +104,9 @@ export default Vue.extend({
     onclickHandler: null
   },
   data () {
-    return {}
+    return {
+      touchMoving: false
+    }
   },
   computed: {
     isHiRes () {
@@ -115,6 +118,7 @@ export default Vue.extend({
       return false
     }
   },
+  created () { },
   mounted () { },
   methods: {
     itemClicked (mediaItem) {
index 83c608f3c2fb077ee4d088f4f30904b21ee5678f..cd861575e96d4e02b0245946fc7bfbe0ed9d891d 100644 (file)
@@ -11,11 +11,26 @@ import vuetify from './plugins/vuetify'
 import store from './plugins/store'
 import server from './plugins/server'
 import '@babel/polyfill'
+import VueLogger from 'vuejs-logger'
+import LongPress from 'vue-directive-long-press'
+
+const isProduction = process.env.NODE_ENV === 'production'
+const loggerOptions = {
+  isEnabled: true,
+  logLevel: isProduction ? 'error' : 'debug',
+  stringifyArguments: false,
+  showLogLevel: true,
+  showMethodName: false,
+  separator: '|',
+  showConsoleColors: true
+}
 
 Vue.config.productionTip = false
+Vue.use(VueLogger, loggerOptions)
 Vue.use(VueVirtualScroller)
 Vue.use(store)
 Vue.use(server)
+Vue.directive('long-press', LongPress)
 
 // eslint-disable-next-line no-extend-native
 String.prototype.formatDuration = function () {
index e5d1dec30aa4fd0a0ea86cd19b33e38b57a275ed..5a5265a4574f1cffcc057b106d7e8cfb06cb1993 100644 (file)
@@ -27,13 +27,20 @@ const server = new Vue({
   },
   methods: {
 
-    connect (serverAddress) {
+    async connect (serverAddress) {
       // Connect to the server
       if (!serverAddress.endsWith('/')) {
         serverAddress = serverAddress + '/'
       }
       this._address = serverAddress
       let wsAddress = serverAddress.replace('http', 'ws') + 'ws'
+      // retrieve all players
+      let players = await this.getData('players')
+      for (let player of players) {
+        Vue.set(this.players, player.player_id, player)
+      }
+      this._selectActivePlayer()
+      this.$emit('players changed')
       this._ws = new WebSocket(wsAddress)
       this._ws.onopen = this._onWsConnect
       this._ws.onmessage = this._onWsMessage
@@ -43,22 +50,23 @@ const server = new Vue({
 
     async toggleLibrary (item) {
       /// triggered when user clicks the library (heart) button
-      let endpoint = item.media_type + '/' + item.item_id
-      let action = 'library_remove'
       if (item.in_library.length === 0) {
-        action = 'library_add'
-      }
-      await this.getData(endpoint, { provider: item.provider, action: action })
-      if (action === '/library_remove') {
-        item.in_library = []
-      } else {
+        // add to library
+        await this.putData('library', item)
         item.in_library = [item.provider]
+      } else {
+        // remove from library
+        await this.deleteData('library', item)
+        item.in_library = []
       }
     },
 
     getImageUrl (mediaItem, imageType = 'image', size = 0) {
       // format the image url
       if (!mediaItem || !mediaItem.media_type) return ''
+      if (mediaItem.media_type in ['playlists', 'radios'] && imageType !== 'image') {
+        return ''
+      }
       if (mediaItem.provider === 'database') {
         return `${this._address}api/${mediaItem.media_type}/${mediaItem.item_id}/image?type=${imageType}&provider=${mediaItem.provider}&size=${size}`
       } else if (mediaItem.metadata && mediaItem.metadata['image']) {
@@ -74,13 +82,34 @@ const server = new Vue({
       // get data from the server
       let url = this._address + 'api/' + endpoint
       let result = await _axios.get(url, { params: params })
+      Vue.$log.debug('getData', endpoint, result)
       return result.data
     },
 
     async postData (endpoint, data) {
       // post data to the server
       let url = this._address + 'api/' + endpoint
+      data = JSON.stringify(data)
       let result = await _axios.post(url, data)
+      Vue.$log.debug('postData', endpoint, result)
+      return result.data
+    },
+
+    async putData (endpoint, data) {
+      // put data to the server
+      let url = this._address + 'api/' + endpoint
+      data = JSON.stringify(data)
+      let result = await _axios.put(url, data)
+      Vue.$log.debug('putData', endpoint, result)
+      return result.data
+    },
+
+    async deleteData (endpoint, dataObj) {
+      // delete data on the server
+      let url = this._address + 'api/' + endpoint
+      dataObj = JSON.stringify(dataObj)
+      let result = await _axios.delete(url, { data: dataObj })
+      Vue.$log.debug('deleteData', endpoint, result)
       return result.data
     },
 
@@ -99,27 +128,21 @@ const server = new Vue({
         })
         .done(function (fullList) {
           // truncate list if needed
-          if (list.length === 0) {
-            list = []
-          } else if (list.length > index) {
-            list = list.slice(0, index)
+          if (list.length > fullList.items.length) {
+            list.splice(fullList.items.length)
           }
         })
     },
 
-    playerCommand (cmd, cmd_opt = null, playerId = this.activePlayerId) {
-      let msgDetails = {
-        player_id: playerId,
-        cmd: cmd,
-        cmd_args: cmd_opt
-      }
-      this._ws.send(JSON.stringify({ message: 'player command', message_details: msgDetails }))
+    playerCommand (cmd, cmd_opt = '', playerId = this.activePlayerId) {
+      let endpoint = 'players/' + playerId + '/cmd/' + cmd
+      this.postData(endpoint, cmd_opt)
     },
 
     async playItem (item, queueOpt) {
       this.$store.loading = true
-      let endpoint = 'players/' + this.activePlayerId + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueOpt
-      await this.getData(endpoint)
+      let endpoint = 'players/' + this.activePlayerId + '/play_media/' + queueOpt
+      await this.postData(endpoint, item)
       this.$store.loading = false
     },
 
@@ -131,7 +154,7 @@ const server = new Vue({
 
     _onWsConnect () {
       // Websockets connection established
-      // console.log('Connected to server ' + this._address)
+      Vue.$log.info('Connected to server ' + this._address)
       this.connected = true
       // request all players
       let data = JSON.stringify({ message: 'players', message_details: null })
@@ -151,12 +174,6 @@ const server = new Vue({
         Vue.delete(this.players, msg.message_details.player_id)
         this._selectActivePlayer()
         this.$emit('players changed')
-      } else if (msg.message === 'players') {
-        for (var item of msg.message_details) {
-          Vue.set(this.players, item.player_id, item)
-        }
-        this._selectActivePlayer()
-        this.$emit('players changed')
       } else if (msg.message === 'music sync status') {
         this.syncStatus = msg.message_details
       } else {
@@ -166,7 +183,7 @@ const server = new Vue({
 
     _onWsClose (e) {
       this.connected = false
-      // console.log('Socket is closed. Reconnect will be attempted in 5 seconds.', e.reason)
+      Vue.$log.error('Socket is closed. Reconnect will be attempted in 5 seconds.', e.reason)
       setTimeout(function () {
         this.connect(this._address)
       }.bind(this), 5000)
index 393f5f50bd7574c2878cdbcb072d341481193161..5a51797f6e1b0d7cf494ce6c4ba1ddbb7853c941 100644 (file)
@@ -45,11 +45,16 @@ export default {
   created () {
     this.$store.windowtitle = this.$t(this.mediatype)
     this.getItems()
+    this.$server.$on('refresh_listing', this.getItems)
+  },
+  beforeDestroy () {
+    this.$server.$off('refresh_listing')
   },
   methods: {
     async getItems () {
       // retrieve the full list of items
-      return this.$server.getAllItems(this.mediatype, this.items)
+      let endpoint = 'library/' + this.mediatype
+      return this.$server.getAllItems(endpoint, this.items)
     }
   }
 }
index dcb9d29321e1ce1556e70f1dd4a7b7f2d842d96c..b87d25a725b1516d36215817db8518cda0282562 100644 (file)
@@ -297,7 +297,7 @@ export default {
     },
     async confChanged (key, subkey, newvalue) {
       let endpoint = 'config/' + key + '/' + subkey
-      let result = await this.$server.postData(endpoint, newvalue)
+      let result = await this.$server.putData(endpoint, newvalue)
       if (result.restart_required) {
         this.restart_message = true
       }
index 7f8908f2fcbdf804c600860e60bc0cf501f95d97..640ae14dad3a2be45ba157fbff303cfa2872ce37 100755 (executable)
@@ -478,13 +478,13 @@ class Database():
     async def artist_tracks(self, artist_id, orderby='name') -> List[Track]:
         ''' get all library tracks for the given artist '''
         artist_id = try_parse_int(artist_id)
-        sql_query = 'SELECT * FROM tracks WHERE track_id in (SELECT track_id FROM track_artists WHERE artist_id = %d)' % artist_id
+        sql_query = 'SELECT * FROM tracks WHERE track_id in (SELECT track_id FROM track_artists WHERE artist_id = %s)' % artist_id
         async for item in self.tracks(sql_query, orderby=orderby, fulldata=False):
             yield item
 
     async def artist_albums(self, artist_id, orderby='name') -> List[Album]:
         ''' get all library albums for the given artist '''
-        sql_query = ' WHERE artist_id = %d' % artist_id
+        sql_query = ' WHERE artist_id = %s' % artist_id
         async for item in self.albums(sql_query, orderby=orderby, fulldata=False):
             yield item
    
index afc326bff79f178ac413c08b77c60f34270d1733..591cb5d0f28030c21aa02b4d2e714f3cad5f297a 100755 (executable)
@@ -375,8 +375,7 @@ class MusicProvider():
         search_results = await self.search(searchstr, [MediaType.Album],
                                            limit=5)
         for item in search_results["albums"]:
-            if (item and
-                (item.name in searchalbum.name
+            if (item and (item.name in searchalbum.name
                  or searchalbum.name in item.name) and compare_strings(
                      item.artist.name, searchalbum.artist.name, strict=False)):
                 # some providers mess up versions in the title, try to fix that situation
index 01769f249ae8ec84275ed4d1f6275050414cf587..cfc4fa9b32cfca5b27955044c0ab988f3543bd09 100755 (executable)
@@ -8,6 +8,7 @@ import random
 import uuid
 import os
 import pickle
+from enum import Enum
 
 from ..utils import LOGGER, json, filename_from_string
 from ..constants import CONF_ENABLED, EVENT_PLAYBACK_STARTED, EVENT_PLAYBACK_STOPPED, EVENT_QUEUE_UPDATED
@@ -15,6 +16,13 @@ from .media_types import Track, TrackQuality
 from .playerstate import PlayerState
 
 
+class QueueOption(str, Enum):
+    Play = "play"
+    Replace = "replace"
+    Next = "next"
+    Add = "add"
+
+
 class QueueItem(Track):
     ''' representation of a queue item, simplified version of track '''
     def __init__(self, media_item=None):
index cfb0c8d9c9151abf5df60f52305e29cf8d875dd0..59a02d0c2d0d77ac6749ae6a097d034e42281063 100755 (executable)
@@ -12,7 +12,7 @@ from PIL import Image
 import aiohttp
 
 from .utils import run_periodic, LOGGER, load_provider_modules
-from .models.media_types import MediaType, Track, Artist, Album, Playlist, Radio
+from .models.media_types import MediaItem, MediaType, Track, Artist, Album, Playlist, Radio
 from .constants import CONF_KEY_MUSICPROVIDERS, EVENT_MUSIC_SYNC_STATUS
 
 
@@ -31,8 +31,6 @@ def sync_task(desc):
                         prov_id)
                     return
             sync_job = (prov_id, desc)
-            if not method_class.running_sync_jobs:
-                LOGGER.info("Music provider sync started")
             method_class.running_sync_jobs.append(sync_job)
             await method_class.mass.signal_event(
                 EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs)
@@ -41,11 +39,7 @@ def sync_task(desc):
             method_class.running_sync_jobs.remove(sync_job)
             await method_class.mass.signal_event(
                 EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs)
-            if not method_class.running_sync_jobs:
-                LOGGER.info("Music provider sync completed")
-
         return wrapped
-
     return wrapper
 
 
@@ -84,39 +78,39 @@ class MusicManager():
         else:
             return None
 
-    async def library_artists(self, orderby='name', provider_filter=None) -> List[Artist]:
+    async def library_artists(self, orderby='name',
+                              provider_filter=None) -> List[Artist]:
         ''' return all library artists, optionally filtered by provider '''
-        async for item in self.mass.db.library_artists(provider=provider_filter, orderby=orderby):
+        async for item in self.mass.db.library_artists(
+                provider=provider_filter, orderby=orderby):
             yield item
 
-    async def library_albums(self,
-                             orderby='name',
+    async def library_albums(self, orderby='name',
                              provider_filter=None) -> List[Album]:
         ''' return all library albums, optionally filtered by provider '''
-        async for item in self.mass.db.library_albums(provider=provider_filter, orderby=orderby):
+        async for item in self.mass.db.library_albums(provider=provider_filter,
+                                                      orderby=orderby):
             yield item
 
-    async def library_tracks(self,
-                             orderby='name',
+    async def library_tracks(self, orderby='name',
                              provider_filter=None) -> List[Track]:
         ''' return all library tracks, optionally filtered by provider '''
-        async for item in self.mass.db.library_tracks(provider=provider_filter, orderby=orderby):
+        async for item in self.mass.db.library_tracks(provider=provider_filter,
+                                                      orderby=orderby):
             yield item
 
-    async def library_playlists(self,
-                        orderby='name',
-                        provider_filter=None) -> List[Playlist]:
+    async def library_playlists(self, orderby='name',
+                                provider_filter=None) -> List[Playlist]:
         ''' return all library playlists, optionally filtered by provider '''
-        async for item in self.mass.db.library_playlists(provider=provider_filter,
-                                            orderby=orderby):
+        async for item in self.mass.db.library_playlists(
+                provider=provider_filter, orderby=orderby):
             yield item
 
-    async def library_radios(self,
-                     orderby='name',
-                     provider_filter=None) -> List[Playlist]:
+    async def library_radios(self, orderby='name',
+                             provider_filter=None) -> List[Playlist]:
         ''' return all library radios, optionally filtered by provider '''
         async for item in self.mass.db.library_radios(provider=provider_filter,
-                                         orderby=orderby):
+                                                      orderby=orderby):
             yield item
 
     async def artist(self, item_id, provider='database', lazy=True) -> Artist:
@@ -204,12 +198,14 @@ class MusicManager():
         async for item in prov_obj.album_tracks(prov['item_id']):
             yield item
 
-    async def playlist_tracks(self, playlist_id, provider='database') -> List[Track]:
+    async def playlist_tracks(self, playlist_id,
+                              provider='database') -> List[Track]:
         ''' get the tracks for given playlist '''
         playlist = await self.playlist(playlist_id, provider)
         # return playlist tracks from provider
         prov = playlist.provider_ids[0]
-        async for item in self.providers[prov['provider']].playlist_tracks(prov['item_id']):
+        async for item in self.providers[prov['provider']].playlist_tracks(
+                prov['item_id']):
             yield item
 
     async def search(self,
@@ -236,109 +232,101 @@ class MusicManager():
                     toolz.unique(items, key=operator.attrgetter('item_id')))
         return result
 
-    async def item_action(self,
-                          item_id,
-                          media_type,
-                          provider,
-                          action,
-                          action_details=None):
-        ''' perform action on item (such as library add/remove) '''
-        result = None
-        item = await self.item(item_id, media_type, provider, lazy=False)
-        if not item:
-            return False
-        if 'library_' in action:
-            # remove or add item to the library
-            for prov_mapping in item.provider_ids:
-                prov_id = prov_mapping['provider']
-                prov_item_id = prov_mapping['item_id']
-                for prov in self.providers.values():
-                    if prov.prov_id == prov_id:
-                        if action == 'library_add':
-                            result = await prov.add_library(
-                                prov_item_id, media_type)
-                            await self.mass.db.add_to_library(
-                                item.item_id, item.media_type, prov_id)
-                        elif action == 'library_remove':
-                            result = await prov.remove_library(
-                                prov_item_id, media_type)
-                            await self.mass.db.remove_from_library(
-                                item.item_id, item.media_type, prov_id)
-        elif action == 'playlist_add':
-            result = await self.add_playlist_tracks(action_details, [item])
-        elif action == 'playlist_remove':
-            result = await self.remove_playlist_tracks(action_details, [item])
+    async def library_add(self, media_items: List[MediaItem]):
+        '''Add media item(s) to the library'''
+        result = False
+        for item in media_items:
+            # make sure we have a database item
+            media_item = await self.item(item.item_id, item.media_type, item.provider, lazy=False)
+            if not media_item:
+                continue
+            # add to provider's libraries
+            for prov in item.provider_ids:
+                prov_id = prov['provider']
+                prov_item_id = prov['item_id']
+                if prov_id in self.providers:
+                    result = await self.providers[prov_id].add_library(
+                        prov_item_id, media_item.media_type)
+                # mark as library item in internal db
+                await self.mass.db.add_to_library(
+                    media_item.item_id, media_item.media_type, prov_id)
         return result
 
-    async def add_playlist_tracks(self, playlist_id, tracks: List[Track]):
+    async def library_remove(self, media_items: List[MediaItem]):
+        '''Remove media item(s) from the library'''
+        result = False
+        for item in media_items:
+            # make sure we have a database item
+            media_item = await self.item(item.item_id, item.media_type, item.provider, lazy=False)
+            if not media_item:
+                continue
+            # remove from provider's libraries
+            for prov in item.provider_ids:
+                prov_id = prov['provider']
+                prov_item_id = prov['item_id']
+                if prov_id in self.providers:
+                    result = await self.providers[prov_id].remove_library(
+                        prov_item_id, media_item.media_type)
+                # mark as library item in internal db
+                await self.mass.db.remove_from_library(
+                    media_item.item_id, media_item.media_type, prov_id)
+        return result
+
+    async def add_playlist_tracks(self, db_playlist_id, tracks: List[Track]):
         ''' add tracks to playlist - make sure we dont add dupes '''
         # we can only edit playlists that are in the database (marked as editable)
-        playlist = await self.playlist(playlist_id, 'database')
+        playlist = await self.playlist(db_playlist_id, 'database')
         if not playlist or not playlist.is_editable:
-            LOGGER.warning(
-                "Playlist %s is not editable - skip addition of tracks" %
-                (playlist.name))
             return False
         # playlist can only have one provider (for now)
         playlist_prov = playlist.provider_ids[0]
-        cur_playlist_tracks = await self.mass.db.playlist_tracks(playlist_id,
-                                                                 limit=0)
-        # grab all (database) track ids in the playlist so we can check for duplicates
-        cur_playlist_track_ids = [item.item_id for item in cur_playlist_tracks]
+        # grab all existing track ids in the playlist so we can check for duplicates
+        cur_playlist_track_ids = []
+        async for item in self.providers[playlist_prov['provider']].playlist_tracks(
+                    playlist_prov['item_id']):
+            cur_playlist_track_ids.append(item.item_id)
+            cur_playlist_track_ids += [i['item_id'] for i in item.provider_ids]
         track_ids_to_add = []
-        for index, track in enumerate(tracks):
-            if not track.provider == 'database':
-                # make sure we have a database track
-                track = await self.track(track.item_id,
-                                         track.provider,
-                                         lazy=False)
-            if track.item_id in cur_playlist_track_ids:
-                LOGGER.warning(
-                    "Track %s already in playlist %s - skip addition" %
-                    (track.name, playlist.name))
+        for track in tracks:
+            # check for duplicates
+            already_exists = track.item_id in cur_playlist_track_ids
+            for track_prov in track.provider_ids:
+                if track_prov['item_id'] in cur_playlist_track_ids:
+                    already_exists = True
+            if already_exists:
                 continue
             # we can only add a track to a provider playlist if the track is available on that provider
-            # exception is the file provider which does accept tracks from all providers in the m3u playlist
             # this should all be handled in the frontend but these checks are here just to be safe
-            track_playlist_provs = [
-                item['provider'] for item in track.provider_ids
-            ]
-            if playlist_prov['provider'] in track_playlist_provs:
-                # a track can contain multiple versions on the same provider
-                # simply sort by quality and just add the first one (assuming the track is still available)
-                track_versions = sorted(track.provider_ids,
-                                        key=operator.itemgetter('quality'),
-                                        reverse=True)
-                for track_version in track_versions:
-                    if track_version['provider'] == playlist_prov['provider']:
-                        track_ids_to_add.append(track_version['item_id'])
-                        break
-            elif playlist_prov['provider'] == 'file':
-                # the file provider can handle uri's from all providers in the file so simply add the db id
-                track_ids_to_add.append(track.item_id)
-            else:
-                LOGGER.warning(
-                    "Track %s not available on provider %s - skip addition to playlist %s"
-                    % (track.name, playlist_prov['provider'], playlist.name))
-                continue
+            # a track can contain multiple versions on the same provider
+            # simply sort by quality and just add the first one (assuming the track is still available)
+            for track_version in sorted(track.provider_ids,
+                                    key=operator.itemgetter('quality'),
+                                    reverse=True):
+                if track_version['provider'] == playlist_prov['provider']:
+                    track_ids_to_add.append(track_version['item_id'])
+                    break
+                elif playlist_prov['provider'] == 'file':
+                    # the file provider can handle uri's from all providers in the file so simply add the uri
+                    uri = f'{track_version["provider"]}://{track_version["item_id"]}'
+                    track_ids_to_add.append(uri)
+                    break
         # actually add the tracks to the playlist on the provider
         if track_ids_to_add:
             # invalidate cache
-            await self.mass.db.update_playlist(playlist.item_id, 'checksum', str(time.time()))
+            await self.mass.db.update_playlist(playlist.item_id, 'checksum',
+                                               str(time.time()))
+            # return result of the action on the provioer
             return await self.providers[playlist_prov['provider']
                                         ].add_playlist_tracks(
                                             playlist_prov['item_id'],
                                             track_ids_to_add)
-            
+        return False
 
-    async def remove_playlist_tracks(self, playlist_id, tracks: List[Track]):
+    async def remove_playlist_tracks(self, db_playlist_id, tracks: List[Track]):
         ''' remove tracks from playlist '''
         # we can only edit playlists that are in the database (marked as editable)
-        playlist = await self.playlist(playlist_id, 'database')
+        playlist = await self.playlist(db_playlist_id, 'database')
         if not playlist or not playlist.is_editable:
-            LOGGER.warning(
-                "Playlist %s is not editable - skip removal of tracks" %
-                (playlist.name))
             return False
         # playlist can only have one provider (for now)
         prov_playlist = playlist.provider_ids[0]
@@ -346,11 +334,6 @@ class MusicManager():
         prov_playlist_provider_id = prov_playlist['provider']
         track_ids_to_remove = []
         for track in tracks:
-            if not track.provider == 'database':
-                # make sure we have a database track
-                track = await self.track(track.item_id,
-                                         track.provider,
-                                         lazy=False)
             # a track can contain multiple versions on the same provider, remove all
             for track_provider in track.provider_ids:
                 if track_provider['provider'] == prov_playlist_provider_id:
@@ -358,7 +341,8 @@ class MusicManager():
         # actually remove the tracks from the playlist on the provider
         if track_ids_to_remove:
             # invalidate cache
-            await self.mass.db.update_playlist(playlist.item_id, 'checksum', str(time.time()))
+            await self.mass.db.update_playlist(playlist.item_id, 'checksum',
+                                               str(time.time()))
             return await self.providers[prov_playlist_provider_id
                                         ].remove_playlist_tracks(
                                             prov_playlist_playlist_id,
@@ -385,8 +369,10 @@ class MusicManager():
     async def sync_library_artists(self, prov_id):
         ''' sync library artists for given provider'''
         music_provider = self.providers[prov_id]
-        prev_items = [item async for item in self.library_artists(provider_filter=prov_id)]
-        prev_db_ids = [item.item_id for item in prev_items]
+        prev_db_ids = [
+            item.item_id
+            async for item in self.library_artists(provider_filter=prov_id)
+        ]
         cur_db_ids = []
         async for item in music_provider.get_library_artists():
             db_item = await music_provider.artist(item.item_id, lazy=False)
@@ -404,8 +390,10 @@ class MusicManager():
     async def sync_library_albums(self, prov_id):
         ''' sync library albums for given provider'''
         music_provider = self.providers[prov_id]
-        prev_items = [item async for item in self.library_albums(provider_filter=prov_id)]
-        prev_db_ids = [item.item_id for item in prev_items]
+        prev_db_ids = [
+            item.item_id
+            async for item in self.library_albums(provider_filter=prov_id)
+        ]
         cur_db_ids = []
         async for item in music_provider.get_library_albums():
             db_album = await music_provider.album(item.item_id, lazy=False)
@@ -425,8 +413,10 @@ class MusicManager():
     async def sync_library_tracks(self, prov_id):
         ''' sync library tracks for given provider'''
         music_provider = self.providers[prov_id]
-        prev_items = [item async for item in self.library_tracks(provider_filter=prov_id)]
-        prev_db_ids = [item.item_id for item in prev_items]
+        prev_db_ids = [
+            item.item_id
+            async for item in self.library_tracks(provider_filter=prov_id)
+        ]
         cur_db_ids = []
         async for item in music_provider.get_library_tracks():
             db_item = await music_provider.track(item.item_id, lazy=False)
@@ -444,8 +434,10 @@ class MusicManager():
     async def sync_library_playlists(self, prov_id):
         ''' sync library playlists for given provider'''
         music_provider = self.providers[prov_id]
-        prev_items = [item async for item in self.library_playlists(provider_filter=prov_id)]
-        prev_db_ids = [item.item_id for item in prev_items]
+        prev_db_ids = [
+            item.item_id
+            async for item in self.library_playlists(provider_filter=prov_id)
+        ]
         cur_db_ids = []
         async for item in music_provider.get_library_playlists():
             # always add to db because playlist attributes could have changed
@@ -454,6 +446,8 @@ class MusicManager():
             if not db_id in prev_db_ids:
                 await self.mass.db.add_to_library(db_id, MediaType.Playlist,
                                                   prov_id)
+            # precache playlist tracks
+            [item async for item in music_provider.playlist_tracks(item.item_id)]
         # process playlist deletions
         for db_id in prev_db_ids:
             if db_id not in cur_db_ids:
@@ -465,8 +459,10 @@ class MusicManager():
     async def sync_library_radios(self, prov_id):
         ''' sync library radios for given provider'''
         music_provider = self.providers[prov_id]
-        prev_items = [item async for item in self.library_radios(provider_filter=prov_id)]
-        prev_db_ids = [item.item_id for item in prev_items]
+        prev_db_ids = [
+            item.item_id
+            async for item in self.library_radios(provider_filter=prov_id)
+        ]
         cur_db_ids = []
         async for item in music_provider.get_radios():
             db_id = await self.mass.db.get_database_id(prov_id, item.item_id,
index 5fccd1789e9e031d798a913688fe8c1b9c1e33c5..6acc2fd813abf1ed7b1af5f7ad3c6c7d0df9fc28 100644 (file)
@@ -117,7 +117,7 @@ class QobuzProvider(MusicProvider):
 
     async def get_library_playlists(self) -> List[Playlist]:
         ''' retrieve all library playlists from the provider '''
-        endpoint = 'favorite/getUserPlaylists'
+        endpoint = 'playlist/getUserPlaylists'
         async for item in self.__get_all_items(endpoint, key='playlists'):
             playlist = await self.__parse_playlist(item)
             if playlist:
@@ -594,17 +594,11 @@ class QobuzProvider(MusicProvider):
                                              headers=headers,
                                              params=params,
                                              verify_ssl=False) as response:
-                try:
-                    result = await response.json()
-                    if "error" in result:
-                        return None
-                    return result
-                except Exception as exc:
-                    LOGGER.error(exc)
-                    LOGGER.debug(url)
-                    LOGGER.debug(params)
-                    result = await response.text()
-                    LOGGER.error(result)
+                result = await response.json()
+                if 'error' in result or ('status' in result and 'error' in result['status']):
+                    LOGGER.error('%s - %s', endpoint, result)
+                    return None
+                return result
 
     async def __post_data(self, endpoint, params=None, data=None):
         ''' post data to api'''
@@ -619,14 +613,8 @@ class QobuzProvider(MusicProvider):
                                           params=params,
                                           json=data,
                                           verify_ssl=False) as response:
-            try:
-                result = await response.json()
-                if "error" in result:
-                    return None
-                return result
-            except Exception as exc:
-                LOGGER.error(exc)
-                LOGGER.debug(url)
-                LOGGER.debug(params)
-                result = await response.text()
-                LOGGER.error(result)
+            result = await response.json()
+            if 'error' in result or ('status' in result and 'error' in result['status']):
+                LOGGER.error('%s - %s', endpoint, result)
+                return None
+            return result
index 1aee3b1c267dbbb1a9612f716c1ad0646d414531..850b442056fc63492d978955697f672abe3ab837 100644 (file)
@@ -477,8 +477,7 @@ class SpotifyProvider(MusicProvider):
                                              verify_ssl=False) as response:
                 result = await response.json()
                 if not result or 'error' in result:
-                    LOGGER.error(url)
-                    LOGGER.error(params)
+                    LOGGER.error('%s - %s', endpoint, result)
                     result = None
                 return result
 
index a32eca8b6d45830f464a3269564df04fb7d346e5..adcbf7b5cc52fd858cfb7945eaa11b48c55d91c0 100755 (executable)
@@ -4,6 +4,7 @@
 import asyncio
 import os
 from enum import Enum
+from typing import List
 import operator
 import random
 import functools
@@ -11,10 +12,11 @@ import urllib
 
 from .constants import CONF_KEY_PLAYERPROVIDERS, EVENT_PLAYER_ADDED, EVENT_PLAYER_REMOVED, EVENT_HASS_ENTITY_CHANGED
 from .utils import run_periodic, LOGGER, try_parse_int, try_parse_float, \
-    get_ip, run_async_background_task, load_provider_modules
-from .models.media_types import MediaType, TrackQuality
-from .models.player_queue import QueueItem
+    get_ip, run_async_background_task, load_provider_modules, iter_items
+from .models.media_types import MediaItem, MediaType, TrackQuality
+from .models.player_queue import QueueItem, QueueOption
 from .models.playerstate import PlayerState
+from .models.player import Player
 
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" )
@@ -42,15 +44,15 @@ class PlayerManager():
         ''' return list of all players '''
         return self._players.values()
 
-    async def get_player(self, player_id):
+    async def get_player(self, player_id:str):
         ''' return player by id '''
         return self._players.get(player_id, None)
 
-    def get_player_sync(self, player_id):
+    def get_player_sync(self, player_id:str):
         ''' return player by id (non async) '''
         return self._players.get(player_id, None)
 
-    async def add_player(self, player):
+    async def add_player(self, player:Player):
         ''' register a new player '''
         player._initialized = True
         self._players[player.player_id] = player
@@ -59,48 +61,50 @@ class PlayerManager():
         LOGGER.info(f"New player added: {player.player_provider}/{player.player_id}")
         return player
 
-    async def remove_player(self, player_id):
+    async def remove_player(self, player_id:str):
         ''' handle a player remove '''
         self._players.pop(player_id, None)
         await self.mass.signal_event(EVENT_PLAYER_REMOVED, {"player_id": player_id})
         LOGGER.info(f"Player removed: {player_id}")
 
-    async def trigger_update(self, player_id):
+    async def trigger_update(self, player_id:str):
         ''' manually trigger update for a player '''
         if player_id in self._players:
             await self._players[player_id].update(force=True)
     
-    async def play_media(self, player_id, media_item, queue_opt='play'):
+    async def play_media(self, 
+                        player_id:str, 
+                        media_items:List[MediaItem], 
+                        queue_opt:QueueOption='play'):
         ''' 
-            play media item(s) on the given player 
+            play media item(s) on the given player
             :param media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio)
                         single item or list of items
             :param queue_opt: 
-                play -> insert new items in queue and start playing at the inserted position
-                replace -> replace queue contents with these items
-                next -> play item(s) after current playing item
-                add -> append new items at end of the queue
+                QueueOption.Play -> insert new items in queue and start playing at the inserted position
+                QueueOption.Replace -> replace queue contents with these items
+                QueueOption.Next -> play item(s) after current playing item
+                QueueOption.Add -> append new items at end of the queue
         '''
         player = await self.get_player(player_id)
         if not player:
             return
         # a single item or list of items may be provided
-        media_items = media_item if isinstance(media_item, list) else [media_item]
         queue_items = []
         for media_item in media_items:
             # collect tracks to play
             if media_item.media_type == MediaType.Artist:
-                tracks = await self.mass.music.artist_toptracks(media_item.item_id, 
+                tracks = self.mass.music.artist_toptracks(media_item.item_id, 
                         provider=media_item.provider)
             elif media_item.media_type == MediaType.Album:
-                tracks = await self.mass.music.album_tracks(media_item.item_id, 
+                tracks = self.mass.music.album_tracks(media_item.item_id, 
                         provider=media_item.provider)
             elif media_item.media_type == MediaType.Playlist:
-                tracks = await self.mass.music.playlist_tracks(media_item.item_id, 
+                tracks = self.mass.music.playlist_tracks(media_item.item_id, 
                         provider=media_item.provider) 
             else:
-                tracks = [media_item] # single track
-            for track in tracks:
+                tracks = iter_items(media_item) # single track
+            async for track in tracks:
                 queue_item = QueueItem(track)
                 # generate uri for this queue item
                 queue_item.uri = 'http://%s:%s/stream/%s/%s'% (
@@ -108,13 +112,16 @@ class PlayerManager():
                 queue_items.append(queue_item)
                     
         # load items into the queue
-        if queue_opt == 'replace' or (queue_opt in ['next', 'play'] and len(queue_items) > 10):
+        if (queue_opt == QueueOption.Replace or
+                (len(queue_items) > 10 and
+                queue_opt == QueueOption.Play or
+                queue_opt == QueueOption.Next)):
             return await player.queue.load(queue_items)
-        elif queue_opt == 'next':
+        elif queue_opt == QueueOption.Next:
             return await player.queue.insert(queue_items, 1)
-        elif queue_opt == 'play':
+        elif queue_opt == QueueOption.Play:
             return await player.queue.insert(queue_items, 0)
-        elif queue_opt == 'add':
+        elif queue_opt == QueueOption.Add:
             return await player.queue.append(queue_items)
     
     async def handle_mass_events(self, msg, msg_details=None):
index d1dd8361688588b2b385686372d4df2cc68a3358..43be7c080c14fa273c91660c1bcd293e249da446 100755 (executable)
@@ -64,6 +64,14 @@ def try_parse_int(possible_int):
     except:
         return 0
 
+async def iter_items(items):
+    '''fake async iterator for compatability reasons.'''
+    if not isinstance(items, list):
+        yield items
+    else:
+        for item in items:
+            yield items
+
 def try_parse_float(possible_float):
     try:
         return float(possible_float)
index abe7cbc8b04b890ea9f167dd2688f8f1dc4aecf5..8c338dc7843416fde781de9fd7607c03194817ef 100755 (executable)
@@ -4,6 +4,7 @@
 import asyncio
 import os
 import aiohttp
+import inspect
 import aiohttp_cors
 from aiohttp import web
 from functools import partial
@@ -27,8 +28,31 @@ else:
             ('cert_fqdn_host', '', 'cert_fqdn_host')
             ]
 
+class ClassRouteTableDef(web.RouteTableDef):
+    def __repr__(self) -> str:
+        return "<ClassRouteTableDef count={}>".format(len(self._items))
+
+    def route(self,
+              method: str,
+              path: str,
+              **kwargs):
+        def inner(handler):
+            handler.route_info = (method, path, kwargs)
+            return handler
+        return inner
+
+    def add_class_routes(self, instance) -> None:
+        def predicate(member) -> bool:
+            return all((inspect.iscoroutinefunction(member),
+                        hasattr(member, "route_info")))
+        for _, handler in inspect.getmembers(instance, predicate):
+            method, path, kwargs = handler.route_info
+            super().route(method, path, **kwargs)(handler)
+routes = ClassRouteTableDef()
+
 class Web():
     """ webserver and json/websocket api """
+    runner = None
     
     def __init__(self, mass):
         self.mass = mass
@@ -52,44 +76,26 @@ class Web():
             if config['ssl_certificate'] and not os.path.isfile(
                     config['ssl_certificate']):
                 enable_ssl = False
-                LOGGER.warning("SSL certificate file not found: %s" % config['ssl_certificate'])
+                LOGGER.warning("SSL certificate file not found: %s", config['ssl_certificate'])
             if config['ssl_key'] and not os.path.isfile(config['ssl_key']):
                 enable_ssl = False
-                LOGGER.warning( "SSL certificate key file not found: %s" % config['ssl_key'])
+                LOGGER.warning( "SSL certificate key file not found: %s", config['ssl_key'])
             self.https_port = config['https_port']
             self._enable_ssl = enable_ssl
 
     async def setup(self):
         """ perform async setup """
+        routes.add_class_routes(self)
         app = web.Application()
-        app.add_routes([web.get('/jsonrpc.js', self.json_rpc)])
-        app.add_routes([web.post('/jsonrpc.js', self.json_rpc)])
-        app.add_routes([web.get('/ws', self.websocket_handler)])
-        app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.stream, allow_head=False)])
-        app.add_routes([web.get('/stream/{player_id}/{queue_item_id}', self.mass.http_streamer.stream, allow_head=False)])
-        app.add_routes([web.get('/api/search', self.search)])
-        app.add_routes([web.post('/api/config/{key}/{subkey}', self.save_config)])
-        app.add_routes([web.get('/api/config', self.get_config)])
-        app.add_routes([web.get('/api/players', self.players)])
-        app.add_routes([web.get('/api/players/{player_id}', self.player)])
-        app.add_routes([web.get('/api/players/{player_id}/queue', self.player_queue)])
-        app.add_routes([web.get('/api/players/{player_id}/queue/{item_id}', self.player_queue_item)])
-        app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}', self.player_command)])
-        app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}/{cmd_args}', self.player_command)])
-        app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}', self.play_media)])
-        app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}/{queue_opt}', self.play_media)])
-        app.add_routes([web.get('/api/playlists/{playlist_id}/tracks', self.playlist_tracks)])
-        app.add_routes([web.get('/api/artists/{artist_id}/toptracks', self.artist_toptracks)])
-        app.add_routes([web.get('/api/artists/{artist_id}/albums', self.artist_albums)])
-        app.add_routes([web.get('/api/albums/{album_id}/tracks', self.album_tracks)])
-        app.add_routes([web.get('/api/{media_type}/{media_id}/image', self.get_image)])
-        app.add_routes([web.get('/api/{media_type}/{media_id}', self.get_item)])
-        app.add_routes([web.get('/api/artists', self.library_artists)])
-        app.add_routes([web.get('/api/albums', self.library_albums)])
-        app.add_routes([web.get('/api/tracks', self.library_tracks)])
-        app.add_routes([web.get('/api/radios', self.library_radios)])
-        app.add_routes([web.get('/api/playlists', self.library_playlists)])
-        app.add_routes([web.get('/', self.index)])
+        app.add_routes(routes)
+        app.add_routes([
+            web.get('/stream/{player_id}', self.mass.http_streamer.stream, allow_head=False),
+            web.get('/stream/{player_id}/{queue_item_id}', self.mass.http_streamer.stream, allow_head=False),
+            web.get('/', self.index),
+            web.get('/jsonrpc.js', self.json_rpc),
+            web.post('/jsonrpc.js', self.json_rpc),
+            web.get('/ws', self.websocket_handler)
+        ])
         webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web/')
         app.router.add_static("/", webdir)
         
@@ -99,7 +105,8 @@ class Web():
                 "*": aiohttp_cors.ResourceOptions(
                     allow_credentials=True,
                     expose_headers="*",
-                    allow_headers="*")
+                    allow_headers="*",
+                    allow_methods=["POST", "PUT", "DELETE"])
             })
         for route in list(app.router.routes()):
             cors.add(route)
@@ -107,14 +114,20 @@ class Web():
         await self.runner.setup()
         http_site = web.TCPSite(self.runner, '0.0.0.0', self.http_port)
         await http_site.start()
-        LOGGER.info("Started HTTP webserver on port %s" % self.http_port)
+        LOGGER.info("Started HTTP webserver on port %s", self.http_port)
         if self._enable_ssl:
             ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
             ssl_context.load_cert_chain(self.config['ssl_certificate'], self.config['ssl_key'])
             https_site = web.TCPSite(self.runner, '0.0.0.0', self.config['https_port'], ssl_context=ssl_context)
             await https_site.start()
-            LOGGER.info("Started HTTPS webserver on port %s" % self.config['https_port'])
+            LOGGER.info("Started HTTPS webserver on port %s", self.config['https_port'])
+
+    async def index(self, request):
+        index_file = os.path.join(
+                os.path.dirname(os.path.abspath(__file__)), 'web/index.html')
+        return web.FileResponse(index_file)
 
+    @routes.get('/api/library/artists')
     async def library_artists(self, request):
         """Get all library artists."""
         orderby = request.query.get('orderby', 'name')
@@ -122,6 +135,7 @@ class Web():
         iterator = self.mass.music.library_artists(orderby=orderby, provider_filter=provider_filter)
         return await self.__stream_json(request, iterator)
 
+    @routes.get('/api/library/albums')
     async def library_albums(self, request):
         """Get all library albums."""
         orderby = request.query.get('orderby', 'name')
@@ -129,6 +143,7 @@ class Web():
         iterator = self.mass.music.library_albums(orderby=orderby, provider_filter=provider_filter)
         return await self.__stream_json(request, iterator)
 
+    @routes.get('/api/library/tracks')
     async def library_tracks(self, request):
         """Get all library tracks."""
         orderby = request.query.get('orderby', 'name')
@@ -136,6 +151,7 @@ class Web():
         iterator = self.mass.music.library_tracks(orderby=orderby, provider_filter=provider_filter)
         return await self.__stream_json(request, iterator)
 
+    @routes.get('/api/library/radios')
     async def library_radios(self, request):
         """Get all library radios."""
         orderby = request.query.get('orderby', 'name')
@@ -143,6 +159,7 @@ class Web():
         iterator = self.mass.music.library_radios(orderby=orderby, provider_filter=provider_filter)
         return await self.__stream_json(request, iterator)
 
+    @routes.get('/api/library/playlists')
     async def library_playlists(self, request):
         """Get all library playlists."""
         orderby = request.query.get('orderby', 'name')
@@ -150,22 +167,73 @@ class Web():
         iterator = self.mass.music.library_playlists(orderby=orderby, provider_filter=provider_filter)
         return await self.__stream_json(request, iterator)
 
-    async def get_item(self, request):
-        """ get item full details"""
-        media_type_str = request.match_info.get('media_type')
-        media_type = media_type_from_string(media_type_str)
-        media_id = request.match_info.get('media_id')
-        # optional params
-        action = request.rel_url.query.get('action','')
-        action_details = request.rel_url.query.get('action_details')
-        lazy = request.rel_url.query.get('lazy', '') != 'false'
+    @routes.put('/api/library')
+    async def library_add(self, request):
+        """Add item(s) to the library"""
+        body = await request.json()
+        media_items = await self.__media_items_from_body(body)
+        result = await self.mass.music.library_add(media_items)
+        return web.json_response(result, dumps=json_serializer)
+
+    @routes.delete('/api/library')
+    async def library_remove(self, request):
+        """R remove item(s) from the library"""
+        body = await request.json()
+        media_items = await self.__media_items_from_body(body)
+        result = await self.mass.music.library_remove(media_items)
+        return web.json_response(result, dumps=json_serializer)
+
+    @routes.get('/api/artists/{item_id}')
+    async def artist(self, request):
+        """ get full artist details"""
+        item_id = request.match_info.get('item_id')
         provider = request.rel_url.query.get('provider')
-        if action:
-            result = await self.mass.music.item_action(media_id, media_type, provider, action, action_details)
-        else:
-            result = await self.mass.music.item(media_id, media_type, provider, lazy=lazy)
+        if (item_id is None or provider is None):
+            return web.Response(text='invalid item or provider', status=501)
+        result = await self.mass.music.artist(item_id, provider, lazy=False)
+        return web.json_response(result, dumps=json_serializer)
+    
+    @routes.get('/api/albums/{item_id}')
+    async def album(self, request):
+        """ get full album details"""
+        item_id = request.match_info.get('item_id')
+        provider = request.rel_url.query.get('provider')
+        if (item_id is None or provider is None):
+            return web.Response(text='invalid item or provider', status=501)
+        result = await self.mass.music.album(item_id, provider, lazy=False)
         return web.json_response(result, dumps=json_serializer)
 
+    @routes.get('/api/tracks/{item_id}')
+    async def track(self, request):
+        """ get full track details"""
+        item_id = request.match_info.get('item_id')
+        provider = request.rel_url.query.get('provider')
+        if (item_id is None or provider is None):
+            return web.Response(text='invalid item or provider', status=501)
+        result = await self.mass.music.track(item_id, provider, lazy=False)
+        return web.json_response(result, dumps=json_serializer)
+
+    @routes.get('/api/playlists/{item_id}')
+    async def playlist(self, request):
+        """ get full playlist details"""
+        item_id = request.match_info.get('item_id')
+        provider = request.rel_url.query.get('provider')
+        if (item_id is None or provider is None):
+            return web.Response(text='invalid item or provider', status=501)
+        result = await self.mass.music.playlist(item_id, provider)
+        return web.json_response(result, dumps=json_serializer)
+
+    @routes.get('/api/radios/{item_id}')
+    async def radio(self, request):
+        """ get full radio details"""
+        item_id = request.match_info.get('item_id')
+        provider = request.rel_url.query.get('provider')
+        if (item_id is None or provider is None):
+            return web.Response(text='invalid item or provider', status=501)
+        result = await self.mass.music.radio(item_id, provider)
+        return web.json_response(result, dumps=json_serializer)
+
+    @routes.get('/api/{media_type}/{media_id}/image')
     async def get_image(self, request):
         """ get item image """
         media_type_str = request.match_info.get('media_type')
@@ -175,12 +243,14 @@ class Web():
         provider = request.rel_url.query.get('provider', 'database')
         size = int(request.rel_url.query.get('size', 0))
         type_key = request.rel_url.query.get('type', 'image')
-        img_file = await self.mass.music.get_image_path(media_id, media_type, provider, size, type_key)
+        img_file = await self.mass.music.get_image_path(
+                    media_id, media_type, provider, size, type_key)
         if not img_file or not os.path.isfile(img_file):
             return web.Response(status=404)
         headers = {'Cache-Control': 'max-age=86400, public', 'Pragma': 'public'}
         return web.FileResponse(img_file, headers=headers)
 
+    @routes.get('/api/artists/{artist_id}/toptracks')
     async def artist_toptracks(self, request):
         """ get top tracks for given artist """
         artist_id = request.match_info.get('artist_id')
@@ -188,6 +258,7 @@ class Web():
         iterator = self.mass.music.artist_toptracks(artist_id, provider)
         return await self.__stream_json(request, iterator)
 
+    @routes.get('/api/artists/{artist_id}/albums')
     async def artist_albums(self, request):
         """ get (all) albums for given artist """
         artist_id = request.match_info.get('artist_id')
@@ -195,6 +266,7 @@ class Web():
         iterator = self.mass.music.artist_albums(artist_id, provider)
         return await self.__stream_json(request, iterator)
 
+    @routes.get('/api/playlists/{playlist_id}/tracks')
     async def playlist_tracks(self, request):
         """ get playlist tracks from provider"""
         playlist_id = request.match_info.get('playlist_id')
@@ -202,18 +274,38 @@ class Web():
         iterator = self.mass.music.playlist_tracks(playlist_id, provider)
         return await self.__stream_json(request, iterator)
 
+    @routes.put('/api/playlists/{playlist_id}/tracks')
+    async def add_playlist_tracks(self, request):
+        """Add tracks to (editable) playlist."""
+        playlist_id = request.match_info.get('playlist_id')
+        body = await request.json()
+        tracks = await self.__media_items_from_body(body)
+        result = await self.mass.music.add_playlist_tracks(playlist_id, tracks)
+        return web.json_response(result, dumps=json_serializer)
+
+    @routes.delete('/api/playlists/{playlist_id}/tracks')
+    async def remove_playlist_tracks(self, request):
+        """Remove tracks from (editable) playlist."""
+        playlist_id = request.match_info.get('playlist_id')
+        body = await request.json()
+        tracks = await self.__media_items_from_body(body)
+        result = await self.mass.music.remove_playlist_tracks(playlist_id, tracks)
+        return web.json_response(result, dumps=json_serializer)
+
+    @routes.get('/api/albums/{album_id}/tracks')
     async def album_tracks(self, request):
         """ get album tracks from provider"""
         album_id = request.match_info.get('album_id')
-        provider = request.rel_url.query.get('provider','database')
+        provider = request.rel_url.query.get('provider', 'database')
         iterator = self.mass.music.album_tracks(album_id, provider)
         return await self.__stream_json(request, iterator)
 
+    @routes.get('/api/search')
     async def search(self, request):
         """ search database or providers """
         searchquery = request.rel_url.query.get('query')
         media_types_query = request.rel_url.query.get('media_types')
-        limit = request.rel_url.query.get('media_id', 5)
+        limit = request.rel_url.query.get('limit', 5)
         online = request.rel_url.query.get('online', False)
         media_types = []
         if not media_types_query or "artists" in media_types_query:
@@ -230,49 +322,59 @@ class Web():
         result = await self.mass.music.search(searchquery, media_types, limit=limit, online=online)
         return web.json_response(result, dumps=json_serializer)
 
+    @routes.get('/api/players')
     async def players(self, request):
         """ get all players """
         players = list(self.mass.players.players)
         players.sort(key=lambda x: x.name, reverse=False)
         return web.json_response(players, dumps=json_serializer)
 
-    async def player(self, request):
-        """ get single player """
-        player_id = request.match_info.get('player_id')
-        player = await self.mass.players.get_player(player_id)
-        return web.json_response(player, dumps=json_serializer)
-
+    @routes.post('/api/players/{player_id}/cmd/{cmd}')
     async def player_command(self, request):
         """ issue player command"""
         result = False
         player_id = request.match_info.get('player_id')
         player = await self.mass.players.get_player(player_id)
-        if player:
-            cmd = request.match_info.get('cmd')
-            cmd_args = request.match_info.get('cmd_args')
-            player_cmd = getattr(player, cmd, None)
-            if player_cmd and cmd_args != None:
-                result = await player_cmd(cmd_args)
-            elif player_cmd:
-                result = await player_cmd()
-            else:
-                LOGGER.error("Received non-existing command %s for player %s" %(cmd, player.name))
+        if not player:
+            return web.Response(text='invalid player', status=404)
+        cmd = request.match_info.get('cmd')
+        cmd_args = await request.json()
+        player_cmd = getattr(player, cmd, None)
+        if player_cmd and cmd_args is not None:
+            result = await player_cmd(cmd_args)
+        elif player_cmd:
+            result = await player_cmd()
         else:
-            LOGGER.error("Received command for non-existing player %s" %(player_id))
+            return web.Response(text='invalid command', status=501)
         return web.json_response(result, dumps=json_serializer) 
     
-    async def play_media(self, request):
+    @routes.post('/api/players/{player_id}/play_media/{queue_opt}')
+    async def player_play_media(self, request):
         """ issue player play_media command"""
         player_id = request.match_info.get('player_id')
-        media_type_str = request.match_info.get('media_type')
-        media_type = media_type_from_string(media_type_str)
-        media_id = request.match_info.get('media_id')
-        queue_opt = request.match_info.get('queue_opt','')
-        provider = request.rel_url.query.get('provider')
-        media_item = await self.mass.music.item(media_id, media_type, provider, lazy=True)
-        result = await self.mass.players.play_media(player_id, media_item, queue_opt)
-        return web.json_response(result, dumps=json_serializer) 
+        player = await self.mass.players.get_player(player_id)
+        if not player:
+            return web.Response(status=404)
+        queue_opt = request.match_info.get('queue_opt', 'play')
+        body = await request.json()
+        media_items = await self.__media_items_from_body(body)
+        result = await self.mass.players.play_media(player_id, media_items, queue_opt)
+        return web.json_response(result, dumps=json_serializer)
+    
+    @routes.get('/api/players/{player_id}/queue/{queue_item}')
+    async def player_queue_item(self, request):
+        """ return item (by index or queue item id) from the player's queue """
+        player_id = request.match_info.get('player_id')
+        item_id = request.match_info.get('queue_item')
+        player = await self.mass.players.get_player(player_id)
+        try:
+            item_id = int(item_id)
+            queue_item = await player.queue.get_item(item_id)
+        except ValueError:
+            queue_item = await player.queue.by_item_id(item_id)
+        return web.json_response(queue_item, dumps=json_serializer)
     
+    @routes.get('/api/players/{player_id}/queue')
     async def player_queue(self, request):
         """ return the items in the player's queue """
         player_id = request.match_info.get('player_id')
@@ -282,22 +384,42 @@ class Web():
                 yield item
         return await self.__stream_json(request, queue_tracks_iter())
 
-    async def player_queue_item(self, request):
-        """ return item (by index or queue item id) from the player's queue """
+    @routes.get('/api/players/{player_id}')
+    async def player(self, request):
+        """ get single player """
         player_id = request.match_info.get('player_id')
-        item_id = request.match_info.get('item_id')
         player = await self.mass.players.get_player(player_id)
-        try:
-            item_id = int(item_id)
-            queue_item = await player.queue.get_item(item_id)
-        except:
-            queue_item = await player.queue.by_item_id(item_id)
-        return web.json_response(queue_item, dumps=json_serializer)
-    
-    async def index(self, request):
-        index_file = os.path.join(
-                os.path.dirname(os.path.abspath(__file__)), 'web/index.html')
-        return web.FileResponse(index_file)
+        if not player:
+            return web.Response(text='invalid player', status=404)
+        return web.json_response(player, dumps=json_serializer)
+
+    @routes.get('/api/config')
+    async def get_config(self, request):
+        """ get the config """
+        return web.json_response(self.mass.config)
+
+    @routes.put('/api/config/{key}/{subkey}')
+    async def put_config(self, request):
+        """ save (partial) config """
+        conf_key = request.match_info.get('key')
+        conf_subkey = request.match_info.get('subkey')
+        new_values = await request.json()
+        LOGGER.debug(f'save config called for {conf_key}/{conf_subkey} - new value: {new_values}')
+        cur_values = self.mass.config[conf_key][conf_subkey]
+        result = {"success": True, "restart_required": False, "settings_changed": False}
+        if cur_values != new_values:
+            # config changed
+            result["settings_changed"] = True
+            self.mass.config[conf_key][conf_subkey] = new_values
+            if conf_key == "player_settings":
+                # player settings don't require restart, force update of player
+                self.mass.event_loop.create_task(
+                    self.mass.players.trigger_update(conf_subkey))
+            else:
+                # TODO: allow some settings without restart ?
+                result["restart_required"] = True
+            self.mass.config.save()
+        return web.json_response(result)
 
     async def websocket_handler(self, request):
         """ websockets handler """
@@ -323,26 +445,9 @@ class Web():
                     LOGGER.warning(msg.data)
                 else:
                     data = msg.json()
-                    # for now we only use WS for (simple) player commands
-                    if data['message'] == 'players':
-                        players = list(self.mass.players.players)
-                        players.sort(key=lambda x: x.name, reverse=False)
-                        ws_msg = {'message': 'players', 'message_details': players}
-                        await ws.send_json(ws_msg, dumps=json_serializer)
-                    elif data['message'] == 'player command':
-                        player_id = data['message_details']['player_id']
-                        cmd = data['message_details']['cmd']
-                        cmd_args = data['message_details']['cmd_args']
-                        player = await self.mass.players.get_player(player_id)
-                        player_cmd = getattr(player, cmd, None)
-                        if player_cmd and cmd_args:
-                            result = await player_cmd(cmd_args)
-                        elif player_cmd:
-                            result = await player_cmd()
-                    else:
-                        # echo the websocket message on event bus
-                        # can be picked up by other modules, e.g. the webplayer
-                        await self.mass.signal_event(data['message'], data['message_details'])
+                    # echo the websocket message on event bus
+                    # can be picked up by other modules, e.g. the webplayer
+                    await self.mass.signal_event(data['message'], data['message_details'])
         except (Exception, AssertionError, asyncio.CancelledError) as exc:
             LOGGER.warning("Websocket disconnected - %s" % str(exc))
         finally:
@@ -350,56 +455,6 @@ class Web():
         LOGGER.debug('websocket connection closed')
         return ws
 
-    async def get_config(self, request):
-        """ get the config """
-        return web.json_response(self.mass.config)
-
-    async def save_config(self, request):
-        """ save (partial) config """
-        conf_key = request.match_info.get('key')
-        conf_subkey = request.match_info.get('subkey')
-        new_values = await request.json()
-        LOGGER.debug(f'save config called for {conf_key}/{conf_subkey} - new value: {new_values}')
-        cur_values = self.mass.config[conf_key][conf_subkey]
-        result = {"success": True, "restart_required": False, "settings_changed": False}
-        if cur_values != new_values:
-            # config changed
-            result["settings_changed"] = True
-            self.mass.config[conf_key][conf_subkey] = new_values
-            if conf_key == "player_settings":
-                # player settings don't require restart, force update of player
-                self.mass.event_loop.create_task(
-                    self.mass.players.trigger_update(conf_subkey))
-            else:
-                # TODO: allow some settings without restart ?
-                result["restart_required"] = True
-            self.mass.config.save()
-        return web.json_response(result)
-
-    async def headers_only(self, request):
-        return web.Response(status=200)
-
-    async def __stream_json(self, request, iterator):
-        """ stream items from async iterator as json object """
-        resp = web.StreamResponse(status=200,
-                                  reason='OK',
-                                  headers={'Content-Type': 'application/json'})
-        await resp.prepare(request)
-        # write json open tag
-        json_response = '{ "items": ['
-        await resp.write(json_response.encode('utf-8'))
-        count = 0
-        async for item in iterator:
-            # write each item into the items object of the json
-            json_response = json_serializer(item) + ','
-            await resp.write(json_response.encode('utf-8'))
-            count += 1
-        # write json close tag
-        json_response = '], "count": %s }' % count
-        await resp.write((json_response).encode('utf-8'))
-        await resp.write_eof()
-        return resp
-
     async def json_rpc(self, request):
         """ 
             implement LMS jsonrpc interface 
@@ -453,4 +508,38 @@ class Web():
         else:
             return web.Response(text='command not supported')
         return web.Response(text='success')
-        
\ No newline at end of file
+    
+    async def __media_items_from_body(self, data):
+        """Helper to turn posted body data into media items."""
+        if not isinstance(data, list):
+            data = [data]
+        media_items = []
+        for item in data:
+            media_item = await self.mass.music.item(
+                item['item_id'], item['media_type'], item['provider'], lazy=True)
+            media_items.append(media_item)
+        return media_items
+    
+    async def __stream_json(self, request, iterator):
+        """ stream items from async iterator as json object """
+        resp = web.StreamResponse(status=200,
+                                  reason='OK',
+                                  headers={'Content-Type': 'application/json'})
+        await resp.prepare(request)
+        # write json open tag
+        json_response = '{ "items": ['
+        await resp.write(json_response.encode('utf-8'))
+        count = 0
+        async for item in iterator:
+            # write each item into the items object of the json
+            if count:
+                json_response = ',' + json_serializer(item)
+            else:
+                json_response = json_serializer(item)
+            await resp.write(json_response.encode('utf-8'))
+            count += 1
+        # write json close tag
+        json_response = '], "count": %s }' % count
+        await resp.write((json_response).encode('utf-8'))
+        await resp.write_eof()
+        return resp