Initial commit 🎵

This commit is contained in:
Ben Arc 2021-04-27 10:07:17 +01:00
parent bcecf6d431
commit c0b18d78cc
12 changed files with 770 additions and 0 deletions

View file

@ -0,0 +1,5 @@
# Jukebox
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 here https://developer.spotify.com/dashboard/applications
Select the playlists you want people to be able to pay for, share the frontend page, profit :)

View file

@ -0,0 +1,14 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_jukebox")
jukebox_ext: Blueprint = Blueprint(
"jukebox", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa
from .lnurl import * # noqa

View file

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

View file

@ -0,0 +1,34 @@
from typing import List, Optional
from . import db
from .wordlists import animals
from .models import Shop, Item
async def create_update_jukebox(wallet_id: str) -> int:
juke_id = urlsafe_short_hash()
result = await db.execute(
"""
INSERT INTO jukebox (id, wallet, user, secret, token, playlists)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(juke_id, wallet_id, "", "", "", ""),
)
return result._result_proxy.lastrowid
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
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
async def delete_jukebox(shop: int, item_id: int):
await db.execute(
"""
DELETE FROM jukebox WHERE id = ?
""",
(shop, item_id),
)

View file

@ -0,0 +1,16 @@
async def m001_initial(db):
"""
Initial jukebox table.
"""
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
);
"""
)

View file

@ -0,0 +1,18 @@
import json
import base64
import hashlib
from collections import OrderedDict
from quart import url_for
from typing import NamedTuple, Optional, List, Dict
class Jukebox(NamedTuple):
id: int
wallet: str
user: str
secret: str
token: str
playlists: str
@classmethod
def from_row(cls, row: Row) -> "Charges":
return cls(**dict(row))

View file

@ -0,0 +1,216 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
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: {
show: false,
data: {...defaultItemData},
units: ['sat']
}
}
},
computed: {
printItems() {
return this.jukebox.items.filter(({enabled}) => enabled)
}
},
methods: {
openNewDialog() {
this.itemDialog.show = true
this.itemDialog.data = {...defaultItemData}
},
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
})
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
)
.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
)
.then(response => {
this.$q.notify({
message: `Item deleted.`,
timeout: 700
})
this.jukebox.items.splice(
this.jukebox.items.findIndex(item => item.id === itemId),
1
)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
})
}
},
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)
})
}
})

View file

@ -0,0 +1,146 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="How to use"
: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>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item
group="api"
dense
expand-separator
label="Create item (a shop will be created automatically based on the wallet you use)"
>
<q-card>
<q-card-section>
<code><span class="text-blue">POST</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 OK</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}/jukebox/api/v1/jukebox/items -H
"Content-Type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].inkey }}" -d '{"name": &lt;string&gt;,
"description": &lt;string&gt;, "image": &lt;data-uri string&gt;,
"price": &lt;integer&gt;, "unit": &lt;"sat" or "USD"&gt;}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get the shop data along with items"
>
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;integer&gt;, "wallet": &lt;string&gt;, "wordlist":
&lt;string&gt;, "items": [{"id": &lt;integer&gt;, "name":
&lt;string&gt;, "description": &lt;string&gt;, "image":
&lt;string&gt;, "enabled": &lt;boolean&gt;, "price": &lt;integer&gt;,
"unit": &lt;string&gt;, "lnurl": &lt;string&gt;}, ...]}&lt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}/jukebox/api/v1/jukebox -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Update item (all fields must be sent again)"
>
<q-card>
<q-card-section>
<code><span class="text-blue">PUT</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}/jukebox/api/v1/jukebox/items/&lt;item_id&gt; -H "Content-Type:
application/json" -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -d
'{"name": &lt;string&gt;, "description": &lt;string&gt;, "image":
&lt;data-uri string&gt;, "price": &lt;integer&gt;, "unit": &lt;"sat"
or "USD"&gt;}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Delete item">
<q-card>
<q-card-section>
<code><span class="text-blue">DELETE</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}/jukebox/api/v1/jukebox/items/&lt;item_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View file

@ -0,0 +1,211 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<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>
</div>
{% raw %}
<q-table
dense
flat
selection="multiple"
:data="jukebox.items"
row-key="id"
no-data-label="No items for sale yet"
:pagination="{rowsPerPage: 0}"
:binary-state-sort="true"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width>Name</q-th>
<q-th auto-width>Description</q-th>
<q-th auto-width>Image</q-th>
<q-th auto-width>Price</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
:icon="props.row.enabled ? 'done' : 'block'"
:color="props.row.enabled ? 'green' : ($q.dark.isActive ? 'grey-7' : 'grey-5')"
type="a"
@click="toggleItem(props.row.id)"
target="_blank"
></q-btn>
</q-td>
<q-td auto-width class="text-center">{{ props.row.name }}</q-td>
<q-td auto-width> {{ props.row.description }} </q-td>
<q-td class="text-center" auto-width>
<img
v-if="props.row.image"
:src="props.row.image"
style="height: 1.5em"
/>
</q-td>
<q-td class="text-center" auto-width>
{{ props.row.price }} {{ props.row.unit }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="delete"
color="negative"
type="a"
@click="deleteItem(props.row.id)"
target="_blank"
></q-btn>
</q-td>
</q-tr>
</template>
</q-table>
{% endraw %}
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits jukebox extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "jukebox/_api_docs.html" %} </q-list>
</q-card-section>
</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-input
filled
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})`"
></q-input>
<q-select
filled
dense
v-model="itemDialog.data.unit"
type="text"
label="Unit"
:options="itemDialog.units"
></q-select>
<div class="row q-mt-lg">
<div class="col q-ml-lg">
<q-btn
unelevated
color="deep-purple"
:disable="disabledAddItemButton()"
type="submit"
>
{% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %}
Item
</q-btn>
</div>
<div class="col q-ml-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
<script src="/jukebox/static/js/index.js"></script>
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends "print.html" %} {% block page %} {% raw %}
<div class="row justify-center">
<div v-for="item in items" class="q-my-sm q-mx-lg">
<div class="text-center q-ma-none q-mb-sm">{{ item.name }}</div>
<qrcode :value="item.lnurl" :options="{margin: 0, width: 250}"></qrcode>
<div class="text-center q-ma-none q-mt-sm">{{ item.price }}</div>
</div>
</div>
{% endraw %} {% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
created: function () {
window.print()
},
data: function () {
return {
items: JSON.parse('{{items | tojson}}')
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,25 @@
import time
from datetime import datetime
from quart import g, render_template, request
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.core.models import Payment
from lnbits.core.crud import get_standalone_payment
from . import jukebox_ext
from .crud import get_jukebox
@jukebox_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("jukebox/index.html", user=g.user)
@jukebox_ext.route("/<juke_id>")
async def print_qr_codes(juke_id):
jukebox = await get_jukebox(juke_id)
return await render_template("jukebox/jukebox.html", jukebox=jukebox)

View file

@ -0,0 +1,54 @@
from quart import g, jsonify
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
from . import jukebox_ext
from .crud import (
create_update_jukebox,
get_jukebox,
get_jukeboxs,
delete_jukebox,
)
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,
)
#websocket get spotify crap
@jukebox_ext.route("/api/v1/jukebox/items", methods=["POST"])
@jukebox_ext.route("/api/v1/jukebox/items/<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},
}
)
async def api_create_update_jukebox(item_id=None):
jukebox = await create_update_jukebox(g.wallet.id, **g.data)
return jsonify(jukebox._asdict()), HTTPStatus.CREATED
@jukebox_ext.route("/api/v1/jukebox/<juke_id>", methods=["DELETE"])
@api_check_wallet_key("admin")
async def api_delete_item(juke_id):
shop = await delete_jukebox(juke_id)
return "", HTTPStatus.NO_CONTENT