mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-24 06:48:02 +01:00
commit
94acbd84e0
17 changed files with 1324 additions and 8 deletions
|
@ -370,9 +370,9 @@ async def check_internal(
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT checking_id FROM apipayments
|
SELECT checking_id FROM apipayments
|
||||||
WHERE hash = ? AND pending AND amount > 0
|
WHERE hash = ? AND pending AND amount > 0
|
||||||
""",
|
""",
|
||||||
(payment_hash,),
|
(payment_hash,),
|
||||||
)
|
)
|
||||||
if not row:
|
if not row:
|
||||||
|
|
|
@ -211,6 +211,9 @@ new Vue({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
paymentTableRowKey: function (row) {
|
||||||
|
return row.payment_hash + row.amount
|
||||||
|
},
|
||||||
closeCamera: function () {
|
closeCamera: function () {
|
||||||
this.parse.camera.show = false
|
this.parse.camera.show = false
|
||||||
},
|
},
|
||||||
|
|
|
@ -87,9 +87,10 @@
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:data="filteredPayments"
|
:data="filteredPayments"
|
||||||
row-key="payment_hash"
|
:row-key="paymentTableRowKey"
|
||||||
:columns="paymentsTable.columns"
|
:columns="paymentsTable.columns"
|
||||||
:pagination.sync="paymentsTable.pagination"
|
:pagination.sync="paymentsTable.pagination"
|
||||||
|
no-data-label="No transactions made yet"
|
||||||
>
|
>
|
||||||
{% raw %}
|
{% raw %}
|
||||||
<template v-slot:header="props">
|
<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: {
|
events: {
|
||||||
onInvoicePaid: function (wallet, cb) {
|
onInvoicePaid: function (wallet, cb) {
|
||||||
if (!this.pis) {
|
let listener = ev => {
|
||||||
this.pis = new EventSource(
|
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
|
'/api/v1/payments/sse?api-key=' + wallet.inkey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pis.addEventListener('payment-received', ev =>
|
this.listeners[wallet.inkey].addEventListener(
|
||||||
cb(JSON.parse(ev.data))
|
'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: {
|
href: {
|
||||||
|
|
Loading…
Add table
Reference in a new issue