diff --git a/lnbits/extensions/jukebox/README.md b/lnbits/extensions/jukebox/README.md
new file mode 100644
index 000000000..c761db448
--- /dev/null
+++ b/lnbits/extensions/jukebox/README.md
@@ -0,0 +1,36 @@
+# Jukebox
+
+## An actual Jukebox where users pay sats to play their favourite music from your playlists
+
+**Note:** To use this extension you need a Premium Spotify subscription.
+
+## Usage
+
+1. Click on "ADD SPOTIFY JUKEBOX"\
+ 
+2. Follow the steps required on the form\
+
+ - give your jukebox a name
+ - select a wallet to receive payment
+ - define the price a user must pay to select a song\
+ 
+ - follow the steps to get your Spotify App and get the client ID and secret key\
+ 
+ - paste the codes in the form\
+ 
+ - copy the _Redirect URL_ presented on the form\
+ 
+ - on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
+ 
+ - back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
+ - choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
+ - and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\
+ 
+
+3. After Jukebox is created, click the icon to open the dialog with the shareable QR, open the Jukebox page, etc...\
+ 
+4. The users will see the Jukebox page and choose a song from the selected playlist\
+ 
+5. After selecting a song they'd like to hear next a dialog will show presenting the music\
+ 
+6. After payment, the song will automatically start playing on the device selected or enter the queue if some other music is already playing
diff --git a/lnbits/extensions/jukebox/__init__.py b/lnbits/extensions/jukebox/__init__.py
new file mode 100644
index 000000000..076ae4d9d
--- /dev/null
+++ b/lnbits/extensions/jukebox/__init__.py
@@ -0,0 +1,17 @@
+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 .tasks import register_listeners
+
+from lnbits.tasks import record_async
+
+jukebox_ext.record(record_async(register_listeners))
diff --git a/lnbits/extensions/jukebox/config.json b/lnbits/extensions/jukebox/config.json
new file mode 100644
index 000000000..6b57bec46
--- /dev/null
+++ b/lnbits/extensions/jukebox/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Spotify Jukebox",
+ "short_description": "Spotify jukebox middleware",
+ "icon": "radio",
+ "contributors": ["benarc"]
+}
diff --git a/lnbits/extensions/jukebox/crud.py b/lnbits/extensions/jukebox/crud.py
new file mode 100644
index 000000000..4e3ba2f15
--- /dev/null
+++ b/lnbits/extensions/jukebox/crud.py
@@ -0,0 +1,122 @@
+from typing import List, Optional
+
+from . import db
+from .models import Jukebox, JukeboxPayment
+from lnbits.helpers import urlsafe_short_hash
+
+
+async def create_jukebox(
+ inkey: str,
+ user: str,
+ wallet: str,
+ title: str,
+ price: int,
+ sp_user: str,
+ sp_secret: str,
+ sp_access_token: Optional[str] = "",
+ sp_refresh_token: Optional[str] = "",
+ sp_device: Optional[str] = "",
+ sp_playlists: Optional[str] = "",
+) -> Jukebox:
+ juke_id = urlsafe_short_hash()
+ result = await db.execute(
+ """
+ INSERT INTO jukebox.jukebox (id, user, title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ juke_id,
+ user,
+ title,
+ wallet,
+ sp_user,
+ sp_secret,
+ sp_access_token,
+ sp_refresh_token,
+ sp_device,
+ sp_playlists,
+ int(price),
+ 0,
+ ),
+ )
+ jukebox = await get_jukebox(juke_id)
+ assert jukebox, "Newly created Jukebox couldn't be retrieved"
+ return jukebox
+
+
+async def update_jukebox(juke_id: str, **kwargs) -> Optional[Jukebox]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (*kwargs.values(), juke_id)
+ )
+ row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
+ return Jukebox(**row) if row else None
+
+
+async def get_jukebox(juke_id: str) -> Optional[Jukebox]:
+ row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
+ return Jukebox(**row) if row else None
+
+
+async def get_jukebox_by_user(user: str) -> Optional[Jukebox]:
+ row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE sp_user = ?", (user,))
+ return Jukebox(**row) if row else None
+
+
+async def get_jukeboxs(user: str) -> List[Jukebox]:
+ rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
+ for row in rows:
+ if row.sp_playlists == "":
+ await delete_jukebox(row.id)
+ rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
+ return [Jukebox.from_row(row) for row in rows]
+
+
+async def delete_jukebox(juke_id: str):
+ await db.execute(
+ """
+ DELETE FROM jukebox.jukebox WHERE id = ?
+ """,
+ (juke_id),
+ )
+
+
+#####################################PAYMENTS
+
+
+async def create_jukebox_payment(
+ song_id: str, payment_hash: str, juke_id: str
+) -> JukeboxPayment:
+ result = await db.execute(
+ """
+ INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
+ VALUES (?, ?, ?, ?)
+ """,
+ (
+ payment_hash,
+ juke_id,
+ song_id,
+ False,
+ ),
+ )
+ jukebox_payment = await get_jukebox_payment(payment_hash)
+ assert jukebox_payment, "Newly created Jukebox Payment couldn't be retrieved"
+ return jukebox_payment
+
+
+async def update_jukebox_payment(
+ payment_hash: str, **kwargs
+) -> Optional[JukeboxPayment]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE jukebox.jukebox_payment SET {q} WHERE payment_hash = ?",
+ (*kwargs.values(), payment_hash),
+ )
+ return await get_jukebox_payment(payment_hash)
+
+
+async def get_jukebox_payment(payment_hash: str) -> Optional[JukeboxPayment]:
+ row = await db.fetchone(
+ "SELECT * FROM jukebox.jukebox_payment WHERE payment_hash = ?", (payment_hash,)
+ )
+ return JukeboxPayment(**row) if row else None
diff --git a/lnbits/extensions/jukebox/migrations.py b/lnbits/extensions/jukebox/migrations.py
new file mode 100644
index 000000000..a0a3bd285
--- /dev/null
+++ b/lnbits/extensions/jukebox/migrations.py
@@ -0,0 +1,39 @@
+async def m001_initial(db):
+ """
+ Initial jukebox table.
+ """
+ await db.execute(
+ """
+ CREATE TABLE jukebox.jukebox (
+ id TEXT PRIMARY KEY,
+ "user" TEXT,
+ title TEXT,
+ wallet TEXT,
+ inkey TEXT,
+ sp_user TEXT NOT NULL,
+ sp_secret TEXT NOT NULL,
+ sp_access_token TEXT,
+ sp_refresh_token TEXT,
+ sp_device TEXT,
+ sp_playlists TEXT,
+ price INTEGER,
+ profit INTEGER
+ );
+ """
+ )
+
+
+async def m002_initial(db):
+ """
+ Initial jukebox_payment table.
+ """
+ await db.execute(
+ """
+ CREATE TABLE jukebox.jukebox_payment (
+ payment_hash TEXT PRIMARY KEY,
+ juke_id TEXT,
+ song_id TEXT,
+ paid BOOL
+ );
+ """
+ )
diff --git a/lnbits/extensions/jukebox/models.py b/lnbits/extensions/jukebox/models.py
new file mode 100644
index 000000000..03c41d674
--- /dev/null
+++ b/lnbits/extensions/jukebox/models.py
@@ -0,0 +1,33 @@
+from typing import NamedTuple
+from sqlite3 import Row
+
+
+class Jukebox(NamedTuple):
+ id: str
+ user: str
+ title: str
+ wallet: str
+ inkey: str
+ sp_user: str
+ sp_secret: str
+ sp_access_token: str
+ sp_refresh_token: str
+ sp_device: str
+ sp_playlists: str
+ price: int
+ profit: int
+
+ @classmethod
+ def from_row(cls, row: Row) -> "Jukebox":
+ return cls(**dict(row))
+
+
+class JukeboxPayment(NamedTuple):
+ payment_hash: str
+ juke_id: str
+ song_id: str
+ paid: bool
+
+ @classmethod
+ def from_row(cls, row: Row) -> "JukeboxPayment":
+ return cls(**dict(row))
diff --git a/lnbits/extensions/jukebox/static/js/index.js b/lnbits/extensions/jukebox/static/js/index.js
new file mode 100644
index 000000000..fc382d711
--- /dev/null
+++ b/lnbits/extensions/jukebox/static/js/index.js
@@ -0,0 +1,420 @@
+/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
+
+Vue.component(VueQrcode.name, VueQrcode)
+
+var mapJukebox = obj => {
+ obj._data = _.clone(obj)
+ obj.sp_id = obj.id
+ obj.device = obj.sp_device.split('-')[0]
+ playlists = obj.sp_playlists.split(',')
+ var i
+ playlistsar = []
+ for (i = 0; i < playlists.length; i++) {
+ playlistsar.push(playlists[i].split('-')[0])
+ }
+ obj.playlist = playlistsar.join()
+ return obj
+}
+
+new Vue({
+ el: '#vue',
+ mixins: [windowMixin],
+ data() {
+ return {
+ JukeboxTable: {
+ columns: [
+ {
+ name: 'title',
+ align: 'left',
+ label: 'Title',
+ field: 'title'
+ },
+ {
+ name: 'device',
+ align: 'left',
+ label: 'Device',
+ field: 'device'
+ },
+ {
+ name: 'playlist',
+ align: 'left',
+ label: 'Playlist',
+ field: 'playlist'
+ },
+ {
+ name: 'price',
+ align: 'left',
+ label: 'Price',
+ field: 'price'
+ }
+ ],
+ pagination: {
+ rowsPerPage: 10
+ }
+ },
+ isPwd: true,
+ tokenFetched: true,
+ devices: [],
+ filter: '',
+ jukebox: {},
+ playlists: [],
+ JukeboxLinks: [],
+ step: 1,
+ locationcbPath: '',
+ locationcb: '',
+ jukeboxDialog: {
+ show: false,
+ data: {}
+ },
+ spotifyDialog: false,
+ qrCodeDialog: {
+ show: false,
+ data: null
+ }
+ }
+ },
+ computed: {},
+ methods: {
+ openQrCodeDialog: function (linkId) {
+ var link = _.findWhere(this.JukeboxLinks, {id: linkId})
+
+ this.qrCodeDialog.data = _.clone(link)
+ console.log(this.qrCodeDialog.data)
+ this.qrCodeDialog.data.url =
+ window.location.protocol + '//' + window.location.host
+ this.qrCodeDialog.show = true
+ },
+ getJukeboxes() {
+ self = this
+ LNbits.api
+ .request(
+ 'GET',
+ '/jukebox/api/v1/jukebox',
+ self.g.user.wallets[0].adminkey
+ )
+ .then(function (response) {
+ self.JukeboxLinks = response.data.map(mapJukebox)
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ },
+ deleteJukebox(juke_id) {
+ self = this
+ LNbits.utils
+ .confirmDialog('Are you sure you want to delete this Jukebox?')
+ .onOk(function () {
+ LNbits.api
+ .request(
+ 'DELETE',
+ '/jukebox/api/v1/jukebox/' + juke_id,
+ self.g.user.wallets[0].adminkey
+ )
+ .then(function (response) {
+ self.JukeboxLinks = _.reject(self.JukeboxLinks, function (obj) {
+ return obj.id === juke_id
+ })
+ })
+
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ })
+ },
+ updateJukebox: function (linkId) {
+ self = this
+ var link = _.findWhere(self.JukeboxLinks, {id: linkId})
+ self.jukeboxDialog.data = _.clone(link._data)
+ console.log(this.jukeboxDialog.data.sp_access_token)
+
+ self.refreshDevices()
+ self.refreshPlaylists()
+
+ self.step = 4
+ self.jukeboxDialog.data.sp_device = []
+ self.jukeboxDialog.data.sp_playlists = []
+ self.jukeboxDialog.data.sp_id = self.jukeboxDialog.data.id
+ self.jukeboxDialog.data.price = String(self.jukeboxDialog.data.price)
+ self.jukeboxDialog.show = true
+ },
+ closeFormDialog() {
+ this.jukeboxDialog.data = {}
+ this.jukeboxDialog.show = false
+ this.step = 1
+ },
+ submitSpotifyKeys() {
+ self = this
+ self.jukeboxDialog.data.user = self.g.user.id
+
+ LNbits.api
+ .request(
+ 'POST',
+ '/jukebox/api/v1/jukebox/',
+ self.g.user.wallets[0].adminkey,
+ self.jukeboxDialog.data
+ )
+ .then(response => {
+ if (response.data) {
+ self.jukeboxDialog.data.sp_id = response.data.id
+ self.step = 3
+ }
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ },
+ authAccess() {
+ self = this
+ self.requestAuthorization()
+ self.getSpotifyTokens()
+ self.$q.notify({
+ spinner: true,
+ message: 'Processing',
+ timeout: 10000
+ })
+ },
+ getSpotifyTokens() {
+ self = this
+ var counter = 0
+ var timerId = setInterval(function () {
+ counter++
+ if (!self.jukeboxDialog.data.sp_user) {
+ clearInterval(timerId)
+ }
+ LNbits.api
+ .request(
+ 'GET',
+ '/jukebox/api/v1/jukebox/' + self.jukeboxDialog.data.sp_id,
+ self.g.user.wallets[0].adminkey
+ )
+ .then(response => {
+ if (response.data.sp_access_token) {
+ self.fetchAccessToken(response.data.sp_access_token)
+ if (self.jukeboxDialog.data.sp_access_token) {
+ self.refreshPlaylists()
+ self.refreshDevices()
+ console.log('this.devices')
+ console.log(self.devices)
+ console.log('this.devices')
+ setTimeout(function () {
+ if (self.devices.length < 1 || self.playlists.length < 1) {
+ self.$q.notify({
+ spinner: true,
+ color: 'red',
+ message:
+ 'Error! Make sure Spotify is open on the device you wish to use, has playlists, and is playing something',
+ timeout: 10000
+ })
+ LNbits.api
+ .request(
+ 'DELETE',
+ '/jukebox/api/v1/jukebox/' + response.data.id,
+ self.g.user.wallets[0].adminkey
+ )
+ .then(function (response) {
+ self.getJukeboxes()
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ clearInterval(timerId)
+ self.closeFormDialog()
+ } else {
+ self.step = 4
+ clearInterval(timerId)
+ }
+ }, 2000)
+ }
+ }
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ }, 3000)
+ },
+ requestAuthorization() {
+ self = this
+ var url = 'https://accounts.spotify.com/authorize'
+ url += '?client_id=' + self.jukeboxDialog.data.sp_user
+ url += '&response_type=code'
+ url +=
+ '&redirect_uri=' +
+ encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id)
+ url += '&show_dialog=true'
+ url +=
+ '&scope=user-read-private user-read-email user-modify-playback-state user-read-playback-position user-library-read streaming user-read-playback-state user-read-recently-played playlist-read-private'
+
+ window.open(url)
+ },
+ openNewDialog() {
+ this.jukeboxDialog.show = true
+ this.jukeboxDialog.data = {}
+ },
+ createJukebox() {
+ self = this
+ self.jukeboxDialog.data.sp_playlists = self.jukeboxDialog.data.sp_playlists.join()
+ self.updateDB()
+ self.jukeboxDialog.show = false
+ self.getJukeboxes()
+ },
+ updateDB() {
+ self = this
+ console.log(self.jukeboxDialog.data)
+ LNbits.api
+ .request(
+ 'PUT',
+ '/jukebox/api/v1/jukebox/' + this.jukeboxDialog.data.sp_id,
+ self.g.user.wallets[0].adminkey,
+ self.jukeboxDialog.data
+ )
+ .then(function (response) {
+ console.log(response.data)
+ if (
+ self.jukeboxDialog.data.sp_playlists &&
+ self.jukeboxDialog.data.sp_devices
+ ) {
+ self.getJukeboxes()
+ // self.JukeboxLinks.push(mapJukebox(response.data))
+ }
+ })
+ },
+ playlistApi(method, url, body) {
+ self = this
+ let xhr = new XMLHttpRequest()
+ xhr.open(method, url, true)
+ xhr.setRequestHeader('Content-Type', 'application/json')
+ xhr.setRequestHeader(
+ 'Authorization',
+ 'Bearer ' + this.jukeboxDialog.data.sp_access_token
+ )
+ xhr.send(body)
+ xhr.onload = function () {
+ if (xhr.status == 401) {
+ self.refreshAccessToken()
+ self.playlistApi(
+ 'GET',
+ 'https://api.spotify.com/v1/me/playlists',
+ null
+ )
+ }
+ let responseObj = JSON.parse(xhr.response)
+ self.jukeboxDialog.data.playlists = null
+ self.playlists = []
+ self.jukeboxDialog.data.playlists = []
+ var i
+ for (i = 0; i < responseObj.items.length; i++) {
+ self.playlists.push(
+ responseObj.items[i].name + '-' + responseObj.items[i].id
+ )
+ }
+ console.log(self.playlists)
+ }
+ },
+ refreshPlaylists() {
+ self = this
+ self.playlistApi('GET', 'https://api.spotify.com/v1/me/playlists', null)
+ },
+ deviceApi(method, url, body) {
+ self = this
+ let xhr = new XMLHttpRequest()
+ xhr.open(method, url, true)
+ xhr.setRequestHeader('Content-Type', 'application/json')
+ xhr.setRequestHeader(
+ 'Authorization',
+ 'Bearer ' + this.jukeboxDialog.data.sp_access_token
+ )
+ xhr.send(body)
+ xhr.onload = function () {
+ if (xhr.status == 401) {
+ self.refreshAccessToken()
+ self.deviceApi(
+ 'GET',
+ 'https://api.spotify.com/v1/me/player/devices',
+ null
+ )
+ }
+ let responseObj = JSON.parse(xhr.response)
+ self.jukeboxDialog.data.devices = []
+
+ self.devices = []
+ var i
+ for (i = 0; i < responseObj.devices.length; i++) {
+ self.devices.push(
+ responseObj.devices[i].name + '-' + responseObj.devices[i].id
+ )
+ }
+ }
+ },
+ refreshDevices() {
+ self = this
+ self.deviceApi(
+ 'GET',
+ 'https://api.spotify.com/v1/me/player/devices',
+ null
+ )
+ },
+ fetchAccessToken(code) {
+ self = this
+ let body = 'grant_type=authorization_code'
+ body += '&code=' + code
+ body +=
+ '&redirect_uri=' +
+ encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id)
+
+ self.callAuthorizationApi(body)
+ },
+ refreshAccessToken() {
+ self = this
+ let body = 'grant_type=refresh_token'
+ body += '&refresh_token=' + self.jukeboxDialog.data.sp_refresh_token
+ body += '&client_id=' + self.jukeboxDialog.data.sp_user
+ self.callAuthorizationApi(body)
+ },
+ callAuthorizationApi(body) {
+ self = this
+ console.log(
+ btoa(
+ self.jukeboxDialog.data.sp_user +
+ ':' +
+ self.jukeboxDialog.data.sp_secret
+ )
+ )
+ let xhr = new XMLHttpRequest()
+ xhr.open('POST', 'https://accounts.spotify.com/api/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 = function () {
+ let responseObj = JSON.parse(xhr.response)
+ if (responseObj.access_token) {
+ self.jukeboxDialog.data.sp_access_token = responseObj.access_token
+ self.jukeboxDialog.data.sp_refresh_token = responseObj.refresh_token
+ self.updateDB()
+ }
+ }
+ }
+ },
+ created() {
+ console.log(this.g.user.wallets[0])
+ var getJukeboxes = this.getJukeboxes
+ getJukeboxes()
+ this.selectedWallet = this.g.user.wallets[0]
+ this.locationcbPath = String(
+ [
+ window.location.protocol,
+ '//',
+ window.location.host,
+ '/jukebox/api/v1/jukebox/spotify/cb/'
+ ].join('')
+ )
+ this.locationcb = this.locationcbPath
+ }
+})
diff --git a/lnbits/extensions/jukebox/static/js/jukebox.js b/lnbits/extensions/jukebox/static/js/jukebox.js
new file mode 100644
index 000000000..ddbb27646
--- /dev/null
+++ b/lnbits/extensions/jukebox/static/js/jukebox.js
@@ -0,0 +1,14 @@
+/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
+
+Vue.component(VueQrcode.name, VueQrcode)
+
+new Vue({
+ el: '#vue',
+ mixins: [windowMixin],
+ data() {
+ return {}
+ },
+ computed: {},
+ methods: {},
+ created() {}
+})
diff --git a/lnbits/extensions/jukebox/static/spotapi.gif b/lnbits/extensions/jukebox/static/spotapi.gif
new file mode 100644
index 000000000..023efc9a9
Binary files /dev/null and b/lnbits/extensions/jukebox/static/spotapi.gif differ
diff --git a/lnbits/extensions/jukebox/static/spotapi1.gif b/lnbits/extensions/jukebox/static/spotapi1.gif
new file mode 100644
index 000000000..478032c56
Binary files /dev/null and b/lnbits/extensions/jukebox/static/spotapi1.gif differ
diff --git a/lnbits/extensions/jukebox/tasks.py b/lnbits/extensions/jukebox/tasks.py
new file mode 100644
index 000000000..65fca93dc
--- /dev/null
+++ b/lnbits/extensions/jukebox/tasks.py
@@ -0,0 +1,28 @@
+import json
+import trio # type: ignore
+
+from lnbits.core.models import Payment
+from lnbits.core.crud import create_payment
+from lnbits.core import db as core_db
+from lnbits.tasks import register_invoice_listener, internal_invoice_paid
+from lnbits.helpers import urlsafe_short_hash
+
+from .crud import get_jukebox, update_jukebox_payment
+
+
+async def register_listeners():
+ invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
+ register_invoice_listener(invoice_paid_chan_send)
+ await wait_for_paid_invoices(invoice_paid_chan_recv)
+
+
+async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
+ async for payment in invoice_paid_chan:
+ await on_invoice_paid(payment)
+
+
+async def on_invoice_paid(payment: Payment) -> None:
+ if "jukebox" != payment.extra.get("tag"):
+ # not a jukebox invoice
+ return
+ await update_jukebox_payment(payment.payment_hash, paid=True)
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..f5a913130
--- /dev/null
+++ b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html
@@ -0,0 +1,125 @@
+
Select the playlists you want people to be able to pay for, share
+ the frontend page, profit :)
+ Made by,
+ benarc.
+ Inspired by,
+ pirosb3.
+GET /jukebox/api/v1/jukebox
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ [<jukebox_object>, ...]
+ Curl example
+ curl -X GET {{ request.url_root }}api/v1/jukebox -H "X-Api-Key: {{
+ g.user.wallets[0].adminkey }}"
+
+ GET
+ /jukebox/api/v1/jukebox/<juke_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ <jukebox_object>
+ Curl example
+ curl -X GET {{ request.url_root }}api/v1/jukebox/<juke_id> -H
+ "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
+
+ POST/PUT
+ /jukebox/api/v1/jukebox/
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ <jukbox_object>
+ Curl example
+ curl -X POST {{ request.url_root }}api/v1/jukebox/ -d '{"user":
+ <string, user_id>, "title": <string>,
+ "wallet":<string>, "sp_user": <string,
+ spotify_user_account>, "sp_secret": <string,
+ spotify_user_secret>, "sp_access_token": <string,
+ not_required>, "sp_refresh_token": <string, not_required>,
+ "sp_device": <string, spotify_user_secret>, "sp_playlists":
+ <string, not_required>, "price": <integer, not_required>}'
+ -H "Content-type: application/json" -H "X-Api-Key:
+ {{g.user.wallets[0].adminkey }}"
+
+ DELETE
+ /jukebox/api/v1/jukebox/<juke_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ <jukebox_object>
+ Curl example
+ curl -X DELETE {{ request.url_root }}api/v1/jukebox/<juke_id>
+ -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
+
+
Currently playing
+Pick a song
+