Merge pull request #172 from lnbits/livestream

DJ Livestream extension
This commit is contained in:
Arc 2021-04-12 17:25:54 +01:00 committed by GitHub
commit 94acbd84e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1324 additions and 8 deletions

View file

@ -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:

View file

@ -211,6 +211,9 @@ new Vue({
}
},
methods: {
paymentTableRowKey: function (row) {
return row.payment_hash + row.amount
},
closeCamera: function () {
this.parse.camera.show = false
},

View file

@ -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">

View 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://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/)

View 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))

View 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
}

View 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]

View 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())

View 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
);
"""
)

View 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

View 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()
}
})

View 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

View 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": &lt;invoice_key&gt;}</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>[&lt;livestream_object&gt;, ...]</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/&lt;track_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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/&lt;track_id&gt; -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/&lt;fee_pct&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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/&lt;fee_pct&gt; -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": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"name": &lt;string&gt;, "download_url": &lt;string&gt;,
"price_msat": &lt;integer&gt;, "producer_id": &lt;integer&gt;,
"producer_name": &lt;string&gt;}</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": &lt;string&gt;, "download_url": &lt;string&gt;,
"price_msat": &lt;integer&gt;, "producer_id": &lt;integer&gt;,
"producer_name": &lt;string&gt;}' -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/&lt;track_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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/&lt;track_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View 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 %}

View 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)

View 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

View file

@ -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: {