mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-23 22:47:05 +01:00
commit
94acbd84e0
17 changed files with 1324 additions and 8 deletions
|
@ -370,9 +370,9 @@ async def check_internal(
|
|||
) -> Optional[str]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT checking_id FROM apipayments
|
||||
WHERE hash = ? AND pending AND amount > 0
|
||||
""",
|
||||
SELECT checking_id FROM apipayments
|
||||
WHERE hash = ? AND pending AND amount > 0
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
if not row:
|
||||
|
|
|
@ -211,6 +211,9 @@ new Vue({
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
paymentTableRowKey: function (row) {
|
||||
return row.payment_hash + row.amount
|
||||
},
|
||||
closeCamera: function () {
|
||||
this.parse.camera.show = false
|
||||
},
|
||||
|
|
|
@ -87,9 +87,10 @@
|
|||
dense
|
||||
flat
|
||||
:data="filteredPayments"
|
||||
row-key="payment_hash"
|
||||
:row-key="paymentTableRowKey"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
no-data-label="No transactions made yet"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
|
|
13
lnbits/extensions/livestream/README.md
Normal file
13
lnbits/extensions/livestream/README.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
# DJ Livestream
|
||||
|
||||
An extension to help DJs to conduct music livestreams.
|
||||
|
||||
It produces a single static QR code that can be shown on screen. Once someone scans that QR code with an lnurl-pay capable wallet they will see the name of the track being played at that time and the name of the producer.
|
||||
|
||||
They will then be given the opportunity to send a tip and a message related to that specific track and if they pay an amount over a specific threshold they will be given a link to download it (optional).
|
||||
|
||||
The revenue will be sent to a wallet created specifically for that producer, with optional revenue splitting between the DJ and the producer.
|
||||
|
||||
## Sponsored by
|
||||
|
||||
[](https://cryptograffiti.com/)
|
19
lnbits/extensions/livestream/__init__.py
Normal file
19
lnbits/extensions/livestream/__init__.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from quart import Blueprint
|
||||
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_livestream")
|
||||
|
||||
livestream_ext: Blueprint = Blueprint(
|
||||
"livestream", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
||||
livestream_ext.record(record_async(register_listeners))
|
10
lnbits/extensions/livestream/config.json
Normal file
10
lnbits/extensions/livestream/config.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "DJ Livestream",
|
||||
"short_description": "Sell tracks and split revenue with a static QR code.",
|
||||
"icon": "speaker",
|
||||
"contributors": [
|
||||
"fiatjaf",
|
||||
"cryptograffiti"
|
||||
],
|
||||
"hidden": false
|
||||
}
|
167
lnbits/extensions/livestream/crud.py
Normal file
167
lnbits/extensions/livestream/crud.py
Normal file
|
@ -0,0 +1,167 @@
|
|||
import unicodedata
|
||||
from typing import List, Optional
|
||||
|
||||
from lnbits.core.crud import create_account, create_wallet
|
||||
|
||||
from . import db
|
||||
from .models import Livestream, Track, Producer
|
||||
|
||||
|
||||
async def create_livestream(*, wallet_id: str) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO livestreams (wallet)
|
||||
VALUES (?)
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def get_livestream(ls_id: int) -> Optional[Livestream]:
|
||||
row = await db.fetchone("SELECT * FROM livestreams WHERE id = ?", (ls_id,))
|
||||
return Livestream(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_livestream_by_track(track_id: int) -> Optional[Livestream]:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT livestreams.* FROM livestreams
|
||||
INNER JOIN tracks ON tracks.livestream = livestreams.id
|
||||
WHERE tracks.id = ?
|
||||
""",
|
||||
(track_id,),
|
||||
)
|
||||
return Livestream(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]:
|
||||
row = await db.fetchone("SELECT * FROM livestreams WHERE wallet = ?", (wallet,))
|
||||
|
||||
if not row:
|
||||
# create on the fly
|
||||
ls_id = await create_livestream(wallet_id=wallet)
|
||||
return await get_livestream(ls_id)
|
||||
|
||||
return Livestream(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def update_current_track(ls_id: int, track_id: Optional[int]):
|
||||
await db.execute(
|
||||
"UPDATE livestreams SET current_track = ? WHERE id = ?",
|
||||
(track_id, ls_id),
|
||||
)
|
||||
|
||||
|
||||
async def update_livestream_fee(ls_id: int, fee_pct: int):
|
||||
await db.execute(
|
||||
"UPDATE livestreams SET fee_pct = ? WHERE id = ?",
|
||||
(fee_pct, ls_id),
|
||||
)
|
||||
|
||||
|
||||
async def add_track(
|
||||
livestream: int,
|
||||
name: str,
|
||||
download_url: Optional[str],
|
||||
price_msat: int,
|
||||
producer_name: Optional[str],
|
||||
producer_id: Optional[int],
|
||||
) -> int:
|
||||
if producer_id:
|
||||
p_id = producer_id
|
||||
elif producer_name:
|
||||
p_id = await add_producer(livestream, producer_name)
|
||||
else:
|
||||
raise TypeError("need either producer_id or producer_name arguments")
|
||||
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO tracks (livestream, name, download_url, price_msat, producer)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(livestream, name, download_url, price_msat, p_id),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def get_track(track_id: Optional[int]) -> Optional[Track]:
|
||||
if not track_id:
|
||||
return None
|
||||
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT id, download_url, price_msat, name, producer
|
||||
FROM tracks WHERE id = ?
|
||||
""",
|
||||
(track_id,),
|
||||
)
|
||||
return Track(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_tracks(livestream: int) -> List[Track]:
|
||||
rows = await db.fetchall(
|
||||
"""
|
||||
SELECT id, download_url, price_msat, name, producer
|
||||
FROM tracks WHERE livestream = ?
|
||||
""",
|
||||
(livestream,),
|
||||
)
|
||||
return [Track(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def delete_track_from_livestream(livestream: int, track_id: int):
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM tracks WHERE livestream = ? AND id = ?
|
||||
""",
|
||||
(livestream, track_id),
|
||||
)
|
||||
|
||||
|
||||
async def add_producer(livestream: int, name: str) -> int:
|
||||
name = "".join([unicodedata.normalize("NFD", l)[0] for l in name if l]).strip()
|
||||
|
||||
existing = await db.fetchall(
|
||||
"""
|
||||
SELECT id FROM producers
|
||||
WHERE livestream = ? AND lower(name) = ?
|
||||
""",
|
||||
(livestream, name.lower()),
|
||||
)
|
||||
if existing:
|
||||
return existing[0].id
|
||||
|
||||
user = await create_account()
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name="livestream: " + name)
|
||||
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO producers (livestream, name, user, wallet)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(livestream, name, user.id, wallet.id),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def get_producer(producer_id: int) -> Optional[Producer]:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT id, user, wallet, name
|
||||
FROM producers WHERE id = ?
|
||||
""",
|
||||
(producer_id,),
|
||||
)
|
||||
return Producer(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_producers(livestream: int) -> List[Producer]:
|
||||
rows = await db.fetchall(
|
||||
"""
|
||||
SELECT id, user, wallet, name
|
||||
FROM producers WHERE livestream = ?
|
||||
""",
|
||||
(livestream,),
|
||||
)
|
||||
return [Producer(**dict(row)) for row in rows]
|
93
lnbits/extensions/livestream/lnurl.py
Normal file
93
lnbits/extensions/livestream/lnurl.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
import hashlib
|
||||
import math
|
||||
from quart import jsonify, url_for, request
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
|
||||
from . import livestream_ext
|
||||
from .crud import get_livestream, get_livestream_by_track, get_track
|
||||
|
||||
|
||||
@livestream_ext.route("/lnurl/<ls_id>", methods=["GET"])
|
||||
async def lnurl_response(ls_id):
|
||||
ls = await get_livestream(ls_id)
|
||||
if not ls:
|
||||
return jsonify({"status": "ERROR", "reason": "Livestream not found."})
|
||||
|
||||
track = await get_track(ls.current_track)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "This livestream is offline."})
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for(
|
||||
"livestream.lnurl_callback", track_id=track.id, _external=True
|
||||
),
|
||||
min_sendable=track.min_sendable,
|
||||
max_sendable=track.max_sendable,
|
||||
metadata=await track.lnurlpay_metadata(),
|
||||
)
|
||||
|
||||
params = resp.dict()
|
||||
params["commentAllowed"] = 300
|
||||
|
||||
return jsonify(params)
|
||||
|
||||
|
||||
@livestream_ext.route("/lnurl/cb/<track_id>", methods=["GET"])
|
||||
async def lnurl_callback(track_id):
|
||||
track = await get_track(track_id)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "Couldn't find track."})
|
||||
|
||||
amount_received = int(request.args.get("amount"))
|
||||
|
||||
if amount_received < track.min_sendable:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}."
|
||||
).dict()
|
||||
),
|
||||
)
|
||||
elif track.max_sendable < amount_received:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}."
|
||||
).dict()
|
||||
),
|
||||
)
|
||||
|
||||
comment = request.args.get("comment")
|
||||
if len(comment or "") > 300:
|
||||
return jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Got a comment with {len(comment)} characters, but can only accept 300"
|
||||
).dict()
|
||||
)
|
||||
|
||||
ls = await get_livestream_by_track(track_id)
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=ls.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=await track.fullname(),
|
||||
description_hash=hashlib.sha256(
|
||||
(await track.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "livestream", "track": track.id, "comment": comment},
|
||||
)
|
||||
|
||||
if amount_received < track.price_msat:
|
||||
success_action = None
|
||||
else:
|
||||
success_action = track.success_action(payment_hash)
|
||||
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=success_action,
|
||||
routes=[],
|
||||
)
|
||||
|
||||
return jsonify(resp.dict())
|
39
lnbits/extensions/livestream/migrations.py
Normal file
39
lnbits/extensions/livestream/migrations.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial livestream tables.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE livestreams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wallet TEXT NOT NULL,
|
||||
fee_pct INTEGER NOT NULL DEFAULT 10,
|
||||
current_track INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE producers (
|
||||
livestream INTEGER NOT NULL REFERENCES livestreams (id),
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE tracks (
|
||||
livestream INTEGER NOT NULL REFERENCES livestreams (id),
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_url TEXT,
|
||||
price_msat INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT,
|
||||
producer INTEGER REFERENCES producers (id) NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
76
lnbits/extensions/livestream/models.py
Normal file
76
lnbits/extensions/livestream/models.py
Normal file
|
@ -0,0 +1,76 @@
|
|||
import json
|
||||
from quart import url_for
|
||||
from typing import NamedTuple, Optional
|
||||
from lnurl import Lnurl, encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||
|
||||
|
||||
class Livestream(NamedTuple):
|
||||
id: int
|
||||
wallet: str
|
||||
fee_pct: int
|
||||
current_track: Optional[int]
|
||||
|
||||
@property
|
||||
def lnurl(self) -> Lnurl:
|
||||
url = url_for("livestream.lnurl_response", ls_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
||||
|
||||
|
||||
class Track(NamedTuple):
|
||||
id: int
|
||||
download_url: str
|
||||
price_msat: int
|
||||
name: str
|
||||
producer: int
|
||||
|
||||
@property
|
||||
def min_sendable(self) -> int:
|
||||
return min(100_000, self.price_msat or 100_000)
|
||||
|
||||
@property
|
||||
def max_sendable(self) -> int:
|
||||
return max(50_000_000, self.price_msat * 5)
|
||||
|
||||
async def fullname(self) -> str:
|
||||
from .crud import get_producer
|
||||
|
||||
producer = await get_producer(self.producer)
|
||||
if producer:
|
||||
producer_name = producer.name
|
||||
else:
|
||||
producer_name = "unknown author"
|
||||
|
||||
return f"'{self.name}', from {producer_name}."
|
||||
|
||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
description = (
|
||||
await self.fullname()
|
||||
) + " Like this track? Send some sats in appreciation."
|
||||
|
||||
if self.download_url:
|
||||
description += f" Send {round(self.price_msat/1000)} sats or more and you can download it."
|
||||
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", description]]))
|
||||
|
||||
def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
|
||||
if not self.download_url:
|
||||
return None
|
||||
|
||||
return UrlAction(
|
||||
url=url_for(
|
||||
"livestream.track_redirect_download",
|
||||
track_id=self.id,
|
||||
p=payment_hash,
|
||||
_external=True,
|
||||
),
|
||||
description=f"Download the track {self.name}!",
|
||||
)
|
||||
|
||||
|
||||
class Producer(NamedTuple):
|
||||
id: int
|
||||
user: str
|
||||
wallet: str
|
||||
name: str
|
201
lnbits/extensions/livestream/static/js/index.js
Normal file
201
lnbits/extensions/livestream/static/js/index.js
Normal file
|
@ -0,0 +1,201 @@
|
|||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
cancelListener: () => {},
|
||||
selectedWallet: null,
|
||||
nextCurrentTrack: null,
|
||||
livestream: {
|
||||
tracks: [],
|
||||
producers: []
|
||||
},
|
||||
trackDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedTracks() {
|
||||
return this.livestream.tracks.sort((a, b) => a.name - b.name)
|
||||
},
|
||||
tracksMap() {
|
||||
return Object.fromEntries(
|
||||
this.livestream.tracks.map(track => [track.id, track])
|
||||
)
|
||||
},
|
||||
producersMap() {
|
||||
return Object.fromEntries(
|
||||
this.livestream.producers.map(prod => [prod.id, prod])
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getTrackLabel(trackId) {
|
||||
let track = this.tracksMap[trackId]
|
||||
return `${track.name}, ${this.producersMap[track.producer].name}`
|
||||
},
|
||||
disabledAddTrackButton() {
|
||||
return (
|
||||
!this.trackDialog.data.name ||
|
||||
this.trackDialog.data.name.length === 0 ||
|
||||
!this.trackDialog.data.producer ||
|
||||
this.trackDialog.data.producer.length === 0
|
||||
)
|
||||
},
|
||||
changedWallet(wallet) {
|
||||
this.selectedWallet = wallet
|
||||
this.loadLivestream()
|
||||
this.startPaymentNotifier()
|
||||
},
|
||||
loadLivestream() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/livestream/api/v1/livestream',
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.livestream = response.data
|
||||
this.nextCurrentTrack = this.livestream.current_track
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
startPaymentNotifier() {
|
||||
this.cancelListener()
|
||||
|
||||
this.cancelListener = LNbits.events.onInvoicePaid(
|
||||
this.selectedWallet,
|
||||
payment => {
|
||||
let satoshiAmount = Math.round(payment.amount / 1000)
|
||||
let trackName = (
|
||||
this.tracksMap[payment.extra.track] || {name: '[unknown]'}
|
||||
).name
|
||||
|
||||
this.$q.notify({
|
||||
message: `Someone paid <b>${satoshiAmount} sat</b> for the track <em>${trackName}</em>.`,
|
||||
caption: payment.extra.comment
|
||||
? `<em>"${payment.extra.comment}"</em>`
|
||||
: undefined,
|
||||
color: 'secondary',
|
||||
html: true,
|
||||
timeout: 0,
|
||||
actions: [{label: 'Dismiss', color: 'white', handler: () => {}}]
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
addTrack() {
|
||||
let {name, producer, price_sat, download_url} = this.trackDialog.data
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/livestream/api/v1/livestream/tracks',
|
||||
this.selectedWallet.inkey,
|
||||
{
|
||||
download_url:
|
||||
download_url && download_url.length > 0
|
||||
? download_url
|
||||
: undefined,
|
||||
name,
|
||||
price_msat: price_sat * 1000 || 0,
|
||||
producer_name: typeof producer === 'string' ? producer : undefined,
|
||||
producer_id: typeof producer === 'object' ? producer.id : undefined
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Track '${this.trackDialog.data.name}' added.`,
|
||||
timeout: 700
|
||||
})
|
||||
this.loadLivestream()
|
||||
this.trackDialog.show = false
|
||||
this.trackDialog.data = {}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deleteTrack(trackId) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this track?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/livestream/api/v1/livestream/tracks/' + trackId,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Track deleted`,
|
||||
timeout: 700
|
||||
})
|
||||
this.livestream.tracks.splice(
|
||||
this.livestream.tracks.findIndex(track => track.id === trackId),
|
||||
1
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
updateCurrentTrack(track) {
|
||||
if (this.livestream.current_track === track) {
|
||||
// if clicking the same, stop it
|
||||
track = 0
|
||||
}
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/livestream/api/v1/livestream/track/' + track,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(() => {
|
||||
this.livestream.current_track = track
|
||||
this.$q.notify({
|
||||
message: `Current track updated.`,
|
||||
timeout: 700
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
updateFeePct() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/livestream/api/v1/livestream/fee/' + this.livestream.fee_pct,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(() => {
|
||||
this.$q.notify({
|
||||
message: `Percentage updated.`,
|
||||
timeout: 700
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
producerAdded(added, cb) {
|
||||
cb(added)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.selectedWallet = this.g.user.wallets[0]
|
||||
this.loadLivestream()
|
||||
this.startPaymentNotifier()
|
||||
}
|
||||
})
|
89
lnbits/extensions/livestream/tasks.py
Normal file
89
lnbits/extensions/livestream/tasks.py
Normal file
|
@ -0,0 +1,89 @@
|
|||
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_track, get_producer, get_livestream_by_track
|
||||
|
||||
|
||||
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 "livestream" != payment.extra.get("tag"):
|
||||
# not a livestream invoice
|
||||
return
|
||||
|
||||
track = await get_track(payment.extra.get("track", -1))
|
||||
if not track:
|
||||
print("this should never happen", payment)
|
||||
return
|
||||
|
||||
if payment.extra.get("shared_with"):
|
||||
print("payment was shared already", payment)
|
||||
return
|
||||
|
||||
producer = await get_producer(track.producer)
|
||||
assert producer, f"track {track.id} is not associated with a producer"
|
||||
|
||||
ls = await get_livestream_by_track(track.id)
|
||||
assert ls, f"track {track.id} is not associated with a livestream"
|
||||
|
||||
# now we make a special kind of internal transfer
|
||||
amount = int(payment.amount * (100 - ls.fee_pct) / 100)
|
||||
|
||||
# mark the original payment with two extra keys, "shared_with" and "received"
|
||||
# (this prevents us from doing this process again and it's informative)
|
||||
# and reduce it by the amount we're going to send to the producer
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments
|
||||
SET extra = ?, amount = ?
|
||||
WHERE hash = ?
|
||||
AND checking_id NOT LIKE 'internal_%'
|
||||
""",
|
||||
(
|
||||
json.dumps(
|
||||
dict(
|
||||
**payment.extra,
|
||||
shared_with=[producer.name, producer.id],
|
||||
received=payment.amount,
|
||||
)
|
||||
),
|
||||
payment.amount - amount,
|
||||
payment.payment_hash,
|
||||
),
|
||||
)
|
||||
|
||||
# perform an internal transfer using the same payment_hash to the producer wallet
|
||||
internal_checking_id = f"internal_{urlsafe_short_hash()}"
|
||||
await create_payment(
|
||||
wallet_id=producer.wallet,
|
||||
checking_id=internal_checking_id,
|
||||
payment_request="",
|
||||
payment_hash=payment.payment_hash,
|
||||
amount=amount,
|
||||
memo=f"Revenue from '{track.name}'.",
|
||||
pending=False,
|
||||
)
|
||||
|
||||
# manually send this for now
|
||||
await internal_invoice_paid.send(internal_checking_id)
|
||||
|
||||
# so the flow is the following:
|
||||
# - we receive, say, 1000 satoshis
|
||||
# - if the fee_pct is, say, 30%, the amount we will send is 700
|
||||
# - we change the amount of receiving payment on the database from 1000 to 300
|
||||
# - we create a new payment on the producer's wallet with amount 700
|
146
lnbits/extensions/livestream/templates/livestream/_api_docs.html
Normal file
146
lnbits/extensions/livestream/templates/livestream/_api_docs.html
Normal file
|
@ -0,0 +1,146 @@
|
|||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="How to use"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>Add tracks, profit.</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="List livestream links"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/livestream/api/v1/livestream</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<livestream_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update track">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/livestream/api/v1/livestream/track/<track_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root
|
||||
}}api/v1/livestream/track/<track_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update fee">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/livestream/api/v1/livestream/fee/<fee_pct></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root
|
||||
}}api/v1/livestream/fee/<fee_pct> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Add track">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span>
|
||||
/livestream/api/v1/livestream/tracks</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"name": <string>, "download_url": <string>,
|
||||
"price_msat": <integer>, "producer_id": <integer>,
|
||||
"producer_name": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/livestream/tracks -d
|
||||
'{"name": <string>, "download_url": <string>,
|
||||
"price_msat": <integer>, "producer_id": <integer>,
|
||||
"producer_name": <string>}' -H "Content-type: application/json"
|
||||
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a withdraw link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/livestream/api/v1/livestream/tracks/<track_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.url_root
|
||||
}}api/v1/livestream/tracks/<track_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
288
lnbits/extensions/livestream/templates/livestream/index.html
Normal file
288
lnbits/extensions/livestream/templates/livestream/index.html
Normal file
|
@ -0,0 +1,288 @@
|
|||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<q-form
|
||||
@submit="updateCurrentTrack(nextCurrentTrack)"
|
||||
class="q-gutter-md"
|
||||
>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-select
|
||||
dense
|
||||
filled
|
||||
v-model="nextCurrentTrack"
|
||||
use-input
|
||||
hide-selected
|
||||
fill-input
|
||||
input-debounce="0"
|
||||
:options="sortedTracks.map(track => track.id)"
|
||||
option-value="id"
|
||||
:option-label="getTrackLabel"
|
||||
options-dense
|
||||
label="Current track"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
{% raw %}
|
||||
<q-btn unelevated color="deep-purple" type="submit">
|
||||
{{ nextCurrentTrack === livestream.current_track ? 'Stop' : 'Set'
|
||||
}} current track
|
||||
</q-btn>
|
||||
{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
<q-form @submit="updateFeePct" class="q-gutter-md">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="livestream.fee_pct"
|
||||
type="number"
|
||||
label="Revenue to keep (%)"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn unelevated color="deep-purple" type="submit"
|
||||
>Set percent rate</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Tracks</h5>
|
||||
</div>
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
@click="trackDialog.show = true"
|
||||
>Add new track</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="sortedTracks"
|
||||
row-key="id"
|
||||
no-data-label="No tracks added yet"
|
||||
:pagination="{rowsPerPage: 0}"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width>Name</q-th>
|
||||
<q-th auto-width>Producer</q-th>
|
||||
<q-th auto-width>Price</q-th>
|
||||
<q-th auto-width>Download URL</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
:icon="livestream.current_track !== props.row.id ? 'play_circle_outline' : 'play_arrow'"
|
||||
:color="livestream.current_track !== props.row.id ? ($q.dark.isActive ? 'grey-7' : 'grey-5') : 'green'"
|
||||
type="a"
|
||||
@click="updateCurrentTrack(props.row.id)"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>{{ props.row.name }}</q-td>
|
||||
<q-td auto-width>
|
||||
{{ producersMap[props.row.producer].name }}
|
||||
</q-td>
|
||||
<q-td class="text-right" auto-width
|
||||
>{{ props.row.price_msat }}</q-td
|
||||
>
|
||||
<q-td class="text-center" auto-width
|
||||
>{{ props.row.download_url }}</q-td
|
||||
>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
type="a"
|
||||
@click="deleteTrack(props.row.id)"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Producers</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="livestream.producers"
|
||||
row-key="id"
|
||||
no-data-label="To include a producer, add a track"
|
||||
:pagination="{rowsPerPage: 0}"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width>Name</q-th>
|
||||
<q-th auto-width>Wallet</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>{{ props.row.name }}</q-td>
|
||||
<q-td class="text-center" auto-width>
|
||||
<a
|
||||
target="_blank"
|
||||
:href="'/wallet?usr=' + props.row.user + '&wal=' + props.row.wallet"
|
||||
>
|
||||
{{ props.row.wallet }}
|
||||
</a>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm col-5">
|
||||
<q-card-section class="q-pa-none text-center">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
:options="g.user.wallets"
|
||||
:value="selectedWallet"
|
||||
label="Using wallet:"
|
||||
option-label="name"
|
||||
@input="changedWallet"
|
||||
>
|
||||
</q-select>
|
||||
</q-form>
|
||||
|
||||
<a :href="livestream.url">
|
||||
<q-responsive :ratio="1" class="q-mx-sm">
|
||||
<qrcode
|
||||
:value="livestream.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(livestream.lnurl)"
|
||||
class="text-center q-mb-md"
|
||||
>Copy LNURL-pay code</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits Livestream extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "livestream/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="trackDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<q-form @submit="addTrack" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="trackDialog.data.producer"
|
||||
use-input
|
||||
hide-selected
|
||||
fill-input
|
||||
option-label="name"
|
||||
input-debounce="0"
|
||||
@new-value="producerAdded"
|
||||
:options="livestream.producers"
|
||||
options-dense
|
||||
label="Producer"
|
||||
hint="Select an existing producer or add a new one by name (press Enter to add)."
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="trackDialog.data.name"
|
||||
type="text"
|
||||
label="Track name"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="trackDialog.data.price_sat"
|
||||
type="number"
|
||||
min="1"
|
||||
label="Track price (sat)"
|
||||
hint="This is the minimum price for buying the track download link. It does nothing for tracks without a download URL."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="trackDialog.data.download_url"
|
||||
type="text"
|
||||
label="Download URL"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="disabledAddTrackButton()"
|
||||
type="submit"
|
||||
>Add track</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/livestream/static/js/index.js"></script>
|
||||
{% endblock %}
|
38
lnbits/extensions/livestream/views.py
Normal file
38
lnbits/extensions/livestream/views.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
from quart import g, render_template, request, redirect
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.crud import get_wallet_payment
|
||||
|
||||
from . import livestream_ext
|
||||
from .crud import get_track, get_livestream_by_track
|
||||
|
||||
|
||||
@livestream_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("livestream/index.html", user=g.user)
|
||||
|
||||
|
||||
@livestream_ext.route("/track/<track_id>")
|
||||
async def track_redirect_download(track_id):
|
||||
payment_hash = request.args.get("p")
|
||||
track = await get_track(track_id)
|
||||
ls = await get_livestream_by_track(track_id)
|
||||
payment: Payment = await get_wallet_payment(ls.wallet, payment_hash)
|
||||
|
||||
if not payment:
|
||||
return (
|
||||
f"Couldn't find the payment {payment_hash} or track {track.id}.",
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if payment.pending:
|
||||
return (
|
||||
f"Payment {payment_hash} wasn't received yet. Please try again in a minute.",
|
||||
HTTPStatus.PAYMENT_REQUIRED,
|
||||
)
|
||||
|
||||
return redirect(track.download_url)
|
111
lnbits/extensions/livestream/views_api.py
Normal file
111
lnbits/extensions/livestream/views_api.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
from quart import g, jsonify
|
||||
from http import HTTPStatus
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from . import livestream_ext
|
||||
from .crud import (
|
||||
get_or_create_livestream_by_wallet,
|
||||
add_track,
|
||||
get_tracks,
|
||||
get_producers,
|
||||
update_livestream_fee,
|
||||
update_current_track,
|
||||
delete_track_from_livestream,
|
||||
)
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_livestream_from_wallet():
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
tracks = await get_tracks(ls.id)
|
||||
producers = await get_producers(ls.id)
|
||||
|
||||
try:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
**ls._asdict(),
|
||||
**{
|
||||
"lnurl": ls.lnurl,
|
||||
"tracks": [track._asdict() for track in tracks],
|
||||
"producers": [producer._asdict() for producer in producers],
|
||||
},
|
||||
}
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except LnurlInvalidUrl:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
|
||||
}
|
||||
),
|
||||
HTTPStatus.UPGRADE_REQUIRED,
|
||||
)
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/track/<track_id>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_update_track(track_id):
|
||||
try:
|
||||
id = int(track_id)
|
||||
except ValueError:
|
||||
id = 0
|
||||
if id <= 0:
|
||||
id = None
|
||||
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await update_current_track(ls.id, id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/fee/<fee_pct>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_update_fee(fee_pct):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await update_livestream_fee(ls.id, int(fee_pct))
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/tracks", methods=["POST"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"download_url": {"type": "string", "empty": False, "required": False},
|
||||
"price_msat": {"type": "number", "min": 0, "required": False},
|
||||
"producer_id": {
|
||||
"type": "number",
|
||||
"required": True,
|
||||
"excludes": "producer_name",
|
||||
},
|
||||
"producer_name": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"excludes": "producer_id",
|
||||
},
|
||||
}
|
||||
)
|
||||
async def api_add_track():
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await add_track(
|
||||
ls.id,
|
||||
g.data["name"],
|
||||
g.data.get("download_url"),
|
||||
g.data.get("price_msat", 0),
|
||||
g.data.get("producer_name"),
|
||||
g.data.get("producer_id"),
|
||||
)
|
||||
return "", HTTPStatus.CREATED
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/tracks/<track_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_delete_track(track_id):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await delete_track_from_livestream(ls.id, track_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
|
@ -65,15 +65,37 @@ window.LNbits = {
|
|||
},
|
||||
events: {
|
||||
onInvoicePaid: function (wallet, cb) {
|
||||
if (!this.pis) {
|
||||
this.pis = new EventSource(
|
||||
let listener = ev => {
|
||||
cb(JSON.parse(ev.data))
|
||||
}
|
||||
|
||||
this.listenersCount = this.listenersCount || {[wallet.inkey]: 0}
|
||||
this.listenersCount[wallet.inkey]++
|
||||
|
||||
this.listeners = this.listeners || {}
|
||||
if (!(wallet.inkey in this.listeners)) {
|
||||
this.listeners[wallet.inkey] = new EventSource(
|
||||
'/api/v1/payments/sse?api-key=' + wallet.inkey
|
||||
)
|
||||
}
|
||||
|
||||
this.pis.addEventListener('payment-received', ev =>
|
||||
cb(JSON.parse(ev.data))
|
||||
this.listeners[wallet.inkey].addEventListener(
|
||||
'payment-received',
|
||||
listener
|
||||
)
|
||||
|
||||
return () => {
|
||||
this.listeners[wallet.inkey].removeEventListener(
|
||||
'payment-received',
|
||||
listener
|
||||
)
|
||||
this.listenersCount[wallet.inkey]--
|
||||
|
||||
if (this.listenersCount[wallet.inkey] <= 0) {
|
||||
this.listeners[wallet.inkey].close()
|
||||
delete this.listeners[wallet.inkey]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
href: {
|
||||
|
|
Loading…
Add table
Reference in a new issue