remove livestream

This commit is contained in:
dni ⚡ 2023-02-15 10:58:36 +01:00
parent 5bc2646103
commit 6df8494ed6
No known key found for this signature in database
GPG Key ID: 886317704CC4E618
14 changed files with 0 additions and 1461 deletions

View File

@ -1,45 +0,0 @@
# 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)
[![video tutorial livestream](http://img.youtube.com/vi/zDrSWShKz7k/0.jpg)](https://youtu.be/zDrSWShKz7k 'video tutorial offline shop')
## Usage
1. Start by adding a track\
![add new track](https://i.imgur.com/Cu0eGrW.jpg)
- 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\
![track settings](https://i.imgur.com/HTJYwcW.jpg)
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\
![adjust percentage](https://i.imgur.com/9weHKAB.jpg)
3. For every different producer added, when adding tracks, a wallet is generated for them\
![producer wallet](https://i.imgur.com/YFIZ7Tm.jpg)
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\
![play tracks](https://i.imgur.com/7ytiBkq.jpg)
6. You'll see the current track playing and a green icon indicating active track also\
![active track](https://i.imgur.com/W1vBz54.jpg)
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\
![producer wallet](https://i.imgur.com/OM9LawA.jpg)
## 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://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/)

View File

@ -1,35 +0,0 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_livestream")
livestream_static_files = [
{
"path": "/livestream/static",
"app": StaticFiles(packages=[("lnbits", "extensions/livestream/static")]),
"name": "livestream_static",
}
]
livestream_ext: APIRouter = APIRouter(prefix="/livestream", tags=["livestream"])
def livestream_renderer():
return template_renderer(["lnbits/extensions/livestream/templates"])
from .lnurl import * # noqa: F401,F403
from .tasks import wait_for_paid_invoices
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403
def livestream_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -1,10 +0,0 @@
{
"name": "DJ Livestream",
"short_description": "Sell tracks and split revenue (lnurl-pay)",
"tile": "/livestream/static/image/livestream.png",
"contributors": [
"fiatjaf",
"cryptograffiti"
],
"hidden": false
}

View File

@ -1,203 +0,0 @@
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, Producer, Track
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] # type: ignore
async def get_livestream(ls_id: int) -> Optional[Livestream]:
row = await db.fetchone(
"SELECT * FROM livestream.livestreams WHERE id = ?", (ls_id,)
)
return Livestream(**row) if row else None
async def get_livestream_by_track(track_id: int) -> Optional[Livestream]:
row = await db.fetchone(
"""
SELECT * FROM livestream.tracks WHERE tracks.id = ?
""",
(track_id,),
)
row2 = await db.fetchone(
"""
SELECT * FROM livestream.livestreams WHERE livestreams.id = ?
""",
(row.livestream,),
)
return Livestream(**row2) if row2 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(**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(**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(**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] # type: ignore
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(**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(**row) for row in rows]

View File

@ -1,118 +0,0 @@
import math
from http import HTTPStatus
from fastapi import HTTPException, Query, Request
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
from lnurl.models import ClearnetUrl, LightningInvoice, MilliSatoshi
from lnbits.core.services import create_invoice
from . import livestream_ext
from .crud import get_livestream, get_livestream_by_track, get_track
@livestream_ext.get("/lnurl/{ls_id}", name="livestream.lnurl_livestream")
async def lnurl_livestream(ls_id, request: Request):
ls = await get_livestream(ls_id)
if not ls:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Livestream not found."
)
track = await get_track(ls.current_track)
if not track:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="This livestream is offline."
)
resp = LnurlPayResponse(
callback=ClearnetUrl(
request.url_for("livestream.lnurl_callback", track_id=track.id),
scheme="https",
),
minSendable=MilliSatoshi(track.min_sendable),
maxSendable=MilliSatoshi(track.max_sendable),
metadata=await track.lnurlpay_metadata(),
)
params = resp.dict()
params["commentAllowed"] = 300
return params
@livestream_ext.get("/lnurl/t/{track_id}", name="livestream.lnurl_track")
async def lnurl_track(track_id, request: Request):
track = await get_track(track_id)
if not track:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Track not found.")
resp = LnurlPayResponse(
callback=ClearnetUrl(
request.url_for("livestream.lnurl_callback", track_id=track.id),
scheme="https",
),
minSendable=MilliSatoshi(track.min_sendable),
maxSendable=MilliSatoshi(track.max_sendable),
metadata=await track.lnurlpay_metadata(),
)
params = resp.dict()
params["commentAllowed"] = 300
return params
@livestream_ext.get("/lnurl/cb/{track_id}", name="livestream.lnurl_callback")
async def lnurl_callback(
track_id, request: Request, amount: int = Query(...), comment: str = Query("")
):
track = await get_track(track_id)
if not track:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Track not found.")
amount_received = int(amount or 0)
if amount_received < track.min_sendable:
return 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 LnurlErrorResponse(
reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}."
).dict()
if len(comment or "") > 300:
return LnurlErrorResponse(
reason=f"Got a comment with {len(comment)} characters, but can only accept 300"
).dict()
ls = await get_livestream_by_track(track_id)
assert ls
extra_amount = amount_received - int(amount_received * (100 - ls.fee_pct) / 100)
payment_hash, payment_request = await create_invoice(
wallet_id=ls.wallet,
amount=int(amount_received / 1000),
memo=await track.fullname(),
unhashed_description=(await track.lnurlpay_metadata()).encode(),
extra={
"tag": "livestream",
"track": track.id,
"comment": comment,
"amount": int(extra_amount / 1000),
},
)
assert track.price_msat
if amount_received < track.price_msat:
success_action = None
else:
success_action = track.success_action(payment_hash, request=request)
resp = LnurlPayActionResponse(
pr=LightningInvoice(payment_request), successAction=success_action, routes=[]
)
return resp.dict()

View File

@ -1,39 +0,0 @@
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
);
"""
)

View File

@ -1,103 +0,0 @@
import json
from sqlite3 import Row
from typing import Optional
from fastapi import Query, Request
from lnurl import Lnurl
from lnurl import encode as lnurl_encode
from lnurl.models import ClearnetUrl, Max144Str, UrlAction
from lnurl.types import LnurlPayMetadata
from pydantic import BaseModel
class CreateTrack(BaseModel):
name: str = Query(...)
download_url: str = Query(None)
price_msat: int = Query(None, ge=0)
producer_id: str = Query(None)
producer_name: str = Query(None)
class Livestream(BaseModel):
id: int
wallet: str
fee_pct: int
current_track: Optional[int]
def lnurl(self, request: Request) -> Lnurl:
url = request.url_for("livestream.lnurl_livestream", ls_id=self.id)
return lnurl_encode(url)
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class Track(BaseModel):
id: int
download_url: Optional[str]
price_msat: int = 0
name: str
producer: int
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
@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)
def lnurl(self, request: Request) -> Lnurl:
url = request.url_for("livestream.lnurl_track", track_id=self.id)
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, request: Request
) -> Optional[UrlAction]:
if not self.download_url:
return None
url = request.url_for("livestream.track_redirect_download", track_id=self.id)
url_with_query = f"{url}?p={payment_hash}"
return UrlAction(
url=ClearnetUrl(url_with_query, scheme="https"),
description=Max144Str(f"Download the track {self.name}!"),
)
class Producer(BaseModel):
id: int
user: str
wallet: str
name: str
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

View File

@ -1,216 +0,0 @@
/* 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()
}
})

View File

@ -1,70 +0,0 @@
import asyncio
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_livestream_by_track, get_producer, get_track
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name())
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "livestream":
# not a livestream invoice
return
track = await get_track(payment.extra.get("track", -1))
if not track:
logger.error("this should never happen", payment)
return
if payment.extra.get("shared_with"):
logger.error("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"
amount = int(payment.amount * (100 - ls.fee_pct) / 100)
payment_hash, payment_request = await create_invoice(
wallet_id=producer.wallet,
amount=int(amount / 1000),
internal=True,
memo=f"Revenue from '{track.name}'.",
)
logger.debug(
f"livestream: producer invoice created: {payment_hash}, {amount} msats"
)
checking_id = await pay_invoice(
payment_request=payment_request,
wallet_id=payment.wallet_id,
extra={
**payment.extra,
"shared_with": f"Producer ID: {producer.id}",
"received": payment.amount,
},
)
logger.debug(f"livestream: producer invoice paid: {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

View File

@ -1,150 +0,0 @@
<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-btn flat label="Swagger API" type="a" href="../docs#/livestream"></q-btn>
<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": &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.base_url }}livestream/api/v1/livestream -H
"X-Api-Key: {{ 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/&lt;track_id&gt;</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 201 CREATED (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.base_url }}
livestream/api/v1/livestream/track/&lt;track_id&gt; -H "X-Api-Key: {{
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/&lt;fee_pct&gt;</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 201 CREATED (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.base_url }}
livestream/api/v1/livestream/fee/&lt;fee_pct&gt; -H "X-Api-Key: {{
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": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"name": &lt;string&gt;, "download_url": &lt;string&gt;,
"price_msat": &lt;integer&gt;, "producer_id": &lt;integer&gt;,
"producer_name": &lt;string&gt;}</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.base_url }}
livestream/api/v1/livestream/tracks -d '{"name": &lt;string&gt;,
"download_url": &lt;string&gt;, "price_msat": &lt;integer&gt;,
"producer_id": &lt;integer&gt;, "producer_name": &lt;string&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
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"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/livestream/api/v1/livestream/tracks/&lt;track_id&gt;</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">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.base_url }}
livestream/api/v1/livestream/tracks/&lt;track_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -1,323 +0,0 @@
{% 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
class="text-secondary"
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 class="text-secondary" :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 class="text-secondary" :href="'lightning:' + trackDialog.data.lnurl">
<q-responsive :ratio="1" class="q-mx-sm">
<qrcode
:value="'lightning:' + trackDialog.data.lnurl.toUpperCase()"
: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 %}

View File

@ -1,48 +0,0 @@
from http import HTTPStatus
from fastapi import Depends, HTTPException, Query, Request
from starlette.datastructures import URL
from starlette.responses import HTMLResponse, RedirectResponse
from lnbits.core.crud import get_wallet_payment
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import livestream_ext, livestream_renderer
from .crud import get_livestream_by_track, get_track
@livestream_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return livestream_renderer().TemplateResponse(
"livestream/index.html", {"request": request, "user": user.dict()}
)
@livestream_ext.get("/track/{track_id}", name="livestream.track_redirect_download")
async def track_redirect_download(track_id, p: str = Query(...)):
payment_hash = p
track = await get_track(track_id)
ls = await get_livestream_by_track(track_id)
assert ls
payment = await get_wallet_payment(ls.wallet, payment_hash)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Couldn't find the payment {payment_hash}.",
)
if not track:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Couldn't find the track {track_id}.",
)
if payment.pending:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute.",
)
assert track.download_url
return RedirectResponse(url=URL(track.download_url))

View File

@ -1,101 +0,0 @@
from http import HTTPStatus
from fastapi import Depends, HTTPException, Request
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from lnbits.decorators import WalletTypeInfo, get_key_type
from . import livestream_ext
from .crud import (
add_producer,
add_track,
delete_track_from_livestream,
get_or_create_livestream_by_wallet,
get_producers,
get_tracks,
update_current_track,
update_livestream_fee,
update_track,
)
from .models import CreateTrack
@livestream_ext.get("/api/v1/livestream")
async def api_livestream_from_wallet(
req: Request, g: WalletTypeInfo = Depends(get_key_type)
):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
assert ls
tracks = await get_tracks(ls.id)
producers = await get_producers(ls.id)
try:
return {
**ls.dict(),
**{
"lnurl": ls.lnurl(request=req),
"tracks": [
dict(lnurl=track.lnurl(request=req), **track.dict())
for track in tracks
],
"producers": [producer.dict() for producer in producers],
},
}
except LnurlInvalidUrl:
raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED,
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
)
@livestream_ext.put("/api/v1/livestream/track/{track_id}")
async def api_update_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
try:
id = int(track_id)
except ValueError:
id = 0
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
assert ls
await update_current_track(ls.id, None if id <= 0 else id)
return "", HTTPStatus.NO_CONTENT
@livestream_ext.put("/api/v1/livestream/fee/{fee_pct}")
async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
assert ls
await update_livestream_fee(ls.id, int(fee_pct))
return "", HTTPStatus.NO_CONTENT
@livestream_ext.post("/api/v1/livestream/tracks")
@livestream_ext.put("/api/v1/livestream/tracks/{id}")
async def api_add_track(
data: CreateTrack, id=None, g: WalletTypeInfo = Depends(get_key_type)
):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
assert ls
if data.producer_id:
p_id = int(data.producer_id)
elif data.producer_name:
p_id = await add_producer(ls.id, data.producer_name)
else:
raise TypeError("need either producer_id or producer_name arguments")
if id:
await update_track(
ls.id, id, data.name, data.download_url, data.price_msat or 0, p_id
)
else:
await add_track(ls.id, data.name, data.download_url, data.price_msat or 0, p_id)
return
@livestream_ext.delete("/api/v1/livestream/tracks/{track_id}")
async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
assert ls
await delete_track_from_livestream(ls.id, track_id)
return "", HTTPStatus.NO_CONTENT