Browse Source

livestream extension.

(except docs)
livestream
fiatjaf 4 years ago
parent
commit
179d290cdf
  1. 6
      lnbits/core/crud.py
  2. 3
      lnbits/core/static/js/wallet.js
  3. 3
      lnbits/core/templates/core/wallet.html
  4. 22
      lnbits/decorators.py
  5. 1
      lnbits/extensions/livestream/README.md
  6. 17
      lnbits/extensions/livestream/__init__.py
  7. 8
      lnbits/extensions/livestream/config.json
  8. 167
      lnbits/extensions/livestream/crud.py
  9. 69
      lnbits/extensions/livestream/lnurl.py
  10. 39
      lnbits/extensions/livestream/migrations.py
  11. 57
      lnbits/extensions/livestream/models.py
  12. 175
      lnbits/extensions/livestream/static/js/index.js
  13. 82
      lnbits/extensions/livestream/tasks.py
  14. 44
      lnbits/extensions/livestream/templates/livestream/_api_docs.html
  15. 288
      lnbits/extensions/livestream/templates/livestream/index.html
  16. 32
      lnbits/extensions/livestream/views.py
  17. 99
      lnbits/extensions/livestream/views_api.py

6
lnbits/core/crud.py

@ -298,9 +298,9 @@ async def delete_payment(checking_id: str) -> None:
async def check_internal(payment_hash: str) -> Optional[str]: async def check_internal(payment_hash: str) -> Optional[str]:
row = await db.fetchone( row = await 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:

3
lnbits/core/static/js/wallet.js

@ -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
}, },

3
lnbits/core/templates/core/wallet.html

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

22
lnbits/decorators.py

@ -81,17 +81,17 @@ def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = Fals
def wrap(view): def wrap(view):
@wraps(view) @wraps(view)
async def wrapped_view(**kwargs): async def wrapped_view(**kwargs):
query_params = {param: request.args.get(param, type=str) for param in params} for param in params:
value = request.args.get(param, type=str)
for param, value in query_params.items(): if not value:
if not value and (required is True or (required and param in required)): if required is True or (required and param in required):
abort(HTTPStatus.BAD_REQUEST, f"`{param}` is required.") abort(HTTPStatus.BAD_REQUEST, f"`{param}` is required.")
continue
if value:
try: try:
UUID(value, version=version) UUID(value, version=version)
except ValueError: except ValueError:
abort(HTTPStatus.BAD_REQUEST, f"`{param}` is not a valid UUID.") abort(HTTPStatus.BAD_REQUEST, f"`{param}` is not a valid UUID.")
return await view(**kwargs) return await view(**kwargs)

1
lnbits/extensions/livestream/README.md

@ -0,0 +1 @@
# Livestream

17
lnbits/extensions/livestream/__init__.py

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

8
lnbits/extensions/livestream/config.json

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

167
lnbits/extensions/livestream/crud.py

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

69
lnbits/extensions/livestream/lnurl.py

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

39
lnbits/extensions/livestream/migrations.py

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

57
lnbits/extensions/livestream/models.py

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

175
lnbits/extensions/livestream/static/js/index.js

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

82
lnbits/extensions/livestream/tasks.py

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

44
lnbits/extensions/livestream/templates/livestream/_api_docs.html

@ -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": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;livestream object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}/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>

288
lnbits/extensions/livestream/templates/livestream/index.html

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

32
lnbits/extensions/livestream/views.py

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

99
lnbits/extensions/livestream/views_api.py

@ -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…
Cancel
Save