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 @@
+
+ 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
+ Powered by LNURL-pay.
+
+ [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.
+ 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 }}"
+
+