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"\ + ![add jukebox](https://i.imgur.com/NdVoKXd.png) +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\ + ![pick wallet price](https://i.imgur.com/4bJ8mb9.png) + - follow the steps to get your Spotify App and get the client ID and secret key\ + ![spotify keys](https://i.imgur.com/w2EzFtB.png) + - paste the codes in the form\ + ![api keys](https://i.imgur.com/6b9xauo.png) + - copy the _Redirect URL_ presented on the form\ + ![redirect url](https://i.imgur.com/GMzl0lG.png) + - on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt + ![spotify app setting](https://i.imgur.com/vb0x4Tl.png) + - 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)\ + ![select playlists](https://i.imgur.com/g4dbtED.png) + +3. After Jukebox is created, click the icon to open the dialog with the shareable QR, open the Jukebox page, etc...\ + ![shareable jukebox](https://i.imgur.com/EAh9PI0.png) +4. The users will see the Jukebox page and choose a song from the selected playlist\ + ![select song](https://i.imgur.com/YYjeQAs.png) +5. After selecting a song they'd like to hear next a dialog will show presenting the music\ + ![play for sats](https://i.imgur.com/eEHl3o8.png) +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 @@ + + 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 + +

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 }}" + +
+
+
diff --git a/lnbits/extensions/jukebox/templates/jukebox/error.html b/lnbits/extensions/jukebox/templates/jukebox/error.html new file mode 100644 index 000000000..f6f7fd584 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/error.html @@ -0,0 +1,37 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

Jukebox error

+
+ + +
+ Ask the host to turn on the device and launch spotify +
+
+
+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/jukebox/templates/jukebox/index.html b/lnbits/extensions/jukebox/templates/jukebox/index.html new file mode 100644 index 000000000..9b4efbd5c --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/index.html @@ -0,0 +1,368 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + Add Spotify Jukebox + + {% raw %} + + + + + + + {% endraw %} + + +
+ +
+ + +
+ {{SITE_TITLE}} jukebox extension +
+
+ + + {% include "jukebox/_api_docs.html" %} + +
+
+ + + + + + + + + +
+
+ Continue + Continue +
+
+ Cancel +
+
+ +
+
+ + + + 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. + + + + + + + +
+
+ Submit keys + Submit keys +
+
+ Cancel +
+
+ +
+
+ + + + In the app go to edit-settings, set the redirect URI to this link +
+ {% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw + %} Click to copy URL + +
+ Settings can be found + here. + +
+
+ Authorise access + Authorise access +
+
+ Cancel +
+
+ +
+
+ + + + +
+
+ Create Jukebox + Create Jukebox +
+
+ Cancel +
+
+
+
+
+
+ + + +
+
Shareable Jukebox QR
+
+ + + +
+ + Copy jukebox link + Open jukebox + Close +
+
+
+
+{% 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..cb3ab49d8 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/jukebox.html @@ -0,0 +1,277 @@ +{% extends "public.html" %} {% block page %} {% raw %} +
+
+ + +

Currently playing

+
+
+ +
+
+ {{ currentPlay.name }}
+ {{ currentPlay.artist }} +
+
+
+
+ + + +

Pick a song

+ + +
+ + + + + + +
+
+ + + + +
+
+ +
+
+ {{ receive.name }}
+ {{ receive.artist }} +
+
+
+
+
+ Play for {% endraw %}{{ price }}{% raw %} sats + +
+
+
+ + + + + +
+ Copy invoice +
+
+
+
+{% 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..9934ddcaf --- /dev/null +++ b/lnbits/extensions/jukebox/views.py @@ -0,0 +1,50 @@ +import json +import time +from datetime import datetime +from http import HTTPStatus +from lnbits.decorators import check_user_exists +from . import jukebox_ext, jukebox_renderer +from .crud import get_jukebox +from fastapi import FastAPI, Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from lnbits.core.models import User, Payment + + +@jukebox_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return jukebox_renderer().TemplateResponse( + "jukebox/index.html", {"request": request, "user": user.dict()} + ) + + +@jukebox_ext.get("/{juke_id}", response_class=HTMLResponse) +async def connect_to_jukebox(request: Request, juke_id): + jukebox = await get_jukebox(juke_id) + if not jukebox: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Jukebox does not exist." + ) + deviceCheck = await api_get_jukebox_device_check(juke_id) + devices = json.loads(deviceCheck[0].text) + deviceConnected = False + for device in devices["devices"]: + if device["id"] == jukebox.sp_device.split("-")[1]: + deviceConnected = True + if deviceConnected: + return jukebox_renderer().TemplateResponse( + "jukebox/display.html", + { + "request": request, + "playlists": jukebox.sp_playlists.split(","), + "juke_id": juke_id, + "price": jukebox.price, + "inkey": jukebox.inkey, + }, + ) + else: + return jukebox_renderer().TemplateResponse( + "jukebox/error.html", {"request": request} + ) diff --git a/lnbits/extensions/jukebox/views_api.py b/lnbits/extensions/jukebox/views_api.py new file mode 100644 index 000000000..71db2b8ee --- /dev/null +++ b/lnbits/extensions/jukebox/views_api.py @@ -0,0 +1,490 @@ +from quart import g, jsonify, request +from http import HTTPStatus +import base64 +from lnbits.core.crud import get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +import json + +from lnbits.decorators import api_check_wallet_key, api_validate_post_request +import httpx +from . import jukebox_ext +from .crud import ( + create_jukebox, + update_jukebox, + get_jukebox, + get_jukeboxs, + delete_jukebox, + create_jukebox_payment, + get_jukebox_payment, + update_jukebox_payment, +) + + +@jukebox_ext.route("/api/v1/jukebox", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_get_jukeboxs(): + try: + return ( + jsonify( + [{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)] + ), + HTTPStatus.OK, + ) + except: + return "", HTTPStatus.NO_CONTENT + + +##################SPOTIFY AUTH##################### + + +@jukebox_ext.route("/api/v1/jukebox/spotify/cb/", methods=["GET"]) +async def api_check_credentials_callbac(juke_id): + sp_code = "" + sp_access_token = "" + sp_refresh_token = "" + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + if request.args.get("code"): + sp_code = request.args.get("code") + jukebox = await update_jukebox( + juke_id=juke_id, sp_secret=jukebox.sp_secret, sp_access_token=sp_code + ) + if request.args.get("access_token"): + sp_access_token = request.args.get("access_token") + sp_refresh_token = request.args.get("refresh_token") + jukebox = await update_jukebox( + juke_id=juke_id, + sp_secret=jukebox.sp_secret, + sp_access_token=sp_access_token, + sp_refresh_token=sp_refresh_token, + ) + return "

Success!

You can close this window

" + + +@jukebox_ext.route("/api/v1/jukebox/", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_check_credentials_check(juke_id): + jukebox = await get_jukebox(juke_id) + return jsonify(jukebox._asdict()), HTTPStatus.CREATED + + +@jukebox_ext.route("/api/v1/jukebox/", methods=["POST"]) +@jukebox_ext.route("/api/v1/jukebox/", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "user": {"type": "string", "empty": False, "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_access_token": {"type": "string", "required": False}, + "sp_refresh_token": {"type": "string", "required": False}, + "sp_device": {"type": "string", "required": False}, + "sp_playlists": {"type": "string", "required": False}, + "price": {"type": "string", "required": False}, + } +) +async def api_create_update_jukebox(juke_id=None): + if juke_id: + jukebox = await update_jukebox(juke_id=juke_id, inkey=g.wallet.inkey, **g.data) + else: + jukebox = await create_jukebox(inkey=g.wallet.inkey, **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): + await delete_jukebox(juke_id) + try: + return ( + jsonify( + [{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)] + ), + HTTPStatus.OK, + ) + except: + return "", HTTPStatus.NO_CONTENT + + +################JUKEBOX ENDPOINTS################## + +######GET ACCESS TOKEN###### + + +@jukebox_ext.route( + "/api/v1/jukebox/jb/playlist//", methods=["GET"] +) +async def api_get_jukebox_song(juke_id, sp_playlist, retry=False): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + tracks = [] + async with httpx.AsyncClient() as client: + try: + r = await client.get( + "https://api.spotify.com/v1/playlists/" + sp_playlist + "/tracks", + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + if "items" not in r.json(): + if r.status_code == 401: + token = await api_get_token(juke_id) + if token == False: + return False + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return await api_get_jukebox_song( + juke_id, sp_playlist, retry=True + ) + return r, HTTPStatus.OK + for item in r.json()["items"]: + tracks.append( + { + "id": item["track"]["id"], + "name": item["track"]["name"], + "album": item["track"]["album"]["name"], + "artist": item["track"]["artists"][0]["name"], + "image": item["track"]["album"]["images"][0]["url"], + } + ) + except AssertionError: + something = None + return jsonify([track for track in tracks]) + + +async def api_get_token(juke_id): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + + async with httpx.AsyncClient() as client: + try: + r = await client.post( + "https://accounts.spotify.com/api/token", + timeout=40, + params={ + "grant_type": "refresh_token", + "refresh_token": jukebox.sp_refresh_token, + "client_id": jukebox.sp_user, + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + + base64.b64encode( + str(jukebox.sp_user + ":" + jukebox.sp_secret).encode("ascii") + ).decode("ascii"), + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + if "access_token" not in r.json(): + return False + else: + await update_jukebox( + juke_id=juke_id, sp_access_token=r.json()["access_token"] + ) + except AssertionError: + something = None + return True + + +######CHECK DEVICE + + +@jukebox_ext.route("/api/v1/jukebox/jb/", methods=["GET"]) +async def api_get_jukebox_device_check(juke_id, retry=False): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + async with httpx.AsyncClient() as client: + rDevice = await client.get( + "https://api.spotify.com/v1/me/player/devices", + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + + if rDevice.status_code == 204 or rDevice.status_code == 200: + return ( + rDevice, + HTTPStatus.OK, + ) + elif rDevice.status_code == 401 or rDevice.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + return ( + jsonify({"error": "No device connected"}), + HTTPStatus.FORBIDDEN, + ) + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return api_get_jukebox_device_check(juke_id, retry=True) + else: + return ( + jsonify({"error": "No device connected"}), + HTTPStatus.FORBIDDEN, + ) + + +######GET INVOICE STUFF + + +@jukebox_ext.route("/api/v1/jukebox/jb/invoice//", methods=["GET"]) +async def api_get_jukebox_invoice(juke_id, song_id): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + try: + deviceCheck = await api_get_jukebox_device_check(juke_id) + devices = json.loads(deviceCheck[0].text) + deviceConnected = False + for device in devices["devices"]: + if device["id"] == jukebox.sp_device.split("-")[1]: + deviceConnected = True + if not deviceConnected: + return ( + jsonify({"error": "No device connected"}), + HTTPStatus.NOT_FOUND, + ) + except: + return ( + jsonify({"error": "No device connected"}), + HTTPStatus.NOT_FOUND, + ) + + invoice = await create_invoice( + wallet_id=jukebox.wallet, + amount=jukebox.price, + memo=jukebox.title, + extra={"tag": "jukebox"}, + ) + + jukebox_payment = await create_jukebox_payment(song_id, invoice[0], juke_id) + + return jsonify(invoice, jukebox_payment) + + +@jukebox_ext.route( + "/api/v1/jukebox/jb/checkinvoice//", methods=["GET"] +) +async def api_get_jukebox_invoice_check(pay_hash, juke_id): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + try: + status = await check_invoice_status(jukebox.wallet, pay_hash) + is_paid = not status.pending + except Exception as exc: + return jsonify({"paid": False}), HTTPStatus.OK + if is_paid: + wallet = await get_wallet(jukebox.wallet) + payment = await wallet.get_payment(pay_hash) + await payment.set_pending(False) + await update_jukebox_payment(pay_hash, paid=True) + return jsonify({"paid": True}), HTTPStatus.OK + return jsonify({"paid": False}), HTTPStatus.OK + + +@jukebox_ext.route( + "/api/v1/jukebox/jb/invoicep///", methods=["GET"] +) +async def api_get_jukebox_invoice_paid(song_id, juke_id, pay_hash, retry=False): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + await api_get_jukebox_invoice_check(pay_hash, juke_id) + jukebox_payment = await get_jukebox_payment(pay_hash) + if jukebox_payment.paid: + async with httpx.AsyncClient() as client: + r = await client.get( + "https://api.spotify.com/v1/me/player/currently-playing?market=ES", + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + rDevice = await client.get( + "https://api.spotify.com/v1/me/player", + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + isPlaying = False + if rDevice.status_code == 200: + isPlaying = rDevice.json()["is_playing"] + + if r.status_code == 204 or isPlaying == False: + async with httpx.AsyncClient() as client: + uri = ["spotify:track:" + song_id] + r = await client.put( + "https://api.spotify.com/v1/me/player/play?device_id=" + + jukebox.sp_device.split("-")[1], + json={"uris": uri}, + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + if r.status_code == 204: + return jsonify(jukebox_payment), HTTPStatus.OK + elif r.status_code == 401 or r.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.FORBIDDEN, + ) + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return api_get_jukebox_invoice_paid( + song_id, juke_id, pay_hash, retry=True + ) + else: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.FORBIDDEN, + ) + elif r.status_code == 200: + async with httpx.AsyncClient() as client: + r = await client.post( + "https://api.spotify.com/v1/me/player/queue?uri=spotify%3Atrack%3A" + + song_id + + "&device_id=" + + jukebox.sp_device.split("-")[1], + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + if r.status_code == 204: + return jsonify(jukebox_payment), HTTPStatus.OK + + elif r.status_code == 401 or r.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.OK, + ) + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return await api_get_jukebox_invoice_paid( + song_id, juke_id, pay_hash + ) + else: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.OK, + ) + elif r.status_code == 401 or r.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.OK, + ) + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return await api_get_jukebox_invoice_paid( + song_id, juke_id, pay_hash + ) + return jsonify({"error": "Invoice not paid"}), HTTPStatus.OK + + +############################GET TRACKS + + +@jukebox_ext.route("/api/v1/jukebox/jb/currently/", methods=["GET"]) +async def api_get_jukebox_currently(juke_id, retry=False): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + async with httpx.AsyncClient() as client: + try: + r = await client.get( + "https://api.spotify.com/v1/me/player/currently-playing?market=ES", + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + if r.status_code == 204: + return jsonify({"error": "Nothing"}), HTTPStatus.OK + elif r.status_code == 200: + try: + response = r.json() + + track = { + "id": response["item"]["id"], + "name": response["item"]["name"], + "album": response["item"]["album"]["name"], + "artist": response["item"]["artists"][0]["name"], + "image": response["item"]["album"]["images"][0]["url"], + } + return jsonify(track), HTTPStatus.OK + except: + return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND + + elif r.status_code == 401: + token = await api_get_token(juke_id) + if token == False: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.FORBIDDEN, + ) + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return await api_get_jukebox_currently(juke_id, retry=True) + else: + return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND + except AssertionError: + return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND