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