livestream extension.
(except docs)
This commit is contained in:
parent
10671e5732
commit
76199c66a1
16 changed files with 1086 additions and 4 deletions
|
|
@ -211,6 +211,9 @@ new Vue({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
paymentTableRowKey: function (row) {
|
||||||
|
return row.payment_hash + row.amount
|
||||||
|
},
|
||||||
closeCamera: function () {
|
closeCamera: function () {
|
||||||
this.parse.camera.show = false
|
this.parse.camera.show = false
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,10 @@
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:data="filteredPayments"
|
:data="filteredPayments"
|
||||||
row-key="payment_hash"
|
:row-key="paymentTableRowKey"
|
||||||
:columns="paymentsTable.columns"
|
:columns="paymentsTable.columns"
|
||||||
:pagination.sync="paymentsTable.pagination"
|
:pagination.sync="paymentsTable.pagination"
|
||||||
|
no-data-label="No transactions made yet"
|
||||||
>
|
>
|
||||||
{% raw %}
|
{% raw %}
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
|
|
|
||||||
1
lnbits/extensions/livestream/README.md
Normal file
1
lnbits/extensions/livestream/README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Livestream
|
||||||
17
lnbits/extensions/livestream/__init__.py
Normal file
17
lnbits/extensions/livestream/__init__.py
Normal file
|
|
@ -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
Normal file
8
lnbits/extensions/livestream/config.json
Normal file
|
|
@ -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
Normal file
167
lnbits/extensions/livestream/crud.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import unicodedata
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from lnbits.core.crud import create_account, create_wallet
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import Livestream, Track, Producer
|
||||||
|
|
||||||
|
|
||||||
|
async def create_livestream(*, wallet_id: str) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO livestreams (wallet)
|
||||||
|
VALUES (?)
|
||||||
|
""",
|
||||||
|
(wallet_id,),
|
||||||
|
)
|
||||||
|
return result._result_proxy.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
async def get_livestream(ls_id: int) -> Optional[Livestream]:
|
||||||
|
row = await db.fetchone("SELECT * FROM livestreams WHERE id = ?", (ls_id,))
|
||||||
|
return Livestream(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_livestream_by_track(track_id: int) -> Optional[Livestream]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"""
|
||||||
|
SELECT livestreams.* FROM livestreams
|
||||||
|
INNER JOIN tracks ON tracks.livestream = livestreams.id
|
||||||
|
WHERE tracks.id = ?
|
||||||
|
""",
|
||||||
|
(track_id,),
|
||||||
|
)
|
||||||
|
return Livestream(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]:
|
||||||
|
row = await db.fetchone("SELECT * FROM livestreams WHERE wallet = ?", (wallet,))
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
# create on the fly
|
||||||
|
ls_id = await create_livestream(wallet_id=wallet)
|
||||||
|
return await get_livestream(ls_id)
|
||||||
|
|
||||||
|
return Livestream(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def update_current_track(ls_id: int, track_id: Optional[int]):
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE livestreams SET current_track = ? WHERE id = ?",
|
||||||
|
(track_id, ls_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_livestream_fee(ls_id: int, fee_pct: int):
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE livestreams SET fee_pct = ? WHERE id = ?",
|
||||||
|
(fee_pct, ls_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_track(
|
||||||
|
livestream: int,
|
||||||
|
name: str,
|
||||||
|
download_url: Optional[str],
|
||||||
|
price_msat: int,
|
||||||
|
producer_name: Optional[str],
|
||||||
|
producer_id: Optional[int],
|
||||||
|
) -> int:
|
||||||
|
if producer_id:
|
||||||
|
p_id = producer_id
|
||||||
|
elif producer_name:
|
||||||
|
p_id = await add_producer(livestream, producer_name)
|
||||||
|
else:
|
||||||
|
raise TypeError("need either producer_id or producer_name arguments")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tracks (livestream, name, download_url, price_msat, producer)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(livestream, name, download_url, price_msat, p_id),
|
||||||
|
)
|
||||||
|
return result._result_proxy.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
async def get_track(track_id: Optional[int]) -> Optional[Track]:
|
||||||
|
if not track_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = await db.fetchone(
|
||||||
|
"""
|
||||||
|
SELECT id, download_url, price_msat, name, producer
|
||||||
|
FROM tracks WHERE id = ?
|
||||||
|
""",
|
||||||
|
(track_id,),
|
||||||
|
)
|
||||||
|
return Track(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tracks(livestream: int) -> List[Track]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT id, download_url, price_msat, name, producer
|
||||||
|
FROM tracks WHERE livestream = ?
|
||||||
|
""",
|
||||||
|
(livestream,),
|
||||||
|
)
|
||||||
|
return [Track(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_track_from_livestream(livestream: int, track_id: int):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM tracks WHERE livestream = ? AND id = ?
|
||||||
|
""",
|
||||||
|
(livestream, track_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_producer(livestream: int, name: str) -> int:
|
||||||
|
name = "".join([unicodedata.normalize("NFD", l)[0] for l in name if l]).strip()
|
||||||
|
|
||||||
|
existing = await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT id FROM producers
|
||||||
|
WHERE livestream = ? AND lower(name) = ?
|
||||||
|
""",
|
||||||
|
(livestream, name.lower()),
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing[0].id
|
||||||
|
|
||||||
|
user = await create_account()
|
||||||
|
wallet = await create_wallet(user_id=user.id, wallet_name="livestream: " + name)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO producers (livestream, name, user, wallet)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(livestream, name, user.id, wallet.id),
|
||||||
|
)
|
||||||
|
return result._result_proxy.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
async def get_producer(producer_id: int) -> Optional[Producer]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"""
|
||||||
|
SELECT id, user, wallet, name
|
||||||
|
FROM producers WHERE id = ?
|
||||||
|
""",
|
||||||
|
(producer_id,),
|
||||||
|
)
|
||||||
|
return Producer(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_producers(livestream: int) -> List[Producer]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT id, user, wallet, name
|
||||||
|
FROM producers WHERE livestream = ?
|
||||||
|
""",
|
||||||
|
(livestream,),
|
||||||
|
)
|
||||||
|
return [Producer(**dict(row)) for row in rows]
|
||||||
69
lnbits/extensions/livestream/lnurl.py
Normal file
69
lnbits/extensions/livestream/lnurl.py
Normal file
|
|
@ -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
Normal file
39
lnbits/extensions/livestream/migrations.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial livestream tables.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE livestreams (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
fee_pct INTEGER NOT NULL DEFAULT 10,
|
||||||
|
current_track INTEGER
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE producers (
|
||||||
|
livestream INTEGER NOT NULL REFERENCES livestreams (id),
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE tracks (
|
||||||
|
livestream INTEGER NOT NULL REFERENCES livestreams (id),
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
download_url TEXT,
|
||||||
|
price_msat INTEGER NOT NULL DEFAULT 0,
|
||||||
|
name TEXT,
|
||||||
|
producer INTEGER REFERENCES producers (id) NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
57
lnbits/extensions/livestream/models.py
Normal file
57
lnbits/extensions/livestream/models.py
Normal file
|
|
@ -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
Normal file
175
lnbits/extensions/livestream/static/js/index.js
Normal file
|
|
@ -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
Normal file
82
lnbits/extensions/livestream/tasks.py
Normal file
|
|
@ -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>
|
||||||
288
lnbits/extensions/livestream/templates/livestream/index.html
Normal file
288
lnbits/extensions/livestream/templates/livestream/index.html
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl">
|
||||||
|
<q-form
|
||||||
|
@submit="updateCurrentTrack(nextCurrentTrack)"
|
||||||
|
class="q-gutter-md"
|
||||||
|
>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col">
|
||||||
|
<q-select
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
v-model="nextCurrentTrack"
|
||||||
|
use-input
|
||||||
|
hide-selected
|
||||||
|
fill-input
|
||||||
|
input-debounce="0"
|
||||||
|
:options="sortedTracks.map(track => track.id)"
|
||||||
|
option-value="id"
|
||||||
|
:option-label="getTrackLabel"
|
||||||
|
options-dense
|
||||||
|
label="Current track"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
{% raw %}
|
||||||
|
<q-btn unelevated color="deep-purple" type="submit">
|
||||||
|
{{ nextCurrentTrack === livestream.current_track ? 'Stop' : 'Set'
|
||||||
|
}} current track
|
||||||
|
</q-btn>
|
||||||
|
{% endraw %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
<q-form @submit="updateFeePct" class="q-gutter-md">
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="livestream.fee_pct"
|
||||||
|
type="number"
|
||||||
|
label="Revenue to keep (%)"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-btn unelevated color="deep-purple" type="submit"
|
||||||
|
>Set percent rate</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Tracks</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
@click="trackDialog.show = true"
|
||||||
|
>Add new track</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="sortedTracks"
|
||||||
|
row-key="id"
|
||||||
|
no-data-label="No tracks added yet"
|
||||||
|
:pagination="{rowsPerPage: 0}"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width>Name</q-th>
|
||||||
|
<q-th auto-width>Producer</q-th>
|
||||||
|
<q-th auto-width>Price</q-th>
|
||||||
|
<q-th auto-width>Download URL</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
:icon="livestream.current_track !== props.row.id ? 'play_circle_outline' : 'play_arrow'"
|
||||||
|
:color="livestream.current_track !== props.row.id ? ($q.dark.isActive ? 'grey-7' : 'grey-5') : 'green'"
|
||||||
|
type="a"
|
||||||
|
@click="updateCurrentTrack(props.row.id)"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>{{ props.row.name }}</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
{{ producersMap[props.row.producer].name }}
|
||||||
|
</q-td>
|
||||||
|
<q-td class="text-right" auto-width
|
||||||
|
>{{ props.row.price_msat }}</q-td
|
||||||
|
>
|
||||||
|
<q-td class="text-center" auto-width
|
||||||
|
>{{ props.row.download_url }}</q-td
|
||||||
|
>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="delete"
|
||||||
|
color="negative"
|
||||||
|
type="a"
|
||||||
|
@click="deleteTrack(props.row.id)"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Producers</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="livestream.producers"
|
||||||
|
row-key="id"
|
||||||
|
no-data-label="To include a producer, add a track"
|
||||||
|
:pagination="{rowsPerPage: 0}"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width>Name</q-th>
|
||||||
|
<q-th auto-width>Wallet</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>{{ props.row.name }}</q-td>
|
||||||
|
<q-td class="text-center" auto-width>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
:href="'/wallet?usr=' + props.row.user + '&wal=' + props.row.wallet"
|
||||||
|
>
|
||||||
|
{{ props.row.wallet }}
|
||||||
|
</a>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card class="q-pa-sm col-5">
|
||||||
|
<q-card-section class="q-pa-none text-center">
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
:options="g.user.wallets"
|
||||||
|
:value="selectedWallet"
|
||||||
|
label="Using wallet:"
|
||||||
|
option-label="name"
|
||||||
|
@input="changedWallet"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</q-form>
|
||||||
|
|
||||||
|
<a :href="livestream.url">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-sm">
|
||||||
|
<qrcode
|
||||||
|
:value="livestream.lnurl"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(livestream.lnurl)"
|
||||||
|
class="text-center q-mb-md"
|
||||||
|
>Copy LNURL-pay code</q-btn
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">LNbits Livestream extension</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "livestream/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="trackDialog.show" 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
Normal file
32
lnbits/extensions/livestream/views.py
Normal file
|
|
@ -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
Normal file
99
lnbits/extensions/livestream/views_api.py
Normal file
|
|
@ -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…
Add table
Add a link
Reference in a new issue