import redis
from redis import RedisError
import asyncio
from lnbits import LNbits
from userhelper import User
import settings
import json
import logging
import qrcode
import os
from time import time

class Invoice:
    def __init__(self, payment_hash, payment_request = None):
        self.payment_hash = payment_hash
        self.payment_request = payment_request
        self.rediskey = f"invoice:{self.payment_hash}"
        self.recipient = None
        self.user = None
        self.amount_to_pay = None
        self.spotify_uri_list = None
        self.title = None
        self.chat_id = None
        self.message_id = None
        self.ttl = 300
    
    def toJson(self):
        userdata = {
            'payment_hash':self.payment_hash,
            'payment_request':self.payment_request,
            'amount_to_pay': self.amount_to_pay,
            'recipient': {
                'userid': self.recipient.userid,
                'username': self.recipient.username
            },
            'user': {
                'userid': self.user.userid,
                'username': self.user.username
            },
            'spotify_uri_list': self.spotify_uri_list,
            'title': self.title,
            'chat_id': self.chat_id,
            'message_id': self.message_id
    
        }
        return json.dumps(userdata)
        
    def loadJson(self, data):
        assert(data is not None)
        data = json.loads(data)
        assert(data is not None)
        assert(data['payment_hash'] == self.payment_hash)

        if self.payment_request is not None:
            assert(self.payment_request == data['payment_request'])
        else:
            self.payment_request = data['payment_request']

        udata = data['recipient']
        self.recipient = User(udata['userid'],udata['username'])
        udata = data['user']
        self.user = User(udata['userid'],udata['username'])
        
        self.amount_to_pay = data['amount_to_pay']
        self.spotify_uri_list = data['spotify_uri_list']
        self.title = data['title']
        self.chat_id = data['chat_id']
        self.message_id = data['message_id']

# Get/Create a QR code and store in filename
async def create_invoice(user: User, amount: int, memo: str) -> Invoice:
    lnbits_invoice = await settings.lnbits.createInvoice(user.invoicekey,amount,memo, None)
    invoice = Invoice(lnbits_invoice['payment_hash'],lnbits_invoice['payment_request'])
    return invoice


async def pay_invoice(user: User, invoice: Invoice):
    assert(user is not None)
    assert(invoice is not None)
    result = await settings.lnbits.payInvoice(invoice.payment_request,user.adminkey)

    if result['result'] == True:
        return {
            'result': True,
            'detail': 'Payment success'
        }
    else:
        retval = {
            'result': False,
            'detail': result['detail']
        }
        return retval

async def save_invoice(invoice: Invoice) -> None:
    settings.rds.set(invoice.rediskey,invoice.toJson())
            
async def delete_invoice(payment_hash: str) -> bool:
    if payment_hash is None:
        logging.error("Delete invoice called with None payment_hash")
        return False
    rediskey = f"invoice:{payment_hash}"
    if settings.rds.delete(rediskey) == 0:
        logging.info("Invoice already deleted")
        return False    
    return True
    
async def invoice_paid(invoice: Invoice) -> bool:
    result = await settings.lnbits.checkInvoice(invoice.recipient.invoicekey,invoice.payment_hash)
    if result == True:
        return True
    else:
        return False


async def get_invoice(payment_hash: str) -> Invoice:
    """
    load invoice from redis
    """
    rediskey = f"invoice:{payment_hash}"
    data = settings.rds.get(rediskey)
    if data is None:
        return None

    invoice = Invoice(payment_hash, None)
    invoice.loadJson(data)
    print(invoice)
    return invoice


src/invoicehelper.py
LightningJukeboxBot/LightningJukeboxBot
#!/usr/bin/env python
import asyncio
import re
import os
import base64
import json
from time import time
import html
import logging
from dataclasses import dataclass
from http import HTTPStatus
import redis
import random
import string
import telegramhelper
from telegramhelper import TelegramCommand
import statshelper
import qrcode
from PIL import Image
import asyncio_mqtt as aiomqtt

import uvicorn
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import PlainTextResponse, Response, RedirectResponse, HTMLResponse, JSONResponse
from starlette.routing import Route

from telegram import __version__ as TG_VER


try:
    from telegram import __version_info__
except ImportError:
    __version_info__ = (0, 0, 0, 0, 0)  # type: ignore[assignment]

if __version_info__ < (20, 0, 0, "alpha", 1):
    raise RuntimeError(
        f"This script is not compatible with your current PTB version {TG_VER}. "
    )

import spotipy
from spotipy.oauth2 import SpotifyOAuth


from telegram import (
    Update,
    InlineKeyboardMarkup,
    InlineKeyboardButton
)
from telegram.constants import ParseMode
from telegram.ext import (
    Application,
    CallbackContext,
    CommandHandler,
    ContextTypes,
    ExtBot,
    TypeHandler,
    CallbackQueryHandler,
    CallbackContext,
    MessageHandler,
    filters    
)

# import all local stuff
from lnbits import LNbits
import userhelper
from userhelper import User
import spotifyhelper
from spotifyhelper import SpotifySettings, CacheJukeboxHandler
import settings
import jukeboxtexts
import invoicehelper
from invoicehelper import Invoice

jukeboxtexts.init()
settings.init()

# the local cache of messages that disply current playing track
now_playing_message = {}

# message debouncer to prevent processing the same message twice
message_debounce = {}

def adminonly(func):
    """
    This decorator function manages that only admin in a group chat are allowed to execute the function
    """
    async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:        
        admin = False
        if update.message.chat.type == "private":
            admin = True
        else:
            for member in await context.bot.get_chat_administrators(update.message.chat.id):
                if member.user.id == update.effective_user.id and member.status in ['administrator','creator']:
                    admin = True            
        if admin == True:
            await func(update, context)
            return

        # say to user to go away
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=jukeboxtexts.you_are_not_admin)
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})
        return
        

            
    return wrapper

def debounce(func):
    """
    This decorator function manages the debouncing of message when executing commands
    """
    async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
        # debounce to prevent the same message being processed twice
        if update.effective_chat.id in message_debounce and update.message.id <= message_debounce[update.effective_chat.id]:
            logging.info("Message bounced")
            return wrapper
        else:
            message_debounce[update.effective_chat.id] = update.message.id
            await func(update, context)

            # delete the command from the user
            try:
                await update.message.delete()
            except:
                logging.warning("Failed to delete message")
            
    return wrapper

# delete telegram messages
# This function is used in callbacks to enable the deletion of messages from users or the bot itself after some time
async def delete_message(context: ContextTypes.DEFAULT_TYPE):
    try:
        await context.job.data['message'].delete()
    except:
        logging.warning("Could not delete message")
        
# start command handler, returns help information
@debounce
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    # send the message
    message = await context.bot.send_message(chat_id=update.effective_chat.id,text=jukeboxtexts.help)

    
    # only create a callback to delete the message when not in a private chat
    if update.message.chat.type != "private":
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})

# display stats
@debounce
async def stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    userid: int = update.effective_user.id

    if update.message.chat.type != "private":
        return

    if userid not in settings.superadmins:
        logging.info(f"User {userid} is not a superadmin. Access to stats denied")
        return
    
    results = await statshelper.get_jukebox_groups()
    balance = await statshelper.get_bot_stack()

    statsText = f"Bot balance: {balance} sats \n"

    statsText += f"Number of groups: {results['numgroups']}. List of owners: \n"
    for group in results['group']:
        if group['owner'] is not None:
            statsText += f" - {group['groupid']} : @{group['owner'].username}\n"
        else:
            statsText += f" - {group['groupid']} : Unknown owner\n"
    message = await context.bot.send_message(
        chat_id=update.effective_chat.id,
        text=statsText)
    


# get the current balance
@debounce
async def balance(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    # do not show balance in other than private chats
    if update.message.chat.type != "private":
        bot_me = await context.bot.get_me()
        
        # direct the user to their private chat
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=jukeboxtexts.balance_in_group,
            reply_markup=InlineKeyboardMarkup([
                [InlineKeyboardButton(f"Take me there",url=f"https://t.me/{bot_me.username}")]
            ]))

        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})
        return

    # we're in a private chat now
    user = await userhelper.get_or_create_user(update.effective_user.id,update.effective_user.username)

    # get the balance from LNbits
    balance = await userhelper.get_balance(user)
    
    # create a message with the balance
    logging.info(f"User {user.userid} balance is {balance} sats")
    message = await context.bot.send_message(
        chat_id=update.effective_chat.id,
        text=f"Your balance is {balance} sats.")


# Disconnect a spotify player from the bot, the connect command
@debounce
@adminonly
async def disconnect(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    # this command can only be used in group chats, send instructions if used in a private chat
    if update.message.chat.type == "private":
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            parse_mode='HTML',
            text=jukeboxtexts.disconnect_in_private_chat)
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})        

        # stop here
        return

    # delete the owner of the group, all admins can do this
    await userhelper.delete_group_owner(update.effective_chat.id)
    result = await spotifyhelper.delete_auth_manager(update.effective_chat.id)
    
    # get an auth manager, if no auth manager is available, dump a message
    if result == True:        
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            parse_mode='HTML',
            text=jukeboxtexts.spotify_authorisation_removed)            
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})        
    else:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            parse_mode='HTML',
            text=jukeboxtexts.spotify_authorisation_removed_error)
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})        

# Connect a spotify player to the bot, the connect command
@debounce
#@adminonly
async def connect(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    # get spotify settings for the user
    sps = await spotifyhelper.get_spotify_settings(update.effective_user.id)    
    
    # this command has to be execute from within a group
    if update.message.chat.type == "private":

        await context.bot.send_message(
            chat_id=update.effective_user.id,
            parse_mode='HTML',
            text=f"""
To connect this bot to your spotify account, you have to create an app in the developer portal of Spotify <A href="https://developer.spotify.com/dashboard/applications">here</a>.

1. Click on the 'Create an app' button and give the bot a random name and description. Then click 'Create".

2. Record the 'Client ID' and 'Client Secret'. 

3. Click 'Edit Settings' and add EXACTLY this url <pre>{settings.spotify_redirect_uri}</pre> under 'Redirect URIs'. Do not forget to click 'Add' and 'Save'

4. Use the /setclientid and /setclientsecret commands to configure the 'Client ID' and 'Client Secret'. 

5. Give the '/couple' command in the group that you want to connect to your account. That will redirect you to an authorisation page.
 
""")
        
        # check that client_id is not None
        if sps.client_id is None:
            await context.bot.send_message(
                chat_id=update.effective_user.id,
                text=jukeboxtexts.no_client_id_set)
        else:
            await context.bot.send_message(
                chat_id=update.effective_user.id,
                text=jukeboxtexts.client_id_set.format(sps.client_id))
            
        # check that client secret is not None
        if sps.client_secret is None:
            await context.bot.send_message(
                chat_id=update.effective_user.id,
                text=jukeboxtexts.no_client_secret_set)
        else:
            await context.bot.send_message(
                chat_id=update.effective_user.id,
                text=jukeboxtexts.client_secret_set)

        # hint the user for the connect command
        if sps.client_id is not None and sps.client_secret is not None:
            await context.bot.send_message(
                chat_id=update.effective_user.id,
                text=jukeboxtexts.everything_set_now_do_connect)

        return

    # send message in group to go to private chat
    bot_me = await context.bot.get_me()

    # if both variables are not none, ask the user to authorize
    if sps.client_id is not None and sps.client_secret is not None:

        # get an auth manaer 
        auth_manager = await spotifyhelper.get_auth_manager(update.effective_chat.id)
        if auth_manager is not None:
            message = await context.bot.send_message(
                chat_id=update.effective_chat.id,
                text="A player is already connected to this group chat. disconnect it first using the /decouple command before connecting a new one")
            context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})
            return
            
            
        auth_manager = await spotifyhelper.init_auth_manager(update.effective_chat.id,sps.client_id,sps.client_secret)

        # send instructions in the group
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=jukeboxtexts.instructions_in_private_chat,
            reply_markup=InlineKeyboardMarkup([
                [InlineKeyboardButton(jukeboxtexts.button_to_private_chat,url=f"https://t.me/{bot_me.username}")]
            ]))
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})

        state = base64.b64encode(f"{update.effective_chat.id}:{update.effective_user.id}".encode('ascii')).decode('ascii')
        
        # send a message to the private chat of the bot
        await context.bot.send_message(
            chat_id=update.effective_user.id,
            text=jukeboxtexts.click_the_button_to_authorize,
            reply_markup=InlineKeyboardMarkup([
                [InlineKeyboardButton(f"Authorize player",url=auth_manager.get_authorize_url(state=state))]
            ]))
    else:
        # send a message that configuration is required
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"Additional configuration is required, execute this command in a private chat with me.",
            reply_markup=InlineKeyboardMarkup([
                [InlineKeyboardButton(f"Take me there",url=f"https://t.me/{bot_me.username}")]
            ]))

            
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})


# display the play queue
@debounce
@adminonly
async def price(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    if update.message.chat.type == "private":
        await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"The /price command only works in a group chat.")
        return

    price = await spotifyhelper.get_price(update.effective_chat.id)
    donation = await spotifyhelper.get_donation_fee(update.effective_chat.id)
    
    if update.message.text == '/price':
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            parse_mode='HTML',
            text=f"Current track price is {price}. Per requested track, {donation} sats is donated to the Jukebox Bot.")
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})        
        return


    # parse and validate the price command
    result = re.search("/price\s+([0-9]+)\s+([0-9]+)$",update.message.text)
    if result is None:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"Use command as follows: /price <price> <donation>\n<price> is the track price in sats\n<donation> is the amount in sats donated to the bot per reqested track. The donation is substracted from the track price.")
        return

    newprice = int(result.groups()[0])
    newdonation = int(result.groups()[1])

    if newdonation > newprice:
        newdonation = newprice

    # update 
    await spotifyhelper.set_price(update.effective_chat.id, newprice)
    await spotifyhelper.set_donation_fee(update.effective_chat.id, newdonation)
        
    message = await context.bot.send_message(
        chat_id=update.effective_chat.id,
        text=f"Updating price to {newprice} sats. Donation amount is {newdonation} sats.")
    
    context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':update.message})

# display the play queue
@debounce
async def queue(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    if update.message.chat.type == "private":
        message = await context.bot.send_message(
                    chat_id=update.effective_chat.id,
                    text=f"Execute the /queue command in the group instead of the private chat.")
        return
    
    # get an auth managher, if no auth manager is available, dump a message
    auth_manager = await spotifyhelper.get_auth_manager(update.effective_chat.id)
    if auth_manager is None:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            parse_mode='HTML',
            text="Bot not connected to player. The admin should perform the /couple command to authorize the bot.")
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})        
        return

    # create spotify instance
    try:
        sp = spotipy.Spotify(auth_manager=auth_manager)
    except:
        message = await context.bot.send_message(chat_id=update.effective_chat.id,text="Failed to connect to music player")    
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})
        return
    
    # get the current track
    try:
        track = sp.current_user_playing_track()
    except:
        track = None
        
    title = "Nothing is playing at the moment"    
    if track:                    
        title = "🎵 {title} 🎵".format(title=spotifyhelper.get_track_title(track['item']))
    
    # query the queue
    try:
        result = sp.queue()
    except:
        message = await context.bot.send_message(chat_id=update.effective_chat.id,text="Failed to retrieve queue")    
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})
        return
        
    text = ""
    for i in range(min(10,len(result['queue']))):
        item = result['queue'][i]       
        text += " {count}. {title}\n".format(count=(i+1),title=spotifyhelper.get_track_title(item))

    if len(text) == 0:
        text = title + "\nNo items in queue."
    else:
        text = title + "\nUpcoming tracks:\n" + text
            
    message = await context.bot.send_message(chat_id=update.effective_chat.id,text=text)    
    context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})

@debounce
@adminonly
async def service(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """
    This command sends a service message to all Jukebot group owners, that is all users marked as owner of a group
    """
    userid: int = update.effective_user.id

    if update.message.chat.type != "private":
        return

    if userid not in settings.superadmins:
        logging.info(f"User {userid} is not a superadmin. Access to stats denied")
        return

    result = re.search("^/service \S.*",update.message.text)
    if result is None:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"Use the /service command as follows: /service <message>\nThe message is sent to all owners of bot")
        return

    # set message, strip the command
    msgstr = update.message.text[9:]

    results = await statshelper.get_jukebox_groups()
    num = 0
    for group in results['group']:
        # skip if no owner is set
        if group['owner'] is None:
            continue

        # send a message each owner
        try:
            message = await context.bot.send_message(
                chat_id=group['owner'].userid,
                text=f"Service message from the Jukebox Bot:\n\n{msgstr}\n\nThank you!")
            num += 1
        except:
            pass

    # send a message each owner
    message = await context.bot.send_message(
        chat_id=update.effective_chat.id,
        text=f"The following message was sent to {num} users:\n\nService message from the Jukebox Bot:\n\n{msgstr}\n\nThank you!")

            
    
    
    
# connect a spotify player to the bot, the setclient secret and set client id commands
@debounce
async def spotify_settings(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    if update.message.chat.type != "private":
        bot_me = await context.bot.get_me()
               
        # direct the user to their private chat
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text="Like keeping your mnenomic seedphrase offline, it is better to perform these actions in a private chat with me.",
            reply_markup=InlineKeyboardMarkup([
                [InlineKeyboardButton(f"Take me there",url=f"https://t.me/{bot_me.username}")]
            ]))

        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})
        return
    
    # get spotify settings for the user
    sps = await spotifyhelper.get_spotify_settings(update.effective_user.id)

    result = re.search("/(setclientid|setclientsecret)\s+([a-z0-9]+)\s*$",update.message.text)
    if result is None:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"Incorrect usage. ")
        return

    # after validation
    command = result.groups()[0]
    value = result.groups()[1]

    bSave = False
    if command == 'setclientid':
        sps.client_id = value
        bSave = True
        
    if command == 'setclientsecret':
        sps.client_secret = value
        bSave = True
        
    if bSave == True:
        await spotifyhelper.save_spotify_settings(sps)
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"Settings updated. Type /couple for current settings and instructions.")


# fund the wallet of the user
@debounce
async def fund(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    user = await userhelper.get_or_create_user(update.effective_user.id,update.effective_user.username)

    text = f"Click on the button to fund the wallet of @{user.username}."
    if user.lnaddress is not None:
        text += f"\nYou can also fund the wallet by sending sats to the following address: {user.lnaddress}"

    message = await context.bot.send_message(
        chat_id=update.effective_chat.id,
        text=text,
        reply_markup=InlineKeyboardMarkup([
            [InlineKeyboardButton(f"Fund sats",url=f"https://{settings.domain}/jukebox/fund?command={telegramhelper.add_command(TelegramCommand(update.effective_user.id,'FUND'))}")]
            ]))
    context.job_queue.run_once(delete_message, settings.delete_message_timeout_long, data={'message':message})

# view the history of recently played tracks
@debounce
async def history(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:

    if update.message.chat.type == "private":
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"Execute the /history command in the group instead of the private chat.")
        return
    
    # get an auth managher, if no auth manager is available, dump a message
    auth_manager = await spotifyhelper.get_auth_manager(update.effective_chat.id)
    if auth_manager is None:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            parse_mode='HTML',
            text="Bot not connected to player. The admin should perform the /couple command to authorize the bot.")
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})        
        return

    # create spotify instance
    sp = spotipy.Spotify(auth_manager=auth_manager)
        
    text = "Track history:\n"
    history = await spotifyhelper.get_history(update.effective_chat.id,20)
    for title in history:
        text += f"{title}\n"
        
    message = await context.bot.send_message(chat_id=update.effective_chat.id,text=text)    
    context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})
    
# get lndhub link for user
@debounce
async def link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    # create a message tyo do this in a private chat
    if update.message.chat.type != "private":
        bot_me = await context.bot.get_me()
    
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text="Like keeping your mnenomic seedphrase offline, it is better to request your lndhub link in a private chat with me.",
            reply_markup=InlineKeyboardMarkup([
                [InlineKeyboardButton(f"Take me there",url=f"https://t.me/{bot_me.username}")]
            ]))

        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})
        return

    # we're in a private chat now
    user = await userhelper.get_or_create_user(update.effective_user.id,update.effective_user.username)

    # create QR code for the link
    filename = userhelper.get_qrcode_filename(user.lndhub)
    with open(filename,'rb') as file:
        await context.bot.send_photo(
            update.effective_chat.id,
            file,
            caption=f"Scan this QR code with an lndhub compatible wallet like BlueWallet or Zeus.",
            parse_mode='HTML')

        await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"<pre>{user.lndhub}</pre>",
            parse_mode='HTML')


# pay a lightning invoice
@debounce
async def pay(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    result = re.search("/refund\s+(lnbc[a-z0-9]+)\s*$",update.message.text)
    if result is None:
        await context.bot.send_message(
            chat_id=update.effective_user.id,
            text="Unknown lightning invoice format. Should start with 'lnbc'.")
        return

    # get the patment request from the regular expression
    payment_request = result.groups()[0]

    user = await userhelper.get_or_create_user(update.effective_user.id,update.effective_user.username)
    
    # pay the invoice
    payment_result = await settings.lnbits.payInvoice(payment_request,user.adminkey)
    if payment_result['result']:
        await context.bot.send_message(
            chat_id=update.effective_user.id,
            text="Payment succes.")
        logging.info(f"User {user.userid} paid and invoice")
    else:
        logging.warning(payment_result)
        await context.bot.send_message(
            chat_id=update.effective_user.id,
            parse_mode='HTML',
            text=payment_result['detail'])

# search for a track
@debounce
async def search(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """
    This function searches for tracks in spotify and createas a list of tracks to play
    If a playlist URL is provided, that playlist is used
    This function only works in a group chat
    """
    
    if update.message.chat.type == "private":
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"Execute the /add command in the group instead of the private chat.")
        return
    
    # get an auth manager, if no auth manager is available, dump a message
    auth_manager = await spotifyhelper.get_auth_manager(update.effective_chat.id)
    if auth_manager is None:
        await context.bot.send_message(
            chat_id=update.effective_chat.id,
            parse_mode='HTML',
            text="Bot not connected to player. The admin should perform the /couple command to authorize the bot.")
        return

    # create spotify instance
    sp = spotipy.Spotify(auth_manager=auth_manager)
    
    # validate the search string
    searchstr = update.message.text.split(' ',1)
    if len(searchstr) > 1:
        searchstr = searchstr[1]
    else:
        message = await context.bot.send_message(chat_id=update.effective_chat.id,text=jukeboxtexts.add_command_help)
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})
        return

    # check if the search string is a spotify URL
    match = re.search('https://open.spotify.com/playlist/([A-Za-z0-9]+).*$',searchstr)
    if (match):
        playlistid = match.groups()[0]
        result = sp.playlist(playlistid,fields=['name'])
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"@{update.effective_user.username} suggests to play tracks from the '{result['name']}' playlist.",
            reply_markup=InlineKeyboardMarkup([[
                InlineKeyboardButton(f"Pay {await spotifyhelper.get_price(update.effective_chat.id)} sats for a random track", callback_data = telegramhelper.add_command(TelegramCommand(0,telegramhelper.playrandom,playlistid)))
            ]]))

        # start a job to kill the message  after 30 seconds if not used
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_long, data={'message':message})
        return

    # search for tracks
    numtries: int = 3
    while numtries > 0:
        try:
            result = sp.search(searchstr)
        except spotipy.oauth2.SpotifyOauthError:
            # spotify not properly authenticated
            logging.info("Spotify Oauth error")
            message = await context.bot.send_message(
                chat_id=update.effective_chat.id,
                text="Music player not available, search aborted.")
            return
            
        except spotipy.exceptions.SpotifyException:
            numtries -= 1
            if numtries == 0:
                # spotify still triggers an exception
                logging.error("Spotify returned and exception, not returning search result")
                message = await context.bot.send_message(
                    chat_id=update.effective_chat.id,
                    text="Music player unavailable, search aborted.")
                return
            logging.warning("Spotify returned and exception, retrying")
            continue
        
        break

    # create a list of max five buttons, each with a unique song title
    if len(result['tracks']['items']) > 0:
        tracktitles  = {}
        button_list = []
        for item in result['tracks']['items']:            
            title = spotifyhelper.get_track_title(item)
            if title not in tracktitles:
                tracktitles[title] = 1
                button_list.append([InlineKeyboardButton(title, callback_data = telegramhelper.add_command(TelegramCommand(update.effective_user.id,telegramhelper.add,item['uri'])))])
                
                # max five suggestions
                if len(tracktitles) == 5:
                    break

        # Add a cancel button to the list
        button_list.append([InlineKeyboardButton('Cancel', callback_data = telegramhelper.add_command(TelegramCommand(update.effective_user.id,telegramhelper.cancel,None)))])

        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"Results for '{searchstr}'",
            reply_markup=InlineKeyboardMarkup(button_list))

        # start a job to kill the search window after 30 seconds if not used
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})
    else:
        message = await context.bot.send_message(chat_id=update.effective_chat.id,text=f"No results for '{searchstr}'")
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})


# send sats from user to user
@debounce
async def dj(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """
    Send sats from one user to another
    """
    # verify that this is not a private chat
    # verify that the message is a reply
    if update.message.reply_to_message is None:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"The /dj command only works as a reply to another user. If no amount is specified, the price for a track, {await spotifyhelper.get_price(update.effective_chat.id)} is sent.")
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})        
        return

    # parse the amount to be paid
    amount = await spotifyhelper.get_price(update.effective_chat.id)
    result = re.search("/[a-z]+(\s+([0-9]+))?\s*$",update.message.text)
    if result is not None:
        amount = result.groups()[1]
        if amount is None:
            amount = 21
        else:
            amount = int(amount)
            
    # get the user that is sending the sats and check his balance
    sender = await userhelper.get_or_create_user(update.effective_user.id,update.effective_user.username)
    balance = await userhelper.get_balance(sender)
    
    if balance < amount:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"Insufficient balance, /fund your balance first to /dj another user.")
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})        

        # and stop here
        return 
        
    # get the receiving user and create an invoice
    recipient = await userhelper.get_or_create_user(update.message.reply_to_message.from_user.id,update.message.reply_to_message.from_user.username)
    invoice = await invoicehelper.create_invoice(recipient, amount, f"@{sender.username} thinks you're a DJ!" )
    invoice.recipient = recipient
    invoice.user = sender    

    # pay the invoice
    result = await invoicehelper.pay_invoice(sender, invoice)
    if result['result'] == True:
        # send message in the group chat
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f"@{sender.username} sent {amount} sats to @{recipient.username}.")
        #context.job_queue.run_once(delete_message, settings.delete_message_timeout_medium, data={'message':message})        

        # send a message in the private chat
        if not update.message.reply_to_message.from_user.is_bot:
            try:
                message = await context.bot.send_message(
                    chat_id=recipient.userid,
                    text=f"Received {amount} sats from @{sender.username}.")
            except:
                logging.info("Could not send message to user, probably not allowed")
        else:
            logging.info(f"@{sender.username} is sending {amount} sats to the bot")

        # send a message in the private chat
        try:
            message = await context.bot.send_message(
                chat_id=sender.userid,
                text=f"Sent {amount} sats to  @{recipient.username}.")
        except:
            logging.info("Could not send message to sender user, probably not allowed")

        logging.info(f"User {sender.userid} sent {amount} sats to {recipient.userid}")
    else:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=f'Payment failed. Sorry.')
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})        

async def callback_paid_invoice(invoice: Invoice):
    if invoice is None:
        logging.error("Invoice is None")
        return
    logging.info("callback_paid_invoice")
    logging.info(invoice.toJson())
    
    if invoice.chat_id is None:
        logging.error("Invoice chat_id is None")
        return

    if await invoicehelper.delete_invoice(invoice.payment_hash) == False:
        logging.info("invoicehelper.delete_invoice returned False")
        return

    
    auth_manager = await spotifyhelper.get_auth_manager(invoice.chat_id)
    if auth_manager is None:
        logging.error("No auth manager after succesfull payment")
        return


    
    try:
        logging.info(f"Trying to delete chat_id {invoice.chat_id}, messageid {invoice.message_id}")
        await application.bot.delete_message(invoice.chat_id,invoice.message_id)            
    except:
        pass


    # add to the queue and inform others
    sp = spotipy.Spotify(auth_manager=auth_manager)
    
    spotifyhelper.add_to_queue(sp, invoice.spotify_uri_list)
    try:
        await application.bot.send_message(
            chat_id=invoice.chat_id,
            parse_mode='HTML',
            text=f"'{invoice.title}' was added to the queue.")
    except:
        logging.error("Could not  send message to the group that track was added to the queue")
    
    try:
        async with aiomqtt.Client("localhost") as client:
            await client.publish(f"{invoice.chat_id}/added_to_queue", payload=invoice.title)
    except:
        logging.error("Exception when publishing queue add to mqtt")
        pass
        

    if False:
        try:
            await application.bot.send_message(
                chat_id=invoice.user.userid,
                parse_mode='HTML',
                text=f"You paid {invoice.amount_to_pay} sats for {invoice.title}.")
        except:
            logging.info("Could not send individual message to user that")
    
     # make donation to the bot
    jukeboxbot = await userhelper.get_or_create_user(settings.bot_id)
    donator = await userhelper.get_or_create_user(invoice.recipient.userid)
    donation_amount : int = await spotifyhelper.get_donation_fee(invoice.chat_id)
    donation_amount = min(donation_amount,invoice.amount_to_pay)
    donation_invoice = await invoicehelper.create_invoice(jukeboxbot, donation_amount, "donation to the bot")
    result = await invoicehelper.pay_invoice(donator, donation_invoice)

    return


@debounce
async def web(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """
    return the Web URL where tracks can be requested using a browser
    """
    userid: int = update.effective_user.id

    if update.message.chat.type == "private":
        return

    jukebox_url = f"https://{settings.domain}/jukebox/web/{update.effective_chat.id}"
    filename = os.path.join(settings.qrcode_path,f"web_url_{update.effective_chat.id}.png")

    if not os.path.isfile(filename):    
        img_bg = Image.open('../assets/web_jukebox_template.png')
        qr = qrcode.QRCode(box_size=7, border=0)
        qr.add_data(jukebox_url)
        qr.make()
        img_qr = qr.make_image()
        pos = (int((img_bg.size[0] - img_qr.size[0])/2), 385 - int(img_qr.size[1] / 2))
        img_bg.paste(img_qr, pos)
        img_bg.save(filename)


    with open(filename,'rb') as file:
        message = await context.bot.send_photo(
            update.effective_chat.id,
            file,
            caption=f'Access this Jukebox directly at the following URL: {jukebox_url}. Pro tip: print out this image and scan it with your phone.',
            parse_mode='HTML')


    context.job_queue.run_once(delete_message, settings.delete_message_timeout_long, data={'message':message})
    

async def check_invoice_callback(context: ContextTypes.DEFAULT_TYPE):
    """
    This function checks an invoice if it has been paid
    if it does not exist anymore, or the timeout is expired, the callback stops
    """
    invoice = context.job.data
    if invoice is None:
        logging.error("Got callback with a None invoice")
        return
    
    redis_invoice = await invoicehelper.get_invoice(invoice.payment_hash)
    if redis_invoice is None:
        logging.info("Invoice no longer exists, probably has been paid or canceled")
        return

    # check if invoice was paid    
    if await invoicehelper.invoice_paid(invoice) == True:
        await callback_paid_invoice(invoice)
        return

    # invoice has not been paid
    invoice.ttl -= 15
    if invoice.ttl <= 0:
        await invoicehelper.delete_invoice(invoice.payment_hash)
        try:
            await context.bot.delete_message(invoice.chat_id,invoice.message_id)            
        except:
            pass
    else:
        application.job_queue.run_once(check_invoice_callback, 15, data = invoice)
    

async def regular_cleanup(context: ContextTypes.DEFAULT_TYPE) -> None:
    """
    This function performs tasks to clean up stuff at regular intervals
    just empties the now playing list so that the callback_spotify function creates a new message
    """
    logging.info("Running regular clean up")
    for chatid in list(now_playing_message.keys()):
        del now_playing_message[chatid]

    telegramhelper.purge_commands()


async def callback_spotify(context: ContextTypes.DEFAULT_TYPE) -> None:
    """
    This function creates a message of the current playing track. It reschedules itself depending on the remaining time
    for the current playing track. Basically two seconds after the time, the first track has finished playing
    """

    interval = 300
    try:
        for key in settings.rds.scan_iter("group:*"):
            #logging.info(f"callback_spotify for group {key}")
            chat_id = key.decode('utf-8').split(':')[1]
            auth_manager = await spotifyhelper.get_auth_manager(chat_id)
            if auth_manager is None:
                #logging.warning("Auth manager is None in callback_spotify")
                continue

            currenttrack = None
            try:
                sp = spotipy.Spotify(auth_manager=auth_manager)
                currenttrack = sp.current_user_playing_track()
            except:
                #logging.info("Exception while querying the current playing track at spotify")
                continue
            
            title = "Nothing playing at the moment"
            if currenttrack is not None and 'item' in currenttrack and currenttrack['item'] is not None:
                title = spotifyhelper.get_track_title(currenttrack['item'])

                # update history
                await spotifyhelper.update_history(chat_id, title)

                newinterval  = ( currenttrack['item']['duration_ms'] - currenttrack['progress_ms'] ) / 1000 + 2
                if newinterval < interval:
                    interval = newinterval
            elif currenttrack is not None:
                logging.info(json.dumps(currenttrack))

            # update the title
            if chat_id in now_playing_message:
                [message_id, prev_title] = now_playing_message[chat_id]
                if prev_title != title:                    
                    try:
                        await context.bot.editMessageText(title,chat_id=chat_id,message_id=message_id)
                        now_playing_message[chat_id] = [ message_id, title ]
                        logging.info(f"Now playing {title} in chat {chat_id}")
                    except:
                        #logging.error("Exception when refreshing now playing")
                        pass

                    try:
                        async with aiomqtt.Client("localhost") as client:
                            await client.publish(f"{chat_id}/now_playing", payload=title)
                    except:
                        logging.error("Exception when publishing current track to mqtt")
                        pass

            else:
                logging.info("Creating new pinned message")
                try:
                    message = await context.bot.send_message(text=title,chat_id=chat_id)
                    await context.bot.pin_chat_message(chat_id=chat_id, message_id=message.id)
                    now_playing_message[chat_id] = [ message.id, title ]
                except:
                    logging.error("Exception when sending message to group")
    except:
       logging.error("Unhandled exception in callback_spotify")             
    finally:
        if interval < 30 or interval > 300:
            interval = 30
        logging.info(f"Next run in {interval} seconds")
        context.job_queue.run_once(callback_spotify, interval, job_kwargs = {'misfire_grace_time':None})
                                                               
        
#callback for button presses
async def callback_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """
    This functions handles all button presses that are fed back into the application
    """
    key = update.callback_query.data

    # CallbackQueries need to be answered, even if no notification to the user is needed    
    # Some clients may have trouble otherwise. See https://core.telegram.org/bots/api#callbackquery    
    await update.callback_query.answer()

    if key is None:
        return
    
    command = telegramhelper.get_command(key)

    if command is None:
        logging.info("Command is None")
        return

    # parse the callback data.
    # TODO: Should convert this into an access reference map pattern

    # only the user that requested the track can select a track
    # or when the userid is explicitly set to 0
    if command.userid != 0 and command.userid != update.effective_user.id:
        logging.debug("Avoiding real click")
        return
    
    # process the various commands
    # cancel command
    if command.command == telegramhelper.cancel:
        """
        Cancel just deletes the message
        """
        await update.callback_query.delete_message()
        return
    
    if command.command == telegramhelper.cancelinvoice:
        await update.callback_query.delete_message()

        invoice = command.data
        if invoice is not None:
            await invoicehelper.delete_invoice(invoice.payment_hash)
        return


    # the commands from here on modify a list of tracks to be queue
    # and we have to check hat we have spotify available
    # get an auth managher, if no auth manager is available, dump a message    
    auth_manager = await spotifyhelper.get_auth_manager(update.effective_chat.id)
    if auth_manager is None:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            parse_mode='HTML',
            text="Bot not connected to player. The admin should perform the /couple command to authorize the bot.")
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})        
        return

    # create spotify instance
    sp = spotipy.Spotify(auth_manager=auth_manager)

    # verify that player is available, otherwise it has no use to queue a track
    track = sp.current_user_playing_track()
    if track is None:
        message = await context.bot.send_message(
            chat_id=update.effective_chat.id,
            parse_mode='HTML',
            text="Player is not active at the moment. Payment aborted.")
        context.job_queue.run_once(delete_message, settings.delete_message_timeout_short, data={'message':message})        
        return
    
                      
    # Play a random track from a playlist
    spotify_uri_list = []          
    if  command.command == telegramhelper.add:
        # add a single track to the list
        spotify_uri_list = [command.data]
        await update.callback_query.delete_message()
    elif  command.command == telegramhelper.playrandom:
        playlistid = command.data
        result = sp.playlist_items(playlistid,offset=0,limit=1)
        idxs = random.sample(range(0,result['total']),1)
        for idx in idxs:    
            result = sp.playlist_items(playlistid,offset=idx,limit=1)
            for item in result['items']:
                spotify_uri_list.append(item['track']['uri'])                
    else:
        logging.error(f"Unknown command: {command.command}")
        return

    # validate payment conditions
    payment_required = True
    amount_to_pay = int((await spotifyhelper.get_price(update.effective_chat.id)) * len(spotify_uri_list))    
    logging.info(f"Amount to pay = {amount_to_pay}")
    if amount_to_pay == 0:
        payment_required = False
            
    # if no payment required, add the tracks to the queue one by one
    if payment_required == False:
        spotifyhelper.add_to_queue(sp, spotify_uri_list)

        for uri in spotify_uri_list:

            tracktitle = spotifyhelper.get_track_title(sp.track(uri))
            
            try:
                await context.bot.send_message(
                    chat_id=update.effective_chat.id,
                    parse_mode='HTML',
                    text=f"@{update.effective_user.username} added '{tracktitle}' to the queue.")
            except:
                pass

            try:
                await context.bot.send_message(
                    chat_id=update.effective_user.id,
                    parse_mode='HTML',
                    text=f"You added '{tracktitle}' to the queue for {amount_to_pay} sats.")
            except:
                pass

            try:
                async with aiomqtt.Client("localhost") as client:
                    await client.publish(f"{update.effective_chat.id}/added_to_queue", payload=tracktitle)
            except:
                logging.error("Exception when publishing queue add to mqtt")
                pass
        

            
        # return 
        return
        
    # create an invoice title
    invoice_title = f"'{spotifyhelper.get_track_title(sp.track(spotify_uri_list[0]))}'"
    for i in range(1,len(spotify_uri_list)):
        title += f",'{spotifyhelper.get_track_title(sp.track(spotify_uri_list[0]))}'"

    # create the invoice
    # the owner is the one that has his spotify player connected
    recipient = await userhelper.get_group_owner(update.effective_chat.id)
    invoice = await invoicehelper.create_invoice(recipient, amount_to_pay, invoice_title)

    # get the user wallet and try to pay the invoice
    user = await userhelper.get_or_create_user(update.effective_user.id,update.effective_user.username)
    invoice.user = user
    invoice.title = invoice_title
    invoice.recipient = recipient    
    invoice.spotify_uri_list = spotify_uri_list
    invoice.title = invoice_title
    invoice.chat_id = update.effective_chat.id
    invoice.amount_to_pay = amount_to_pay

    # pay the invoice
    payment_result = await invoicehelper.pay_invoice(invoice.user, invoice)

    # if payment success
    if payment_result['result'] == True:
        spotifyhelper.add_to_queue(sp, spotify_uri_list)
        await context.bot.send_message(
            chat_id=update.effective_chat.id,
            parse_mode='HTML',
            text=f"@{update.effective_user.username} added {invoice_title} to the queue.")
        try:
            await context.bot.send_message(
                chat_id=update.effective_user.id,
                parse_mode='HTML',
                text=f"You paid {amount_to_pay} sats for {invoice_title}.")
        except:
            logging.info("Could not send message to user")

            
        try:
            async with aiomqtt.Client("localhost") as client:
                await client.publish(f"{update.effective_chat.id}/added_to_queue", payload=invoice_title)
        except:
            logging.error("Exception when publishing queue add to mqtt")
            pass

            
        # make donation to the bot
        jukeboxbot = await userhelper.get_or_create_user(settings.bot_id)
        donation_amount : int = await spotifyhelper.get_donation_fee(invoice.chat_id)
        donation_amount = min(donation_amount,invoice.amount_to_pay)
        donation_invoice = await invoicehelper.create_invoice(jukeboxbot, donation_amount, "donation to the bot")
        result = await invoicehelper.pay_invoice(recipient, donation_invoice)

        return

    # store the invoice in a list of open invoices        
    # add extra data 
    
    # we failed paying the invoice, popup the lnurlp
    message = await context.bot.send_message(
        chat_id=update.effective_chat.id,
        text=f"@{update.effective_user.username} add '{invoice_title}' to the queue?\n\nClick to pay below or fund the bot with /fund@Jukebox_Lightning_bot.",       
        parse_mode='HTML',
        reply_markup=InlineKeyboardMarkup([[        
            InlineKeyboardButton(f"Pay {amount_to_pay} sats",url=f"https://{settings.domain}/jukebox/payinvoice?payment_hash={invoice.payment_hash}"),
            InlineKeyboardButton('Cancel', callback_data = telegramhelper.add_command(TelegramCommand(update.effective_user.id,telegramhelper.cancelinvoice,invoice)))
        ]]))


    # add data to the invoice
    invoice.message_id = message.id

    # and save the invoice
    await invoicehelper.save_invoice(invoice)

    # change this into an SSE
    # start a loop to check the invoice, for a period of 10 minutes
    application.job_queue.run_once(check_invoice_callback, 15, data = invoice)


async def main() -> None:
    """Set up the application and a custom webserver."""
    global application

    # Here we set updater to None because we want our custom webhook server to handle the updates
    # and hence we don't need an Updater instance
    application = (
        Application.builder().token(settings.bot_token).updater(None).build()
    )
 
    # register handlers
    application.add_handler(CommandHandler('add', search))  # search for a track
    application.add_handler(CommandHandler(['stack','balance'], balance)) # view wallet balance
    application.add_handler(CommandHandler('couple', connect)) # connect to spotify account
    application.add_handler(CommandHandler('decouple', disconnect)) # disconnect from spotify account
    application.add_handler(CommandHandler('fund',fund)) # add funds to wallet
    application.add_handler(CommandHandler('history', history)) # view history of tracks
    application.add_handler(CommandHandler('link',link)) # view LNDHUB QR 
    application.add_handler(CommandHandler('refund', pay)) # pay a lightning invoice
    application.add_handler(CommandHandler('price', price)) # set the track price
    application.add_handler(CommandHandler('queue', queue)) # view the queue
    application.add_handler(CommandHandler('service', service)) # service notifications to bot users
    application.add_handler(CommandHandler("setclientsecret",spotify_settings)) # set the secret for a spotify app
    application.add_handler(CommandHandler("setclientid",spotify_settings))  # set the clientid or a spotify app
    application.add_handler(CommandHandler("stats",stats)) # dump various stats
    application.add_handler(CommandHandler(["start","faq"],start))  # help message
    application.add_handler(CommandHandler('dj', dj))  # pay another user    
    application.add_handler(CommandHandler('web', web))  # display the web URL

    application.add_handler(CallbackQueryHandler(callback_button))
    application.job_queue.run_repeating(regular_cleanup, 12 * 3600)
    application.job_queue.run_once(callback_spotify, 2)



    # Pass webhook settings to telegram
    logging.info(f"Jukebox url: \"https://{settings.domain}/jukebox/telegram\"")
    logging.info(f"Jukebox IP: {settings.ipaddress}")
    await application.bot.set_webhook(
        url=f"https://{settings.domain}/jukebox/telegram",
        allowed_updates=['callback_query','message'],
	    ip_address=settings.ipaddress
    )


    # Set up webserver
    async def telegram(request: Request) -> Response:
        """Handle incoming Telegram updates by putting them into the `update_queue`"""
        try:
            await application.update_queue.put(
                Update.de_json(data=await request.json(), bot=application.bot)
            )
        finally:
            return Response()

    async def invoicepaid_callback(request: Request) -> Response:
        data = await request.json()
        payment_hash = data['payment_hash']
                       
        invoice = await invoicehelper.get_invoice(payment_hash)
        if invoice is None:
            return Response()
        
        # process in the bot
        await callback_paid_invoice(invoice)
            
        return Response()

    async def jukebox_fund(request: Request) -> Response:
        if 'command' not in request.query_params:
            return Response()

        key = request.query_params['command']
        if key is None:
            return Response()
        
        command = telegramhelper.get_command(key)
        if command is None:
            return Response()

        if command.command != 'FUND':
            return Response()

        user = await userhelper.get_or_create_user(command.userid) 
        if user is None:
            return      

        lnurl = await userhelper.get_funding_lnurl(user)
        
            

        return Response(f"""
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Fund the wallet of '{user.username}'</title>
  <link rel="stylesheet" href="/jukebox/assets/JukeboxBot.css">
</head>
<body>
  <div class="container">
    <div class="image-container">
      <div class="image-content">
        <img src="/jukebox/assets/jukeboxbot_fund.png" alt="JukeboxBot" />
        <div class="qr-code-container">
          <img id="qr-code-image" alt="QR code image" lnurl="{lnurl}">
        </div>
        <button class="copy-data" aria-label="Copy LNRUL"></button>
      </div>
    </div>
  </div>
  <script src="/jukebox/assets/JukeboxBotFund.js"></script>   
</body>
</html>
""")

    async def payinvoice_callback(request: Request) -> Response:
        if 'payment_hash' not in request.query_params:
            return Response("Invoice not found")
        
        payment_hash = request.query_params["payment_hash"]

        invoice = await invoicehelper.get_invoice(payment_hash)
        if invoice is None:
            return Response("Invoice not found")


        return Response(f"""
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Pay invoice to add '{invoice.title}' to queue</title>
  <link rel="stylesheet" href="/jukebox/assets/JukeboxBot.css">
</head>
<body>
  <div class="container">
    <div class="image-container">
      <div class="image-content">
        <img src="/jukebox/assets/jukeboxbot_payinvoice.png" alt="JukeboxBot" />
        <div class="qr-code-container">
          <img id="qr-code-image" alt="QR code image" invoice="{invoice.payment_request}" paymenthash="{invoice.payment_hash}">
        </div>
        <button class="copy-data" aria-label="Copy invoice"></button>
        <pay-with-ln payment-request="{invoice.payment_request}"></pay-with-ln>
      </div>
    </div>
  </div>
  <script src="/jukebox/assets/JukeboxBotInvoice.js"></script>
  <script src="https://unpkg.com/pay-with-ln@0.1.0/dist/pay-with-ln.js" integrity="sha384-Uid8n0M8dpAoE1SOQOXOcMfDy9hvqtSp+A3xMFilQn+Z6fxsnmCayPVP8na5vdAv" crossorigin="anonymous"></script>
</body>
</html>
""")
    
    async def jukebox_status(request: Request) -> PlainTextResponse:
        if 'chat_id' not in request.query_params:
            return Response("{}",media_type="application/json")
        
        chat_id = request.query_params["chat_id"]

        auth_manager = await spotifyhelper.get_auth_manager(chat_id)                               
        if auth_manager is None:
            return PlainTextResponse("""{
                "title":"Nothing is playing at the moment."
            }""", media_type="application/json")

        # create spotify instance
        sp = spotipy.Spotify(auth_manager=auth_manager)

        # get the current track
        track = sp.current_user_playing_track()
        title = "Nothing is playing at the moment"
        if track:
            title = spotifyhelper.get_track_title(track['item'])
        return PlainTextResponse(f'{{"title":"{title}"}}',media_type="application/json")

    async def spotify_callback(request: Request) -> PlainTextResponse:
        """
        This function handles the callback from spotify when authorizing request to an account
        """

        logging.info("Got callback from spotify")

        if 'code' not in request.query_params:
            logging.error("no code in response from spotify")
            # callback without code
            return Response()

        code = request.query_params["code"]
        if not re.search("^[A-Za-z0-9\-\_]+$",code):
            logging.warning("authorisation code does not match regex")
            return Response()

        state = request.query_params["state"]
        if not re.search("^[0-9A-Za-z\-]+",state):
            logging.warning("state parameter does not match regex")
            return Response()

        try:
            state = base64.b64decode(state.encode('ascii')).decode('ascii')        
            [chatid, userid] = state.split(':')
            chatid = int(chatid)            
            userid = int(userid)
        except:
            logging.error("Failure during state query parameter parsing")
            return Response()

        logging.info(f"Spotify callback for {chatid} {userid} with code {code}")

        try:
            auth_manager = await spotifyhelper.get_auth_manager(chatid)                               
            if auth_manager is not None:
                auth_manager.get_access_token(code)                    
                await userhelper.set_group_owner(chatid, userid)
                await application.bot.send_message(
                    chat_id=userid,
                    text=f"Spotify connected to the chat. All revenues of requested tracks are coming your way. Execute the /decouple command in the group to remove the authorisation.")
        except Exception as e:            
            logging.error(e)
            logging.error("Failure during auth_manager instantiation")
            return Response()

        return Response("""    
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Authorisation succesfull!</title>
  <link rel="stylesheet" href="/jukebox/assets/JukeboxBot.css">
</head>
<body>
  <div class="container">
    <div class="image-container">
      <div class="image-content">
        <img src="/jukebox/assets/auth_success.png" alt="JukeboxBot" />
    </div>
  </div>
</body>
</html>    
""") 
    
    async def lnbits_lnurlp_callback(request: Request) -> PlainTextResponse:
        """
        Callback from LNbits when a wallet is funded. Send a message to the telegram user

        The callback is a POST request with a userid parameter in the URL

        """
        tguserid = request.query_params['userid']
        if re.search("^[0-9]+$",tguserid):            
            obj = json.loads(await request.body())
            amount = int(obj['amount'] / 1000)            
            await application.bot.send_message(
                chat_id=int(tguserid),
                text=f"Received {amount} sats. Type /stack to view your sats stack.")
        return Response()

    async def web_home(request: Request) -> PlainTextResponse:
        chat_id = request.path_params['chat_id']
        
        if chat_id is None:
            return HTMLResponse("Incomplete request")

        try:
            chat_id = int(chat_id)
        except:
            return HTMLResponse("Incomplete request")

        # get spotify auth manager 
        auth_manager = await spotifyhelper.get_auth_manager(chat_id)                               
        if auth_manager is None:
            return HTMLResponse("Incomplete request")

        return HTMLResponse(f"""<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Add Music</title>
    <link rel="stylesheet" href="/jukebox/assets/JukeboxBot.css?4689">
  </head>
  <body>
    <form onsubmit="submitSearch(); return false;" action="">
      <div class="container">
        <div class="image-container">
          <div class="image-content">
            <img src="/jukebox/assets/jukeboxbot_addmusic.png" alt="JukeboxBot" />
            <div class="search-container">
              <input name="query" value="" placeholder="Enter song title">
            </div>
            <button class="search-button" type="submit" aria-label="Search"></button>
            <div class="search-results-container">
              <div class="search-result-container"></div>
              <div class="search-result-container"></div>
              <div class="search-result-container"></div>
              <div class="search-result-container"></div>
              <div class="search-result-container"></div>
            </div>
          </div>
        </div>
      </div>
    </form>
  </body>
  <script>
   const req1 = new XMLHttpRequest();	
   req1.onreadystatechange = function() {{
      if (this.readyState == 4 && this.status == 200) {{
        obj = JSON.parse(this.responseText);
        if ( obj.status == 200 ) {{
          var nodes = document.querySelectorAll(".search-result-container");
          for(let i=0;(i<obj.results.length);i++) {{
            nodes[i].innerText = obj.results[i].title;
            nodes[i].onclick = function(){{
              req2.open("GET","/jukebox/web/{chat_id}/add?track_id=" + obj.results[i].track_id);
              req2.setRequestHeader("Accept", "application/json");	
              req2.setRequestHeader("Content-Type", "application/json"); 
              req2.send();             
            }};
          }}
        }}
      }}
   }};

   const req2 = new XMLHttpRequest();
   req2.onreadystatechange = function() {{        
      if (this.readyState == 4 && this.status == 200) {{
        obj = JSON.parse(this.responseText);
        if ( obj.status == 200 ) {{
          window.location.href = obj.payment_url;
        }}
      }}
   }};

  function submitSearch() {{  
    req1.open("POST", "/jukebox/web/{chat_id}/search"); 
    req1.setRequestHeader("Accept", "application/json");	
    req1.setRequestHeader("Content-Type", "application/json"); 
    req1.send(JSON.stringify({{query:document.getElementsByName("query")[0].value}}));
  }}  

  </script> </html>""")

    async def web_search(request: Request) -> JSONResponse:
        chat_id = request.path_params['chat_id']
        print("Web search")
        
        # validate that chat_id is present
        if chat_id is None:
            return JSONResponse({"status":400,"message":"Incomplete request. Chat ID is None"})
        
        # validate that chat_id is a digit
        try:
            chat_id = int(chat_id)
        except:
            return JSONResponse({"status":400,"message":"Incomplete request. Failed to cast chat_id"})
        
        # get form        
        form = await request.json()
        
        print("Web search for ",form)
        # validate that form is present
        if form is None:
            return JSONResponse({"status":400,"message":"Incomplete request form is None"})

        # get query
        query = form["query"]

        # validate that query is present
        if query is None:
            return JSONResponse({"status":400,"message":"Incomplete request query is None"})

        # validate allow characters in query
        if not re.search("^[A-Za-z0-9 ]+$",query):
            return JSONResponse({"status":400,"message":"Incomplete request query is invalid"})
            
        # get spotify auth manager 
        auth_manager = await spotifyhelper.get_auth_manager(chat_id)                               
        if auth_manager is None:
            return JSONResponse({"status":400,"message":"Incomplete request auth manager is None"})
            
        # create spotify instance
        sp = spotipy.Spotify(auth_manager=auth_manager)
        if sp is None:
            return JSONResponse({"status":400,"message":"Incomplete response sp is none"})

        # search for tracks
        numtries: int = 3
        while numtries > 0:
            try:
                result = sp.search(query)
            except spotipy.exceptions.SpotifyException:
                numtries -= 1
                if numtries == 0:
                    logging.error("Spotify returned and exception, not returning search result")
                    return JSONResponse({"status":400,"message":"Search currently unavailable"})
                logging.warning("Spotify returned and exception, retrying")
                continue
            break

        # create a list of max five items
        if len(result['tracks']['items']) == 0:
            return JSONResponse({"status":200,"results":[]})
        
        tracktitles  = {}
        results = []

        for item in result['tracks']['items']:            
            title = spotifyhelper.get_track_title(item)
            track_id = item['uri']
            result = re.search("^spotify:track:([A-Z0-9a-z]+)$",track_id)
            if not result:
                continue

            # strip spotify from the track id
            track_id = result.groups()[0]

            if title not in tracktitles:
                tracktitles[title] = 1
                results.append({"title":title,"track_id":track_id})
                                
                # max five suggestions
                if len(results) == 5:
                    break

        return JSONResponse({"status":200,"results":results})


    async def web_add(request: Request) -> JSONResponse:
        chat_id = request.path_params['chat_id']
        track_id = request.query_params['track_id']        

        # validate that chat_id is present
        if chat_id is None:
            logging.info("chat_id is NULL")
            return JSONResponse({"status":400,"message":"Incomplete request"})

        # validate that chat_id is a digit
        try:
            chat_id = int(chat_id)
        except:
            logging.warning("chat_id is not an integer")
            return JSONResponse({"status":400,"message":"Incomplete request"})
        

        # validate track_id is not None
        if track_id is None:
            logging.info("track_id is None")
            return JSONResponse({"status":400,"message":"Incomplete request"})


        # vaidate track id is spotify track
        #spotify:track:1532ejaMFnQPcHD9BAeqwr
        if not re.search("^[A-Za-z0-9]+$",track_id):
            logging.warning("track_id does not match regular expression")
            return JSONResponse({"status":400,"message":"Incomplete request"})


        # add spotify prefix to the track_id
        track_id = f"spotify:track:{track_id}"

        # get spotify auth manager 
        auth_manager = await spotifyhelper.get_auth_manager(chat_id)                               
        if auth_manager is None:
            logging.warning("Auth_manager is NULL")
            return JSONResponse({"status":400,"message":"Incomplete request"})

       
        # create spotify instance
        sp = spotipy.Spotify(auth_manager=auth_manager)

        if sp is None:
            logging.warning("sp is None")
            return JSONResponse({"status":400,"message":"Incomplete request"})


        track = sp.track(track_id)        
        track_len = track['duration_ms'] / 1000
        
        amount_to_pay = int(await spotifyhelper.get_price(chat_id))
        if ( track_len > 600 ):
            amount_to_pay = 10 * amount_to_pay
#        if ( track_len > 1800 ):
#            amount_to_pay = 10 * amount_
#        elif ( track_len > 600 ):
#            amount_to_pay = amount_to_pay * 1.0166428 ** (track_len - 300)
        recipient = await userhelper.get_group_owner(chat_id)
        invoice_title = f"'{spotifyhelper.get_track_title(track)}'"
        invoice = await invoicehelper.create_invoice(recipient, amount_to_pay, invoice_title)
        if invoice is None:
            return JSONResponse({"status":400,"message":"Payments not available"})


        invoice.user = User(80,"Web user")
        invoice.title = invoice_title
        invoice.recipient = recipient   
        logging.info("recipient when creating invoice: " + recipient.toJson())
        invoice.spotify_uri_list = [ track_id ]
        invoice.title = invoice_title
        invoice.chat_id = chat_id
        invoice.amount_to_pay = amount_to_pay

        # save the invoice
        await invoicehelper.save_invoice(invoice)

        application.job_queue.run_once(check_invoice_callback, 15, data = invoice)

        return JSONResponse({"status":200,"payment_url":f"https://{settings.domain}/jukebox/payinvoice?payment_hash={invoice.payment_hash}"});
    

    starlette_app = Starlette(
        routes=[
            Route(f"/jukebox/telegram", telegram, methods=["GET","POST"]),
            Route("/jukebox/lnbitscallback", lnbits_lnurlp_callback, methods=["POST"]),
            Route("/spotify", spotify_callback, methods=["GET"]),
            Route("/jukebox/payinvoice",payinvoice_callback, methods=["GET"]),
            Route("/jukebox/invoicecallback",invoicepaid_callback, methods=["POST"]),
            Route("/jukebox/status.json",jukebox_status, methods=["GET"]),
            Route("/jukebox/fund",jukebox_fund, methods=["GET"]),
            Route("/jukebox/web/{chat_id}",web_home, methods=["GET"]),
            Route("/jukebox/web/{chat_id}/search",web_search, methods=["POST"]),
            Route("/jukebox/web/{chat_id}/add",web_add, methods=["GET"])
            
        ]
    )

    webserver = uvicorn.Server(
        config=uvicorn.Config(
            app=starlette_app,
            port=settings.port,
            use_colors=False,
            host="127.0.0.1",
        )
    )

    # Run application and webserver together
    async with application:
        await application.start()
        await webserver.serve()
        await application.stop()

if __name__ == "__main__":
    asyncio.run(main())

src/jukeboxbot.py
LightningJukeboxBot/LightningJukeboxBot
def init():
    # when the help, start command is given
    global help
    help = """
You can use one of the following commands:
    
/start and /faq both result in this output
/add to search for tracks. Searches can be refined using statements such as 'artist:'.
/queue to view the list of upcoming tracks. 
/history view the list of tracks that were played recently
/dj <amount> to share some of your sats balance with another user. Use this command in a reply to them.

You can also chat in private with me to do stuff like viewing your balance.

/stack shows your balance.
/fund can be used to add sats to your balance. While you can pay for each track, you can also add some sats in advance.
/link provides an lndhub URL so that you can connect BlueWallet or Zeus to your wallet in the bot. That also makes it possible to withdraw/add funds.
/refund <lightning invoice> pays a lightning invoice so you can get your sats back.

The /couple, /decouple, /setclientid and /setclientsecret are commands to connect your own music player to this bot and start your own Jukebox!

If you like this bot, send a donation to herovk@ln.tips or simply /dj the bot.
"""           

    # when someone executes the balance command in a group
    global balance_in_group
    balance_in_group = "Like keeping your mnenomic seedphrase offline, it is better to query your balance in a private chat with me."

    # when someone executes the disconnect command in a private chat
    global disconnect_in_private_chat
    disconnect_in_private_chat = "Execute the /decouple command in a group where you want the player disconnected"

    # when someone tries to perform a command that requires admin permissions
    global you_are_not_admin
    you_are_not_admin = "You are not an admin in this chat."

    # when the spotify authorisation is removed
    global spotify_authorisation_removed
    spotify_authorisation_removed = "Removed player from group. To reconnect, an admin should perform the /couple command to authorize the bot."

    global spotify_authorisation_removed_error
    spotify_authorisation_removed_error = "Removed player from group failed. Sorry. retry or dm the captain."

    global no_client_id_set
    no_client_id_set = "No spotify ClientID set Use the /setclientid command to enter this id"

    global client_id_set
    client_id_set = "Spotify ClientID is set to {}"
    
    global no_client_secret_set
    no_client_secret_set = "No Spotify Client Secret set. Use the /setclientsecret command to enter this secret"        

    global client_secret_set
    client_secret_set = "Spotify Client Secret is set"

    global everything_set_now_do_connect
    everything_set_now_do_connect = "Both client_id and client_secret are set. Execute the /couple command in the group that you want to connect to the bot"

    global instructions_in_private_chat
    instructions_in_private_chat = "I'm sending instructions in the private chat."

    global button_to_private_chat
    button_to_private_chat = "Take me there"

    global click_the_button_to_authorize
    click_the_button_to_authorize = "Clicking on the button will take you to Spotify where you can grant the bot the authorisation to control the player."

    global add_command_help
    add_command_help = "Use the /add command to search for tracks and add them to the playlist. Enter the name of the artist and/or the song title after the /add command and select a track from the results. For example: \n/add rage against the machine killing in the name\n/add 7th element\n"

src/jukeboxtexts.py
LightningJukeboxBot/LightningJukeboxBot
import json
import httpx
import logging

class LNbits:
    def __init__(self, protocol, host, admin_adminkey, admin_invoicekey, admin_usrkey):
        self.protocol = protocol
        self.host = host
        self._admin_adminkey = admin_adminkey
        self._admin_invoicekey = admin_invoicekey
        self._admin_usrkey = admin_usrkey

    # get balance
    async def getBalance(self, invoicekey):
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{self.protocol}://{self.host}/api/v1/wallet",
                headers = {"X-Api-Key": invoicekey})
            result = json.loads(response.text)
            return int(result['balance']/1000)
    
    # pay an invoice
    async def payInvoice(self, invoice, adminkey):
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.protocol}://{self.host}/api/v1/payments",
                json = {"out": True, "bolt11": invoice },
                headers = {"X-Api-Key": adminkey})
            result = json.loads(response.text)
            if "payment_hash" in result:
                result['result'] = True
            else:            
                result['result'] = False
                if result['detail'] ==  'Insufficient balance.': 
                    pass
                elif result['detail'].startswith('(sqlite3.IntegrityError) UNIQUE constraint failed'):
                    result['detail'] = 'Duplicate invoice, payment failed.'
                else:    
                    logging.info(result)
                    result['detail'] = 'Payment failed.'


            return result
        
    # creat an lnbits invoice
    async def createInvoice(self, invoicekey, amount, memo, extra = None):
        payload ={
            "out": False,
            "amount": amount,
            "memo": memo,
            "webhook": "http://127.0.0.1:7000/jukebox/invoicecallback"
        }
        if extra is not None:
            payload['extra'] = extra
        
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.protocol}://{self.host}/api/v1/payments",
                headers={'X-Api-Key':invoicekey},
                json=payload)
        
            return json.loads(response.text)

    # create a user and initial wallet
    async def createUser(self, name):
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.protocol}://{self.host}/usermanager/api/v1/users",
                headers={'X-Api-Key':self._admin_invoicekey},
                json={
                    "admin_id": self._admin_usrkey,
                    "wallet_name": name,
                    "user_name": name                    
                })
        user = json.loads(response.text)
        return user['id']

    # delete user and their wallets
    async def deleteUser(self, lnbitsuserid):
        async with httpx.AsyncClient() as client:
            response = await client.delete(
                f"{self.protocol}://{self.host}/usermanager/api/v1/users/{lnbitsuserid}",
                headers={'X-Api-Key':self._admin_adminkey})
                         
    # create a wallet for a user
    async def createWallet(self, lnbitsuserid, name):
        print("We should not come in the function createWallet")
        return None
        
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.protocol}://{self.host}/usermanager/api/v1/wallets",
                headers={'X-Api-Key':self._admin_invoicekey},
                json={
                    "user_id": lnbitsuserid,
                    "wallet_name": name,
                    "admin_id": self._admin_usrkey
                })
        wallet = json.loads(response.text)
        return wallet

    # enable extension
    async def enableExtension(self, name, lnbitsuserid):
        # enable extension for user wallet
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.protocol}://{self.host}/usermanager/api/v1/extensions?extension={name}&userid={lnbitsuserid}&active=true",
                headers={'X-Api-Key': self._admin_invoicekey})
            if response.status_code == 200:
                return True
            else:
                return False
        
    # creates LNURLP link
    async def createLnurlp(self, adminkey, payload):
        # delete all previous paylinks, there could be conflicts
        paylinks = []

        # get all paylinks
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{self.protocol}://{self.host}/lnurlp/api/v1/links",
                headers={'X-Api-Key':adminkey})            
            paylinks.extend(response.json())
    

        # delete all paylinks
        for paylink in paylinks:
            logging.info(f"Deleting old paylink with id {paylink['id']}")
            async with httpx.AsyncClient() as client:
                response = await client.delete(
                    f"{self.protocol}://{self.host}/lnurlp/api/v1/links/{paylink['id']}",
                    headers={'X-Api-Key':adminkey})            
                
        # create lnurlp link
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.protocol}://{self.host}/lnurlp/api/v1/links",
                json=payload,
                headers={'X-Api-Key':adminkey})            
            result = response.json()

            
            if not 'id' in result:
                if result['detail'].startswith("Username already exists."):
                    logging.warning(f"Username already exists {payload['username']}, retrying without username")
                    del payload['username']
                    return await self.createLnurlp(adminkey,payload)
                else:
                    logging.error(f"Could not create Lnurlpay link for payload '{json.dumps(payload)}'  the response was: '{json.dumps(result)}'")
                    return None
            else:
                return result

    # retrieves LNURLP link
    async def getLnurlp(self, baseurl, adminkey, payid):
        """
        Retrieve a Lnurlp link details
        """
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{baseurl}lnurlp/api/v1/links/{payid}",
                headers={'X-Api-Key':adminkey})

            if response.status_code == 200:
                return response.json()
            else:
                logging.error(f"LNbits returned and error {response.status_code}")
                return None
            
            
    # check wether an invoice has been paid. Returns True if paid. Otherwise False
    async def checkInvoice(self,invoicekey,payment_hash):
        async with httpx.AsyncClient() as client:
            try:
                response = await client.get(
                    f"{self.protocol}://{self.host}/api/v1/payments/{payment_hash}",
                    headers = {"X-Api-Key": invoicekey})
            
                jsobj = json.loads(response.text)
                if jsobj['paid'] == True:
                    return True
            except httpx.ReadTimeout:
                logging.warning("LNbits read timeout")
                return False
        return False


    # get all wallets in lnbits
    async def getWallets(self):
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{self.protocol}://{self.host}/usermanager/api/v1/wallets",
                headers = {"X-Api-Key": self._admin_adminkey})
            return json.loads(response.text)

    # get all wallets in lnbits
    async def getUsers(self):
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{self.protocol}://{self.host}/usermanager/api/v1/users",
                headers = {"X-Api-Key": self._admin_adminkey})
            return json.loads(response.text)

    # return the wallet for a specific user
    async def getWallet(self, lnbitsuserid):
        async with httpx.AsyncClient() as client:
            response = await client.get(
                f"{self.protocol}://{self.host}/usermanager/api/v1/wallets/{lnbitsuserid}",
                headers = {"X-Api-Key": self._admin_adminkey})
            result = json.loads(response.text)
            if len(result) > 0:
                return result[0]

        return None
        


src/lnbits.py
LightningJukeboxBot/LightningJukeboxBot
import redis
import logging
from lnbits import LNbits
import os
import random
import string
from time import time



def init():
    global environment
    global rds
    global lnbits
    global price
    global fund_max
    global fund_min
    global domain
    global spotify_redirect_uri
    global bot_token
    global delete_message_timeout_short
    global delete_message_timeout_medium
    global delete_message_timeout_long
    global secret_token
    global qrcode_path
    global port
    global max_connections
    global ipaddress
    global bot_id
    global donation_fee
    global superadmins

    domain=os.environ['JUKEBOX_DOMAIN']
    
    # set the new environment and fall back to development
    env = None
    if 'JUKEBOX_ENV' in os.environ:
        env = os.environ['JUKEBOX_ENV']
        
    # already initialised for this environment
    try:
        if environment == env:
            return True
    except NameError:
        pass

    # short time for deleting messages in seconds
    delete_message_timeout_short = 10
    delete_message_timeout_medium = 60
    delete_message_timeout_long = 300

    # set secret token for telegram
    secret_token = "".join(random.sample(string.ascii_letters,12))
    spotify_redirect_uri=f'https://{domain}/spotify' # this must literaly match the config in spotify
    max_connections = 5

    # webserver port
    port = 7000
    
    price = 21
    fund_max = 42000
    fund_min = price
    rds = redis.Redis(db=2)
    lnbits = LNbits(
        os.environ['LNBITS_PROTOCOL'],
        os.environ['LNBITS_HOST'],
        os.environ['LNBITS_ADMINKEY'],
        os.environ['LNBITS_INVOICEKEY'],
        os.environ['LNBITS_USRKEY'])
    bot_token=os.environ['BOT_TOKEN']
    qrcode_path = '/tmp'
    ipaddress = os.environ['BOT_IPADDRESS']
    bot_id=int(os.environ['BOT_ID'])
    donation_fee = 21  # default donation fee
    superadmins = [int(superadmin) for superadmin in os.environ['SUPERADMINS'].split(',')]

    environment = env
    if env == 'production':    
        logging.basicConfig(
            filename="logfile_{time}.dat".format(time=time()),
            format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
            level=logging.INFO
        )
        return True
    elif env == 'development':
        logging.basicConfig(
            filename="logfile.dat",
            format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
            level=logging.INFO
        )
        return True
    else:
        print("unknown environment")
        quit()
        

src/settings.py
LightningJukeboxBot/LightningJukeboxBot
import redis
from redis import RedisError
import asyncio
import settings
import json
import logging
from spotipy import CacheHandler
import spotipy
from spotipy.oauth2 import SpotifyOAuth
from time import time

class SpotifySettings:
    def __init__(self, tguserid):
        self.userid = tguserid
        self.userkey = f"user:{self.userid}"        
        self.client_secret = None
        self.client_id = None

    def toJson(self):
        data = {
            'telegram_userid': self.userid,
            'client_secret': self.client_secret,
            'client_id': self.client_id
        }
        return json.dumps(data)

    def loadJson(self, data):
        assert(data is not None)
        obj = json.loads(data)
        assert(obj is not None)
        assert(obj['telegram_userid'] == self.userid)

        if 'client_secret' in obj:
            self.client_secret = obj['client_secret']

        if 'client_id' in obj:
            self.client_id = obj['client_id']
            
class CacheJukeboxHandler(CacheHandler):
    """
    This cache handler keeps track of spotify auth data and is stored in the redis database per group so that multiple authorisations can be active at the same time
    """    
    def __init__(self, chat_id):
        self.chat_id = chat_id
        self.rediskey = f"spotify_token:{self.chat_id}"
        self.token = None

    def get_cached_token(self):
        logging.debug("Obtain cached token")
        token_info = None
    
        try:
            token_info = settings.rds.get(self.rediskey)
            if token_info:
                return json.loads(token_info)
        except RedisError as e:
            logging.warning('Error getting token from cache: ' + str(e))       

        return token_info


    def save_token_to_cache(self, token_info):
        logging.info("saving token to cache")
        try:
            settings.rds.set(self.rediskey, json.dumps(token_info))
        except RedisError as e:
            logging.warning('Error saving token to cache: ' + str(e))

def add_to_queue(sp, spotify_uri_list):
    """
    Add a list of tracks to the queue
    """
    for uri in spotify_uri_list:
        sp.add_to_queue(uri)            

# construct the track title from a Spotify track item
def get_track_title(item):
    """
    Get a readable version of the track title
    """
    if item is None:
      return "No track item"
    if not 'artists' in item:
      return "No artists"
    if item['artists'][0] is None:
      return "No artist"
    if item['artists'][0]['name'] is None:
      return "No name"
    if item['name'] is None:
      return "No track name"

    artist=item['artists'][0]['name']
    track=item['name']

    return f"{artist} - {track}"

async def get_price(chat_id):
    """
    Gets the price for tracks in this group. Defaults to the initial price of 21 sats
    """
    rediskey = f"group:{chat_id}"
    price = settings.rds.hget(rediskey,"price")
    if price is None:
        price = settings.price
    return int(price)

async def set_price(chat_id, price):
    """
    Set the price in a group
    """
    rediskey = f"group:{chat_id}"
    price = settings.rds.hset(rediskey,"price",price)

async def create_auth_manager(chat_id, client_id, client_secret):
    logging.debug("create auth manager")
    cache_handler = CacheJukeboxHandler(chat_id)
    return SpotifyOAuth(
        scope='user-read-currently-playing,user-modify-playback-state,user-read-playback-state',
        client_secret=client_secret,
        client_id=client_id,
        redirect_uri=settings.spotify_redirect_uri,
        show_dialog=False,
        open_browser=False,        
        cache_handler=cache_handler)
            
async def init_auth_manager(chat_id, client_id, client_secret):
    """
    Initialize a spotify auth manager for a specific group
    """
    logging.info("init auth manager")
    data = {
        'chat_id': chat_id,
        'client_id': client_id,
        'client_secret': client_secret
    }
    settings.rds.hset(f"group:{chat_id}", "authmanager", json.dumps(data))

    return await create_auth_manager(chat_id, client_id, client_secret)
        
        
async def get_auth_manager(chat_id):
    """
    Create a spotify auth manager for a specific group
    """
    logging.debug("Get Auth Manager")
    am_data = settings.rds.hget(f"group:{chat_id}","authmanager")
    if am_data is None:
        return None
    
    data = json.loads(am_data)
    return await create_auth_manager(data['chat_id'], data['client_id'], data['client_secret'])


# TODO: maybe we can perform a de-authorize call at spotify instead of just removing the key
async def delete_auth_manager(chat_id):
    """
    Removes an auth manager from our local store
    """
    data = settings.rds.hget(f"group:{chat_id}","authmanager")
    if data is None:
        return True

    # delete the spotify token as well 
    settings.rds.delete(f"spotify_token:{chat_id}")
    
    settings.rds.hdel(f"group:{chat_id}","authmanager")

    # delete the spotify token as well 
    settings.rds.delete(f"spotify_token:{chat_id}")
    return True


async def save_spotify_settings(sps):
    """
    Store spotify settings in Redis
    """
    settings.rds.hset(sps.userkey,"spotify",sps.toJson())
    

async def get_spotify_settings(userid):
    """
    Get the spotify settings for this user
    """
    sps = SpotifySettings(userid)    
    data = settings.rds.hget(sps.userkey,"spotify")
    if data is not None:
        sps.loadJson(data)
    return sps


async def get_history(chat_id, maxlen):
    rediskey = f"history:{chat_id}"
    titles = []
    for i in range(0, min(maxlen,settings.rds.llen(rediskey))):
        titles.append(settings.rds.lindex(rediskey, i).decode('utf-8'))
    return titles

async def update_history(chat_id: int, title: str) -> None:
    rediskey = f"history:{chat_id}"
    currenttitle = settings.rds.lindex(rediskey,0)

    if currenttitle is None:
        settings.rds.lpush(rediskey,title)
    else:
        currenttitle = currenttitle.decode('utf-8')
        if currenttitle != title:
            settings.rds.lpush(rediskey,title)
            
        if settings.rds.llen(rediskey) > 100:
            settings.rds.rpop(rediskey)
                    
    # update last played entry
    settings.rds.hset(f"lastplayed:{chat_id}",title,int(time()))
        
async def get_donation_fee(chat_id: int) -> int:
    """
    Gets the donation fee
    """
    rediskey = f"group:{chat_id}"
    fee = settings.rds.hget(rediskey,"donation_fee")    
    if fee is None:
        fee = settings.donation_fee
    fee = int(fee)
    if fee < 0:
        fee = settings.donation_fee
    return int(fee)

async def set_donation_fee(chat_id: int, fee: int) -> None:
    """
    Sets the donation fee
    """
    rediskey = f"group:{chat_id}"
    settings.rds.hset(rediskey,"donation_fee",fee)    

src/spotifyhelper.py
LightningJukeboxBot/LightningJukeboxBot
# -*- coding: utf-8 -*-

""" A simple and thin Python library for the Spotify Web API """

__all__ = ["Spotify", "SpotifyException"]

import json
import logging
import re
import warnings

import requests
import six
import urllib3

from spotipy.exceptions import SpotifyException

logger = logging.getLogger(__name__)


class Spotify(object):
    """
        Example usage::

            import spotipy

            urn = 'spotify:artist:3jOstUTkEu2JkjvRdBA5Gu'
            sp = spotipy.Spotify()

            artist = sp.artist(urn)
            print(artist)

            user = sp.user('plamere')
            print(user)
    """
    max_retries = 3
    default_retry_codes = (429, 500, 502, 503, 504)
    country_codes = [
        "AD",
        "AR",
        "AU",
        "AT",
        "BE",
        "BO",
        "BR",
        "BG",
        "CA",
        "CL",
        "CO",
        "CR",
        "CY",
        "CZ",
        "DK",
        "DO",
        "EC",
        "SV",
        "EE",
        "FI",
        "FR",
        "DE",
        "GR",
        "GT",
        "HN",
        "HK",
        "HU",
        "IS",
        "ID",
        "IE",
        "IT",
        "JP",
        "LV",
        "LI",
        "LT",
        "LU",
        "MY",
        "MT",
        "MX",
        "MC",
        "NL",
        "NZ",
        "NI",
        "NO",
        "PA",
        "PY",
        "PE",
        "PH",
        "PL",
        "PT",
        "SG",
        "ES",
        "SK",
        "SE",
        "CH",
        "TW",
        "TR",
        "GB",
        "US",
        "UY"]

    # Spotify URI scheme defined in [1], and the ID format as base-62 in [2].
    #
    # Unfortunately the IANA specification is out of date and doesn't include the new types
    # show and episode. Additionally, for the user URI, it does not specify which characters
    # are valid for usernames, so the assumption is alphanumeric which coincidentially are also
    # the same ones base-62 uses.
    # In limited manual exploration this seems to hold true, as newly accounts are assigned an
    # identifier that looks like the base-62 of all other IDs, but some older accounts only have
    # numbers and even older ones seemed to have been allowed to freely pick this name.
    #
    # [1] https://www.iana.org/assignments/uri-schemes/prov/spotify
    # [2] https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
    _regex_spotify_uri = r'^spotify:(?P<type>track|artist|album|playlist|show|episode|user):(?P<id>[0-9A-Za-z]+)$'  # noqa: E501

    # Spotify URLs are defined at [1]. The assumption is made that they are all
    # pointing to open.spotify.com, so a regex is used to parse them as well,
    # instead of a more complex URL parsing function.
    #
    # [1] https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids
    _regex_spotify_url = r'^(http[s]?:\/\/)?open.spotify.com\/(?P<type>track|artist|album|playlist|show|episode|user)\/(?P<id>[0-9A-Za-z]+)(\?.*)?$'  # noqa: E501

    _regex_base62 = r'^[0-9A-Za-z]+$'

    def __init__(
        self,
        auth=None,
        requests_session=True,
        client_credentials_manager=None,
        oauth_manager=None,
        auth_manager=None,
        proxies=None,
        requests_timeout=5,
        status_forcelist=None,
        retries=max_retries,
        status_retries=max_retries,
        backoff_factor=0.3,
        language=None,
    ):
        """
        Creates a Spotify API client.

        :param auth: An access token (optional)
        :param requests_session:
            A Requests session object or a truthy value to create one.
            A falsy value disables sessions.
            It should generally be a good idea to keep sessions enabled
            for performance reasons (connection pooling).
        :param client_credentials_manager:
            SpotifyClientCredentials object
        :param oauth_manager:
            SpotifyOAuth object
        :param auth_manager:
            SpotifyOauth, SpotifyClientCredentials,
            or SpotifyImplicitGrant object
        :param proxies:
            Definition of proxies (optional).
            See Requests doc https://2.python-requests.org/en/master/user/advanced/#proxies
        :param requests_timeout:
            Tell Requests to stop waiting for a response after a given
            number of seconds
        :param status_forcelist:
            Tell requests what type of status codes retries should occur on
        :param retries:
            Total number of retries to allow
        :param status_retries:
            Number of times to retry on bad status codes
        :param backoff_factor:
            A backoff factor to apply between attempts after the second try
            See urllib3 https://urllib3.readthedocs.io/en/latest/reference/urllib3.util.html
        :param language:
            The language parameter advertises what language the user prefers to see.
            See ISO-639-1 language code: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
        """
        self.prefix = "https://api.spotify.com/v1/"
        self._auth = auth
        self.client_credentials_manager = client_credentials_manager
        self.oauth_manager = oauth_manager
        self.auth_manager = auth_manager
        self.proxies = proxies
        self.requests_timeout = requests_timeout
        self.status_forcelist = status_forcelist or self.default_retry_codes
        self.backoff_factor = backoff_factor
        self.retries = retries
        self.status_retries = status_retries
        self.language = language

        if isinstance(requests_session, requests.Session):
            self._session = requests_session
        else:
            if requests_session:  # Build a new session.
                self._build_session()
            else:  # Use the Requests API module as a "session".
                self._session = requests.api

    def set_auth(self, auth):
        self._auth = auth

    @property
    def auth_manager(self):
        return self._auth_manager

    @auth_manager.setter
    def auth_manager(self, auth_manager):
        if auth_manager is not None:
            self._auth_manager = auth_manager
        else:
            self._auth_manager = (
                self.client_credentials_manager or self.oauth_manager
            )

    def __del__(self):
        """Make sure the connection (pool) gets closed"""
        if isinstance(self._session, requests.Session):
            self._session.close()

    def _build_session(self):
        self._session = requests.Session()
        retry = urllib3.Retry(
            total=self.retries,
            connect=None,
            read=False,
            allowed_methods=frozenset(['GET', 'POST', 'PUT', 'DELETE']),
            status=self.status_retries,
            backoff_factor=self.backoff_factor,
            status_forcelist=self.status_forcelist)

        adapter = requests.adapters.HTTPAdapter(max_retries=retry)
        self._session.mount('http://', adapter)
        self._session.mount('https://', adapter)

    def _auth_headers(self):
        if self._auth:
            return {"Authorization": "Bearer {0}".format(self._auth)}
        if not self.auth_manager:
            return {}
        try:
            token = self.auth_manager.get_access_token(as_dict=False)
        except TypeError:
            token = self.auth_manager.get_access_token()
        return {"Authorization": "Bearer {0}".format(token)}

    def _internal_call(self, method, url, payload, params):
        args = dict(params=params)
        if not url.startswith("http"):
            url = self.prefix + url
        headers = self._auth_headers()

        if "content_type" in args["params"]:
            headers["Content-Type"] = args["params"]["content_type"]
            del args["params"]["content_type"]
            if payload:
                args["data"] = payload
        else:
            headers["Content-Type"] = "application/json"
            if payload:
                args["data"] = json.dumps(payload)

        if self.language is not None:
            headers["Accept-Language"] = self.language

        logger.debug('Sending %s to %s with Params: %s Headers: %s and Body: %r ',
                     method, url, args.get("params"), headers, args.get('data'))

        try:
            response = self._session.request(
                method, url, headers=headers, proxies=self.proxies,
                timeout=self.requests_timeout, **args
            )

            response.raise_for_status()
            results = response.json()
        except requests.exceptions.HTTPError as http_error:
            response = http_error.response
            try:
                json_response = response.json()
                error = json_response.get("error", {})
                msg = error.get("message")
                reason = error.get("reason")
            except ValueError:
                # if the response cannot be decoded into JSON (which raises a ValueError),
                # then try to decode it into text

                # if we receive an empty string (which is falsy), then replace it with `None`
                msg = response.text or None
                reason = None

            logger.error(
                'HTTP Error for %s to %s with Params: %s returned %s due to %s',
                method, url, args.get("params"), response.status_code, msg
            )

            raise SpotifyException(
                response.status_code,
                -1,
                "%s:\n %s" % (response.url, msg),
                reason=reason,
                headers=response.headers,
            )
        except requests.exceptions.RetryError as retry_error:
            request = retry_error.request
            logger.error('Max Retries reached')
            try:
                reason = retry_error.args[0].reason
            except (IndexError, AttributeError):
                reason = None
            raise SpotifyException(
                429,
                -1,
                "%s:\n %s" % (request.path_url, "Max Retries"),
                reason=reason
            )
        except ValueError:
            results = None

        logger.debug('RESULTS: %s', results)
        return results

    def _get(self, url, args=None, payload=None, **kwargs):
        if args:
            kwargs.update(args)

        return self._internal_call("GET", url, payload, kwargs)

    def _post(self, url, args=None, payload=None, **kwargs):
        if args:
            kwargs.update(args)
        return self._internal_call("POST", url, payload, kwargs)

    def _delete(self, url, args=None, payload=None, **kwargs):
        if args:
            kwargs.update(args)
        return self._internal_call("DELETE", url, payload, kwargs)

    def _put(self, url, args=None, payload=None, **kwargs):
        if args:
            kwargs.update(args)
        return self._internal_call("PUT", url, payload, kwargs)

    def next(self, result):
        """ returns the next result given a paged result

            Parameters:
                - result - a previously returned paged result
        """
        if result["next"]:
            return self._get(result["next"])
        else:
            return None

    def previous(self, result):
        """ returns the previous result given a paged result

            Parameters:
                - result - a previously returned paged result
        """
        if result["previous"]:
            return self._get(result["previous"])
        else:
            return None

    def track(self, track_id, market=None):
        """ returns a single track given the track's ID, URI or URL

            Parameters:
                - track_id - a spotify URI, URL or ID
                - market - an ISO 3166-1 alpha-2 country code.
        """

        trid = self._get_id("track", track_id)
        return self._get("tracks/" + trid, market=market)

    def tracks(self, tracks, market=None):
        """ returns a list of tracks given a list of track IDs, URIs, or URLs

            Parameters:
                - tracks - a list of spotify URIs, URLs or IDs. Maximum: 50 IDs.
                - market - an ISO 3166-1 alpha-2 country code.
        """

        tlist = [self._get_id("track", t) for t in tracks]
        return self._get("tracks/?ids=" + ",".join(tlist), market=market)

    def artist(self, artist_id):
        """ returns a single artist given the artist's ID, URI or URL

            Parameters:
                - artist_id - an artist ID, URI or URL
        """

        trid = self._get_id("artist", artist_id)
        return self._get("artists/" + trid)

    def artists(self, artists):
        """ returns a list of artists given the artist IDs, URIs, or URLs

            Parameters:
                - artists - a list of  artist IDs, URIs or URLs
        """

        tlist = [self._get_id("artist", a) for a in artists]
        return self._get("artists/?ids=" + ",".join(tlist))

    def artist_albums(
        self, artist_id, album_type=None, country=None, limit=20, offset=0
    ):
        """ Get Spotify catalog information about an artist's albums

            Parameters:
                - artist_id - the artist ID, URI or URL
                - album_type - 'album', 'single', 'appears_on', 'compilation'
                - country - limit the response to one particular country.
                - limit  - the number of albums to return
                - offset - the index of the first album to return
        """

        trid = self._get_id("artist", artist_id)
        return self._get(
            "artists/" + trid + "/albums",
            album_type=album_type,
            country=country,
            limit=limit,
            offset=offset,
        )

    def artist_top_tracks(self, artist_id, country="US"):
        """ Get Spotify catalog information about an artist's top 10 tracks
            by country.

            Parameters:
                - artist_id - the artist ID, URI or URL
                - country - limit the response to one particular country.
        """

        trid = self._get_id("artist", artist_id)
        return self._get("artists/" + trid + "/top-tracks", country=country)

    def artist_related_artists(self, artist_id):
        """ Get Spotify catalog information about artists similar to an
            identified artist. Similarity is based on analysis of the
            Spotify community's listening history.

            Parameters:
                - artist_id - the artist ID, URI or URL
        """
        trid = self._get_id("artist", artist_id)
        return self._get("artists/" + trid + "/related-artists")

    def album(self, album_id, market=None):
        """ returns a single album given the album's ID, URIs or URL

            Parameters:
                - album_id - the album ID, URI or URL
                - market - an ISO 3166-1 alpha-2 country code
        """

        trid = self._get_id("album", album_id)
        if market is not None:
            return self._get("albums/" + trid + '?market=' + market)
        else:
            return self._get("albums/" + trid)

    def album_tracks(self, album_id, limit=50, offset=0, market=None):
        """ Get Spotify catalog information about an album's tracks

            Parameters:
                - album_id - the album ID, URI or URL
                - limit  - the number of items to return
                - offset - the index of the first item to return
                - market - an ISO 3166-1 alpha-2 country code.

        """

        trid = self._get_id("album", album_id)
        return self._get(
            "albums/" + trid + "/tracks/", limit=limit, offset=offset, market=market
        )

    def albums(self, albums, market=None):
        """ returns a list of albums given the album IDs, URIs, or URLs

            Parameters:
                - albums - a list of  album IDs, URIs or URLs
                - market - an ISO 3166-1 alpha-2 country code
        """

        tlist = [self._get_id("album", a) for a in albums]
        if market is not None:
            return self._get("albums/?ids=" + ",".join(tlist) + '&market=' + market)
        else:
            return self._get("albums/?ids=" + ",".join(tlist))

    def show(self, show_id, market=None):
        """ returns a single show given the show's ID, URIs or URL

            Parameters:
                - show_id - the show ID, URI or URL
                - market - an ISO 3166-1 alpha-2 country code.
                           The show must be available in the given market.
                           If user-based authorization is in use, the user's country
                           takes precedence. If neither market nor user country are
                           provided, the content is considered unavailable for the client.
        """

        trid = self._get_id("show", show_id)
        return self._get("shows/" + trid, market=market)

    def shows(self, shows, market=None):
        """ returns a list of shows given the show IDs, URIs, or URLs

            Parameters:
                - shows - a list of show IDs, URIs or URLs
                - market - an ISO 3166-1 alpha-2 country code.
                           Only shows available in the given market will be returned.
                           If user-based authorization is in use, the user's country
                           takes precedence. If neither market nor user country are
                           provided, the content is considered unavailable for the client.
        """

        tlist = [self._get_id("show", s) for s in shows]
        return self._get("shows/?ids=" + ",".join(tlist), market=market)

    def show_episodes(self, show_id, limit=50, offset=0, market=None):
        """ Get Spotify catalog information about a show's episodes

            Parameters:
                - show_id - the show ID, URI or URL
                - limit  - the number of items to return
                - offset - the index of the first item to return
                - market - an ISO 3166-1 alpha-2 country code.
                           Only episodes available in the given market will be returned.
                           If user-based authorization is in use, the user's country
                           takes precedence. If neither market nor user country are
                           provided, the content is considered unavailable for the client.
        """

        trid = self._get_id("show", show_id)
        return self._get(
            "shows/" + trid + "/episodes/", limit=limit, offset=offset, market=market
        )

    def episode(self, episode_id, market=None):
        """ returns a single episode given the episode's ID, URIs or URL

            Parameters:
                - episode_id - the episode ID, URI or URL
                - market - an ISO 3166-1 alpha-2 country code.
                           The episode must be available in the given market.
                           If user-based authorization is in use, the user's country
                           takes precedence. If neither market nor user country are
                           provided, the content is considered unavailable for the client.
        """

        trid = self._get_id("episode", episode_id)
        return self._get("episodes/" + trid, market=market)

    def episodes(self, episodes, market=None):
        """ returns a list of episodes given the episode IDs, URIs, or URLs

            Parameters:
                - episodes - a list of episode IDs, URIs or URLs
                - market - an ISO 3166-1 alpha-2 country code.
                           Only episodes available in the given market will be returned.
                           If user-based authorization is in use, the user's country
                           takes precedence. If neither market nor user country are
                           provided, the content is considered unavailable for the client.
        """

        tlist = [self._get_id("episode", e) for e in episodes]
        return self._get("episodes/?ids=" + ",".join(tlist), market=market)

    def search(self, q, limit=10, offset=0, type="track", market=None):
        """ searches for an item

            Parameters:
                - q - the search query (see how to write a query in the
                      official documentation https://developer.spotify.com/documentation/web-api/reference/search/)  # noqa
                - limit - the number of items to return (min = 1, default = 10, max = 50). The limit is applied
                          within each type, not on the total response.
                - offset - the index of the first item to return
                - type - the types of items to return. One or more of 'artist', 'album',
                         'track', 'playlist', 'show', and 'episode'.  If multiple types are desired,
                         pass in a comma separated string; e.g., 'track,album,episode'.
                - market - An ISO 3166-1 alpha-2 country code or the string
                           from_token.
        """
        return self._get(
            "search", q=q, limit=limit, offset=offset, type=type, market=market
        )

    def search_markets(self, q, limit=10, offset=0, type="track", markets=None, total=None):
        """ (experimental) Searches multiple markets for an item

            Parameters:
                - q - the search query (see how to write a query in the
                      official documentation https://developer.spotify.com/documentation/web-api/reference/search/)  # noqa
                - limit  - the number of items to return (min = 1, default = 10, max = 50). If a search is to be done on multiple
                            markets, then this limit is applied to each market. (e.g. search US, CA, MX each with a limit of 10).
                - offset - the index of the first item to return
                - type - the types of items to return. One or more of 'artist', 'album',
                         'track', 'playlist', 'show', or 'episode'. If multiple types are desired, pass in a comma separated string.
                - markets - A list of ISO 3166-1 alpha-2 country codes. Search all country markets by default.
                - total - the total number of results to return if multiple markets are supplied in the search.
                          If multiple types are specified, this only applies to the first type.
        """
        warnings.warn(
            "Searching multiple markets is an experimental feature. "
            "Please be aware that this method's inputs and outputs can change in the future.",
            UserWarning,
        )
        if not markets:
            markets = self.country_codes

        if not (isinstance(markets, list) or isinstance(markets, tuple)):
            markets = []

        warnings.warn(
            "Searching multiple markets is poorly performing.",
            UserWarning,
        )
        return self._search_multiple_markets(q, limit, offset, type, markets, total)

    def user(self, user):
        """ Gets basic profile information about a Spotify User

            Parameters:
                - user - the id of the usr
        """
        return self._get("users/" + user)

    def current_user_playlists(self, limit=50, offset=0):
        """ Get current user playlists without required getting his profile
            Parameters:
                - limit  - the number of items to return
                - offset - the index of the first item to return
        """
        return self._get("me/playlists", limit=limit, offset=offset)

    def playlist(self, playlist_id, fields=None, market=None, additional_types=("track",)):
        """ Gets playlist by id.

            Parameters:
                - playlist - the id of the playlist
                - fields - which fields to return
                - market - An ISO 3166-1 alpha-2 country code or the
                           string from_token.
                - additional_types - list of item types to return.
                                     valid types are: track and episode
        """
        plid = self._get_id("playlist", playlist_id)
        return self._get(
            "playlists/%s" % (plid),
            fields=fields,
            market=market,
            additional_types=",".join(additional_types),
        )

    def playlist_tracks(
        self,
        playlist_id,
        fields=None,
        limit=100,
        offset=0,
        market=None,
        additional_types=("track",)
    ):
        """ Get full details of the tracks of a playlist.

            Parameters:
                - playlist_id - the playlist ID, URI or URL
                - fields - which fields to return
                - limit - the maximum number of tracks to return
                - offset - the index of the first track to return
                - market - an ISO 3166-1 alpha-2 country code.
                - additional_types - list of item types to return.
                                     valid types are: track and episode
        """
        warnings.warn(
            "You should use `playlist_items(playlist_id, ...,"
            "additional_types=('track',))` instead",
            DeprecationWarning,
        )
        return self.playlist_items(playlist_id, fields, limit, offset,
                                   market, additional_types)

    def playlist_items(
        self,
        playlist_id,
        fields=None,
        limit=100,
        offset=0,
        market=None,
        additional_types=("track", "episode")
    ):
        """ Get full details of the tracks and episodes of a playlist.

            Parameters:
                - playlist_id - the playlist ID, URI or URL
                - fields - which fields to return
                - limit - the maximum number of tracks to return
                - offset - the index of the first track to return
                - market - an ISO 3166-1 alpha-2 country code.
                - additional_types - list of item types to return.
                                     valid types are: track and episode
        """
        plid = self._get_id("playlist", playlist_id)
        return self._get(
            "playlists/%s/tracks" % (plid),
            limit=limit,
            offset=offset,
            fields=fields,
            market=market,
            additional_types=",".join(additional_types)
        )

    def playlist_cover_image(self, playlist_id):
        """ Get cover image of a playlist.

            Parameters:
                - playlist_id - the playlist ID, URI or URL
        """
        plid = self._get_id("playlist", playlist_id)
        return self._get("playlists/%s/images" % (plid))

    def playlist_upload_cover_image(self, playlist_id, image_b64):
        """ Replace the image used to represent a specific playlist

            Parameters:
                - playlist_id - the id of the playlist
                - image_b64 - image data as a Base64 encoded JPEG image string
                    (maximum payload size is 256 KB)
        """
        plid = self._get_id("playlist", playlist_id)
        return self._put(
            "playlists/{}/images".format(plid),
            payload=image_b64,
            content_type="image/jpeg",
        )

    def user_playlist(self, user, playlist_id=None, fields=None, market=None):
        warnings.warn(
            "You should use `playlist(playlist_id)` instead",
            DeprecationWarning,
        )

        """ Gets a single playlist of a user

            Parameters:
                - user - the id of the user
                - playlist_id - the id of the playlist
                - fields - which fields to return
        """
        if playlist_id is None:
            return self._get("users/%s/starred" % user)
        return self.playlist(playlist_id, fields=fields, market=market)

    def user_playlist_tracks(
        self,
        user=None,
        playlist_id=None,
        fields=None,
        limit=100,
        offset=0,
        market=None,
    ):
        warnings.warn(
            "You should use `playlist_tracks(playlist_id)` instead",
            DeprecationWarning,
        )

        """ Get full details of the tracks of a playlist owned by a user.

            Parameters:
                - user - the id of the user
                - playlist_id - the id of the playlist
                - fields - which fields to return
                - limit - the maximum number of tracks to return
                - offset - the index of the first track to return
                - market - an ISO 3166-1 alpha-2 country code.
        """
        return self.playlist_tracks(
            playlist_id,
            limit=limit,
            offset=offset,
            fields=fields,
            market=market,
        )

    def user_playlists(self, user, limit=50, offset=0):
        """ Gets playlists of a user

            Parameters:
                - user - the id of the usr
                - limit  - the number of items to return
                - offset - the index of the first item to return
        """
        return self._get(
            "users/%s/playlists" % user, limit=limit, offset=offset
        )

    def user_playlist_create(self, user, name, public=True, collaborative=False, description=""):
        """ Creates a playlist for a user

            Parameters:
                - user - the id of the user
                - name - the name of the playlist
                - public - is the created playlist public
                - collaborative - is the created playlist collaborative
                - description - the description of the playlist
        """
        data = {
            "name": name,
            "public": public,
            "collaborative": collaborative,
            "description": description
        }

        return self._post("users/%s/playlists" % (user,), payload=data)

    def user_playlist_change_details(
        self,
        user,
        playlist_id,
        name=None,
        public=None,
        collaborative=None,
        description=None,
    ):
        warnings.warn(
            "You should use `playlist_change_details(playlist_id, ...)` instead",
            DeprecationWarning,
        )
        """ Changes a playlist's name and/or public/private state

            Parameters:
                - user - the id of the user
                - playlist_id - the id of the playlist
                - name - optional name of the playlist
                - public - optional is the playlist public
                - collaborative - optional is the playlist collaborative
                - description - optional description of the playlist
        """

        return self.playlist_change_details(playlist_id, name, public,
                                            collaborative, description)

    def user_playlist_unfollow(self, user, playlist_id):
        """ Unfollows (deletes) a playlist for a user

            Parameters:
                - user - the id of the user
                - name - the name of the playlist
        """
        warnings.warn(
            "You should use `current_user_unfollow_playlist(playlist_id)` instead",
            DeprecationWarning,
        )
        return self.current_user_unfollow_playlist(playlist_id)

    def user_playlist_add_tracks(
        self, user, playlist_id, tracks, position=None
    ):
        warnings.warn(
            "You should use `playlist_add_items(playlist_id, tracks)` instead",
            DeprecationWarning,
        )
        """ Adds tracks to a playlist

            Parameters:
                - user - the id of the user
                - playlist_id - the id of the playlist
                - tracks - a list of track URIs, URLs or IDs
                - position - the position to add the tracks
        """
        tracks = [self._get_uri("track", tid) for tid in tracks]
        return self.playlist_add_items(playlist_id, tracks, position)

    def user_playlist_add_episodes(
        self, user, playlist_id, episodes, position=None
    ):
        warnings.warn(
            "You should use `playlist_add_items(playlist_id, episodes)` instead",
            DeprecationWarning,
        )
        """ Adds episodes to a playlist

            Parameters:
                - user - the id of the user
                - playlist_id - the id of the playlist
                - episodes - a list of track URIs, URLs or IDs
                - position - the position to add the episodes
        """
        episodes = [self._get_uri("episode", tid) for tid in episodes]
        return self.playlist_add_items(playlist_id, episodes, position)

    def user_playlist_replace_tracks(self, user, playlist_id, tracks):
        """ Replace all tracks in a playlist for a user

            Parameters:
                - user - the id of the user
                - playlist_id - the id of the playlist
                - tracks - the list of track ids to add to the playlist
        """
        warnings.warn(
            "You should use `playlist_replace_items(playlist_id, tracks)` instead",
            DeprecationWarning,
        )
        return self.playlist_replace_items(playlist_id, tracks)

    def user_playlist_reorder_tracks(
        self,
        user,
        playlist_id,
        range_start,
        insert_before,
        range_length=1,
        snapshot_id=None,
    ):
        """ Reorder tracks in a playlist from a user

            Parameters:
                - user - the id of the user
                - playlist_id - the id of the playlist
                - range_start - the position of the first track to be reordered
                - range_length - optional the number of tracks to be reordered
                                 (default: 1)
                - insert_before - the position where the tracks should be
                                  inserted
                - snapshot_id - optional playlist's snapshot ID
        """
        warnings.warn(
            "You should use `playlist_reorder_items(playlist_id, ...)` instead",
            DeprecationWarning,
        )
        return self.playlist_reorder_items(playlist_id, range_start,
                                           insert_before, range_length,
                                           snapshot_id)

    def user_playlist_remove_all_occurrences_of_tracks(
        self, user, playlist_id, tracks, snapshot_id=None
    ):
        """ Removes all occurrences of the given tracks from the given playlist

            Parameters:
                - user - the id of the user
                - playlist_id - the id of the playlist
                - tracks - the list of track ids to remove from the playlist
                - snapshot_id - optional id of the playlist snapshot

        """
        warnings.warn(
            "You should use `playlist_remove_all_occurrences_of_items"
            "(playlist_id, tracks)` instead",
            DeprecationWarning,
        )
        return self.playlist_remove_all_occurrences_of_items(playlist_id,
                                                             tracks,
                                                             snapshot_id)

    def user_playlist_remove_specific_occurrences_of_tracks(
        self, user, playlist_id, tracks, snapshot_id=None
    ):
        """ Removes all occurrences of the given tracks from the given playlist

            Parameters:
                - user - the id of the user
                - playlist_id - the id of the playlist
                - tracks - an array of objects containing Spotify URIs of the
                    tracks to remove with their current positions in the
                    playlist.  For example:
                        [  { "uri":"4iV5W9uYEdYUVa79Axb7Rh", "positions":[2] },
                        { "uri":"1301WleyT98MSxVHPZCA6M", "positions":[7] } ]
                - snapshot_id - optional id of the playlist snapshot
        """
        warnings.warn(
            "You should use `playlist_remove_specific_occurrences_of_items"
            "(playlist_id, tracks)` instead",
            DeprecationWarning,
        )
        plid = self._get_id("playlist", playlist_id)
        ftracks = []
        for tr in tracks:
            ftracks.append(
                {
                    "uri": self._get_uri("track", tr["uri"]),
                    "positions": tr["positions"],
                }
            )
        payload = {"tracks": ftracks}
        if snapshot_id:
            payload["snapshot_id"] = snapshot_id
        return self._delete(
            "users/%s/playlists/%s/tracks" % (user, plid), payload=payload
        )

    def user_playlist_follow_playlist(self, playlist_owner_id, playlist_id):
        """
        Add the current authenticated user as a follower of a playlist.

        Parameters:
            - playlist_owner_id - the user id of the playlist owner
            - playlist_id - the id of the playlist

        """
        warnings.warn(
            "You should use `current_user_follow_playlist(playlist_id)` instead",
            DeprecationWarning,
        )
        return self.current_user_follow_playlist(playlist_id)

    def user_playlist_is_following(
        self, playlist_owner_id, playlist_id, user_ids
    ):
        """
        Check to see if the given users are following the given playlist

        Parameters:
            - playlist_owner_id - the user id of the playlist owner
            - playlist_id - the id of the playlist
            - user_ids - the ids of the users that you want to check to see
                if they follow the playlist. Maximum: 5 ids.

        """
        warnings.warn(
            "You should use `playlist_is_following(playlist_id, user_ids)` instead",
            DeprecationWarning,
        )
        return self.playlist_is_following(playlist_id, user_ids)

    def playlist_change_details(
        self,
        playlist_id,
        name=None,
        public=None,
        collaborative=None,
        description=None,
    ):
        """ Changes a playlist's name and/or public/private state,
            collaborative state, and/or description

            Parameters:
                - playlist_id - the id of the playlist
                - name - optional name of the playlist
                - public - optional is the playlist public
                - collaborative - optional is the playlist collaborative
                - description - optional description of the playlist
        """

        data = {}
        if isinstance(name, six.string_types):
            data["name"] = name
        if isinstance(public, bool):
            data["public"] = public
        if isinstance(collaborative, bool):
            data["collaborative"] = collaborative
        if isinstance(description, six.string_types):
            data["description"] = description
        return self._put(
            "playlists/%s" % (self._get_id("playlist", playlist_id)), payload=data
        )

    def current_user_unfollow_playlist(self, playlist_id):
        """ Unfollows (deletes) a playlist for the current authenticated
            user

            Parameters:
                - name - the name of the playlist
        """
        return self._delete(
            "playlists/%s/followers" % (playlist_id)
        )

    def playlist_add_items(
        self, playlist_id, items, position=None
    ):
        """ Adds tracks/episodes to a playlist

            Parameters:
                - playlist_id - the id of the playlist
                - items - a list of track/episode URIs or URLs
                - position - the position to add the tracks
        """
        plid = self._get_id("playlist", playlist_id)
        ftracks = [self._get_uri("track", tid) for tid in items]
        return self._post(
            "playlists/%s/tracks" % (plid),
            payload=ftracks,
            position=position,
        )

    def playlist_replace_items(self, playlist_id, items):
        """ Replace all tracks/episodes in a playlist

            Parameters:
                - playlist_id - the id of the playlist
                - items - list of track/episode ids to comprise playlist
        """
        plid = self._get_id("playlist", playlist_id)
        ftracks = [self._get_uri("track", tid) for tid in items]
        payload = {"uris": ftracks}
        return self._put(
            "playlists/%s/tracks" % (plid), payload=payload
        )

    def playlist_reorder_items(
        self,
        playlist_id,
        range_start,
        insert_before,
        range_length=1,
        snapshot_id=None,
    ):
        """ Reorder tracks in a playlist

            Parameters:
                - playlist_id - the id of the playlist
                - range_start - the position of the first track to be reordered
                - range_length - optional the number of tracks to be reordered
                                 (default: 1)
                - insert_before - the position where the tracks should be
                                  inserted
                - snapshot_id - optional playlist's snapshot ID
        """
        plid = self._get_id("playlist", playlist_id)
        payload = {
            "range_start": range_start,
            "range_length": range_length,
            "insert_before": insert_before,
        }
        if snapshot_id:
            payload["snapshot_id"] = snapshot_id
        return self._put(
            "playlists/%s/tracks" % (plid), payload=payload
        )

    def playlist_remove_all_occurrences_of_items(
        self, playlist_id, items, snapshot_id=None
    ):
        """ Removes all occurrences of the given tracks/episodes from the given playlist

            Parameters:
                - playlist_id - the id of the playlist
                - items - list of track/episode ids to remove from the playlist
                - snapshot_id - optional id of the playlist snapshot

        """

        plid = self._get_id("playlist", playlist_id)
        ftracks = [self._get_uri("track", tid) for tid in items]
        payload = {"tracks": [{"uri": track} for track in ftracks]}
        if snapshot_id:
            payload["snapshot_id"] = snapshot_id
        return self._delete(
            "playlists/%s/tracks" % (plid), payload=payload
        )

    def playlist_remove_specific_occurrences_of_items(
        self, playlist_id, items, snapshot_id=None
    ):
        """ Removes all occurrences of the given tracks from the given playlist

            Parameters:
                - playlist_id - the id of the playlist
                - items - an array of objects containing Spotify URIs of the
                    tracks/episodes to remove with their current positions in
                    the playlist.  For example:
                        [  { "uri":"4iV5W9uYEdYUVa79Axb7Rh", "positions":[2] },
                        { "uri":"1301WleyT98MSxVHPZCA6M", "positions":[7] } ]
                - snapshot_id - optional id of the playlist snapshot
        """

        plid = self._get_id("playlist", playlist_id)
        ftracks = []
        for tr in items:
            ftracks.append(
                {
                    "uri": self._get_uri("track", tr["uri"]),
                    "positions": tr["positions"],
                }
            )
        payload = {"tracks": ftracks}
        if snapshot_id:
            payload["snapshot_id"] = snapshot_id
        return self._delete(
            "playlists/%s/tracks" % (plid), payload=payload
        )

    def current_user_follow_playlist(self, playlist_id):
        """
        Add the current authenticated user as a follower of a playlist.

        Parameters:
            - playlist_id - the id of the playlist

        """
        return self._put(
            "playlists/{}/followers".format(playlist_id)
        )

    def playlist_is_following(
        self, playlist_id, user_ids
    ):
        """
        Check to see if the given users are following the given playlist

        Parameters:
            - playlist_id - the id of the playlist
            - user_ids - the ids of the users that you want to check to see
                if they follow the playlist. Maximum: 5 ids.

        """
        endpoint = "playlists/{}/followers/contains?ids={}"
        return self._get(
            endpoint.format(playlist_id, ",".join(user_ids))
        )

    def me(self):
        """ Get detailed profile information about the current user.
            An alias for the 'current_user' method.
        """
        return self._get("me/")

    def current_user(self):
        """ Get detailed profile information about the current user.
            An alias for the 'me' method.
        """
        return self.me()

    def current_user_playing_track(self):
        """ Get information about the current users currently playing track.
        """
        return self._get("me/player/currently-playing")

    def current_user_saved_albums(self, limit=20, offset=0, market=None):
        """ Gets a list of the albums saved in the current authorized user's
            "Your Music" library

            Parameters:
                - limit - the number of albums to return (MAX_LIMIT=50)
                - offset - the index of the first album to return
                - market - an ISO 3166-1 alpha-2 country code.

        """
        return self._get("me/albums", limit=limit, offset=offset, market=market)

    def current_user_saved_albums_add(self, albums=[]):
        """ Add one or more albums to the current user's
            "Your Music" library.
            Parameters:
                - albums - a list of album URIs, URLs or IDs
        """

        alist = [self._get_id("album", a) for a in albums]
        return self._put("me/albums?ids=" + ",".join(alist))

    def current_user_saved_albums_delete(self, albums=[]):
        """ Remove one or more albums from the current user's
            "Your Music" library.

            Parameters:
                - albums - a list of album URIs, URLs or IDs
        """
        alist = [self._get_id("album", a) for a in albums]
        return self._delete("me/albums/?ids=" + ",".join(alist))

    def current_user_saved_albums_contains(self, albums=[]):
        """ Check if one or more albums is already saved in
            the current Spotify user’s “Your Music” library.

            Parameters:
                - albums - a list of album URIs, URLs or IDs
        """
        alist = [self._get_id("album", a) for a in albums]
        return self._get("me/albums/contains?ids=" + ",".join(alist))

    def current_user_saved_tracks(self, limit=20, offset=0, market=None):
        """ Gets a list of the tracks saved in the current authorized user's
            "Your Music" library

            Parameters:
                - limit - the number of tracks to return
                - offset - the index of the first track to return
                - market - an ISO 3166-1 alpha-2 country code

        """
        return self._get("me/tracks", limit=limit, offset=offset, market=market)

    def current_user_saved_tracks_add(self, tracks=None):
        """ Add one or more tracks to the current user's
            "Your Music" library.

            Parameters:
                - tracks - a list of track URIs, URLs or IDs
        """
        tlist = []
        if tracks is not None:
            tlist = [self._get_id("track", t) for t in tracks]
        return self._put("me/tracks/?ids=" + ",".join(tlist))

    def current_user_saved_tracks_delete(self, tracks=None):
        """ Remove one or more tracks from the current user's
            "Your Music" library.

            Parameters:
                - tracks - a list of track URIs, URLs or IDs
        """
        tlist = []
        if tracks is not None:
            tlist = [self._get_id("track", t) for t in tracks]
        return self._delete("me/tracks/?ids=" + ",".join(tlist))

    def current_user_saved_tracks_contains(self, tracks=None):
        """ Check if one or more tracks is already saved in
            the current Spotify user’s “Your Music” library.

            Parameters:
                - tracks - a list of track URIs, URLs or IDs
        """
        tlist = []
        if tracks is not None:
            tlist = [self._get_id("track", t) for t in tracks]
        return self._get("me/tracks/contains?ids=" + ",".join(tlist))

    def current_user_saved_episodes(self, limit=20, offset=0, market=None):
        """ Gets a list of the episodes saved in the current authorized user's
            "Your Music" library

            Parameters:
                - limit - the number of episodes to return
                - offset - the index of the first episode to return
                - market - an ISO 3166-1 alpha-2 country code

        """
        return self._get("me/episodes", limit=limit, offset=offset, market=market)

    def current_user_saved_episodes_add(self, episodes=None):
        """ Add one or more episodes to the current user's
            "Your Music" library.

            Parameters:
                - episodes - a list of episode URIs, URLs or IDs
        """
        elist = []
        if episodes is not None:
            elist = [self._get_id("episode", e) for e in episodes]
        return self._put("me/episodes/?ids=" + ",".join(elist))

    def current_user_saved_episodes_delete(self, episodes=None):
        """ Remove one or more episodes from the current user's
            "Your Music" library.

            Parameters:
                - episodes - a list of episode URIs, URLs or IDs
        """
        elist = []
        if episodes is not None:
            elist = [self._get_id("episode", e) for e in episodes]
        return self._delete("me/episodes/?ids=" + ",".join(elist))

    def current_user_saved_episodes_contains(self, episodes=None):
        """ Check if one or more episodes is already saved in
            the current Spotify user’s “Your Music” library.

            Parameters:
                - episodes - a list of episode URIs, URLs or IDs
        """
        elist = []
        if episodes is not None:
            elist = [self._get_id("episode", e) for e in episodes]
        return self._get("me/episodes/contains?ids=" + ",".join(elist))

    def current_user_saved_shows(self, limit=20, offset=0, market=None):
        """ Gets a list of the shows saved in the current authorized user's
            "Your Music" library

            Parameters:
                - limit - the number of shows to return
                - offset - the index of the first show to return
                - market - an ISO 3166-1 alpha-2 country code

        """
        return self._get("me/shows", limit=limit, offset=offset, market=market)

    def current_user_saved_shows_add(self, shows=[]):
        """ Add one or more albums to the current user's
            "Your Music" library.
            Parameters:
                - shows - a list of show URIs, URLs or IDs
        """
        slist = [self._get_id("show", s) for s in shows]
        return self._put("me/shows?ids=" + ",".join(slist))

    def current_user_saved_shows_delete(self, shows=[]):
        """ Remove one or more shows from the current user's
            "Your Music" library.

            Parameters:
                - shows - a list of show URIs, URLs or IDs
        """
        slist = [self._get_id("show", s) for s in shows]
        return self._delete("me/shows/?ids=" + ",".join(slist))

    def current_user_saved_shows_contains(self, shows=[]):
        """ Check if one or more shows is already saved in
            the current Spotify user’s “Your Music” library.

            Parameters:
                - shows - a list of show URIs, URLs or IDs
        """
        slist = [self._get_id("show", s) for s in shows]
        return self._get("me/shows/contains?ids=" + ",".join(slist))

    def current_user_followed_artists(self, limit=20, after=None):
        """ Gets a list of the artists followed by the current authorized user

            Parameters:
                - limit - the number of artists to return
                - after - the last artist ID retrieved from the previous
                          request

        """
        return self._get(
            "me/following", type="artist", limit=limit, after=after
        )

    def current_user_following_artists(self, ids=None):
        """ Check if the current user is following certain artists

            Returns list of booleans respective to ids

            Parameters:
                - ids - a list of artist URIs, URLs or IDs
        """
        idlist = []
        if ids is not None:
            idlist = [self._get_id("artist", i) for i in ids]
        return self._get(
            "me/following/contains", ids=",".join(idlist), type="artist"
        )

    def current_user_following_users(self, ids=None):
        """ Check if the current user is following certain users

            Returns list of booleans respective to ids

            Parameters:
                - ids - a list of user URIs, URLs or IDs
        """
        idlist = []
        if ids is not None:
            idlist = [self._get_id("user", i) for i in ids]
        return self._get(
            "me/following/contains", ids=",".join(idlist), type="user"
        )

    def current_user_top_artists(
        self, limit=20, offset=0, time_range="medium_term"
    ):
        """ Get the current user's top artists

            Parameters:
                - limit - the number of entities to return
                - offset - the index of the first entity to return
                - time_range - Over what time frame are the affinities computed
                  Valid-values: short_term, medium_term, long_term
        """
        return self._get(
            "me/top/artists", time_range=time_range, limit=limit, offset=offset
        )

    def current_user_top_tracks(
        self, limit=20, offset=0, time_range="medium_term"
    ):
        """ Get the current user's top tracks

            Parameters:
                - limit - the number of entities to return
                - offset - the index of the first entity to return
                - time_range - Over what time frame are the affinities computed
                  Valid-values: short_term, medium_term, long_term
        """
        return self._get(
            "me/top/tracks", time_range=time_range, limit=limit, offset=offset
        )

    def current_user_recently_played(self, limit=50, after=None, before=None):
        """ Get the current user's recently played tracks

            Parameters:
                - limit - the number of entities to return
                - after - unix timestamp in milliseconds. Returns all items
                          after (but not including) this cursor position.
                          Cannot be used if before is specified.
                - before - unix timestamp in milliseconds. Returns all items
                           before (but not including) this cursor position.
                           Cannot be used if after is specified
        """
        return self._get(
            "me/player/recently-played",
            limit=limit,
            after=after,
            before=before,
        )

    def user_follow_artists(self, ids=[]):
        """ Follow one or more artists
            Parameters:
                - ids - a list of artist IDs
        """
        return self._put("me/following?type=artist&ids=" + ",".join(ids))

    def user_follow_users(self, ids=[]):
        """ Follow one or more users
            Parameters:
                - ids - a list of user IDs
        """
        return self._put("me/following?type=user&ids=" + ",".join(ids))

    def user_unfollow_artists(self, ids=[]):
        """ Unfollow one or more artists
            Parameters:
                - ids - a list of artist IDs
        """
        return self._delete("me/following?type=artist&ids=" + ",".join(ids))

    def user_unfollow_users(self, ids=[]):
        """ Unfollow one or more users
            Parameters:
                - ids - a list of user IDs
        """
        return self._delete("me/following?type=user&ids=" + ",".join(ids))

    def featured_playlists(
        self, locale=None, country=None, timestamp=None, limit=20, offset=0
    ):
        """ Get a list of Spotify featured playlists

            Parameters:
                - locale - The desired language, consisting of a lowercase ISO
                  639-1 alpha-2 language code and an uppercase ISO 3166-1 alpha-2
                  country code, joined by an underscore.

                - country - An ISO 3166-1 alpha-2 country code.

                - timestamp - A timestamp in ISO 8601 format:
                  yyyy-MM-ddTHH:mm:ss. Use this parameter to specify the user's
                  local time to get results tailored for that specific date and
                  time in the day

                - limit - The maximum number of items to return. Default: 20.
                  Minimum: 1. Maximum: 50

                - offset - The index of the first item to return. Default: 0
                  (the first object). Use with limit to get the next set of
                  items.
        """
        return self._get(
            "browse/featured-playlists",
            locale=locale,
            country=country,
            timestamp=timestamp,
            limit=limit,
            offset=offset,
        )

    def new_releases(self, country=None, limit=20, offset=0):
        """ Get a list of new album releases featured in Spotify

            Parameters:
                - country - An ISO 3166-1 alpha-2 country code.

                - limit - The maximum number of items to return. Default: 20.
                  Minimum: 1. Maximum: 50

                - offset - The index of the first item to return. Default: 0
                  (the first object). Use with limit to get the next set of
                  items.
        """
        return self._get(
            "browse/new-releases", country=country, limit=limit, offset=offset
        )

    def category(self, category_id, country=None, locale=None):
        """ Get info about a category

            Parameters:
                - category_id - The Spotify category ID for the category.

                - country - An ISO 3166-1 alpha-2 country code.
                - locale - The desired language, consisting of an ISO 639-1 alpha-2
                  language code and an ISO 3166-1 alpha-2 country code, joined
                  by an underscore.
        """
        return self._get(
            "browse/categories/" + category_id,
            country=country,
            locale=locale,
        )

    def categories(self, country=None, locale=None, limit=20, offset=0):
        """ Get a list of categories

            Parameters:
                - country - An ISO 3166-1 alpha-2 country code.
                - locale - The desired language, consisting of an ISO 639-1 alpha-2
                  language code and an ISO 3166-1 alpha-2 country code, joined
                  by an underscore.

                - limit - The maximum number of items to return. Default: 20.
                  Minimum: 1. Maximum: 50

                - offset - The index of the first item to return. Default: 0
                  (the first object). Use with limit to get the next set of
                  items.
        """
        return self._get(
            "browse/categories",
            country=country,
            locale=locale,
            limit=limit,
            offset=offset,
        )

    def category_playlists(
        self, category_id=None, country=None, limit=20, offset=0
    ):
        """ Get a list of playlists for a specific Spotify category

            Parameters:
                - category_id - The Spotify category ID for the category.

                - country - An ISO 3166-1 alpha-2 country code.

                - limit - The maximum number of items to return. Default: 20.
                  Minimum: 1. Maximum: 50

                - offset - The index of the first item to return. Default: 0
                  (the first object). Use with limit to get the next set of
                  items.
        """
        return self._get(
            "browse/categories/" + category_id + "/playlists",
            country=country,
            limit=limit,
            offset=offset,
        )

    def recommendations(
        self,
        seed_artists=None,
        seed_genres=None,
        seed_tracks=None,
        limit=20,
        country=None,
        **kwargs
    ):
        """ Get a list of recommended tracks for one to five seeds.
            (at least one of `seed_artists`, `seed_tracks` and `seed_genres`
            are needed)

            Parameters:
                - seed_artists - a list of artist IDs, URIs or URLs
                - seed_tracks - a list of track IDs, URIs or URLs
                - seed_genres - a list of genre names. Available genres for
                                recommendations can be found by calling
                                recommendation_genre_seeds

                - country - An ISO 3166-1 alpha-2 country code. If provided,
                            all results will be playable in this country.

                - limit - The maximum number of items to return. Default: 20.
                          Minimum: 1. Maximum: 100

                - min/max/target_<attribute> - For the tuneable track
                    attributes listed in the documentation, these values
                    provide filters and targeting on results.
        """
        params = dict(limit=limit)
        if seed_artists:
            params["seed_artists"] = ",".join(
                [self._get_id("artist", a) for a in seed_artists]
            )
        if seed_genres:
            params["seed_genres"] = ",".join(seed_genres)
        if seed_tracks:
            params["seed_tracks"] = ",".join(
                [self._get_id("track", t) for t in seed_tracks]
            )
        if country:
            params["market"] = country

        for attribute in [
            "acousticness",
            "danceability",
            "duration_ms",
            "energy",
            "instrumentalness",
            "key",
            "liveness",
            "loudness",
            "mode",
            "popularity",
            "speechiness",
            "tempo",
            "time_signature",
            "valence",
        ]:
            for prefix in ["min_", "max_", "target_"]:
                param = prefix + attribute
                if param in kwargs:
                    params[param] = kwargs[param]
        return self._get("recommendations", **params)

    def recommendation_genre_seeds(self):
        """ Get a list of genres available for the recommendations function.
        """
        return self._get("recommendations/available-genre-seeds")

    def audio_analysis(self, track_id):
        """ Get audio analysis for a track based upon its Spotify ID
            Parameters:
                - track_id - a track URI, URL or ID
        """
        trid = self._get_id("track", track_id)
        return self._get("audio-analysis/" + trid)

    def audio_features(self, tracks=[]):
        """ Get audio features for one or multiple tracks based upon their Spotify IDs
            Parameters:
                - tracks - a list of track URIs, URLs or IDs, maximum: 100 ids
        """
        if isinstance(tracks, str):
            trackid = self._get_id("track", tracks)
            results = self._get("audio-features/?ids=" + trackid)
        else:
            tlist = [self._get_id("track", t) for t in tracks]
            results = self._get("audio-features/?ids=" + ",".join(tlist))
        # the response has changed, look for the new style first, and if
        # its not there, fallback on the old style
        if "audio_features" in results:
            return results["audio_features"]
        else:
            return results

    def devices(self):
        """ Get a list of user's available devices.
        """
        return self._get("me/player/devices")

    def current_playback(self, market=None, additional_types=None):
        """ Get information about user's current playback.

            Parameters:
                - market - an ISO 3166-1 alpha-2 country code.
                - additional_types - `episode` to get podcast track information
        """
        return self._get("me/player", market=market, additional_types=additional_types)

    def currently_playing(self, market=None, additional_types=None):
        """ Get user's currently playing track.

            Parameters:
                - market - an ISO 3166-1 alpha-2 country code.
                - additional_types - `episode` to get podcast track information
        """
        return self._get("me/player/currently-playing", market=market,
                         additional_types=additional_types)

    def transfer_playback(self, device_id, force_play=True):
        """ Transfer playback to another device.
            Note that the API accepts a list of device ids, but only
            actually supports one.

            Parameters:
                - device_id - transfer playback to this device
                - force_play - true: after transfer, play. false:
                               keep current state.
        """
        data = {"device_ids": [device_id], "play": force_play}
        return self._put("me/player", payload=data)

    def start_playback(
        self, device_id=None, context_uri=None, uris=None, offset=None, position_ms=None
    ):
        """ Start or resume user's playback.

            Provide a `context_uri` to start playback of an album,
            artist, or playlist.

            Provide a `uris` list to start playback of one or more
            tracks.

            Provide `offset` as {"position": <int>} or {"uri": "<track uri>"}
            to start playback at a particular offset.

            Parameters:
                - device_id - device target for playback
                - context_uri - spotify context uri to play
                - uris - spotify track uris
                - offset - offset into context by index or track
                - position_ms - (optional) indicates from what position to start playback.
                                Must be a positive number. Passing in a position that is
                                greater than the length of the track will cause the player to
                                start playing the next song.
        """
        if context_uri is not None and uris is not None:
            logger.warning("Specify either context uri or uris, not both")
            return
        if uris is not None and not isinstance(uris, list):
            logger.warning("URIs must be a list")
            return
        data = {}
        if context_uri is not None:
            data["context_uri"] = context_uri
        if uris is not None:
            data["uris"] = uris
        if offset is not None:
            data["offset"] = offset
        if position_ms is not None:
            data["position_ms"] = position_ms
        return self._put(
            self._append_device_id("me/player/play", device_id), payload=data
        )

    def pause_playback(self, device_id=None):
        """ Pause user's playback.

            Parameters:
                - device_id - device target for playback
        """
        return self._put(self._append_device_id("me/player/pause", device_id))

    def next_track(self, device_id=None):
        """ Skip user's playback to next track.

            Parameters:
                - device_id - device target for playback
        """
        return self._post(self._append_device_id("me/player/next", device_id))

    def previous_track(self, device_id=None):
        """ Skip user's playback to previous track.

            Parameters:
                - device_id - device target for playback
        """
        return self._post(
            self._append_device_id("me/player/previous", device_id)
        )

    def seek_track(self, position_ms, device_id=None):
        """ Seek to position in current track.

            Parameters:
                - position_ms - position in milliseconds to seek to
                - device_id - device target for playback
        """
        if not isinstance(position_ms, int):
            logger.warning("Position_ms must be an integer")
            return
        return self._put(
            self._append_device_id(
                "me/player/seek?position_ms=%s" % position_ms, device_id
            )
        )

    def repeat(self, state, device_id=None):
        """ Set repeat mode for playback.

            Parameters:
                - state - `track`, `context`, or `off`
                - device_id - device target for playback
        """
        if state not in ["track", "context", "off"]:
            logger.warning("Invalid state")
            return
        self._put(
            self._append_device_id(
                "me/player/repeat?state=%s" % state, device_id
            )
        )

    def volume(self, volume_percent, device_id=None):
        """ Set playback volume.

            Parameters:
                - volume_percent - volume between 0 and 100
                - device_id - device target for playback
        """
        if not isinstance(volume_percent, int):
            logger.warning("Volume must be an integer")
            return
        if volume_percent < 0 or volume_percent > 100:
            logger.warning("Volume must be between 0 and 100, inclusive")
            return
        self._put(
            self._append_device_id(
                "me/player/volume?volume_percent=%s" % volume_percent,
                device_id,
            )
        )

    def shuffle(self, state, device_id=None):
        """ Toggle playback shuffling.

            Parameters:
                - state - true or false
                - device_id - device target for playback
        """
        if not isinstance(state, bool):
            logger.warning("state must be a boolean")
            return
        state = str(state).lower()
        self._put(
            self._append_device_id(
                "me/player/shuffle?state=%s" % state, device_id
            )
        )

    def queue(self):
        """ Gets the current user's queue """
        return self._get("me/player/queue")

    def add_to_queue(self, uri, device_id=None):
        """ Adds a song to the end of a user's queue

            If device A is currently playing music and you try to add to the queue
            and pass in the id for device B, you will get a
            'Player command failed: Restriction violated' error
            I therefore recommend leaving device_id as None so that the active device is targeted

            :param uri: song uri, id, or url
            :param device_id:
                the id of a Spotify device.
                If None, then the active device is used.

        """

        uri = self._get_uri("track", uri)

        endpoint = "me/player/queue?uri=%s" % uri

        if device_id is not None:
            endpoint += "&device_id=%s" % device_id

        return self._post(endpoint)

    def available_markets(self):
        """ Get the list of markets where Spotify is available.
            Returns a list of the countries in which Spotify is available, identified by their
            ISO 3166-1 alpha-2 country code with additional country codes for special territories.
        """
        return self._get("markets")

    def _append_device_id(self, path, device_id):
        """ Append device ID to API path.

            Parameters:
                - device_id - device id to append
        """
        if device_id:
            if "?" in path:
                path += "&device_id=%s" % device_id
            else:
                path += "?device_id=%s" % device_id
        return path

    def _get_id(self, type, id):
        uri_match = re.search(Spotify._regex_spotify_uri, id)
        if uri_match is not None:
            uri_match_groups = uri_match.groupdict()
            if uri_match_groups['type'] != type:
                # TODO change to a ValueError in v3
                raise SpotifyException(400, -1, "Unexpected Spotify URI type.")
            return uri_match_groups['id']

        url_match = re.search(Spotify._regex_spotify_url, id)
        if url_match is not None:
            url_match_groups = url_match.groupdict()
            if url_match_groups['type'] != type:
                raise SpotifyException(400, -1, "Unexpected Spotify URL type.")
            # TODO change to a ValueError in v3
            return url_match_groups['id']

        # Raw identifiers might be passed, ensure they are also base-62
        if re.search(Spotify._regex_base62, id) is not None:
            return id

        # TODO change to a ValueError in v3
        raise SpotifyException(400, -1, "Unsupported URL / URI.")

    def _get_uri(self, type, id):
        if self._is_uri(id):
            return id
        else:
            return "spotify:" + type + ":" + self._get_id(type, id)

    def _is_uri(self, uri):
        return re.search(Spotify._regex_spotify_uri, uri) is not None

    def _search_multiple_markets(self, q, limit, offset, type, markets, total):
        if total and limit > total:
            limit = total
            warnings.warn(
                "limit was auto-adjusted to equal {} as it must not be higher than total".format(
                    total),
                UserWarning,
            )

        results = {}
        first_type = type.split(",")[0] + 's'
        count = 0

        for country in markets:
            result = self._get(
                "search", q=q, limit=limit, offset=offset, type=type, market=country
            )
            results[country] = result

            count += len(result[first_type]['items'])
            if total and count >= total:
                break
            if total and limit > total - count:
                # when approaching `total` results, adjust `limit` to not request more
                # items than needed
                limit = total - count

        return results

src/spotipy/client.py
LightningJukeboxBot/LightningJukeboxBot
# -*- coding: utf-8 -*-

__all__ = [
    "SpotifyClientCredentials",
    "SpotifyOAuth",
    "SpotifyOauthError",
    "SpotifyStateError",
    "SpotifyImplicitGrant",
    "SpotifyPKCE"
]

import base64
import logging
import os
import time
import warnings
import webbrowser

import requests
# Workaround to support both python 2 & 3
import six
import six.moves.urllib.parse as urllibparse
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from six.moves.urllib_parse import parse_qsl, urlparse

from spotipy.cache_handler import CacheFileHandler, CacheHandler
from spotipy.util import CLIENT_CREDS_ENV_VARS, get_host_port, normalize_scope

logger = logging.getLogger(__name__)


class SpotifyOauthError(Exception):
    """ Error during Auth Code or Implicit Grant flow """

    def __init__(self, message, error=None, error_description=None, *args, **kwargs):
        self.error = error
        self.error_description = error_description
        self.__dict__.update(kwargs)
        super(SpotifyOauthError, self).__init__(message, *args, **kwargs)


class SpotifyStateError(SpotifyOauthError):
    """ The state sent and state received were different """

    def __init__(self, local_state=None, remote_state=None, message=None,
                 error=None, error_description=None, *args, **kwargs):
        if not message:
            message = ("Expected " + local_state + " but recieved "
                       + remote_state)
        super(SpotifyOauthError, self).__init__(message, error,
                                                error_description, *args,
                                                **kwargs)


def _make_authorization_headers(client_id, client_secret):
    auth_header = base64.b64encode(
        six.text_type(client_id + ":" + client_secret).encode("ascii")
    )
    return {"Authorization": "Basic %s" % auth_header.decode("ascii")}


def _ensure_value(value, env_key):
    env_val = CLIENT_CREDS_ENV_VARS[env_key]
    _val = value or os.getenv(env_val)
    if _val is None:
        msg = "No %s. Pass it or set a %s environment variable." % (
            env_key,
            env_val,
        )
        raise SpotifyOauthError(msg)
    return _val


class SpotifyAuthBase(object):
    def __init__(self, requests_session):
        if isinstance(requests_session, requests.Session):
            self._session = requests_session
        else:
            if requests_session:  # Build a new session.
                self._session = requests.Session()
            else:  # Use the Requests API module as a "session".
                from requests import api
                self._session = api

    def _normalize_scope(self, scope):
        return normalize_scope(scope)

    @property
    def client_id(self):
        return self._client_id

    @client_id.setter
    def client_id(self, val):
        self._client_id = _ensure_value(val, "client_id")

    @property
    def client_secret(self):
        return self._client_secret

    @client_secret.setter
    def client_secret(self, val):
        self._client_secret = _ensure_value(val, "client_secret")

    @property
    def redirect_uri(self):
        return self._redirect_uri

    @redirect_uri.setter
    def redirect_uri(self, val):
        self._redirect_uri = _ensure_value(val, "redirect_uri")

    @staticmethod
    def _get_user_input(prompt):
        return ""
# hard disable user input to avoid blocking
#        try:
#            return raw_input(prompt)
#        except NameError:
#            return input(prompt)

    @staticmethod
    def is_token_expired(token_info):
        now = int(time.time())
        return token_info["expires_at"] - now < 60

    @staticmethod
    def _is_scope_subset(needle_scope, haystack_scope):
        needle_scope = set(needle_scope.split()) if needle_scope else set()
        haystack_scope = (
            set(haystack_scope.split()) if haystack_scope else set()
        )
        return needle_scope <= haystack_scope

    def _handle_oauth_error(self, http_error):
        response = http_error.response
        try:
            error_payload = response.json()
            error = error_payload.get('error')
            error_description = error_payload.get('error_description')
        except ValueError:
            # if the response cannot be decoded into JSON (which raises a ValueError),
            # then try to decode it into text

            # if we receive an empty string (which is falsy), then replace it with `None`
            error = response.text or None
            error_description = None

        raise SpotifyOauthError(
            'error: {0}, error_description: {1}'.format(
                error, error_description
            ),
            error=error,
            error_description=error_description
        )

    def __del__(self):
        """Make sure the connection (pool) gets closed"""
        if isinstance(self._session, requests.Session):
            self._session.close()


class SpotifyClientCredentials(SpotifyAuthBase):
    OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token"

    def __init__(
        self,
        client_id=None,
        client_secret=None,
        proxies=None,
        requests_session=True,
        requests_timeout=None,
        cache_handler=None
    ):
        """
        Creates a Client Credentials Flow Manager.

        The Client Credentials flow is used in server-to-server authentication.
        Only endpoints that do not access user information can be accessed.
        This means that endpoints that require authorization scopes cannot be accessed.
        The advantage, however, of this authorization flow is that it does not require any
        user interaction

        You can either provide a client_id and client_secret to the
        constructor or set SPOTIPY_CLIENT_ID and SPOTIPY_CLIENT_SECRET
        environment variables

        Parameters:
             * client_id: Must be supplied or set as environment variable
             * client_secret: Must be supplied or set as environment variable
             * proxies: Optional, proxy for the requests library to route through
             * requests_session: A Requests session
             * requests_timeout: Optional, tell Requests to stop waiting for a response after
                                 a given number of seconds
             * cache_handler: An instance of the `CacheHandler` class to handle
                              getting and saving cached authorization tokens.
                              Optional, will otherwise use `CacheFileHandler`.
                              (takes precedence over `cache_path` and `username`)

        """

        super(SpotifyClientCredentials, self).__init__(requests_session)

        self.client_id = client_id
        self.client_secret = client_secret
        self.proxies = proxies
        self.requests_timeout = requests_timeout
        if cache_handler:
            assert issubclass(cache_handler.__class__, CacheHandler), \
                "cache_handler must be a subclass of CacheHandler: " + str(type(cache_handler)) \
                + " != " + str(CacheHandler)
            self.cache_handler = cache_handler
        else:
            self.cache_handler = CacheFileHandler()

    def get_access_token(self, as_dict=True, check_cache=True):
        """
        If a valid access token is in memory, returns it
        Else fetches a new token and returns it

            Parameters:
            - as_dict - a boolean indicating if returning the access token
                as a token_info dictionary, otherwise it will be returned
                as a string.
        """
        if as_dict:
            warnings.warn(
                "You're using 'as_dict = True'."
                "get_access_token will return the token string directly in future "
                "versions. Please adjust your code accordingly, or use "
                "get_cached_token instead.",
                DeprecationWarning,
                stacklevel=2,
            )

        if check_cache:
            token_info = self.cache_handler.get_cached_token()
            if token_info and not self.is_token_expired(token_info):
                return token_info if as_dict else token_info["access_token"]

        token_info = self._request_access_token()
        token_info = self._add_custom_values_to_token_info(token_info)
        self.cache_handler.save_token_to_cache(token_info)
        return token_info if as_dict else token_info["access_token"]

    def _request_access_token(self):
        """Gets client credentials access token """
        payload = {"grant_type": "client_credentials"}

        headers = _make_authorization_headers(
            self.client_id, self.client_secret
        )

        logger.debug(
            "sending POST request to %s with Headers: %s and Body: %r",
            self.OAUTH_TOKEN_URL, headers, payload
        )

        try:
            response = self._session.post(
                self.OAUTH_TOKEN_URL,
                data=payload,
                headers=headers,
                verify=True,
                proxies=self.proxies,
                timeout=self.requests_timeout,
            )
            response.raise_for_status()
            token_info = response.json()
            return token_info
        except requests.exceptions.HTTPError as http_error:
            self._handle_oauth_error(http_error)

    def _add_custom_values_to_token_info(self, token_info):
        """
        Store some values that aren't directly provided by a Web API
        response.
        """
        token_info["expires_at"] = int(time.time()) + token_info["expires_in"]
        return token_info


class SpotifyOAuth(SpotifyAuthBase):
    """
    Implements Authorization Code Flow for Spotify's OAuth implementation.
    """
    OAUTH_AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
    OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token"

    def __init__(
            self,
            client_id=None,
            client_secret=None,
            redirect_uri=None,
            state=None,
            scope=None,
            cache_path=None,
            username=None,
            proxies=None,
            show_dialog=False,
            requests_session=True,
            requests_timeout=None,
            open_browser=True,
            cache_handler=None
    ):
        """
        Creates a SpotifyOAuth object

        Parameters:
             * client_id: Must be supplied or set as environment variable
             * client_secret: Must be supplied or set as environment variable
             * redirect_uri: Must be supplied or set as environment variable
             * state: Optional, no verification is performed
             * scope: Optional, either a list of scopes or comma separated string of scopes.
                      e.g, "playlist-read-private,playlist-read-collaborative"
             * cache_path: (deprecated) Optional, will otherwise be generated
                           (takes precedence over `username`)
             * username: (deprecated) Optional or set as environment variable
                         (will set `cache_path` to `.cache-{username}`)
             * proxies: Optional, proxy for the requests library to route through
             * show_dialog: Optional, interpreted as boolean
             * requests_session: A Requests session
             * requests_timeout: Optional, tell Requests to stop waiting for a response after
                                 a given number of seconds
             * open_browser: Optional, whether or not the web browser should be opened to
                             authorize a user
             * cache_handler: An instance of the `CacheHandler` class to handle
                              getting and saving cached authorization tokens.
                              Optional, will otherwise use `CacheFileHandler`.
                              (takes precedence over `cache_path` and `username`)
        """

        super(SpotifyOAuth, self).__init__(requests_session)

        self.client_id = client_id
        self.client_secret = client_secret
        self.redirect_uri = redirect_uri
        self.state = state
        self.scope = self._normalize_scope(scope)
        if username or cache_path:
            warnings.warn("Specifying cache_path or username as arguments to SpotifyOAuth " +
                          "will be deprecated. Instead, please create a CacheFileHandler " +
                          "instance with the desired cache_path and username and pass it " +
                          "to SpotifyOAuth as the cache_handler. For example:\n\n" +
                          "\tfrom spotipy.oauth2 import CacheFileHandler\n" +
                          "\thandler = CacheFileHandler(cache_path=cache_path, " +
                          "username=username)\n" +
                          "\tsp = spotipy.SpotifyOAuth(client_id, client_secret, " +
                          "redirect_uri," +
                          " cache_handler=handler)",
                          DeprecationWarning
                          )
            if cache_handler:
                warnings.warn("A cache_handler has been specified along with a cache_path or " +
                              "username. The cache_path and username arguments will be ignored.")
        if cache_handler:
            assert issubclass(cache_handler.__class__, CacheHandler), \
                "cache_handler must be a subclass of CacheHandler: " + str(type(cache_handler)) \
                + " != " + str(CacheHandler)
            self.cache_handler = cache_handler
        else:
            username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"]))
            self.cache_handler = CacheFileHandler(
                username=username,
                cache_path=cache_path
            )
        self.proxies = proxies
        self.requests_timeout = requests_timeout
        self.show_dialog = show_dialog
        self.open_browser = open_browser

    def validate_token(self, token_info):
        if token_info is None:
            return None

        # if scopes don't match, then bail
        if "scope" not in token_info or not self._is_scope_subset(
                self.scope, token_info["scope"]
        ):
            return None

        if self.is_token_expired(token_info):
            token_info = self.refresh_access_token(
                token_info["refresh_token"]
            )

        return token_info

    def get_authorize_url(self, state=None):
        """ Gets the URL to use to authorize this app
        """
        payload = {
            "client_id": self.client_id,
            "response_type": "code",
            "redirect_uri": self.redirect_uri,
        }
        if self.scope:
            payload["scope"] = self.scope
        if state is None:
            state = self.state
        if state is not None:
            payload["state"] = state
        if self.show_dialog:
            payload["show_dialog"] = True

        urlparams = urllibparse.urlencode(payload)

        return "%s?%s" % (self.OAUTH_AUTHORIZE_URL, urlparams)

    def parse_response_code(self, url):
        """ Parse the response code in the given response url

            Parameters:
                - url - the response url
        """
        _, code = self.parse_auth_response_url(url)
        if code is None:
            return url
        else:
            return code

    @staticmethod
    def parse_auth_response_url(url):
        query_s = urlparse(url).query
        form = dict(parse_qsl(query_s))
        if "error" in form:
            raise SpotifyOauthError("Received error from auth server: "
                                    "{}".format(form["error"]),
                                    error=form["error"])
        return tuple(form.get(param) for param in ["state", "code"])

    def _make_authorization_headers(self):
        return _make_authorization_headers(self.client_id, self.client_secret)

    def _open_auth_url(self):
        auth_url = self.get_authorize_url()
        try:
            webbrowser.open(auth_url)
            logger.info("Opened %s in your browser", auth_url)
        except webbrowser.Error:
            logger.error("Please navigate here: %s", auth_url)

    def _get_auth_response_interactive(self, open_browser=False):
        if open_browser:
            self._open_auth_url()
            prompt = "Enter the URL you were redirected to: "
        else:
            url = self.get_authorize_url()
            prompt = (
                "Go to the following URL: {}\n"
                "Enter the URL you were redirected to: ".format(url)
            )
        response = self._get_user_input(prompt)
        state, code = SpotifyOAuth.parse_auth_response_url(response)
        if self.state is not None and self.state != state:
            raise SpotifyStateError(self.state, state)
        return code

    def _get_auth_response_local_server(self, redirect_port):
        server = start_local_http_server(redirect_port)
        self._open_auth_url()
        server.handle_request()

        if server.error is not None:
            raise server.error
        elif self.state is not None and server.state != self.state:
            raise SpotifyStateError(self.state, server.state)
        elif server.auth_code is not None:
            return server.auth_code
        else:
            raise SpotifyOauthError("Server listening on localhost has not been accessed")

    def get_auth_response(self, open_browser=None):
        logger.info('User authentication requires interaction with your '
                    'web browser. Once you enter your credentials and '
                    'give authorization, you will be redirected to '
                    'a url.  Paste that url you were directed to to '
                    'complete the authorization.')

        redirect_info = urlparse(self.redirect_uri)
        redirect_host, redirect_port = get_host_port(redirect_info.netloc)

        if open_browser is None:
            open_browser = self.open_browser

        if (
                open_browser
                and redirect_host in ("127.0.0.1", "localhost")
                and redirect_info.scheme == "http"
        ):
            # Only start a local http server if a port is specified
            if redirect_port:
                return self._get_auth_response_local_server(redirect_port)
            else:
                logger.warning('Using `%s` as redirect URI without a port. '
                               'Specify a port (e.g. `%s:8080`) to allow '
                               'automatic retrieval of authentication code '
                               'instead of having to copy and paste '
                               'the URL your browser is redirected to.',
                               redirect_host, redirect_host)

        return self._get_auth_response_interactive(open_browser=open_browser)

    def get_authorization_code(self, response=None):
        if response:
            return self.parse_response_code(response)
        return self.get_auth_response()

    def get_access_token(self, code=None, as_dict=True, check_cache=True):
        """ Gets the access token for the app given the code

            Parameters:
                - code - the response code
                - as_dict - a boolean indicating if returning the access token
                            as a token_info dictionary, otherwise it will be returned
                            as a string.
        """
        if as_dict:
            warnings.warn(
                "You're using 'as_dict = True'."
                "get_access_token will return the token string directly in future "
                "versions. Please adjust your code accordingly, or use "
                "get_cached_token instead.",
                DeprecationWarning,
                stacklevel=2,
            )
        if check_cache:
            token_info = self.validate_token(self.cache_handler.get_cached_token())
            if token_info is not None:
                if self.is_token_expired(token_info):
                    token_info = self.refresh_access_token(
                        token_info["refresh_token"]
                    )
                return token_info if as_dict else token_info["access_token"]

        payload = {
            "redirect_uri": self.redirect_uri,
            "code": code or self.get_auth_response(),
            "grant_type": "authorization_code",
        }
        if self.scope:
            payload["scope"] = self.scope
        if self.state:
            payload["state"] = self.state

        headers = self._make_authorization_headers()

        logger.debug(
            "sending POST request to %s with Headers: %s and Body: %r",
            self.OAUTH_TOKEN_URL, headers, payload
        )

        try:
            response = self._session.post(
                self.OAUTH_TOKEN_URL,
                data=payload,
                headers=headers,
                verify=True,
                proxies=self.proxies,
                timeout=self.requests_timeout,
            )
            response.raise_for_status()
            token_info = response.json()
            token_info = self._add_custom_values_to_token_info(token_info)
            self.cache_handler.save_token_to_cache(token_info)
            return token_info if as_dict else token_info["access_token"]
        except requests.exceptions.HTTPError as http_error:
            self._handle_oauth_error(http_error)

    def refresh_access_token(self, refresh_token):
        payload = {
            "refresh_token": refresh_token,
            "grant_type": "refresh_token",
        }

        headers = self._make_authorization_headers()

        logger.debug(
            "sending POST request to %s with Headers: %s and Body: %r",
            self.OAUTH_TOKEN_URL, headers, payload
        )

        try:
            response = self._session.post(
                self.OAUTH_TOKEN_URL,
                data=payload,
                headers=headers,
                proxies=self.proxies,
                timeout=self.requests_timeout,
            )
            response.raise_for_status()
            token_info = response.json()
            token_info = self._add_custom_values_to_token_info(token_info)
            if "refresh_token" not in token_info:
                token_info["refresh_token"] = refresh_token
            self.cache_handler.save_token_to_cache(token_info)
            return token_info
        except requests.exceptions.HTTPError as http_error:
            self._handle_oauth_error(http_error)

    def _add_custom_values_to_token_info(self, token_info):
        """
        Store some values that aren't directly provided by a Web API
        response.
        """
        token_info["expires_at"] = int(time.time()) + token_info["expires_in"]
        token_info["scope"] = self.scope
        return token_info

    def get_cached_token(self):
        warnings.warn("Calling get_cached_token directly on the SpotifyOAuth object will be " +
                      "deprecated. Instead, please specify a CacheFileHandler instance as " +
                      "the cache_handler in SpotifyOAuth and use the CacheFileHandler's " +
                      "get_cached_token method. You can replace:\n\tsp.get_cached_token()" +
                      "\n\nWith:\n\tsp.validate_token(sp.cache_handler.get_cached_token())",
                      DeprecationWarning
                      )
        return self.validate_token(self.cache_handler.get_cached_token())

    def _save_token_info(self, token_info):
        warnings.warn("Calling _save_token_info directly on the SpotifyOAuth object will be " +
                      "deprecated. Instead, please specify a CacheFileHandler instance as " +
                      "the cache_handler in SpotifyOAuth and use the CacheFileHandler's " +
                      "save_token_to_cache method.",
                      DeprecationWarning
                      )
        self.cache_handler.save_token_to_cache(token_info)
        return None


class SpotifyPKCE(SpotifyAuthBase):
    """ Implements PKCE Authorization Flow for client apps

    This auth manager enables *user and non-user* endpoints with only
    a client ID, redirect URI, and username. When the app requests
    an access token for the first time, the user is prompted to
    authorize the new client app. After authorizing the app, the client
    app is then given both access and refresh tokens. This is the
    preferred way of authorizing a mobile/desktop client.

    """

    OAUTH_AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
    OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token"

    def __init__(self,
                 client_id=None,
                 redirect_uri=None,
                 state=None,
                 scope=None,
                 cache_path=None,
                 username=None,
                 proxies=None,
                 requests_timeout=None,
                 requests_session=True,
                 open_browser=True,
                 cache_handler=None):
        """
        Creates Auth Manager with the PKCE Auth flow.

        Parameters:
             * client_id: Must be supplied or set as environment variable
             * redirect_uri: Must be supplied or set as environment variable
             * state: Optional, no verification is performed
             * scope: Optional, either a list of scopes or comma separated string of scopes.
                      e.g, "playlist-read-private,playlist-read-collaborative"
             * cache_path: (deprecated) Optional, will otherwise be generated
                           (takes precedence over `username`)
             * username: (deprecated) Optional or set as environment variable
                         (will set `cache_path` to `.cache-{username}`)
             * proxies: Optional, proxy for the requests library to route through
             * requests_timeout: Optional, tell Requests to stop waiting for a response after
                                 a given number of seconds
             * requests_session: A Requests session
             * open_browser: Optional, whether or not the web browser should be opened to
                             authorize a user
             * cache_handler: An instance of the `CacheHandler` class to handle
                              getting and saving cached authorization tokens.
                              Optional, will otherwise use `CacheFileHandler`.
                              (takes precedence over `cache_path` and `username`)
        """

        super(SpotifyPKCE, self).__init__(requests_session)
        self.client_id = client_id
        self.redirect_uri = redirect_uri
        self.state = state
        self.scope = self._normalize_scope(scope)
        if username or cache_path:
            warnings.warn("Specifying cache_path or username as arguments to SpotifyPKCE " +
                          "will be deprecated. Instead, please create a CacheFileHandler " +
                          "instance with the desired cache_path and username and pass it " +
                          "to SpotifyPKCE as the cache_handler. For example:\n\n" +
                          "\tfrom spotipy.oauth2 import CacheFileHandler\n" +
                          "\thandler = CacheFileHandler(cache_path=cache_path, " +
                          "username=username)\n" +
                          "\tsp = spotipy.SpotifyImplicitGrant(client_id, client_secret, " +
                          "redirect_uri, cache_handler=handler)",
                          DeprecationWarning
                          )
            if cache_handler:
                warnings.warn("A cache_handler has been specified along with a cache_path or " +
                              "username. The cache_path and username arguments will be ignored.")
        if cache_handler:
            assert issubclass(type(cache_handler), CacheHandler), \
                "type(cache_handler): " + str(type(cache_handler)) + " != " + str(CacheHandler)
            self.cache_handler = cache_handler
        else:
            username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"]))
            self.cache_handler = CacheFileHandler(
                username=username,
                cache_path=cache_path
            )
        self.proxies = proxies
        self.requests_timeout = requests_timeout

        self._code_challenge_method = "S256"  # Spotify requires SHA256
        self.code_verifier = None
        self.code_challenge = None
        self.authorization_code = None
        self.open_browser = open_browser

    def _get_code_verifier(self):
        """ Spotify PCKE code verifier - See step 1 of the reference guide below
        Reference:
        https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce
        """
        # Range (33,96) is used to select between 44-128 base64 characters for the
        # next operation. The range looks weird because base64 is 6 bytes
        import random
        length = random.randint(33, 96)

        # The seeded length generates between a 44 and 128 base64 characters encoded string
        try:
            import secrets
            verifier = secrets.token_urlsafe(length)
        except ImportError:  # For python 3.5 support
            import base64
            import os
            rand_bytes = os.urandom(length)
            verifier = base64.urlsafe_b64encode(rand_bytes).decode('utf-8').replace('=', '')
        return verifier

    def _get_code_challenge(self):
        """ Spotify PCKE code challenge - See step 1 of the reference guide below
        Reference:
        https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce
        """
        import base64
        import hashlib
        code_challenge_digest = hashlib.sha256(self.code_verifier.encode('utf-8')).digest()
        code_challenge = base64.urlsafe_b64encode(code_challenge_digest).decode('utf-8')
        return code_challenge.replace('=', '')

    def get_authorize_url(self, state=None):
        """ Gets the URL to use to authorize this app """
        if not self.code_challenge:
            self.get_pkce_handshake_parameters()
        payload = {
            "client_id": self.client_id,
            "response_type": "code",
            "redirect_uri": self.redirect_uri,
            "code_challenge_method": self._code_challenge_method,
            "code_challenge": self.code_challenge
        }
        if self.scope:
            payload["scope"] = self.scope
        if state is None:
            state = self.state
        if state is not None:
            payload["state"] = state
        urlparams = urllibparse.urlencode(payload)
        return "%s?%s" % (self.OAUTH_AUTHORIZE_URL, urlparams)

    def _open_auth_url(self, state=None):
        auth_url = self.get_authorize_url(state)
        try:
            webbrowser.open(auth_url)
            logger.info("Opened %s in your browser", auth_url)
        except webbrowser.Error:
            logger.error("Please navigate here: %s", auth_url)

    def _get_auth_response(self, open_browser=None):
        logger.info('User authentication requires interaction with your '
                    'web browser. Once you enter your credentials and '
                    'give authorization, you will be redirected to '
                    'a url.  Paste that url you were directed to to '
                    'complete the authorization.')

        redirect_info = urlparse(self.redirect_uri)
        redirect_host, redirect_port = get_host_port(redirect_info.netloc)

        if open_browser is None:
            open_browser = self.open_browser

        if (
                open_browser
                and redirect_host in ("127.0.0.1", "localhost")
                and redirect_info.scheme == "http"
        ):
            # Only start a local http server if a port is specified
            if redirect_port:
                return self._get_auth_response_local_server(redirect_port)
            else:
                logger.warning('Using `%s` as redirect URI without a port. '
                               'Specify a port (e.g. `%s:8080`) to allow '
                               'automatic retrieval of authentication code '
                               'instead of having to copy and paste '
                               'the URL your browser is redirected to.',
                               redirect_host, redirect_host)
        return self._get_auth_response_interactive(open_browser=open_browser)

    def _get_auth_response_local_server(self, redirect_port):
        server = start_local_http_server(redirect_port)
        self._open_auth_url()
        server.handle_request()

        if self.state is not None and server.state != self.state:
            raise SpotifyStateError(self.state, server.state)

        if server.auth_code is not None:
            return server.auth_code
        elif server.error is not None:
            raise SpotifyOauthError("Received error from OAuth server: {}".format(server.error))
        else:
            raise SpotifyOauthError("Server listening on localhost has not been accessed")

    def _get_auth_response_interactive(self, open_browser=False):
        if open_browser or self.open_browser:
            self._open_auth_url()
            prompt = "Enter the URL you were redirected to: "
        else:
            url = self.get_authorize_url()
            prompt = (
                "Go to the following URL: {}\n"
                "Enter the URL you were redirected to: ".format(url)
            )
        response = self._get_user_input(prompt)
        state, code = self.parse_auth_response_url(response)
        if self.state is not None and self.state != state:
            raise SpotifyStateError(self.state, state)
        return code

    def get_authorization_code(self, response=None):
        if response:
            return self.parse_response_code(response)
        return self._get_auth_response()

    def validate_token(self, token_info):
        if token_info is None:
            return None

        # if scopes don't match, then bail
        if "scope" not in token_info or not self._is_scope_subset(
                self.scope, token_info["scope"]
        ):
            return None

        if self.is_token_expired(token_info):
            token_info = self.refresh_access_token(
                token_info["refresh_token"]
            )

        return token_info

    def _add_custom_values_to_token_info(self, token_info):
        """
        Store some values that aren't directly provided by a Web API
        response.
        """
        token_info["expires_at"] = int(time.time()) + token_info["expires_in"]
        return token_info

    def get_pkce_handshake_parameters(self):
        self.code_verifier = self._get_code_verifier()
        self.code_challenge = self._get_code_challenge()

    def get_access_token(self, code=None, check_cache=True):
        """ Gets the access token for the app

            If the code is not given and no cached token is used, an
            authentication window will be shown to the user to get a new
            code.

            Parameters:
                - code - the response code from authentication
                - check_cache - if true, checks for a locally stored token
                                before requesting a new token
        """

        if check_cache:
            token_info = self.validate_token(self.cache_handler.get_cached_token())
            if token_info is not None:
                if self.is_token_expired(token_info):
                    token_info = self.refresh_access_token(
                        token_info["refresh_token"]
                    )
                return token_info["access_token"]

        if self.code_verifier is None or self.code_challenge is None:
            self.get_pkce_handshake_parameters()

        payload = {
            "client_id": self.client_id,
            "grant_type": "authorization_code",
            "code": code or self.get_authorization_code(),
            "redirect_uri": self.redirect_uri,
            "code_verifier": self.code_verifier
        }

        headers = {"Content-Type": "application/x-www-form-urlencoded"}

        logger.debug(
            "sending POST request to %s with Headers: %s and Body: %r",
            self.OAUTH_TOKEN_URL, headers, payload
        )

        try:
            response = self._session.post(
                self.OAUTH_TOKEN_URL,
                data=payload,
                headers=headers,
                verify=True,
                proxies=self.proxies,
                timeout=self.requests_timeout,
            )
            response.raise_for_status()
            token_info = response.json()
            token_info = self._add_custom_values_to_token_info(token_info)
            self.cache_handler.save_token_to_cache(token_info)
            return token_info["access_token"]
        except requests.exceptions.HTTPError as http_error:
            self._handle_oauth_error(http_error)

    def refresh_access_token(self, refresh_token):
        payload = {
            "refresh_token": refresh_token,
            "grant_type": "refresh_token",
            "client_id": self.client_id,
        }

        headers = {"Content-Type": "application/x-www-form-urlencoded"}

        logger.debug(
            "sending POST request to %s with Headers: %s and Body: %r",
            self.OAUTH_TOKEN_URL, headers, payload
        )

        try:
            response = self._session.post(
                self.OAUTH_TOKEN_URL,
                data=payload,
                headers=headers,
                proxies=self.proxies,
                timeout=self.requests_timeout,
            )
            response.raise_for_status()
            token_info = response.json()
            token_info = self._add_custom_values_to_token_info(token_info)
            if "refresh_token" not in token_info:
                token_info["refresh_token"] = refresh_token
            self.cache_handler.save_token_to_cache(token_info)
            return token_info
        except requests.exceptions.HTTPError as http_error:
            self._handle_oauth_error(http_error)

    def parse_response_code(self, url):
        """ Parse the response code in the given response url

            Parameters:
                - url - the response url
        """
        _, code = self.parse_auth_response_url(url)
        if code is None:
            return url
        else:
            return code

    @staticmethod
    def parse_auth_response_url(url):
        return SpotifyOAuth.parse_auth_response_url(url)

    def get_cached_token(self):
        warnings.warn("Calling get_cached_token directly on the SpotifyPKCE object will be " +
                      "deprecated. Instead, please specify a CacheFileHandler instance as " +
                      "the cache_handler in SpotifyOAuth and use the CacheFileHandler's " +
                      "get_cached_token method. You can replace:\n\tsp.get_cached_token()" +
                      "\n\nWith:\n\tsp.validate_token(sp.cache_handler.get_cached_token())",
                      DeprecationWarning
                      )
        return self.validate_token(self.cache_handler.get_cached_token())

    def _save_token_info(self, token_info):
        warnings.warn("Calling _save_token_info directly on the SpotifyOAuth object will be " +
                      "deprecated. Instead, please specify a CacheFileHandler instance as " +
                      "the cache_handler in SpotifyOAuth and use the CacheFileHandler's " +
                      "save_token_to_cache method.",
                      DeprecationWarning
                      )
        self.cache_handler.save_token_to_cache(token_info)
        return None


class SpotifyImplicitGrant(SpotifyAuthBase):
    """ Implements Implicit Grant Flow for client apps

    This auth manager enables *user and non-user* endpoints with only
    a client secret, redirect uri, and username. The user will need to
    copy and paste a URI from the browser every hour.

    Security Warning
    -----------------
    The OAuth standard no longer recommends the Implicit Grant Flow for
    client-side code. Spotify has implemented the OAuth-suggested PKCE
    extension that removes the need for a client secret in the
    Authentication Code flow. Use the SpotifyPKCE auth manager instead
    of SpotifyImplicitGrant.

    SpotifyPKCE contains all of the functionality of
    SpotifyImplicitGrant, plus automatic response retrieval and
    refreshable tokens. Only a few replacements need to be made:

    * get_auth_response()['access_token'] ->
      get_access_token(get_authorization_code())
    * get_auth_response() ->
      get_access_token(get_authorization_code()); get_cached_token()
    * parse_response_token(url)['access_token'] ->
      get_access_token(parse_response_code(url))
    * parse_response_token(url) ->
      get_access_token(parse_response_code(url)); get_cached_token()

    The security concern in the Implicit Grant flow is that the token is
    returned in the URL and can be intercepted through the browser. A
    request with an authorization code and proof of origin could not be
    easily intercepted without a compromised network.
    """
    OAUTH_AUTHORIZE_URL = "https://accounts.spotify.com/authorize"

    def __init__(self,
                 client_id=None,
                 redirect_uri=None,
                 state=None,
                 scope=None,
                 cache_path=None,
                 username=None,
                 show_dialog=False,
                 cache_handler=None):
        """ Creates Auth Manager using the Implicit Grant flow

        **See help(SpotifyImplicitGrant) for full Security Warning**

        Parameters
        ----------
        * client_id: Must be supplied or set as environment variable
        * redirect_uri: Must be supplied or set as environment variable
        * state: May be supplied, no verification is performed
        * scope: Optional, either a list of scopes or comma separated string of scopes.
                 e.g, "playlist-read-private,playlist-read-collaborative"
        * cache_handler: An instance of the `CacheHandler` class to handle
                              getting and saving cached authorization tokens.
                              May be supplied, will otherwise use `CacheFileHandler`.
                              (takes precedence over `cache_path` and `username`)
        * cache_path: (deprecated) May be supplied, will otherwise be generated
                      (takes precedence over `username`)
        * username: (deprecated) May be supplied or set as environment variable
                    (will set `cache_path` to `.cache-{username}`)
        * show_dialog: Interpreted as boolean
        """
        logger.warning("The OAuth standard no longer recommends the Implicit "
                       "Grant Flow for client-side code. Use the SpotifyPKCE "
                       "auth manager instead of SpotifyImplicitGrant. For "
                       "more details and a guide to switching, see "
                       "help(SpotifyImplicitGrant).")

        self.client_id = client_id
        self.redirect_uri = redirect_uri
        self.state = state
        if username or cache_path:
            warnings.warn("Specifying cache_path or username as arguments to " +
                          "SpotifyImplicitGrant will be deprecated. Instead, please create " +
                          "a CacheFileHandler instance with the desired cache_path and " +
                          "username and pass it to SpotifyImplicitGrant as the " +
                          "cache_handler. For example:\n\n" +
                          "\tfrom spotipy.oauth2 import CacheFileHandler\n" +
                          "\thandler = CacheFileHandler(cache_path=cache_path, " +
                          "username=username)\n" +
                          "\tsp = spotipy.SpotifyImplicitGrant(client_id, client_secret, " +
                          "redirect_uri, cache_handler=handler)",
                          DeprecationWarning
                          )
            if cache_handler:
                warnings.warn("A cache_handler has been specified along with a cache_path or " +
                              "username. The cache_path and username arguments will be ignored.")
        if cache_handler:
            assert issubclass(type(cache_handler), CacheHandler), \
                "type(cache_handler): " + str(type(cache_handler)) + " != " + str(CacheHandler)
            self.cache_handler = cache_handler
        else:
            username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"]))
            self.cache_handler = CacheFileHandler(
                username=username,
                cache_path=cache_path
            )
        self.scope = self._normalize_scope(scope)
        self.show_dialog = show_dialog
        self._session = None  # As to not break inherited __del__

    def validate_token(self, token_info):
        if token_info is None:
            return None

        # if scopes don't match, then bail
        if "scope" not in token_info or not self._is_scope_subset(
                self.scope, token_info["scope"]
        ):
            return None

        if self.is_token_expired(token_info):
            return None

        return token_info

    def get_access_token(self,
                         state=None,
                         response=None,
                         check_cache=True):
        """ Gets Auth Token from cache (preferred) or user interaction

        Parameters
        ----------
        * state: May be given, overrides (without changing) self.state
        * response: URI with token, can break expiration checks
        * check_cache: Interpreted as boolean
        """
        if check_cache:
            token_info = self.validate_token(self.cache_handler.get_cached_token())
            if not (token_info is None or self.is_token_expired(token_info)):
                return token_info["access_token"]

        if response:
            token_info = self.parse_response_token(response)
        else:
            token_info = self.get_auth_response(state)
        token_info = self._add_custom_values_to_token_info(token_info)
        self.cache_handler.save_token_to_cache(token_info)

        return token_info["access_token"]

    def get_authorize_url(self, state=None):
        """ Gets the URL to use to authorize this app """
        payload = {
            "client_id": self.client_id,
            "response_type": "token",
            "redirect_uri": self.redirect_uri,
        }
        if self.scope:
            payload["scope"] = self.scope
        if state is None:
            state = self.state
        if state is not None:
            payload["state"] = state
        if self.show_dialog:
            payload["show_dialog"] = True

        urlparams = urllibparse.urlencode(payload)

        return "%s?%s" % (self.OAUTH_AUTHORIZE_URL, urlparams)

    def parse_response_token(self, url, state=None):
        """ Parse the response code in the given response url """
        remote_state, token, t_type, exp_in = self.parse_auth_response_url(url)
        if state is None:
            state = self.state
        if state is not None and remote_state != state:
            raise SpotifyStateError(state, remote_state)
        return {"access_token": token, "token_type": t_type,
                "expires_in": exp_in, "state": state}

    @staticmethod
    def parse_auth_response_url(url):
        url_components = urlparse(url)
        fragment_s = url_components.fragment
        query_s = url_components.query
        form = dict(i.split('=') for i
                    in (fragment_s or query_s or url).split('&'))
        if "error" in form:
            raise SpotifyOauthError("Received error from auth server: "
                                    "{}".format(form["error"]),
                                    state=form["state"])
        if "expires_in" in form:
            form["expires_in"] = int(form["expires_in"])
        return tuple(form.get(param) for param in ["state", "access_token",
                                                   "token_type", "expires_in"])

    def _open_auth_url(self, state=None):
        auth_url = self.get_authorize_url(state)
        try:
            webbrowser.open(auth_url)
            logger.info("Opened %s in your browser", auth_url)
        except webbrowser.Error:
            logger.error("Please navigate here: %s", auth_url)

    def get_auth_response(self, state=None):
        """ Gets a new auth **token** with user interaction """
        logger.info('User authentication requires interaction with your '
                    'web browser. Once you enter your credentials and '
                    'give authorization, you will be redirected to '
                    'a url.  Paste that url you were directed to to '
                    'complete the authorization.')

        redirect_info = urlparse(self.redirect_uri)
        redirect_host, redirect_port = get_host_port(redirect_info.netloc)
        # Implicit Grant tokens are returned in a hash fragment
        # which is only available to the browser. Therefore, interactive
        # URL retrieval is required.
        if (redirect_host in ("127.0.0.1", "localhost")
                and redirect_info.scheme == "http" and redirect_port):
            logger.warning('Using a local redirect URI with a '
                           'port, likely expecting automatic '
                           'retrieval. Due to technical limitations, '
                           'the authentication token cannot be '
                           'automatically retrieved and must be '
                           'copied and pasted.')

        self._open_auth_url(state)
        logger.info('Paste that url you were directed to in order to '
                    'complete the authorization')
        response = SpotifyImplicitGrant._get_user_input("Enter the URL you "
                                                        "were redirected to: ")
        return self.parse_response_token(response, state)

    def _add_custom_values_to_token_info(self, token_info):
        """
        Store some values that aren't directly provided by a Web API
        response.
        """
        token_info["expires_at"] = int(time.time()) + token_info["expires_in"]
        token_info["scope"] = self.scope
        return token_info

    def get_cached_token(self):
        warnings.warn("Calling get_cached_token directly on the SpotifyImplicitGrant " +
                      "object will be deprecated. Instead, please specify a " +
                      "CacheFileHandler instance as the cache_handler in SpotifyOAuth " +
                      "and use the CacheFileHandler's get_cached_token method. " +
                      "You can replace:\n\tsp.get_cached_token()" +
                      "\n\nWith:\n\tsp.validate_token(sp.cache_handler.get_cached_token())",
                      DeprecationWarning
                      )
        return self.validate_token(self.cache_handler.get_cached_token())

    def _save_token_info(self, token_info):
        warnings.warn("Calling _save_token_info directly on the SpotifyImplicitGrant " +
                      "object will be deprecated. Instead, please specify a " +
                      "CacheFileHandler instance as the cache_handler in SpotifyOAuth " +
                      "and use the CacheFileHandler's save_token_to_cache method.",
                      DeprecationWarning
                      )
        self.cache_handler.save_token_to_cache(token_info)
        return None


class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.server.auth_code = self.server.error = None
        try:
            state, auth_code = SpotifyOAuth.parse_auth_response_url(self.path)
            self.server.state = state
            self.server.auth_code = auth_code
        except SpotifyOauthError as error:
            self.server.error = error

        self.send_response(200)
        self.send_header("Content-Type", "text/html")
        self.end_headers()

        if self.server.auth_code:
            status = "successful"
        elif self.server.error:
            status = "failed ({})".format(self.server.error)
        else:
            self._write("<html><body><h1>Invalid request</h1></body></html>")
            return

        self._write("""<html>
<script>
window.close()
</script>
<body>
<h1>Authentication status: {}</h1>
This window can be closed.
<script>
window.close()
</script>
<button class="closeButton" style="cursor: pointer" onclick="window.close();">Close Window</button>
</body>
</html>""".format(status))

    def _write(self, text):
        return self.wfile.write(text.encode("utf-8"))

    def log_message(self, format, *args):
        return


def start_local_http_server(port, handler=RequestHandler):
    server = HTTPServer(("127.0.0.1", port), handler)
    server.allow_reuse_address = True
    server.auth_code = None
    server.auth_token_form = None
    server.error = None
    return server

src/spotipy/oauth2.py
LightningJukeboxBot/LightningJukeboxBot
import redis
from redis import RedisError
import asyncio
import settings
import json
import logging
import userhelper
from time import time

async def get_bot_stack() -> int:
    """
    Returns the amount of sats of the Jukebox Bot itself.
    """
    jukeboxbot : userhelper.User = await userhelper.get_or_create_user(settings.bot_id)
    balance : int = await userhelper.get_balance(jukeboxbot)
    return balance


async def get_jukebox_groups() -> dict:
    """
    Returns a list of Jukebox groups and some stats about them
    """
    result = {
        "numgroups": 0,
        "group": []
    }
    for key in settings.rds.scan_iter("group:*"):
        chatid : int = int(key.decode('utf-8').split(':')[1])

        result['numgroups'] += 1

        owner = None
        try:
	        owner = await userhelper.get_group_owner(chatid)
        except:
                logging.error("problem getting group stats")


        result["group"].append({
            "groupid": chatid,
            "owner": owner
        })


    return result

src/statshelper.py
LightningJukeboxBot/LightningJukeboxBot
import random
import string
import logging
from time import time

arf = {}

playrandom = 'PLAYRANDOM'
add = 'ADD'
cancel = 'CANCEL'
cancelinvoice = 'CANCELINVOICE'

class TelegramCommand:
    def __init__(self, userid, command, data = None):
        self.userid = userid
        self.command = command
        self.data = data
        self.time = time()

def add_command(command: TelegramCommand) -> str:
    key = "".join(random.sample(string.ascii_letters,12))
    arf[key] = command
    return key

def get_command(key: str) -> TelegramCommand:
    if key in arf:
        command = arf[key]
        return command
    else:
        return None
    
def purge_commands() -> None:
    now = time()
    for key in list(arf.keys()):
        if now - arf[key].time > 3600: # 60 minutes
            logging.info("Deleting command from cache")
            del arf[key]
src/telegramhelper.py
LightningJukeboxBot/LightningJukeboxBot
import redis
from redis import RedisError
import asyncio
from lnbits import LNbits
import settings
import json
import logging
import qrcode
import os
import re
from time import time

class User:
    def __init__(self, tguserid: int, tgusername:str = None) -> None:
        self.userid = int(tguserid)
        self.username = tgusername
        self.rediskey = f"user:{self.userid}"
        self.invoicekey = None
        self.adminkey = None 
        self.lnbitsuserid = None
        self.walletid = None
        self.lnurlp = None
        self.lndhub = None
        self.lnaddress = None

    def toJson(self) -> str:
        userdata = {
            'telegram_userid':self.userid,
            'lnbits_userid':self.lnbitsuserid,
            'telegram_username':self.username,
            'invoicekey':self.invoicekey,
            'adminkey':self.adminkey,
            'walletid':self.walletid,
            'lnurlp':self.lnurlp,
            'lndhub':self.lndhub
        }

        if self.lnaddress is not None:
            userdata['lnaddress'] = self.lnaddress

        return json.dumps(userdata)
        
    def loadJson(self, data: str) -> None:
        assert(data is not None)
        userdata = json.loads(data)
        assert(userdata is not None)
        if (int(userdata['telegram_userid']) != self.userid):
            logging.error(userdata['telegram_userid'])
            logging.error(self.userid)
            return
        
        if self.username is None:
            self.username = userdata['telegram_username']
        self.invoicekey = userdata['invoicekey']
        self.adminkey = userdata['adminkey']
        self.walletid = userdata['walletid']
        self.lnurlp = userdata['lnurlp']
        self.lndhub = userdata['lndhub']
        for legacy in ['bot.wholestack.nl']:
            if self.lnurlp is not None:
                self.lnurlp = self.lnurlp.replace(legacy,settings.domain)
            if self.lndhub is not None:
                self.lndhub = self.lndhub.replace(legacy,settings.domain)

        self.lnbitsuserid = userdata['lnbits_userid']

        # get lnaddress
        if 'lnaddress' in userdata and userdata['lnaddress'] is not None:
            self.lnaddress = userdata['lnaddress']
        else:
            self.lnaddress = None
       

# Get/Create a QR code and store in filename
def get_qrcode_filename(data: str) -> str:
    filename = os.path.join(settings.qrcode_path,"{}.png".format(hash(data)))
    if not os.path.isfile(filename):
        img = qrcode.make(data)
        file = open(filename,'wb')
        if file:
            img.save(file)
            file.close()
    return filename


async def get_group_owner(chat_id: int) -> User:
    data = settings.rds.hget(f"group:{chat_id}","owner")
    assert(data is not None)
    
    userid = data.decode('utf-8')

    return await get_or_create_user(userid)    

  
async def delete_group_owner(chat_id: int) -> None:
    data = settings.rds.hdel(f"group:{chat_id}","owner")

async def get_balance(user:User) -> int:
    return await settings.lnbits.getBalance(user.invoicekey)


async def set_group_owner(chat_id: int, userid: int) -> None:
    data = settings.rds.hget(f"group:{chat_id}","owner")
    if data is not None:
        rds_userid = data.decode('utf-8')
        assert(userid == rds_userid)
    data = settings.rds.hset(f"group:{chat_id}","owner",userid)

async def get_funding_lnurl(user: User) -> str:
    """
    Return the funding LNURL
    """
    if user is None:
        logging.info(f"User is None")
        return None
    if user.lnurlp is None:
        logging.info(f"User.lnurlp is None")
        return None

    logging.info(f"Get funding URL for {user.lnurlp}")
    
    result = re.search(".*\/([A-Za-z0-9]+)",user.lnurlp)
    if result:
        payid = result.groups()[0]
        details = await settings.lnbits.getLnurlp(f"https://{settings.domain}/",user.invoicekey,payid)
        if details is None:
            logging.error("getLnurlp returned None")
            return None
        
        return details['lnurl']
    else:
        logging.error("No LNURL found for user")
        return None

async def get_or_create_user(userid: int,username: str = None) -> User:
    """
    Get or create a user in redis and lnbits and return the user object
    """    
    user = User(userid,username)
        
    userdata = settings.rds.hget(user.rediskey,"userdata")

    if userdata is not None:
        try:
            user.loadJson(userdata)
            logging.info("Got the fast path for retrieving the user")
            return user
        except AssertionError:
            if userdata == b'null':
                logging.warning(f"Null userdata for redis key '{user.rediskey}', resetting userdata")
                userdata = None
            else:
                logging.error(f"Parse error while loading userdata from redis for key '{user.rediskey}'")
                raise
    
    # no entry in redis, get user and wallet from lnbits 
    if userdata is None:
        print("no user in redis")
        # maybe it is an existing user
        lnusers = await settings.lnbits.getUsers()
        for lnuser in lnusers:
            print(lnuser['name'], user.rediskey)
            if lnuser['name'] == user.rediskey:
                print("Found user")
                user.lnbitsuserid = lnuser['id']
                break

        # create if not existing
        if user.lnbitsuserid is None:
            print("lnbitsuserid is None")
            user.lnbitsuserid = await settings.lnbits.createUser(user.rediskey)

        # get or create wallet if not existing
        wallet = await settings.lnbits.getWallet(user.lnbitsuserid)
        if wallet is None:
            print("Creating wallet")
            wallet = await settings.lnbits.createWallet(user.lnbitsuserid,user.rediskey)

        # copy parameters
        user.invoicekey = wallet['inkey']
        user.adminkey = wallet['adminkey']
        user.walletid = wallet['id']

        # enable extensions for user
        await settings.lnbits.enableExtension("lnurlp",user.lnbitsuserid)
        await settings.lnbits.enableExtension("lndhub",user.lnbitsuserid)
    
        # create lndhub link
        user.lndhub = f"lndhub://admin:{user.adminkey}@https://{settings.domain}/lndhub/ext/"


        payload = {
            "amount": settings.price,
            "max": settings.fund_max,
            "min": settings.fund_min,
            "comment_chars": 0,
            "webhook_url": f"http://127.0.0.1:7000/jukebox/lnbitscallback?userid={user.userid}"
        }

        print(user.toJson())

        if user.username is not None:
            lnuname = user.username.lower()
            lnuname.replace(' ','_')
            lnuname = lnuname[:15]

            if re.search('^[a-z0-9\-_\.]+$',lnuname): 
                payload['username'] = lnuname
            else:
                logging.info(f"Username not allowed for lnaddress {lnuname}")
            payload["description"] = f"Fund the Jukebox wallet of @{user.username}"
        else:
            logging.error(f"username is None for telegram user: {user.userid}")
            payload['description'] = "Fund your personal Jukebox Wallet"
        

        # create lnurlp link
        result = await settings.lnbits.createLnurlp(user.adminkey,payload)
        if result is not None:
            user.lnurlp = f"https://{settings.domain}/lnurlp/link/{result['id']}"    
            if result['username'] is not None:
                user.lnaddress = f"{result['username']}@{settings.domain}"
        else:
            user.lnurlp = None

        # save parameters
        settings.rds.hset(user.rediskey,"userdata",user.toJson())
        
        return user
        
        

src/userhelper.py
LightningJukeboxBot/LightningJukeboxBot