self.event_loop.run_forever()
except (KeyboardInterrupt, SystemExit):
LOGGER.info('Exit requested!')
+ self.signal_event("system_shutdown")
+ self.event_loop.stop()
self.save_config()
+ time.sleep(5)
self.event_loop.close()
LOGGER.info('Shutdown complete.')
- async def event(self, msg, msg_details=None):
- ''' signal event '''
+ def signal_event(self, msg, msg_details=None):
+ ''' signal (systemwide) event '''
LOGGER.debug("Event: %s - %s" %(msg, msg_details))
listeners = list(self.event_listeners.values())
- for listener in listeners:
- await listener(msg, msg_details)
+ for callback, eventfilter in listeners:
+ if not eventfilter or eventfilter in msg:
+ if not asyncio.iscoroutinefunction(callback):
+ callback(msg, msg_details)
+ else:
+ self.event_loop.create_task(callback(msg, msg_details))
- async def add_event_listener(self, cb):
+ def add_event_listener(self, cb, eventfilter=None):
''' add callback to our event listeners '''
cb_id = str(uuid.uuid4())
- self.event_listeners[cb_id] = cb
+ self.event_listeners[cb_id] = (cb, eventfilter)
return cb_id
- async def remove_event_listener(self, cb_id):
+ def remove_event_listener(self, cb_id):
''' remove callback from our event listeners '''
self.event_listeners.pop(cb_id, None)
self.__last_id = 10
LOGGER.info('Homeassistant integration is enabled')
mass.event_loop.create_task(self.__hass_websocket())
- mass.event_loop.create_task(self.mass.add_event_listener(self.mass_event))
+ self.mass.add_event_listener(self.mass_event, "player updated")
mass.event_loop.create_task(self.__get_sources())
async def get_state(self, entity_id, attribute='state', register_listener=None):
queue_tracks = await self.mass.player.player_queue(player_id, queue_index, queue_index+1)
queue_track = queue_tracks[0]
except IndexError:
- LOGGER.info("queue index out of range or end reached")
+ LOGGER.warning("queue index out of range or end reached")
break
params = urllib.parse.parse_qs(queue_track.uri.split('?')[1])
track_id = params['track_id'][0]
provider = params['provider'][0]
- LOGGER.info("Start Streaming queue track: %s (%s) on player %s" % (track_id, queue_track.name, player.name))
+ LOGGER.debug("Start Streaming queue track: %s (%s) on player %s" % (track_id, queue_track.name, player.name))
fade_in_part = b''
cur_chunk = 0
prev_chunk = None
stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
last_part, stderr = await process.communicate(prev_chunk + chunk)
if len(last_part) < fade_bytes:
- # not enough data for crossfade duration
+ # not enough data for crossfade duration after the strip action...
+ last_part = prev_chunk + chunk
+ if len(last_part) < fade_bytes:
+ # still not enough data so we'll skip the crossfading
LOGGER.warning("not enough data for fadeout so skip crossfade... %s" % len(last_part))
sox_proc.stdin.write(last_part)
bytes_written += len(last_part)
# move to next queue index
queue_index += 1
self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, False))
- LOGGER.info("Finished Streaming queue track: %s (%s) on player %s" % (track_id, queue_track.name, player.name))
+ LOGGER.debug("Finished Streaming queue track: %s (%s) on player %s" % (track_id, queue_track.name, player.name))
# end of queue reached, pass last fadeout bits to final output
if last_fadeout_data and not cancelled.is_set():
sox_proc.stdin.write(last_fadeout_data)
sox_effects += ' rate -v %s' % resample
# stream audio from provider
streamdetails = asyncio.run_coroutine_threadsafe(
- self.mass.music.providers[provider].get_stream_details(track_id), self.mass.event_loop).result()
+ self.mass.music.providers[provider].get_stream_details(track_id),
+ self.mass.event_loop).result()
if not streamdetails:
yield (True, b'')
return
# TODO: add support for AAC streams (which sox doesn't natively support)
if streamdetails['type'] == 'url':
- args = 'sox -t %s "%s" -t %s - %s %s' % (streamdetails["content_type"], streamdetails["path"], outputfmt, gain_correct, sox_effects)
+ args = 'sox -t %s "%s" -t %s - %s %s' % (streamdetails["content_type"],
+ streamdetails["path"], outputfmt, gain_correct, sox_effects)
elif streamdetails['type'] == 'executable':
- args = '%s | sox -t %s - -t %s - %s %s' % (streamdetails["path"], streamdetails["content_type"], outputfmt, gain_correct, sox_effects)
+ args = '%s | sox -t %s - -t %s - %s %s' % (streamdetails["path"],
+ streamdetails["content_type"], outputfmt, gain_correct, sox_effects)
LOGGER.debug("Running sox with args: %s" % args)
process = await asyncio.create_subprocess_shell(args,
stdout=asyncio.subprocess.PIPE)
streamdetails["provider"] = provider
streamdetails["track_id"] = track_id
streamdetails["player_id"] = player_id
- self.mass.event_loop.create_task(self.mass.event('streaming_started', streamdetails))
+ self.mass.signal_event('streaming_started', streamdetails)
# yield chunks from stdout
# we keep 1 chunk behind to detect end of stream properly
prev_chunk = b''
if cancelled.is_set():
LOGGER.warning("__get_audio_stream for track_id %s interrupted" % track_id)
else:
- LOGGER.info("__get_audio_stream for track_id %s completed" % track_id)
+ LOGGER.debug("__get_audio_stream for track_id %s completed" % track_id)
# fire event that streaming has ended for this track (needed by some streaming providers)
if resample:
bytes_per_second = resample * (32/8) * 2
bytes_per_second = streamdetails["sample_rate"] * (streamdetails["bit_depth"]/8) * 2
seconds_streamed = int(bytes_sent/bytes_per_second)
streamdetails["seconds"] = seconds_streamed
- self.mass.event_loop.create_task(self.mass.event('streaming_ended', streamdetails))
+ self.mass.signal_event('streaming_ended', streamdetails)
# send task to background to analyse the audio
self.mass.event_loop.create_task(self.__analyze_audio(track_id, provider))
items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
return result
- async def item_action(self, item_id, media_type, provider='database', action=None):
+ 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)
- if item and action in ['add', 'remove']:
+ if item and action in ['library_add', 'library_remove']:
# remove or add item to the library
for prov_mapping in result.provider_ids:
prov_id = prov_mapping['provider']
self.__app_secret = "47249d0eaefa6bf43a959c09aacdbce8" # TEMP! Own key requested
self.__logged_in = False
self.throttler = Throttler(rate_limit=2, period=1)
- mass.event_loop.create_task(mass.add_event_listener(self.mass_event))
+ mass.add_event_listener(self.mass_event, 'streaming_started')
+ mass.add_event_listener(self.mass_event, 'streaming_ended')
async def search(self, searchstring, media_types=List[MediaType], limit=5):
''' perform search on the provider '''
streamdetails = await self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True)
if streamdetails and streamdetails.get('url'):
break
- else:
- await asyncio.sleep(1)
if not streamdetails or not streamdetails.get('url'):
LOGGER.error("Unable to retrieve stream url for track %s" % track_id)
return None
await self.__post_data("track/reportStreamingStart", data=events)
async def __parse_artist(self, artist_obj):
- ''' parse spotify artist object to generic layout '''
+ ''' parse qobuz artist object to generic layout '''
artist = Artist()
if not artist_obj.get('id'):
return None
return artist
async def __parse_album(self, album_obj):
- ''' parse spotify album object to generic layout '''
+ ''' parse qobuz album object to generic layout '''
album = Album()
if not album_obj.get('id') or not album_obj["streamable"] or not album_obj["displayable"]:
# some safety checks
return album
async def __parse_track(self, track_obj):
- ''' parse spotify track object to generic layout '''
+ ''' parse qobuz track object to generic layout '''
track = Track()
if not track_obj.get('id') or not track_obj["streamable"] or not track_obj["displayable"]:
# some safety checks
return track
async def __parse_playlist(self, playlist_obj):
- ''' parse spotify playlist object to generic layout '''
+ ''' parse qobuz playlist object to generic layout '''
playlist = Playlist()
if not playlist_obj.get('id'):
return None
result = await response.json()
if not result or 'error' in result:
LOGGER.error(url)
- LOGGER.error(params)
- LOGGER.error(result)
+ LOGGER.debug(params)
+ LOGGER.debug(result)
return None
return result
except Exception as exc:
result = await response.json()
if not result or 'error' in result:
LOGGER.error(url)
- LOGGER.error(params)
- LOGGER.error(result)
+ LOGGER.debug(params)
+ LOGGER.debug(result)
result = None
return result
\ No newline at end of file
async def remove_player(self, player_id):
''' handle a player remove '''
self._players.pop(player_id, None)
- asyncio.ensure_future(self.mass.event('player removed', player_id))
+ self.mass.signal_event('player removed', player_id)
async def trigger_update(self, player_id):
''' manually trigger update for a player '''
LOGGER.debug('key changed: %s for player %s - new value: %s' % (key, player.name, new_value))
if player_changed:
# player is added or updated!
- asyncio.ensure_future(self.mass.event('player updated', player))
+ self.mass.signal_event('player updated', player)
if player_details.is_group:
# is groupplayer, trigger update of its childs
player_childs = [item for item in self._players.values() if item.group_parent == player_id]
self._player_queue_index = {}
self._player_queue_stream_startindex = {}
self.supported_musicproviders = ['http']
- run_background_task(self.mass.bg_executor, self.__chromecast_discovery)
+ abort_discovery = self.__chromecast_discovery()
+ def on_shutdown(msg, msg_details):
+ LOGGER.info('stopping Chromecast discovery...')
+ abort_discovery()
+ mass.add_event_listener(on_shutdown, 'system_shutdown')
### Provider specific implementation #####
from pychromecast.discovery import start_discovery, stop_discovery
def internal_callback(name):
"""Called when zeroconf has discovered a new chromecast."""
- self.__chromecast_discovered(listener.services[name])
+ #self.__chromecast_discovered(listener.services[name])
+ asyncio.run_coroutine_threadsafe(
+ self.__chromecast_discovered(listener.services[name]), self.mass.event_loop)
def internal_stop():
"""Stops discovery of new chromecasts."""
stop_discovery(browser)
listener, browser = start_discovery(internal_callback)
return internal_stop
- def __chromecast_discovered(self, discovery_info):
+ async def __chromecast_discovered(self, discovery_info):
''' callback when a (new) chromecast device is discovered '''
ip_address, port, uuid, model_name, friendly_name = discovery_info
player_id = str(uuid)
media_type = media_type_from_string(media_type_str)
media_id = request.match_info.get('media_id')
action = request.match_info.get('action','')
+ action_details = request.rel_url.query.get('action_details')
lazy = request.rel_url.query.get('lazy', '') != 'false'
provider = request.rel_url.query.get('provider')
if action:
- result = await self.mass.music.item_action(media_id, media_type, provider, 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)
return web.json_response(result, dumps=json_serializer)
async def send_event(msg, msg_details):
ws_msg = {"message": msg, "message_details": msg_details }
await ws.send_json(ws_msg, dumps=json_serializer)
- cb_id = await self.mass.add_event_listener(send_event)
+ cb_id = self.mass.add_event_listener(send_event)
# process incoming messages
async for msg in ws:
if msg.type != aiohttp.WSMsgType.TEXT:
cmd_args = msg_data_parts[4] if len(msg_data_parts) == 5 else None
await self.mass.player.player_command(player_id, cmd, cmd_args)
finally:
- await self.mass.remove_event_listener(cb_id)
- LOGGER.info('websocket connection closed')
+ self.mass.remove_event_listener(cb_id)
+ LOGGER.debug('websocket connection closed')
return ws
async def get_config(self, request):
return web.json_response(self.mass.config)
async def save_config(self, request):
- ''' save the config '''
+ ''' save (partial) config '''
LOGGER.debug('save config called from api')
new_config = await request.json()
+ config_changed = False
for key, value in self.mass.config.items():
if isinstance(value, dict):
for subkey, subvalue in value.items():
if subkey in new_config[key]:
- self.mass.config[key][subkey] = new_config[key][subkey]
+ if self.mass.config[key][subkey] != new_config[key][subkey]:
+ config_changed = True
+ self.mass.config[key][subkey] = new_config[key][subkey]
elif key in new_config:
- self.mass.config[key] = new_config[key]
- self.mass.save_config()
+ if self.mass.config[key] != new_config[key]:
+ config_changed = True
+ self.mass.config[key] = new_config[key]
+ if config_changed:
+ self.mass.save_config()
+ self.mass.signal_event('config_changed')
return web.Response(text='success')
async def json_rpc(self, request):
await self.mass.player.player_command(player_id, 'volume', 'down')
elif cmd_str == 'button power':
await self.mass.player.player_command(player_id, 'power', 'toggle')
+ else:
+ return web.Response(text='command not supported')
return web.Response(text='success')
\ No newline at end of file
<v-dialog :value="value" @input="$emit('input', $event)" max-width="500px" v-if="$globals.playmenuitem">\r
<v-card>\r
<v-list>\r
- <v-subheader class="title">{{ !!$globals.playmenuitem ? $globals.playmenuitem.name : 'nix' }}</v-subheader>\r
+ <v-subheader class="title">{{ !!$globals.playmenuitem ? $globals.playmenuitem.name : '' }}</v-subheader>\r
<v-subheader>{{ $t('play_on') }} {{ active_player.name }}</v-subheader>\r
\r
<v-list-tile avatar @click="itemClick('play')">\r
</v-list-tile-content>\r
</v-list-tile>\r
<v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
+\r
+ <v-list-tile avatar @click="itemClick('add_playlist')" v-if="$globals.playmenuitem.media_type == 3">\r
+ <v-list-tile-avatar>\r
+ <v-icon>add_circle_outline</v-icon>\r
+ </v-list-tile-avatar>\r
+ <v-list-tile-content>\r
+ <v-list-tile-title>{{ $t('add_playlist') }}</v-list-tile-title>\r
+ </v-list-tile-content>\r
+ </v-list-tile>\r
+ <v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
+\r
+ <v-list-tile avatar @click="itemClick('remove_playlist')" v-if="$globals.playmenuitem.media_type == 3 && this.$route.path.startsWith('/playlists/')">\r
+ <v-list-tile-avatar>\r
+ <v-icon>remove_circle_outline</v-icon>\r
+ </v-list-tile-avatar>\r
+ <v-list-tile-content>\r
+ <v-list-tile-title>{{ $t('remove_playlist') }}</v-list-tile-title>\r
+ </v-list-tile-content>\r
+ </v-list-tile>\r
+ <v-divider v-if="$globals.playmenuitem.media_type == 3 && this.$route.path.startsWith('/playlists/')"/>\r
\r
</v-list>\r
</v-card>\r
created() { },\r
methods: { \r
itemClick(cmd) {\r
- if (cmd == 'info')\r
+ if (cmd == 'info')\r
this.$router.push({ path: '/tracks/' + this.$globals.playmenuitem.item_id, query: {provider: this.$globals.playmenuitem.provider}})\r
else\r
this.$emit('playItem', this.$globals.playmenuitem, cmd)\r
// close dialog\r
this.$globals.showplaymenu = false;\r
- },\r
+ },\r
}\r
})\r
+++ /dev/null
-var Config = Vue.component('Config', {
- template: `
- <section>
-
- <v-list two-line>
-
- <!-- base/generic config -->
- <v-list-group prepend-icon="settings" no-action>
- <template v-slot:activator>
- <v-list-tile>
- <v-list-tile-content>
- <v-list-tile-title>{{ $t('generic_settings') }}</v-list-tile-title>
- </v-list-tile-content>
- </v-list-tile>
- </template>
- <template v-for="(conf_value, conf_key) in conf.base">
- <v-list-tile>
- <v-list-tile-content>
- <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
- </v-list-tile-content>
- </v-list-tile>
-
- <div v-for="conf_item_key in conf.base[conf_key].__desc__">
- <v-list-tile>
- <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])"></v-switch>
- <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-text-field>
- <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-select>
- <v-text-field v-else v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box></v-text-field>
- </v-list-tile>
- </div>
- <v-divider></v-divider>
- </template>
- </v-list-group>
-
-
- <!-- music providers -->
- <v-list-group prepend-icon="library_music" no-action>
- <template v-slot:activator>
- <v-list-tile>
- <v-list-tile-content>
- <v-list-tile-title>{{ $t('music_providers') }}</v-list-tile-title>
- </v-list-tile-content>
- </v-list-tile>
- </template>
- <template v-for="(conf_value, conf_key) in conf.musicproviders">
- <v-list-tile>
- <v-list-tile-avatar>
- <img :src="'images/icons/' + conf_key + '.png'"/>
- </v-list-tile-avatar>
- <v-list-tile-content>
- <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
- </v-list-tile-content>
- </v-list-tile>
-
- <div v-for="conf_item_key in conf.musicproviders[conf_key].__desc__">
- <v-list-tile>
- <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])"></v-switch>
- <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-text-field>
- <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-select>
- <v-text-field v-else v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box></v-text-field>
- </v-list-tile>
- </div>
- <v-divider></v-divider>
- </template>
- </v-list-group>
-
- <!-- player providers -->
- <v-list-group prepend-icon="speaker_group" no-action>
- <template v-slot:activator>
- <v-list-tile>
- <v-list-tile-content>
- <v-list-tile-title>{{ $t('player_providers') }}</v-list-tile-title>
- </v-list-tile-content>
- </v-list-tile>
- </template>
- <template v-for="(conf_value, conf_key) in conf.playerproviders">
- <v-list-tile>
- <v-list-tile-avatar>
- <img :src="'images/icons/' + conf_key + '.png'"/>
- </v-list-tile-avatar>
- <v-list-tile-content>
- <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
- </v-list-tile-content>
- </v-list-tile>
-
- <div v-for="conf_item_key in conf.playerproviders[conf_key].__desc__">
- <v-list-tile>
- <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])"></v-switch>
- <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-text-field>
- <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-select>
- <v-text-field v-else v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box></v-text-field>
- </v-list-tile>
- </div>
- <v-divider></v-divider>
- </template>
- </v-list-group>
-
- <!-- player settings -->
- <v-list-group prepend-icon="speaker" no-action>
- <template v-slot:activator>
- <v-list-tile>
- <v-list-tile-content>
- <v-list-tile-title>{{ $t('player_settings') }}</v-list-tile-title>
- </v-list-tile-content>
- </v-list-tile>
- </template>
- <template v-for="(player, key) in players" v-if="key != '__desc__' && key in players">
- <v-list-tile>
- <v-list-tile-content>
- <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
- <v-list-tile-sub-title class="title">ID: {{ key }} Provider: {{ players[key].player_provider }}</v-list-tile-sub-title>
- </v-list-tile-content>
- </v-list-tile>
-
- <div v-for="conf_item_key in conf.player_settings.__desc__" v-if="conf.player_settings[key].enabled">
- <v-list-tile>
- <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t(conf_item_key[2])"></v-switch>
- <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-text-field>
- <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t(conf_item_key[2])"
- :items="playersLst"
- item-text="name"
- item-value="id" box>
- </v-select>
- <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box></v-text-field>
- </v-list-tile>
- <v-list-tile v-if="!conf.player_settings[key].enabled">
- <v-switch v-model="conf.player_settings[key].enabled" :label="$t('enabled')"></v-switch>
- </v-list-tile>
- </div>
- <div v-if="!conf.player_settings[key].enabled">
- <v-list-tile>
- <v-switch v-model="conf.player_settings[key].enabled" :label="$t('enabled')"></v-switch>
- </v-list-tile>
- </div>
- <v-divider></v-divider>
- </template>
- </v-list-group>
-
- <v-btn @click="saveConfig()">Save</v-btn>
- </v-list>
- </section>
- `,
- props: [],
- data() {
- return {
- conf: {},
- players: {}
- }
- },
- computed: {
- playersLst()
- {
- var playersLst = [];
- for (player_id in this.conf.player_settings)
- if (player_id != '__desc__')
- playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name})
- return playersLst;
- }
-
- },
- created() {
- this.$globals.windowtitle = this.$t('settings');
- this.getPlayers();
- this.getConfig();
- console.log(this.$globals.all_players);
- },
- methods: {
- getConfig () {
- axios
- .get('/api/config')
- .then(result => {
- this.conf = result.data;
- })
- .catch(error => {
- console.log("error", error);
- });
- },
- saveConfig () {
- axios
- .post('/api/config', this.conf)
- .then(result => {
- console.log(result);
- })
- .catch(error => {
- console.log("error", error);
- });
- },
- getPlayers () {
- const api_url = '/api/players';
- axios
- .get(api_url)
- .then(result => {
- for (var item of result.data)
- this.$set(this.players, item.player_id, item)
- })
- .catch(error => {
- console.log("error", error);
- this.showProgress = false;
- });
- },
- }
-})
function toggleLibrary (item) {
var endpoint = "/api/" + item.media_type + "/";
item_id = item.item_id.toString();
- var action = "/remove"
+ var action = "/library_remove"
if (item.in_library.length == 0)
- action = "/add"
+ action = "/library_add"
var url = endpoint + item_id + action;
console.log('loading ' + url);
axios
.then(result => {
data = result.data;
console.log(data);
- if (action == "/remove")
+ if (action == "/library_remove")
item.in_library = []
else
item.in_library = [provider]
type_to_search: "Type here to search...",
add_library: "Add to library",
remove_library: "Remove from library",
+ add_playlist: "Add to playlist...",
+ remove_playlist: "Remove from playlist",
// settings strings
conf: {
enabled: "Enabled",
type_to_search: "Type hier om te zoeken...",
add_library: "Voeg toe aan bibliotheek",
remove_library: "Verwijder uit bibliotheek",
+ add_playlist: "Aan playlist toevoegen...",
+ remove_playlist: "Verwijder uit playlist",
// settings strings
conf: {
enabled: "Ingeschakeld",