Put extensions back so we can start converting
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# DJ Livestream
|
||||
|
||||
## Help DJ's and music producers conduct music livestreams
|
||||
|
||||
LNBits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet.
|
||||
|
||||
When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional).
|
||||
|
||||
The revenue will be sent to a wallet created specifically for that producer, with optional revenue splitting between the DJ and the producer.
|
||||
|
||||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||
|
||||
[](https://youtu.be/zDrSWShKz7k 'video tutorial offline shop')
|
||||
|
||||
## Usage
|
||||
|
||||
1. Start by adding a track\
|
||||

|
||||
- set the producer, or choose an existing one
|
||||
- set the track name
|
||||
- define a minimum price where a user can download the track
|
||||
- set the download URL, where user will be redirected if he tips the livestream and the tip is equal or above the set price\
|
||||

|
||||
2. Adjust the percentage of the pay you want to take from the user's tips. 10%, the default, means that the DJ will keep 10% of all the tips sent by users. The other 90% will go to an auto generated producer wallet\
|
||||

|
||||
3. For every different producer added, when adding tracks, a wallet is generated for them\
|
||||

|
||||
4. On the bottom of the LNBits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed
|
||||
5. After all tracks and producers are added, you can start "playing" songs\
|
||||

|
||||
6. You'll see the current track playing and a green icon indicating active track also\
|
||||

|
||||
7. When a user scans the QR code, and sends a tip, you'll receive 10%, in the example case, in your wallet and the producer's wallet will get the rest. For example someone tips 100 sats, you'll get 10 sats and the producer will get 90 sats
|
||||
- producer's wallet receiving 18 sats from 20 sats tips\
|
||||

|
||||
|
||||
## Use cases
|
||||
|
||||
You can print the QR code and display it on a live gig, a street performance, etc... OR you can use the QR as an overlay in an online stream of you playing music, doing a DJ set, making a podcast.
|
||||
|
||||
You can use the extension's API to trigger updates for the current track, update fees, add tracks...
|
||||
|
||||
## Sponsored by
|
||||
|
||||
[](https://cryptograffiti.com/)
|
||||
@@ -0,0 +1,19 @@
|
||||
from quart import Blueprint
|
||||
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_livestream")
|
||||
|
||||
livestream_ext: Blueprint = Blueprint(
|
||||
"livestream", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
||||
livestream_ext.record(record_async(register_listeners))
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "DJ Livestream",
|
||||
"short_description": "Sell tracks and split revenue (lnurl-pay)",
|
||||
"icon": "speaker",
|
||||
"contributors": [
|
||||
"fiatjaf",
|
||||
"cryptograffiti"
|
||||
],
|
||||
"hidden": false
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from lnbits.core.crud import create_account, create_wallet
|
||||
from lnbits.db import SQLITE
|
||||
from . import db
|
||||
from .models import Livestream, Track, Producer
|
||||
|
||||
|
||||
async def create_livestream(*, wallet_id: str) -> int:
|
||||
returning = "" if db.type == SQLITE else "RETURNING ID"
|
||||
method = db.execute if db.type == SQLITE else db.fetchone
|
||||
|
||||
result = await (method)(
|
||||
f"""
|
||||
INSERT INTO livestream.livestreams (wallet)
|
||||
VALUES (?)
|
||||
{returning}
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
|
||||
if db.type == SQLITE:
|
||||
return result._result_proxy.lastrowid
|
||||
else:
|
||||
return result[0]
|
||||
|
||||
|
||||
async def get_livestream(ls_id: int) -> Optional[Livestream]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM livestream.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 livestream.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 livestream.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 livestream.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 livestream.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: Optional[int],
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO livestream.tracks (livestream, name, download_url, price_msat, producer)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(livestream, name, download_url, price_msat, producer),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def update_track(
|
||||
livestream: int,
|
||||
track_id: int,
|
||||
name: str,
|
||||
download_url: Optional[str],
|
||||
price_msat: int,
|
||||
producer: int,
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
UPDATE livestream.tracks SET
|
||||
name = ?,
|
||||
download_url = ?,
|
||||
price_msat = ?,
|
||||
producer = ?
|
||||
WHERE livestream = ? AND id = ?
|
||||
""",
|
||||
(name, download_url, price_msat, producer, livestream, track_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 livestream.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 livestream.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 livestream.tracks WHERE livestream = ? AND id = ?
|
||||
""",
|
||||
(livestream, track_id),
|
||||
)
|
||||
|
||||
|
||||
async def add_producer(livestream: int, name: str) -> int:
|
||||
name = name.strip()
|
||||
|
||||
existing = await db.fetchall(
|
||||
"""
|
||||
SELECT id FROM livestream.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)
|
||||
|
||||
returning = "" if db.type == SQLITE else "RETURNING ID"
|
||||
method = db.execute if db.type == SQLITE else db.fetchone
|
||||
|
||||
result = await method(
|
||||
f"""
|
||||
INSERT INTO livestream.producers (livestream, name, "user", wallet)
|
||||
VALUES (?, ?, ?, ?)
|
||||
{returning}
|
||||
""",
|
||||
(livestream, name, user.id, wallet.id),
|
||||
)
|
||||
if db.type == SQLITE:
|
||||
return result._result_proxy.lastrowid
|
||||
else:
|
||||
return result[0]
|
||||
|
||||
|
||||
async def get_producer(producer_id: int) -> Optional[Producer]:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT id, "user", wallet, name
|
||||
FROM livestream.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 livestream.producers WHERE livestream = ?
|
||||
""",
|
||||
(livestream,),
|
||||
)
|
||||
return [Producer(**dict(row)) for row in rows]
|
||||
@@ -0,0 +1,114 @@
|
||||
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_livestream(ls_id):
|
||||
ls = await get_livestream(ls_id)
|
||||
if not ls:
|
||||
return jsonify({"status": "ERROR", "reason": "Livestream not found."})
|
||||
|
||||
track = await get_track(ls.current_track)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "This livestream is offline."})
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for(
|
||||
"livestream.lnurl_callback", track_id=track.id, _external=True
|
||||
),
|
||||
min_sendable=track.min_sendable,
|
||||
max_sendable=track.max_sendable,
|
||||
metadata=await track.lnurlpay_metadata(),
|
||||
)
|
||||
|
||||
params = resp.dict()
|
||||
params["commentAllowed"] = 300
|
||||
|
||||
return jsonify(params)
|
||||
|
||||
|
||||
@livestream_ext.route("/lnurl/t/<track_id>", methods=["GET"])
|
||||
async def lnurl_track(track_id):
|
||||
track = await get_track(track_id)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "Track not found."})
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for(
|
||||
"livestream.lnurl_callback", track_id=track.id, _external=True
|
||||
),
|
||||
min_sendable=track.min_sendable,
|
||||
max_sendable=track.max_sendable,
|
||||
metadata=await track.lnurlpay_metadata(),
|
||||
)
|
||||
|
||||
params = resp.dict()
|
||||
params["commentAllowed"] = 300
|
||||
|
||||
return jsonify(params)
|
||||
|
||||
|
||||
@livestream_ext.route("/lnurl/cb/<track_id>", methods=["GET"])
|
||||
async def lnurl_callback(track_id):
|
||||
track = await get_track(track_id)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "Couldn't find track."})
|
||||
|
||||
amount_received = int(request.args.get("amount") or 0)
|
||||
|
||||
if amount_received < track.min_sendable:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}."
|
||||
).dict()
|
||||
),
|
||||
)
|
||||
elif track.max_sendable < amount_received:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}."
|
||||
).dict()
|
||||
),
|
||||
)
|
||||
|
||||
comment = request.args.get("comment")
|
||||
if len(comment or "") > 300:
|
||||
return jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Got a comment with {len(comment)} characters, but can only accept 300"
|
||||
).dict()
|
||||
)
|
||||
|
||||
ls = await get_livestream_by_track(track_id)
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=ls.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=await track.fullname(),
|
||||
description_hash=hashlib.sha256(
|
||||
(await track.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "livestream", "track": track.id, "comment": comment},
|
||||
)
|
||||
|
||||
if amount_received < track.price_msat:
|
||||
success_action = None
|
||||
else:
|
||||
success_action = track.success_action(payment_hash)
|
||||
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=success_action,
|
||||
routes=[],
|
||||
)
|
||||
|
||||
return jsonify(resp.dict())
|
||||
@@ -0,0 +1,39 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial livestream tables.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE livestream.livestreams (
|
||||
id {db.serial_primary_key},
|
||||
wallet TEXT NOT NULL,
|
||||
fee_pct INTEGER NOT NULL DEFAULT 10,
|
||||
current_track INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE livestream.producers (
|
||||
livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id),
|
||||
id {db.serial_primary_key},
|
||||
"user" TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE livestream.tracks (
|
||||
livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id),
|
||||
id {db.serial_primary_key},
|
||||
download_url TEXT,
|
||||
price_msat INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT,
|
||||
producer INTEGER REFERENCES {db.references_schema}producers (id) NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
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_livestream", ls_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
||||
|
||||
|
||||
class Track(NamedTuple):
|
||||
id: int
|
||||
download_url: str
|
||||
price_msat: int
|
||||
name: str
|
||||
producer: int
|
||||
|
||||
@property
|
||||
def min_sendable(self) -> int:
|
||||
return min(100_000, self.price_msat or 100_000)
|
||||
|
||||
@property
|
||||
def max_sendable(self) -> int:
|
||||
return max(50_000_000, self.price_msat * 5)
|
||||
|
||||
@property
|
||||
def lnurl(self) -> Lnurl:
|
||||
url = url_for("livestream.lnurl_track", track_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
||||
|
||||
async def fullname(self) -> str:
|
||||
from .crud import get_producer
|
||||
|
||||
producer = await get_producer(self.producer)
|
||||
if producer:
|
||||
producer_name = producer.name
|
||||
else:
|
||||
producer_name = "unknown author"
|
||||
|
||||
return f"'{self.name}', from {producer_name}."
|
||||
|
||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
description = (
|
||||
await self.fullname()
|
||||
) + " Like this track? Send some sats in appreciation."
|
||||
|
||||
if self.download_url:
|
||||
description += f" Send {round(self.price_msat/1000)} sats or more and you can download it."
|
||||
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", description]]))
|
||||
|
||||
def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
|
||||
if not self.download_url:
|
||||
return None
|
||||
|
||||
return UrlAction(
|
||||
url=url_for(
|
||||
"livestream.track_redirect_download",
|
||||
track_id=self.id,
|
||||
p=payment_hash,
|
||||
_external=True,
|
||||
),
|
||||
description=f"Download the track {self.name}!",
|
||||
)
|
||||
|
||||
|
||||
class Producer(NamedTuple):
|
||||
id: int
|
||||
user: str
|
||||
wallet: str
|
||||
name: str
|
||||
@@ -0,0 +1,216 @@
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
cancelListener: () => {},
|
||||
selectedWallet: null,
|
||||
nextCurrentTrack: null,
|
||||
livestream: {
|
||||
tracks: [],
|
||||
producers: []
|
||||
},
|
||||
trackDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedTracks() {
|
||||
return this.livestream.tracks.sort((a, b) => a.name - b.name)
|
||||
},
|
||||
tracksMap() {
|
||||
return Object.fromEntries(
|
||||
this.livestream.tracks.map(track => [track.id, track])
|
||||
)
|
||||
},
|
||||
producersMap() {
|
||||
return Object.fromEntries(
|
||||
this.livestream.producers.map(prod => [prod.id, prod])
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getTrackLabel(trackId) {
|
||||
if (!trackId) return
|
||||
let track = this.tracksMap[trackId]
|
||||
return `${track.name}, ${this.producersMap[track.producer].name}`
|
||||
},
|
||||
disabledAddTrackButton() {
|
||||
return (
|
||||
!this.trackDialog.data.name ||
|
||||
this.trackDialog.data.name.length === 0 ||
|
||||
!this.trackDialog.data.producer ||
|
||||
this.trackDialog.data.producer.length === 0
|
||||
)
|
||||
},
|
||||
changedWallet(wallet) {
|
||||
this.selectedWallet = wallet
|
||||
this.loadLivestream()
|
||||
this.startPaymentNotifier()
|
||||
},
|
||||
loadLivestream() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/livestream/api/v1/livestream',
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.livestream = response.data
|
||||
this.nextCurrentTrack = this.livestream.current_track
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
startPaymentNotifier() {
|
||||
this.cancelListener()
|
||||
|
||||
this.cancelListener = LNbits.events.onInvoicePaid(
|
||||
this.selectedWallet,
|
||||
payment => {
|
||||
let satoshiAmount = Math.round(payment.amount / 1000)
|
||||
let trackName = (
|
||||
this.tracksMap[payment.extra.track] || {name: '[unknown]'}
|
||||
).name
|
||||
|
||||
this.$q.notify({
|
||||
message: `Someone paid <b>${satoshiAmount} sat</b> for the track <em>${trackName}</em>.`,
|
||||
caption: payment.extra.comment
|
||||
? `<em>"${payment.extra.comment}"</em>`
|
||||
: undefined,
|
||||
color: 'secondary',
|
||||
html: true,
|
||||
timeout: 0,
|
||||
actions: [{label: 'Dismiss', color: 'white', handler: () => {}}]
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
addTrack() {
|
||||
let {id, name, producer, price_sat, download_url} = this.trackDialog.data
|
||||
|
||||
const [method, path] = id
|
||||
? ['PUT', `/livestream/api/v1/livestream/tracks/${id}`]
|
||||
: ['POST', '/livestream/api/v1/livestream/tracks']
|
||||
|
||||
LNbits.api
|
||||
.request(method, path, this.selectedWallet.inkey, {
|
||||
download_url:
|
||||
download_url && download_url.length > 0 ? download_url : undefined,
|
||||
name,
|
||||
price_msat: price_sat * 1000 || 0,
|
||||
producer_name: typeof producer === 'string' ? producer : undefined,
|
||||
producer_id: typeof producer === 'object' ? producer.id : undefined
|
||||
})
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Track '${this.trackDialog.data.name}' added.`,
|
||||
timeout: 700
|
||||
})
|
||||
this.loadLivestream()
|
||||
this.trackDialog.show = false
|
||||
this.trackDialog.data = {}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
openAddTrackDialog() {
|
||||
this.trackDialog.show = true
|
||||
this.trackDialog.data = {}
|
||||
},
|
||||
openUpdateDialog(itemId) {
|
||||
this.trackDialog.show = true
|
||||
let item = this.livestream.tracks.find(item => item.id === itemId)
|
||||
this.trackDialog.data = {
|
||||
...item,
|
||||
producer: this.livestream.producers.find(
|
||||
prod => prod.id === item.producer
|
||||
),
|
||||
price_sat: Math.round(item.price_msat / 1000)
|
||||
}
|
||||
},
|
||||
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) {
|
||||
console.log(this.nextCurrentTrack, this.livestream)
|
||||
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.nextCurrentTrack = track
|
||||
this.$q.notify({
|
||||
message: `Current track updated.`,
|
||||
timeout: 700
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
updateFeePct() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/livestream/api/v1/livestream/fee/' + this.livestream.fee_pct,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(() => {
|
||||
this.$q.notify({
|
||||
message: `Percentage updated.`,
|
||||
timeout: 700
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
producerAdded(added, cb) {
|
||||
cb(added)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.selectedWallet = this.g.user.wallets[0]
|
||||
this.loadLivestream()
|
||||
this.startPaymentNotifier()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import json
|
||||
import trio
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.crud import create_payment
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.tasks import register_invoice_listener, internal_invoice_paid
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from .crud import get_track, get_producer, get_livestream_by_track
|
||||
|
||||
|
||||
async def register_listeners():
|
||||
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
|
||||
register_invoice_listener(invoice_paid_chan_send)
|
||||
await wait_for_paid_invoices(invoice_paid_chan_recv)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
|
||||
async for payment in invoice_paid_chan:
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if "livestream" != payment.extra.get("tag"):
|
||||
# not a livestream invoice
|
||||
return
|
||||
|
||||
track = await get_track(payment.extra.get("track", -1))
|
||||
if not track:
|
||||
print("this should never happen", payment)
|
||||
return
|
||||
|
||||
if payment.extra.get("shared_with"):
|
||||
print("payment was shared already", payment)
|
||||
return
|
||||
|
||||
producer = await get_producer(track.producer)
|
||||
assert producer, f"track {track.id} is not associated with a producer"
|
||||
|
||||
ls = await get_livestream_by_track(track.id)
|
||||
assert ls, f"track {track.id} is not associated with a livestream"
|
||||
|
||||
# now we make a special kind of internal transfer
|
||||
amount = int(payment.amount * (100 - ls.fee_pct) / 100)
|
||||
|
||||
# mark the original payment with two extra keys, "shared_with" and "received"
|
||||
# (this prevents us from doing this process again and it's informative)
|
||||
# and reduce it by the amount we're going to send to the producer
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments
|
||||
SET extra = ?, amount = ?
|
||||
WHERE hash = ?
|
||||
AND checking_id NOT LIKE 'internal_%'
|
||||
""",
|
||||
(
|
||||
json.dumps(
|
||||
dict(
|
||||
**payment.extra,
|
||||
shared_with=[producer.name, producer.id],
|
||||
received=payment.amount,
|
||||
)
|
||||
),
|
||||
payment.amount - amount,
|
||||
payment.payment_hash,
|
||||
),
|
||||
)
|
||||
|
||||
# perform an internal transfer using the same payment_hash to the producer wallet
|
||||
internal_checking_id = f"internal_{urlsafe_short_hash()}"
|
||||
await create_payment(
|
||||
wallet_id=producer.wallet,
|
||||
checking_id=internal_checking_id,
|
||||
payment_request="",
|
||||
payment_hash=payment.payment_hash,
|
||||
amount=amount,
|
||||
memo=f"Revenue from '{track.name}'.",
|
||||
pending=False,
|
||||
)
|
||||
|
||||
# manually send this for now
|
||||
await internal_invoice_paid.send(internal_checking_id)
|
||||
|
||||
# so the flow is the following:
|
||||
# - we receive, say, 1000 satoshis
|
||||
# - if the fee_pct is, say, 30%, the amount we will send is 700
|
||||
# - we change the amount of receiving payment on the database from 1000 to 300
|
||||
# - we create a new payment on the producer's wallet with amount 700
|
||||
@@ -0,0 +1,146 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="How to use"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>Add tracks, profit.</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="List livestream links"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/livestream/api/v1/livestream</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<livestream_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update track">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/livestream/api/v1/livestream/track/<track_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root
|
||||
}}api/v1/livestream/track/<track_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update fee">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/livestream/api/v1/livestream/fee/<fee_pct></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root
|
||||
}}api/v1/livestream/fee/<fee_pct> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Add track">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span>
|
||||
/livestream/api/v1/livestream/tracks</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"name": <string>, "download_url": <string>,
|
||||
"price_msat": <integer>, "producer_id": <integer>,
|
||||
"producer_name": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/livestream/tracks -d
|
||||
'{"name": <string>, "download_url": <string>,
|
||||
"price_msat": <integer>, "producer_id": <integer>,
|
||||
"producer_name": <string>}' -H "Content-type: application/json"
|
||||
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a withdraw link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/livestream/api/v1/livestream/tracks/<track_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.url_root
|
||||
}}api/v1/livestream/tracks/<track_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,322 @@
|
||||
{% 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="primary" type="submit">
|
||||
{{ nextCurrentTrack && 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="primary" 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="primary" @click="openAddTrackDialog"
|
||||
>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
|
||||
>{{ Math.round(props.row.price_msat / 1000) }}</q-td
|
||||
>
|
||||
<q-td class="text-center" auto-width
|
||||
>{{ props.row.download_url }}</q-td
|
||||
>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<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="'lightning:' + livestream.lnurl">
|
||||
<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">
|
||||
{{SITE_TITLE}} Livestream extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "livestream/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="trackDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-card-section
|
||||
v-if="trackDialog.data.lnurl"
|
||||
class="q-pa-none text-center"
|
||||
>
|
||||
<p class="text-subtitle1 q-my-none">
|
||||
Standalone QR Code for this track
|
||||
</p>
|
||||
<a :href="'lightning:' + trackDialog.data.lnurl">
|
||||
<q-responsive :ratio="1" class="q-mx-sm">
|
||||
<qrcode
|
||||
:value="trackDialog.data.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(trackDialog.data.lnurl)"
|
||||
class="text-center q-mb-md"
|
||||
>Copy LNURL-pay code</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-form @submit="addTrack" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="trackDialog.data.producer"
|
||||
use-input
|
||||
hide-selected
|
||||
fill-input
|
||||
option-label="name"
|
||||
input-debounce="0"
|
||||
@new-value="producerAdded"
|
||||
:options="livestream.producers"
|
||||
options-dense
|
||||
label="Producer"
|
||||
hint="Select an existing producer or add a new one by name (press Enter to add)."
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="trackDialog.data.name"
|
||||
type="text"
|
||||
label="Track name"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="trackDialog.data.price_sat"
|
||||
type="number"
|
||||
min="1"
|
||||
label="Track price (sat)"
|
||||
hint="This is the minimum price for buying the track download link. It does nothing for tracks without a download URL."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="trackDialog.data.download_url"
|
||||
type="text"
|
||||
label="Download URL"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="disabledAddTrackButton()"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="trackDialog.data.id">Update track</span>
|
||||
<span v-else>Add track</span>
|
||||
</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,38 @@
|
||||
from quart import g, render_template, request, redirect
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.crud import get_wallet_payment
|
||||
|
||||
from . import livestream_ext
|
||||
from .crud import get_track, get_livestream_by_track
|
||||
|
||||
|
||||
@livestream_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("livestream/index.html", user=g.user)
|
||||
|
||||
|
||||
@livestream_ext.route("/track/<track_id>")
|
||||
async def track_redirect_download(track_id):
|
||||
payment_hash = request.args.get("p")
|
||||
track = await get_track(track_id)
|
||||
ls = await get_livestream_by_track(track_id)
|
||||
payment: Payment = await get_wallet_payment(ls.wallet, payment_hash)
|
||||
|
||||
if not payment:
|
||||
return (
|
||||
f"Couldn't find the payment {payment_hash} or track {track.id}.",
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if payment.pending:
|
||||
return (
|
||||
f"Payment {payment_hash} wasn't received yet. Please try again in a minute.",
|
||||
HTTPStatus.PAYMENT_REQUIRED,
|
||||
)
|
||||
|
||||
return redirect(track.download_url)
|
||||
@@ -0,0 +1,135 @@
|
||||
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,
|
||||
update_track,
|
||||
add_producer,
|
||||
get_producers,
|
||||
update_current_track,
|
||||
update_livestream_fee,
|
||||
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": [
|
||||
dict(lnurl=track.lnurl, **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"])
|
||||
@livestream_ext.route("/api/v1/livestream/tracks/<id>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"download_url": {"type": "string", "empty": False, "required": False},
|
||||
"price_msat": {"type": "number", "min": 0, "required": False},
|
||||
"producer_id": {
|
||||
"type": "number",
|
||||
"required": True,
|
||||
"excludes": "producer_name",
|
||||
},
|
||||
"producer_name": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"excludes": "producer_id",
|
||||
},
|
||||
}
|
||||
)
|
||||
async def api_add_track(id=None):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
|
||||
if "producer_id" in g.data:
|
||||
p_id = g.data["producer_id"]
|
||||
elif "producer_name" in g.data:
|
||||
p_id = await add_producer(ls.id, g.data["producer_name"])
|
||||
else:
|
||||
raise TypeError("need either producer_id or producer_name arguments")
|
||||
|
||||
if id:
|
||||
await update_track(
|
||||
ls.id,
|
||||
id,
|
||||
g.data["name"],
|
||||
g.data.get("download_url"),
|
||||
g.data.get("price_msat", 0),
|
||||
p_id,
|
||||
)
|
||||
return "", HTTPStatus.OK
|
||||
else:
|
||||
await add_track(
|
||||
ls.id,
|
||||
g.data["name"],
|
||||
g.data.get("download_url"),
|
||||
g.data.get("price_msat", 0),
|
||||
p_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
|
||||
Reference in New Issue
Block a user