From c0b18d78cc7ddb4a17ceceb595dee0699d9df507 Mon Sep 17 00:00:00 2001 From: Ben Arc Date: Tue, 27 Apr 2021 10:07:17 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=F0=9F=8E=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lnbits/extensions/jukebox/README.md | 5 + lnbits/extensions/jukebox/__init__.py | 14 ++ lnbits/extensions/jukebox/config.json | 6 + lnbits/extensions/jukebox/crud.py | 34 +++ lnbits/extensions/jukebox/migrations.py | 16 ++ lnbits/extensions/jukebox/models.py | 18 ++ lnbits/extensions/jukebox/static/js/index.js | 216 ++++++++++++++++++ .../jukebox/templates/jukebox/_api_docs.html | 146 ++++++++++++ .../jukebox/templates/jukebox/index.html | 211 +++++++++++++++++ .../jukebox/templates/jukebox/jukebox.html | 25 ++ lnbits/extensions/jukebox/views.py | 25 ++ lnbits/extensions/jukebox/views_api.py | 54 +++++ 12 files changed, 770 insertions(+) create mode 100644 lnbits/extensions/jukebox/README.md create mode 100644 lnbits/extensions/jukebox/__init__.py create mode 100644 lnbits/extensions/jukebox/config.json create mode 100644 lnbits/extensions/jukebox/crud.py create mode 100644 lnbits/extensions/jukebox/migrations.py create mode 100644 lnbits/extensions/jukebox/models.py create mode 100644 lnbits/extensions/jukebox/static/js/index.js create mode 100644 lnbits/extensions/jukebox/templates/jukebox/_api_docs.html create mode 100644 lnbits/extensions/jukebox/templates/jukebox/index.html create mode 100644 lnbits/extensions/jukebox/templates/jukebox/jukebox.html create mode 100644 lnbits/extensions/jukebox/views.py create mode 100644 lnbits/extensions/jukebox/views_api.py diff --git a/lnbits/extensions/jukebox/README.md b/lnbits/extensions/jukebox/README.md new file mode 100644 index 000000000..b92e7ea6f --- /dev/null +++ b/lnbits/extensions/jukebox/README.md @@ -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 :) diff --git a/lnbits/extensions/jukebox/__init__.py b/lnbits/extensions/jukebox/__init__.py new file mode 100644 index 000000000..0e02b92e9 --- /dev/null +++ b/lnbits/extensions/jukebox/__init__.py @@ -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 diff --git a/lnbits/extensions/jukebox/config.json b/lnbits/extensions/jukebox/config.json new file mode 100644 index 000000000..04c69cc11 --- /dev/null +++ b/lnbits/extensions/jukebox/config.json @@ -0,0 +1,6 @@ +{ + "name": "Jukebox", + "short_description": "Spotify jukebox middleware", + "icon": "audiotrack", + "contributors": ["benarc"] +} diff --git a/lnbits/extensions/jukebox/crud.py b/lnbits/extensions/jukebox/crud.py new file mode 100644 index 000000000..c0efe405e --- /dev/null +++ b/lnbits/extensions/jukebox/crud.py @@ -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), + ) diff --git a/lnbits/extensions/jukebox/migrations.py b/lnbits/extensions/jukebox/migrations.py new file mode 100644 index 000000000..552293481 --- /dev/null +++ b/lnbits/extensions/jukebox/migrations.py @@ -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 + ); + """ + ) \ No newline at end of file diff --git a/lnbits/extensions/jukebox/models.py b/lnbits/extensions/jukebox/models.py new file mode 100644 index 000000000..8286bc896 --- /dev/null +++ b/lnbits/extensions/jukebox/models.py @@ -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)) \ No newline at end of file diff --git a/lnbits/extensions/jukebox/static/js/index.js b/lnbits/extensions/jukebox/static/js/index.js new file mode 100644 index 000000000..699f505bd --- /dev/null +++ b/lnbits/extensions/jukebox/static/js/index.js @@ -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) + }) + } +}) diff --git a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html new file mode 100644 index 000000000..7d15aa8f6 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html @@ -0,0 +1,146 @@ + + + +
    +
  1. Register items.
  2. +
  3. + Print QR codes and paste them on your store, your menu, somewhere, + somehow. +
  4. +
  5. + Clients scan the QR codes and get information about the items plus the + price on their phones directly (they must have internet) +
  6. +
  7. + Once they decide to pay, they'll get an invoice on their phones + automatically +
  8. +
  9. + When the payment is confirmed, a confirmation code will be issued for + them. +
  10. +
+

+ 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. +

+

+ For example, if your wordlist is + [apple, banana, coconut] the first purchase will be + apple, the second banana and so on. When it + gets to the end it starts from the beginning again. +

+

Powered by LNURL-pay.

+
+
+
+ + + + + + POST +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 201 OK
+
Curl example
+ 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": <string>, + "description": <string>, "image": <data-uri string>, + "price": <integer>, "unit": <"sat" or "USD">}' + +
+
+
+ + + + GET +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {"id": <integer>, "wallet": <string>, "wordlist": + <string>, "items": [{"id": <integer>, "name": + <string>, "description": <string>, "image": + <string>, "enabled": <boolean>, "price": <integer>, + "unit": <string>, "lnurl": <string>}, ...]}< +
Curl example
+ curl -X GET {{ request.url_root }}/jukebox/api/v1/jukebox -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + PUT +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 200 OK
+
Curl example
+ curl -X GET {{ request.url_root + }}/jukebox/api/v1/jukebox/items/<item_id> -H "Content-Type: + application/json" -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -d + '{"name": <string>, "description": <string>, "image": + <data-uri string>, "price": <integer>, "unit": <"sat" + or "USD">}' + +
+
+
+ + + + DELETE +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 200 OK
+
Curl example
+ curl -X GET {{ request.url_root + }}/jukebox/api/v1/jukebox/items/<item_id> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+
diff --git a/lnbits/extensions/jukebox/templates/jukebox/index.html b/lnbits/extensions/jukebox/templates/jukebox/index.html new file mode 100644 index 000000000..48a83d514 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/index.html @@ -0,0 +1,211 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+
+
Items
+
+
+ Add jukebox +
+
+ {% raw %} + + + + + {% endraw %} +
+
+
+ +
+ + +
LNbits jukebox extension
+
+ + + {% include "jukebox/_api_docs.html" %} + +
+
+ + + + +
+
Adding a new item
+ + + + + +
+ Copy LNURL +
+ + + + + + + + + + +
+
+ + {% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %} + Item + +
+
+ Cancel +
+
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/jukebox/templates/jukebox/jukebox.html b/lnbits/extensions/jukebox/templates/jukebox/jukebox.html new file mode 100644 index 000000000..fff12b4c3 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/jukebox.html @@ -0,0 +1,25 @@ +{% extends "print.html" %} {% block page %} {% raw %} +
+
+
{{ item.name }}
+ +
{{ item.price }}
+
+
+{% endraw %} {% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/jukebox/views.py b/lnbits/extensions/jukebox/views.py new file mode 100644 index 000000000..d434f8317 --- /dev/null +++ b/lnbits/extensions/jukebox/views.py @@ -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("/") +async def print_qr_codes(juke_id): + jukebox = await get_jukebox(juke_id) + + return await render_template("jukebox/jukebox.html", jukebox=jukebox) diff --git a/lnbits/extensions/jukebox/views_api.py b/lnbits/extensions/jukebox/views_api.py new file mode 100644 index 000000000..5433ddded --- /dev/null +++ b/lnbits/extensions/jukebox/views_api.py @@ -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/", 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/", methods=["DELETE"]) +@api_check_wallet_key("admin") +async def api_delete_item(juke_id): + shop = await delete_jukebox(juke_id) + return "", HTTPStatus.NO_CONTENT \ No newline at end of file