diff --git a/lnbits/extensions/jukebox/README.md b/lnbits/extensions/jukebox/README.md new file mode 100644 index 00000000..c761db44 --- /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 00000000..f38b0ec7 --- /dev/null +++ b/lnbits/extensions/jukebox/__init__.py @@ -0,0 +1,39 @@ +import asyncio + +from fastapi import APIRouter, FastAPI +from fastapi.staticfiles import StaticFiles +from starlette.routing import Mount + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_jukebox") + +jukebox_static_files = [ + { + "path": "/jukebox/static", + "app": StaticFiles(directory="lnbits/extensions/jukebox/static"), + "name": "jukebox_static", + } +] + +jukebox_ext: APIRouter = APIRouter(prefix="/jukebox", tags=["jukebox"]) + + +def jukebox_renderer(): + return template_renderer( + [ + "lnbits/extensions/jukebox/templates", + ] + ) + + +from .views_api import * # noqa +from .views import * # noqa +from .tasks import wait_for_paid_invoices + + +def jukebox_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/jukebox/config.json b/lnbits/extensions/jukebox/config.json new file mode 100644 index 00000000..6b57bec4 --- /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 00000000..4e3ba2f1 --- /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 00000000..a0a3bd28 --- /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 00000000..8752ae85 --- /dev/null +++ b/lnbits/extensions/jukebox/models.py @@ -0,0 +1,48 @@ +from typing import NamedTuple +from sqlite3 import Row +from fastapi.param_functions import Query +from pydantic.main import BaseModel + + +class CreateJukeLinkData(BaseModel): + user: str = Query(None) + title: str = Query(None) + wallet: str = Query(None) + sp_user: str = Query(None) + sp_secret: str = Query(None) + sp_access_token: str = Query(None) + sp_refresh_token: str = Query(None) + sp_device: str = Query(None) + sp_playlists: str = Query(None) + price: str = Query(None) + + +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 00000000..fc382d71 --- /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 00000000..ddbb2764 --- /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 00000000..023efc9a 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 00000000..478032c5 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 00000000..52366bea --- /dev/null +++ b/lnbits/extensions/jukebox/tasks.py @@ -0,0 +1,25 @@ +import asyncio +import json +import httpx + +from lnbits.core import db as core_db +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener + +from .crud import get_jukebox, update_jukebox_payment + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + + while True: + payment = await invoice_queue.get() + 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 00000000..b1968b48 --- /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: {{ + 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: {{ 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: + {{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: {{ 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 00000000..f6f7fd58 --- /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 00000000..9b4efbd5 --- /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 00000000..cb3ab49d --- /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 00000000..d8a7c3f6 --- /dev/null +++ b/lnbits/extensions/jukebox/views.py @@ -0,0 +1,55 @@ +import json +import time +from datetime import datetime +from http import HTTPStatus +from lnbits.decorators import check_user_exists, WalletTypeInfo, get_key_type +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 +from .views_api import api_get_jukebox_device_check + + +templates = Jinja2Templates(directory="templates") + + +@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, "jukebox": jukebox.jukebox(req=request)}, + ) diff --git a/lnbits/extensions/jukebox/views_api.py b/lnbits/extensions/jukebox/views_api.py new file mode 100644 index 00000000..571676b9 --- /dev/null +++ b/lnbits/extensions/jukebox/views_api.py @@ -0,0 +1,537 @@ +from fastapi import Request +from http import HTTPStatus +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse, JSONResponse # type: ignore +import base64 +from lnbits.core.crud import get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +import json +from typing import Optional +from fastapi.params import Depends +from fastapi.param_functions import Query +from .models import CreateJukeLinkData +from lnbits.decorators import ( + check_user_exists, + WalletTypeInfo, + get_key_type, + 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.get("/api/v1/jukebox", status_code=HTTPStatus.OK) +async def api_get_jukeboxs( + req: Request, + wallet: WalletTypeInfo = Depends(get_key_type), + all_wallets: bool = Query(False), +): + wallet_user = wallet.wallet.user + + try: + return [ + {**jukebox.dict(), "jukebox": jukebox.jukebox(req)} + for jukebox in await get_jukeboxs(wallet_user) + ] + + except: + raise HTTPException( + status_code=HTTPStatus.NO_CONTENT, + detail="No Jukeboxes", + ) + + +##################SPOTIFY AUTH##################### + + +@jukebox_ext.get("/api/v1/jukebox/spotify/cb/", status_code=HTTPStatus.OK) +async def api_check_credentials_callbac( + juke_id: str = Query(None), + code: str = Query(None), + access_token: str = Query(None), + refresh_token: str = Query(None), +): + sp_code = "" + sp_access_token = "" + sp_refresh_token = "" + try: + jukebox = await get_jukebox(juke_id) + except: + raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN) + if code: + sp_code = code + jukebox = await update_jukebox( + juke_id=juke_id, sp_secret=jukebox.sp_secret, sp_access_token=sp_code + ) + if access_token: + sp_access_token = access_token + sp_refresh_token = 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.get("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK) +async def api_check_credentials_check( + juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) +): + print(juke_id) + jukebox = await get_jukebox(juke_id) + + return jukebox + + +@jukebox_ext.post("/api/v1/jukebox", status_code=HTTPStatus.CREATED) +@jukebox_ext.put("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK) +async def api_create_update_jukebox( + data: CreateJukeLinkData, + juke_id: str = Query(None), + wallet: WalletTypeInfo = Depends(get_key_type), +): + 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 jukebox.dict() + + +@jukebox_ext.delete("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK) +async def api_delete_item( + juke_id=None, + wallet: WalletTypeInfo = Depends(get_key_type), +): + await delete_jukebox(juke_id) + try: + return [{**jukebox.dict()} for jukebox in await get_jukeboxs(g.wallet.user)] + except: + raise HTTPException( + status_code=HTTPStatus.NO_CONTENT, + detail="No Jukebox", + ) + + +################JUKEBOX ENDPOINTS################## + +######GET ACCESS TOKEN###### + + +@jukebox_ext.get( + "/api/v1/jukebox/jb/playlist/{juke_id}/{sp_playlist}", status_code=HTTPStatus.OK +) +async def api_get_jukebox_song( + juke_id: str = Query(None), + sp_playlist: str = Query(None), + retry: bool = Query(False), +): + try: + jukebox = await get_jukebox(juke_id) + except: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="No Jukeboxes", + ) + 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: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Failed to get auth", + ) + else: + return await api_get_jukebox_song( + juke_id, sp_playlist, retry=True + ) + return r + 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: + something = None + return [track for track in tracks] + + +async def api_get_token(juke_id=None): + try: + jukebox = await get_jukebox(juke_id) + except: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="No Jukeboxes", + ) + + 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: + something = None + return True + + +######CHECK DEVICE + + +@jukebox_ext.get("/api/v1/jukebox/jb/{juke_id}", status_code=HTTPStatus.OK) +async def api_get_jukebox_device_check( + juke_id: str = Query(None), + retry: bool = Query(False), +): + try: + jukebox = await get_jukebox(juke_id) + except: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="No Jukeboxes", + ) + 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 + elif rDevice.status_code == 401 or rDevice.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="No devices connected", + ) + elif retry: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Failed to get auth", + ) + else: + return api_get_jukebox_device_check(juke_id, retry=True) + else: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="No device connected", + ) + + +######GET INVOICE STUFF + + +@jukebox_ext.get( + "/api/v1/jukebox/jb/invoice/{juke_id}/{song_id}", status_code=HTTPStatus.OK +) +async def api_get_jukebox_invoice( + juke_id: str = Query(None), + song_id: str = Query(None), +): + try: + jukebox = await get_jukebox(juke_id) + except: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="No jukebox", + ) + 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: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No device connected", + ) + except: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No device connected", + ) + + 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 {invoice, jukebox_payment} + + +@jukebox_ext.get( + "/api/v1/jukebox/jb/checkinvoice/{pay_hash}/{juke_id}", status_code=HTTPStatus.OK +) +async def api_get_jukebox_invoice_check( + pay_hash: str = Query(None), + juke_id: str = Query(None), +): + try: + jukebox = await get_jukebox(juke_id) + except: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="No jukebox", + ) + try: + status = await check_invoice_status(jukebox.wallet, pay_hash) + is_paid = not status.pending + except: + return {"paid": False} + 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 {"paid": True} + return {"paid": False} + + +@jukebox_ext.get( + "/api/v1/jukebox/jb/invoicep/{song_id}/{juke_id}/{pay_hash}", + status_code=HTTPStatus.OK, +) +async def api_get_jukebox_invoice_paid( + song_id: str = Query(None), + juke_id: str = Query(None), + pay_hash: str = Query(None), + retry: bool = Query(False), +): + try: + jukebox = await get_jukebox(juke_id) + except: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="No jukebox", + ) + 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: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Invoice not paid", + ) + elif retry: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Failed to get auth", + ) + else: + return api_get_jukebox_invoice_paid( + song_id, juke_id, pay_hash, retry=True + ) + else: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Invoice not paid", + ) + 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 jukebox_payment + + elif r.status_code == 401 or r.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Invoice not paid", + ) + elif retry: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Failed to get auth", + ) + else: + return await api_get_jukebox_invoice_paid( + song_id, juke_id, pay_hash + ) + else: + raise HTTPException( + status_code=HTTPStatus.OK, + detail="Invoice not paid", + ) + elif r.status_code == 401 or r.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + raise HTTPException( + status_code=HTTPStatus.OK, + detail="Invoice not paid", + ) + elif retry: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Failed to get auth", + ) + else: + return await api_get_jukebox_invoice_paid( + song_id, juke_id, pay_hash + ) + raise HTTPException( + status_code=HTTPStatus.OK, + detail="Invoice not paid", + ) + + +############################GET TRACKS + + +@jukebox_ext.get("/api/v1/jukebox/jb/currently/{juke_id}", status_code=HTTPStatus.OK) +async def api_get_jukebox_currently( + retry: bool = Query(False), + juke_id: str = Query(None), +): + try: + jukebox = await get_jukebox(juke_id) + except: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="No jukebox", + ) + 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: + raise HTTPException( + status_code=HTTPStatus.OK, + detail="Nothing", + ) + 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 track + except: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Something went wrong", + ) + + elif r.status_code == 401: + token = await api_get_token(juke_id) + if token == False: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="INvoice not paid", + ) + elif retry: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Failed to get auth", + ) + else: + return await api_get_jukebox_currently(retry=True, juke_id=juke_id) + else: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Something went wrong", + ) + except: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Something went wrong", + ) diff --git a/lnbits/extensions/lnurlp/__init__.py b/lnbits/extensions/lnurlp/__init__.py index af12d57f..b163dfb7 100644 --- a/lnbits/extensions/lnurlp/__init__.py +++ b/lnbits/extensions/lnurlp/__init__.py @@ -24,6 +24,7 @@ lnurlp_ext: APIRouter = APIRouter( # "lnurlp", __name__, static_folder="static", template_folder="templates" ) + def lnurlp_renderer(): return template_renderer( [ @@ -37,13 +38,12 @@ from .views import * # noqa from .tasks import wait_for_paid_invoices from .lnurl import * # noqa + def lnurlp_start(): loop = asyncio.get_event_loop() loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) - - # from lnbits.tasks import record_async # lnurlp_ext.record(record_async(register_listeners))