mirror of https://github.com/lukechilds/lnbits.git
17 changed files with 1097 additions and 15 deletions
@ -0,0 +1 @@ |
|||
# Livestream |
@ -0,0 +1,17 @@ |
|||
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)) |
@ -0,0 +1,8 @@ |
|||
{ |
|||
"name": "Livestream Manager", |
|||
"short_description": "Do music livestreams, sell songs with revenue splitting from a single QR code.", |
|||
"icon": "speaker", |
|||
"contributors": [ |
|||
"fiatjaf" |
|||
] |
|||
} |
@ -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] |
@ -0,0 +1,69 @@ |
|||
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.price_msat, |
|||
max_sendable=track.price_msat * 5, |
|||
metadata=await track.lnurlpay_metadata, |
|||
) |
|||
|
|||
return jsonify(resp.dict()) |
|||
|
|||
|
|||
@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.price_msat: |
|||
return ( |
|||
jsonify( |
|||
LnurlErrorResponse( |
|||
reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.price_msat / 1000)}." |
|||
).dict() |
|||
), |
|||
) |
|||
elif track.price_msat * 5 < amount_received: |
|||
return ( |
|||
jsonify( |
|||
LnurlErrorResponse( |
|||
reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.price_msat * 5 / 1000)}." |
|||
).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.description(), |
|||
description_hash=hashlib.sha256((await track.lnurlpay_metadata).encode("utf-8")).digest(), |
|||
extra={"tag": "livestream", "track": track.id}, |
|||
) |
|||
|
|||
resp = LnurlPayActionResponse(pr=payment_request, success_action=track.success_action(payment_hash), routes=[],) |
|||
|
|||
return jsonify(resp.dict()) |
@ -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 |
|||
); |
|||
""" |
|||
) |
@ -0,0 +1,57 @@ |
|||
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 |
|||
|
|||
async def description(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"Track '{self.name}', from {producer_name}." |
|||
|
|||
@property |
|||
async def lnurlpay_metadata(self) -> LnurlPayMetadata: |
|||
return LnurlPayMetadata(json.dumps([["text/plain", await self.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 |
@ -0,0 +1,175 @@ |
|||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ |
|||
|
|||
Vue.component(VueQrcode.name, VueQrcode) |
|||
|
|||
new Vue({ |
|||
el: '#vue', |
|||
mixins: [windowMixin], |
|||
data() { |
|||
return { |
|||
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.price_sat || |
|||
!this.trackDialog.data.producer || |
|||
this.trackDialog.data.producer.length === 0 |
|||
) |
|||
}, |
|||
changedWallet(wallet) { |
|||
this.selectedWallet = wallet |
|||
this.loadLivestream() |
|||
}, |
|||
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) |
|||
}) |
|||
}, |
|||
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, |
|||
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() |
|||
} |
|||
}) |
@ -0,0 +1,82 @@ |
|||
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 = ? |
|||
""", |
|||
( |
|||
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 |
@ -0,0 +1,44 @@ |
|||
<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="Get the livestream data" |
|||
> |
|||
<q-card> |
|||
<q-card-section> |
|||
<code><span class="text-blue">GET</span></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 }}/livestream/api/v1/livestream -H |
|||
"X-Api-Key: {{ g.user.wallets[0].inkey }}" |
|||
</code> |
|||
</q-card-section> |
|||
</q-card> |
|||
</q-expansion-item> |
|||
</q-expansion-item> |
@ -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" position="top"> |
|||
<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 a minimum price. Payments up to 5x bigger than this will be accepted." |
|||
></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 %} |
@ -0,0 +1,32 @@ |
|||
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) |
@ -0,0 +1,99 @@ |
|||
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": 1, "required": True}, |
|||
"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["price_msat"], |
|||
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 |
Loading…
Reference in new issue