# -*- coding: utf-8 -*- # # Copyright 2017-18 Nick Boultbee # This file is part of squeeze-alexa. # # squeeze-alexa is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # See LICENSE for full license from __future__ import print_function import random import time import warnings from typing import Iterable from squeezealexa.alexa.handlers import AlexaHandler, IntentHandler from squeezealexa.alexa.intents import * from squeezealexa.alexa.response import audio_response, speech_response, \ _build_response from squeezealexa.alexa.utterances import Utterances from squeezealexa.i18n import _ from squeezealexa.squeezebox.server import Server, print_d, people_from from squeezealexa.utils import human_join, sanitise_text with warnings.catch_warnings(): # The warning's not helpful - see Issue #105 warnings.simplefilter("ignore") from fuzzywuzzy import process class MinConfidences(object): PLAYER = 85 GENRE = 85 MULTI_GENRE = 90 PLAYLIST = 60 SINGLE_GENRE = 98 MAX_GUESSES_PER_SLOT = 2 AUDIO_TIMEOUT_SECS = 60 * 15 handler = IntentHandler() class SqueezeAlexa(AlexaHandler): _audio_touched = 0 def __init__(self, server: Server, app_id=None): super(SqueezeAlexa, self).__init__(app_id) self._server = server def handle(self, event, context=None): request = event['request'] req_type = request['type'] if req_type.startswith('AudioPlayer'): print_d("Ignoring {type} callback {id}", type=request['type'], id=request['requestId']) self.touch_audio() return _build_response({}) return super(SqueezeAlexa, self).handle(event, context) def on_session_started(self, request, session): print_d("Starting new session {session} for request {request}", session=session['sessionId'], request=request['requestId']) def on_launch(self, launch_request, session): print_d("Entering interactive mode for sessionId={id}", id=session['sessionId']) speech_output = _("Squeezebox is online. Please try some commands.") reprompt_text = _("Try resume, pause, next, previous, play some jazz, " "or ask Squeezebox to turn it up or down") return speech_response("Welcome", speech_output, reprompt_text, end=False) def on_intent(self, intent_request, session): intent = intent_request['intent'] intent_name = intent['name'] pid = self.player_id_from(intent) print_d("Received {intent_name}: {intent} (for player {pid})", **locals()) intent_handler = handler.for_name(intent_name) if intent_handler: return intent_handler(self, intent, session, pid=pid) speech = _("Sorry, I don't know how to process a \"{intent}\"").format( intent=intent_name) text = _("Unknown intent: '{intent}'").format(intent=intent_name) return self.smart_response(speech=speech, text=text) @handler.handle(Audio.RESUME) def on_resume(self, intent, session, pid=None): self._server.resume(player_id=pid) return audio_response() @handler.handle(Audio.PAUSE) def on_pause(self, intent, session, pid=None): self._server.pause(player_id=pid) return audio_response() @handler.handle(Audio.PREVIOUS) def on_previous(self, intent, session, pid=None): self._server.previous(player_id=pid) return self.smart_response(speech=_("Rewind!")) @handler.handle(Audio.NEXT) def on_next(self, intent, session, pid=None): self._server.next(player_id=pid) return self.smart_response(speech=_("Yep, pretty lame.")) @handler.handle(Custom.NOW_PLAYING) def now_playing(self, intent, session, pid=None): details = self._server.get_track_details(player_id=pid) title = details.get('title', [None])[0] artists = people_from(details) if title: desc = _("Currently playing: \"{title}\"").format(title=title) if artists: desc += _(", by {artists}.").format( artists=human_join(artists)) heading = _("Now playing: \"{title}\"").format(title=title) else: desc = _("Nothing playing.") heading = None return self.smart_response(text=heading, speech=desc) @handler.handle(Custom.SET_VOL) def on_set_vol(self, intent, session, pid=None): try: vol = float(intent['slots']['Volume']['value']) print_d("Extracted volume slot: {vol:1f}", vol=vol) except KeyError: print_d("Couldn't process volume from: {intent}", intent=intent) desc = _("Select a volume value between 0 and 10") heading = _("Invalid volume value") return self.smart_response(text=heading, speech=desc) if (vol > 10) or (vol < 0): desc = _("Select a volume value between 0 and 10") heading = _("Volume value out of range: {volume}").format( volume=vol) return self.smart_response(text=heading, speech=desc) self._server.set_volume(vol * 10, pid) desc = "OK" vol_out = vol if (vol != int(vol)) else int(vol) heading = _("Set volume to {volume}").format(volume=vol_out) return self.smart_response(text=heading, speech=desc) @handler.handle(Custom.SET_VOL_PERCENT) def on_set_vol_percent(self, intent, session, pid=None): try: vol = int(float(intent['slots']['Volume']['value'])) print_d("Extracted volume slot: {volume}", volume=vol) except KeyError: print_d("Couldn't process volume value from: {intent}", intent=intent) desc = _("Select a volume between 0 and 100 percent") heading = _("Invalid volume") return self.smart_response(text=heading, speech=desc) if (vol > 100) or (vol < 0): desc = _("Select a volume value between 0 and 100 percent") heading = _("Volume value out of range: {volume} percent").format( volume=vol) return self.smart_response(text=heading, speech=desc) self._server.set_volume(vol, pid) desc = _("OK") heading = _("Set volume to {percent} percent").format(percent=vol) return self.smart_response(text=heading, speech=desc) @handler.handle(Custom.INC_VOL) def on_inc_vol(self, intent, session, pid=None): self._server.change_volume(+12.5, player_id=pid) return self.smart_response(text=_("Increase Volume"), speech=_("Pumped it up.")) @handler.handle(Custom.DEC_VOL) def on_dec_vol(self, intent, session, pid=None): self._server.change_volume(-12.5, player_id=pid) return self.smart_response(text=_("Decrease Volume"), speech=_("OK, quieter now.")) @handler.handle(Custom.SELECT_PLAYER) def on_select_player(self, intent, session, pid=None): srv = self._server srv.refresh_status() # Do it again, yes, but not defaulting this time. pid = self.player_id_from(intent, defaulting=False) if pid: player = srv.players[pid] srv.cur_player_id = player.id text = _("Selected {player}").format(player=player.name) title = _("Selected player {player}").format(player=player) return speech_response(title=title, speech=text, store={"player_id": pid}) speech = (_("I only found these players: {players}. " "Could you try again?") .format(players=human_join(srv.player_names))) reprompt = (_("You can select a player by saying \"{utterance}\" " "and then the player name.") .format(utterance=Utterances.SELECT_PLAYER)) try: player = intent['slots']['Player']['value'] title = (_("No player called \"{name}\"").format(name=player)) except KeyError: title = "Didn't recognise a player name" return speech_response(title, speech, reprompt_text=reprompt, end=False) @handler.handle(Audio.SHUFFLE_ON) @handler.handle(CustomAudio.SHUFFLE_ON) def on_shuffle_on(self, intent, session, pid=None): self._server.set_shuffle(True, player_id=pid) return self.smart_response(text=_("Shuffle on"), speech=_("Shuffle is now on")) @handler.handle(Audio.SHUFFLE_OFF) @handler.handle(CustomAudio.SHUFFLE_OFF) def on_shuffle_off(self, intent, session, pid=None): self._server.set_shuffle(False, player_id=pid) return self.smart_response(text=_("Shuffle off"), speech=_("Shuffle is now off")) @handler.handle(Audio.LOOP_ON) @handler.handle(CustomAudio.LOOP_ON) def on_loop_on(self, intent, session, pid=None): self._server.set_repeat(True, player_id=pid) return self.smart_response(text=_("Repeat on"), speech=_("Repeat is now on")) @handler.handle(Audio.LOOP_OFF) @handler.handle(CustomAudio.LOOP_OFF) def on_loop_off(self, intent, session, pid=None): self._server.set_repeat(False, player_id=pid) return self.smart_response(text=_("Repeat Off"), speech=_("Repeat is now off")) @handler.handle(Power.PLAYER_OFF) def on_player_off(self, intent, session, pid=None): if not pid: return self.on_all_off(intent, session) server = self._server server.set_power(on=False, player_id=pid) player = server.players[pid] text = _("Switched {player} off").format(player=player.name) speech = _("{player} is now off").format(player=player.name) return self.smart_response(title=text, text=text, speech=speech) @handler.handle(Power.PLAYER_ON) def on_player_on(self, intent, session, pid=None): if not pid: return self.on_all_on(intent, session) server = self._server server.set_power(on=True, player_id=pid) player = server.players[pid] speech = "{player} is now on".format(player=player.name) if server.cur_player_id != pid: speech += ", and is selected." server.cur_player_id = pid text = _("Switched {player} on").format(player=player.name) return self.smart_response(title=text, text=text, speech=speech) @handler.handle(Power.ALL_OFF) def on_all_off(self, intent, session, pid=None): self._server.set_all_power(on=False) return self.smart_response(text=_("Players all off"), speech=_("Silence.")) @handler.handle(Power.ALL_ON) def on_all_on(self, intent, session, pid=None): self._server.set_all_power(on=True) return self.smart_response(text=_("All On."), speech=_("Ready to rock")) @handler.handle(Play.PLAYLIST) def on_play_playlist(self, intent, session, pid=None): server = self._server try: slot = intent['slots']['Playlist']['value'] print_d("Extracted playlist slot: {slot}", slot=slot) except KeyError: print_d("Couldn't process playlist from: {intent}", intent=intent) if not server.playlists: return speech_response(speech=_("There are no playlists")) pl = random.choice(server.playlists) text = _("Didn't hear a playlist there. " "You could try the \"{name}\" playlist?").format(name=pl) return speech_response(speech=text) else: if not server.playlists: return speech_response( speech=_("No Squeezebox playlists found")) result = process.extractOne(slot, server.playlists) print_d("{guess} was the best guess for '{slot}' from {choices}", guess=str(result), slot=slot, choices=server.playlists) if result and int(result[1]) >= MinConfidences.PLAYLIST: pl = result[0] server.playlist_resume(pl, player_id=pid) name = sanitise_text(pl) return self.smart_response( speech=_("Playing \"{name}\" playlist").format(name=name), text=_("Playing \"{name}\" playlist").format(name=name)) pl = random.choice(server.playlists) title = (_("Couldn't find a playlist matching \"{name}\".") .format(name=slot)) extra = (_("How about the \"{suggestion}\" playlist?") .format(suggestion=pl)) return speech_response(title=title, text=extra, speech=title + extra) @handler.handle(Play.RANDOM_MIX) def on_play_random_mix(self, intent, session, pid=None): server = self._server try: slots = [v.get('value') for k, v in intent['slots'].items() if k.endswith('Genre')] print_d("Extracted genre slots: {slots}", slots=slots) except KeyError: print_d("Couldn't process genres from: {intent}", intent=intent) else: lms_genres = self._genres_from_slots(slots, server.genres) if lms_genres: server.play_genres(lms_genres, player_id=pid) gs = human_join(sanitise_text(g) for g in lms_genres) text = _("Playing mix of {genres}").format(genres=gs) return self.smart_response(text=text, speech=text) else: genres_text = human_join(slots, _("or")) text = _("Don't understand requested genres {genres}").format( genres=genres_text) speech = _("Can't find genres: {genres}").format( genres=genres_text) return self.smart_response(text=text, speech=speech) err_text = "Don't understand intent '{intent}'".format(intent=intent) raise ValueError(err_text) def _genres_from_slots(self, slots: Iterable[str], genres: Iterable[str]): def genres_from(g): if not g: return set() res = process.extract(g, genres)[:MAX_GUESSES_PER_SLOT] print_d("Raw genre results: {data}", data=res) for g, c in res: # Exact(ish) matches shouldn't allow other genres if c > MinConfidences.SINGLE_GENRE: return {g} return {g for g, c in res if g and int(c) >= MinConfidences.MULTI_GENRE} # Grr where's my foldl results = set() for slot in slots: results |= genres_from(slot) return results @handler.handle(General.HELP) def on_help(self, intent, session, pid=None): return self.on_launch(intent, session) @handler.handle(General.CANCEL) @handler.handle(General.STOP) def on_stop(self, intent, session, pid=None): return self.on_session_ended(intent, session) def player_id_from(self, intent, defaulting=True): srv = self._server try: player_name = intent['slots']['Player']['value'] except KeyError: pass else: by_name = {s.name: s for s in srv.players.values()} choices = by_name.keys() result = process.extractOne(player_name, choices) print_d("{guess} was the best guess for '{value}' from {choices}", guess=result, value=player_name, choices=set(choices)) if result and int(result[1]) >= MinConfidences.PLAYER: return by_name.get(result[0]).id return srv.cur_player_id if defaulting else None def on_session_ended(self, intent, session): print_d("Session {id} ended", id=session['sessionId']) speech_output = _("Hasta la vista. Baby.") return speech_response("Session Ended", speech_output, end=True) @classmethod def touch_audio(cls, ts=None): cls._audio_touched = ts or time.time() @property def audio_enabled(self): return (time.time() - self._audio_touched) < AUDIO_TIMEOUT_SECS def smart_response(self, title=None, text=None, speech=None): if self.audio_enabled: return speech_response(title=title or text, speech=speech) return audio_response(speech=speech, text=text, title=title)