Connects to spotify

This commit is contained in:
Ben Arc
2021-04-28 12:04:47 +01:00
parent 3f8890def7
commit dc10a0f52b
9 changed files with 395 additions and 353 deletions

View File

@@ -8,6 +8,5 @@ jukebox_ext: Blueprint = Blueprint(
"jukebox", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa

View File

@@ -1,5 +1,5 @@
{
"name": "Jukebox",
"name": "SpotifyJukebox",
"short_description": "Spotify jukebox middleware",
"icon": "audiotrack",
"contributors": ["benarc"]

View File

@@ -2,32 +2,69 @@ from typing import List, Optional
from . import db
from .models import Jukebox
from lnbits.helpers import urlsafe_short_hash
async def create_update_jukebox(wallet_id: str) -> int:
async def create_jukebox(
wallet: str,
title: str,
price: int,
sp_user: str,
sp_secret: str,
sp_token: Optional[str] = "",
sp_device: Optional[str] = "",
sp_playlists: Optional[str] = "",
) -> Jukebox:
juke_id = urlsafe_short_hash()
result = await db.execute(
"""
INSERT INTO jukebox (id, wallet, user, secret, token, playlists)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO jukebox (id, title, wallet, sp_user, sp_secret, sp_token, sp_device, sp_playlists, price)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(juke_id, wallet_id, "", "", "", ""),
(
juke_id,
title,
wallet,
sp_user,
sp_secret,
sp_token,
sp_device,
sp_playlists,
int(price),
),
)
return result._result_proxy.lastrowid
jukebox = await get_jukebox(juke_id)
assert jukebox, "Newly created Jukebox couldn't be retrieved"
return jukebox
async def update_jukebox(sp_user: str, **kwargs) -> Optional[Jukebox]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE jukebox SET {q} WHERE sp_user = ?", (*kwargs.values(), sp_user)
)
row = await db.fetchone("SELECT * FROM jukebox WHERE sp_user = ?", (sp_user,))
return Jukebox(**row) if row else None
async def get_jukebox(id: str) -> Optional[Jukebox]:
row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (id,))
return Shop(**dict(row)) if row else None
return Jukebox(**row) if row else None
async def get_jukebox_by_user(user: str) -> Optional[Jukebox]:
row = await db.fetchone("SELECT * FROM jukebox WHERE sp_user = ?", (user,))
return Jukebox(**row) if row else None
async def get_jukeboxs(id: str) -> Optional[Jukebox]:
row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (id,))
return Shop(**dict(row)) if row else None
rows = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (id,))
return [Jukebox(**row) for row in rows]
async def delete_jukebox(shop: int, item_id: int):
await db.execute(
"""
DELETE FROM jukebox WHERE id = ?
""",
(shop, item_id),
(Jukebox, item_id),
)

View File

@@ -5,12 +5,15 @@ async def m001_initial(db):
await db.execute(
"""
CREATE TABLE jukebox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wallet TEXT NOT NULL,
user TEXT NOT NULL,
secret TEXT NOT NULL,
token TEXT NOT NULL,
playlists TEXT NOT NULL
id TEXT PRIMARY KEY,
title TEXT,
wallet TEXT,
sp_user TEXT NOT NULL,
sp_secret TEXT NOT NULL,
sp_token TEXT,
sp_device TEXT,
sp_playlists TEXT,
price INTEGER
);
"""
)

View File

@@ -6,14 +6,18 @@ from quart import url_for
from typing import NamedTuple, Optional, List, Dict
from sqlite3 import Row
class Jukebox(NamedTuple):
id: int
id: str
title: str
wallet: str
user: str
secret: str
token: str
playlists: str
sp_user: str
sp_secret: str
sp_token: str
sp_device: str
sp_playlists: str
price: int
@classmethod
def from_row(cls, row: Row) -> "Charges":
def from_row(cls, row: Row) -> "Jukebox":
return cls(**dict(row))

View File

@@ -4,28 +4,25 @@ Vue.component(VueQrcode.name, VueQrcode)
const pica = window.pica()
const defaultItemData = {
unit: 'sat'
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
selectedWallet: null,
confirmationMethod: 'wordlist',
wordlistTainted: false,
jukebox: {
method: null,
wordlist: [],
items: []
},
itemDialog: {
isPwd: true,
tokenFetched: true,
device: [],
jukebox: {},
playlists: [],
step: 1,
locationcbPath: "",
jukeboxDialog: {
show: false,
data: {...defaultItemData},
units: ['sat']
}
data: {}
},
spotifyDialog: false
}
},
computed: {
@@ -34,183 +31,130 @@ new Vue({
}
},
methods: {
openNewDialog() {
this.itemDialog.show = true
this.itemDialog.data = {...defaultItemData}
closeFormDialog() {
this.jukeboxDialog.data = {}
this.jukeboxDialog.show = false
this.step = 1
},
openUpdateDialog(itemId) {
this.itemDialog.show = true
let item = this.jukebox.items.find(item => item.id === itemId)
this.itemDialog.data = item
},
imageAdded(file) {
let blobURL = URL.createObjectURL(file)
let image = new Image()
image.src = blobURL
image.onload = async () => {
let canvas = document.createElement('canvas')
canvas.setAttribute('width', 100)
canvas.setAttribute('height', 100)
await pica.resize(image, canvas, {
quality: 0,
alpha: true,
unsharpAmount: 95,
unsharpRadius: 0.9,
unsharpThreshold: 70
submitSpotify() {
self = this
console.log(self.jukeboxDialog.data)
self.requestAuthorization()
this.$q.notify({
spinner: true,
message: 'Fetching token',
timeout: 4000
})
this.itemDialog.data.image = canvas.toDataURL()
this.itemDialog = {...this.itemDialog}
}
},
imageCleared() {
this.itemDialog.data.image = null
this.itemDialog = {...this.itemDialog}
},
disabledAddItemButton() {
return (
!this.itemDialog.data.name ||
this.itemDialog.data.name.length === 0 ||
!this.itemDialog.data.price ||
!this.itemDialog.data.description ||
!this.itemDialog.data.unit ||
this.itemDialog.data.unit.length === 0
)
},
changedWallet(wallet) {
this.selectedWallet = wallet
this.loadShop()
},
loadShop() {
LNbits.api
.request('GET', '/jukebox/api/v1/jukebox', this.selectedWallet.inkey)
.then(response => {
this.jukebox = response.data
this.confirmationMethod = response.data.method
this.wordlistTainted = false
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
async setMethod() {
try {
await LNbits.api.request(
'PUT',
'/jukebox/api/v1/jukebox/method',
this.selectedWallet.inkey,
{method: this.confirmationMethod, wordlist: this.jukebox.wordlist}
)
} catch (err) {
LNbits.utils.notifyApiError(err)
return
}
this.$q.notify({
message:
`Method set to ${this.confirmationMethod}.` +
(this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''),
timeout: 700
})
this.loadShop()
},
async sendItem() {
let {id, name, image, description, price, unit} = this.itemDialog.data
const data = {
name,
description,
image,
price,
unit
}
try {
if (id) {
await LNbits.api.request(
'PUT',
'/jukebox/api/v1/jukebox/items/' + id,
this.selectedWallet.inkey,
data
)
} else {
await LNbits.api.request(
'POST',
'/jukebox/api/v1/jukebox/items',
this.selectedWallet.inkey,
data
)
this.$q.notify({
message: `Item '${this.itemDialog.data.name}' added.`,
timeout: 700
})
}
} catch (err) {
LNbits.utils.notifyApiError(err)
return
}
this.loadShop()
this.itemDialog.show = false
this.itemDialog.data = {...defaultItemData}
},
toggleItem(itemId) {
let item = this.jukebox.items.find(item => item.id === itemId)
item.enabled = !item.enabled
LNbits.api
.request(
'PUT',
'/jukebox/api/v1/jukebox/items/' + itemId,
this.selectedWallet.inkey,
item
LNbits.api.request(
'POST',
'/jukebox/api/v1/jukebox/',
self.g.user.wallets[0].adminkey,
self.jukeboxDialog.data
)
.then(response => {
this.$q.notify({
message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`,
timeout: 700
})
this.jukebox.items = this.jukebox.items
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
deleteItem(itemId) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this item?')
.onOk(() => {
LNbits.api
.request(
'DELETE',
'/jukebox/api/v1/jukebox/items/' + itemId,
this.selectedWallet.inkey
)
if(response.data){
var timerId = setInterval(function(){
if(!self.jukeboxDialog.data.sp_user){
clearInterval(timerId);
}
LNbits.api
.request('GET', '/jukebox/api/v1/jukebox/spotify/' + self.jukeboxDialog.data.sp_user, self.g.user.wallets[0].inkey)
.then(response => {
this.$q.notify({
message: `Item deleted.`,
timeout: 700
if(response.data.sp_token){
console.log(response.data.sp_token)
self.step = 3
clearInterval(timerId);
self.refreshPlaylists()
self.$q.notify({
message: 'Success! App is now linked!',
timeout: 3000
})
this.jukebox.items.splice(
this.jukebox.items.findIndex(item => item.id === itemId),
1
)
//set devices, playlists
}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
LNbits.utils.notifyApiError(err)
})
}, 3000)
}
})
}
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
requestAuthorization(){
self = this
let url = 'https://accounts.spotify.com/authorize'
url += '?scope=user-modify-playback-state%20user-read-playback-position'
url += '%20user-library-read%20streaming%20user-read-playback-state'
url += '%20user-read-recently-played%20playlist-read-private&response_type=code'
url += '&redirect_uri=' + encodeURIComponent(self.locationcbPath) + self.jukeboxDialog.data.sp_user
url += '&client_id=' + self.jukeboxDialog.data.sp_user
url += '&show_dialog=true'
console.log(url)
window.open(url)
},
openNewDialog() {
this.jukeboxDialog.show = true
this.jukeboxDialog.data = {}
},
openUpdateDialog(itemId) {
this.jukeboxDialog.show = true
let item = this.jukebox.items.find(item => item.id === itemId)
this.jukeboxDialog.data = item
},
callApi(method, url, body, callback){
let xhr = new XMLHttpRequest()
xhr.open(method, url, true)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.setRequestHeader('Authorization', 'Bearer ' + self.jukeboxDialog.data.sp_token)
xhr.send(body)
xhr.onload = callback
},
refreshPlaylists(){
console.log("sdfvasdv")
callApi( "GET", "https://api.spotify.com/v1/me/playlists", null, handlePlaylistsResponse )
},
handlePlaylistsResponse(){
console.log("data")
if ( this.status == 200 ){
var data = JSON.parse(this.responseText)
console.log(data)
}
else if ( this.status == 401 ){
refreshAccessToken()
}
else {
console.log(this.responseText)
alert(this.responseText)
}
},
refreshAccessToken(){
refresh_token = localStorage.getItem("refresh_token")
let body = "grant_type=refresh_token"
body += "&refresh_token=" + self.jukeboxDialog.data.sp_token
body += "&client_id=" + self.jukeboxDialog.data.sp_user
callAuthorizationApi(body)
},
callAuthorizationApi(body){
let xhr = new XMLHttpRequest()
xhr.open("POST", TOKEN, true)
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
xhr.setRequestHeader('Authorization', 'Basic ' + btoa(self.jukeboxDialog.data.sp_user + ":" + self.jukeboxDialog.data.sp_secret))
xhr.send(body)
xhr.onload = handleAuthorizationResponse
},
},
created() {
this.selectedWallet = this.g.user.wallets[0]
this.loadShop()
LNbits.api
.request('GET', '/jukebox/api/v1/currencies')
.then(response => {
this.itemDialog = {...this.itemDialog, units: ['sat', ...response.data]}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
this.locationcbPath = String([
window.location.protocol,
'//',
window.location.host,
'/jukebox/api/v1/jukebox/spotify/cb/'
].join(''))
}
})

View File

@@ -1,42 +1,20 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="How to use"
label="About"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<ol>
<li>Register items.</li>
<li>
Print QR codes and paste them on your store, your menu, somewhere,
somehow.
</li>
<li>
Clients scan the QR codes and get information about the items plus the
price on their phones directly (they must have internet)
</li>
<li>
Once they decide to pay, they'll get an invoice on their phones
automatically
</li>
<li>
When the payment is confirmed, a confirmation code will be issued for
them.
</li>
</ol>
<p>
The confirmation codes are words from a predefined sequential word list.
Each new payment bumps the words sequence by 1. So you can check the
confirmation codes manually by just looking at them.
</p>
<p>
For example, if your wordlist is
<code>[apple, banana, coconut]</code> the first purchase will be
<code>apple</code>, the second <code>banana</code> and so on. When it
gets to the end it starts from the beginning again.
</p>
<p>Powered by LNURL-pay.</p>
To use this extension you need a Spotify client ID and client secret. You
get these by creating an app in the Spotify developers dashboard
<a href="https://developer.spotify.com/dashboard/applications">here </a>
<br /><br />Select the playlists you want people to be able to pay for,
share the frontend page, profit :) <br /><br />
Made by, <a href="https://twitter.com/arcbtc">benarc</a>. Inspired by,
<a href="https://twitter.com/pirosb3/status/1056263089128161280"
>pirosb3</a
>.
</q-card-section>
</q-card>
</q-expansion-item>

View File

@@ -4,15 +4,15 @@
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn
unelevated
color="green-7"
class="q-ma-lg"
@click="openNewDialog()"
>Add Spotify Jukebox</q-btn
>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Items</h5>
</div>
<div class="col q-ml-lg">
<q-btn unelevated color="deep-purple" @click="openNewDialog()"
>Add jukebox</q-btn
>
</div>
<h5 class="text-subtitle1 q-my-none">Items</h5>
</div>
{% raw %}
<q-table
@@ -101,107 +101,170 @@
</q-card>
</div>
<q-dialog v-model="itemDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-card-section>
<h5
class="q-ma-none"
v-if="itemDialog.data.id"
v-text="itemDialog.data.name"
></h5>
<h5 class="q-ma-none q-mb-xl" v-else>Adding a new item</h5>
<q-responsive v-if="itemDialog.data.id" :ratio="1">
<qrcode
:value="itemDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div v-if="itemDialog.data.id" class="row q-gutter-sm justify-center">
<q-btn
outline
color="grey"
@click="copyText(itemDialog.data.lnurl, 'LNURL copied to clipboard!')"
class="q-mb-lg"
>Copy LNURL</q-btn
>
</div>
<q-form @submit="sendItem" class="q-gutter-md">
<q-dialog v-model="jukeboxDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-md q-pt-lg q-mt-md" style="width: 100%">
<q-stepper
v-model="step"
active-color="green-7"
inactive-color="green-10"
vertical
animated
>
<q-step
:name="1"
title="Pick wallet, price"
icon="account_balance_wallet"
:done="step > 1"
>
<q-input
filled
class="q-pt-md"
dense
v-model.trim="itemDialog.data.name"
type="text"
label="Item name"
></q-input>
<q-input
filled
dense
v-model.trim="itemDialog.data.description"
type="text"
label="Brief description"
></q-input>
<q-file
filled
dense
capture="environment"
accept="image/jpeg, image/png"
:max-file-size="3*1024**2"
label="Small image (optional)"
clearable
@input="imageAdded"
@clear="imageCleared"
>
<template v-if="itemDialog.data.image" v-slot:before>
<img style="height: 1em" :src="itemDialog.data.image" />
</template>
<template v-if="itemDialog.data.image" v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="imageCleared"
class="cursor-pointer"
/>
</template>
</q-file>
<q-input
filled
dense
v-model.number="itemDialog.data.price"
type="number"
min="1"
:label="`Item price (${itemDialog.data.unit})`"
v-model.trim="jukeboxDialog.data.title"
label="Jukebox name"
></q-input>
<q-select
class="q-pb-md q-pt-md"
filled
dense
v-model="itemDialog.data.unit"
type="text"
label="Unit"
:options="itemDialog.units"
emit-value
v-model="jukeboxDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet to use"
></q-select>
<div class="row q-mt-lg">
<div class="col q-ml-lg">
<q-input
filled
dense
v-model.trim="jukeboxDialog.data.price"
type="number"
max="1440"
label="Price per track"
class="q-pb-lg"
>
</q-input>
<div class="row">
<div class="col-4">
<q-btn
unelevated
color="deep-purple"
:disable="disabledAddItemButton()"
type="submit"
v-if="jukeboxDialog.data.title != null && jukeboxDialog.data.price != null && jukeboxDialog.data.wallet != null"
color="green-7"
@click="step = 2"
>Continue</q-btn
>
{% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %}
Item
</q-btn>
<q-btn v-else color="green-7" disable>Continue</q-btn>
</div>
<div class="col q-ml-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
<div class="col-8">
<q-btn
color="green-7"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div>
</div>
</q-form>
</q-card-section>
<br />
</q-step>
<q-step :name="2" title="Add api keys" icon="vpn_key" :done="step > 2">
To use this extension you need a Spotify client ID and client secret.
You get these by creating an app in the Spotify developers dashboard
<a
target="_blank"
href="https://developer.spotify.com/dashboard/applications"
>here</a
>. <br />
In the app go to edit-settings, set the redirect URI to this link
(replacing the CLIENT-ID with your own) {% raw %}{{ locationcbPath
}}CLIENT-ID{% endraw %}
<q-input
filled
class="q-pb-md q-pt-md"
dense
v-model.trim="jukeboxDialog.data.sp_user"
label="Client ID"
></q-input>
<q-input
dense
v-model="jukeboxDialog.data.sp_secret"
filled
:type="isPwd ? 'password' : 'text'"
label="Client secret"
>
<template #append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
></q-icon>
</template>
</q-input>
<div class="row q-mt-md">
<div class="col-4">
<q-btn
v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched"
color="green-7"
@click="submitSpotify"
>Get token</q-btn
>
<q-btn v-else color="green-7" disable color="green-7"
>Get token</q-btn
>
</div>
<div class="col-8">
<q-btn
color="green-7"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div>
</div>
<br />
</q-step>
<q-step
:name="3"
title="Select playlists"
icon="queue_music"
active-color="green-8"
:done="step > 3"
>
<q-select
class="q-pb-md q-pt-md"
filled
dense
emit-value
v-model="jukeboxDialog.data.sp_device"
:options="device"
label="Device jukebox will play to"
></q-select>
<q-select
class="q-pb-md"
filled
dense
emit-value
v-model="jukeboxDialog.data.sp_playlists"
:options="playlists"
label="Playlists available to the jukebox"
></q-select>
<div class="row q-mt-md">
<div class="col-5">
<q-btn color="green-7" @click="step = 2">Create Jukebox</q-btn>
</div>
<div class="col-7">
<q-btn
color="green-7"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div>
</div>
</q-step>
</q-stepper>
</q-card>
</q-dialog>
</div>

View File

@@ -1,13 +1,15 @@
from quart import g, jsonify
from quart import g, jsonify, request
from http import HTTPStatus
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
import httpx
from . import jukebox_ext
from .crud import (
create_update_jukebox,
create_jukebox,
update_jukebox,
get_jukebox,
get_jukebox_by_user,
get_jukeboxs,
delete_jukebox,
)
@@ -17,33 +19,45 @@ from .models import Jukebox
@jukebox_ext.route("/api/v1/jukebox", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_get_jukeboxs():
jukebox = await get_jukeboxs(g.wallet.id)
return (
jsonify(
{
jukebox._asdict()
}
),
HTTPStatus.OK,
jsonify([jukebox._asdict() for jukebox in await get_jukeboxs(g.wallet.id)]),
##################SPOTIFY AUTH#####################
@jukebox_ext.route("/api/v1/jukebox/spotify/cb/<sp_user>/", methods=["GET"])
async def api_check_credentials_callbac(sp_user):
jukebox = await get_jukebox_by_user(sp_user)
jukebox = await update_jukebox(
sp_user=sp_user, sp_secret=jukebox.sp_secret, sp_token=request.args.get('code')
)
return "<h1>Success!</h1><h2>You can close this window</h2>"
#websocket get spotify crap
@jukebox_ext.route("/api/v1/jukebox/spotify/<sp_user>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_check_credentials_check(sp_user):
jukebox = await get_jukebox_by_user(sp_user)
return jsonify(jukebox._asdict()), HTTPStatus.CREATED
@jukebox_ext.route("/api/v1/jukebox/items", methods=["POST"])
@jukebox_ext.route("/api/v1/jukebox/items/<item_id>", methods=["PUT"])
@jukebox_ext.route("/api/v1/jukebox/", methods=["POST"])
@jukebox_ext.route("/api/v1/jukebox/<item_id>", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"wallet": {"type": "string", "empty": False},
"user": {"type": "string", "empty": False},
"secret": {"type": "string", "required": False},
"token": {"type": "string", "required": True},
"playlists": {"type": "string", "required": True},
"title": {"type": "string", "empty": False, "required": True},
"wallet": {"type": "string", "empty": False, "required": True},
"sp_user": {"type": "string", "empty": False, "required": True},
"sp_secret": {"type": "string", "required": True},
"sp_token": {"type": "string", "required": False},
"sp_device": {"type": "string", "required": False},
"sp_playlists": {"type": "string", "required": False},
"price": {"type": "string", "required": True},
}
)
async def api_create_update_jukebox(item_id=None):
jukebox = await create_update_jukebox(g.wallet.id, **g.data)
print(g.data)
jukebox = await create_jukebox(**g.data)
return jsonify(jukebox._asdict()), HTTPStatus.CREATED