mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-26 15:42:30 +01:00
commit
e084495814
8 changed files with 1115 additions and 628 deletions
62
lnbits/Pipfile
Normal file
62
lnbits/Pipfile
Normal file
|
@ -0,0 +1,62 @@
|
|||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
aiofiles = "==0.8.0"
|
||||
anyio = "==3.6.1"
|
||||
asyncio = "==3.4.3"
|
||||
attrs = "==21.4.0"
|
||||
bech32 = "==1.2.0"
|
||||
bitstring = "==3.1.9"
|
||||
cerberus = "==1.3.4"
|
||||
certifi = "==2022.6.15"
|
||||
cffi = "==1.15.0"
|
||||
click = "==8.1.3"
|
||||
ecdsa = "==0.18.0"
|
||||
embit = "==0.5.0"
|
||||
environs = "==9.5.0"
|
||||
fastapi = "==0.79.0"
|
||||
h11 = "==0.12.0"
|
||||
httpcore = "==0.15.0"
|
||||
httptools = "==0.4.0"
|
||||
httpx = "==0.23.0"
|
||||
idna = "==3.3"
|
||||
jinja2 = "==3.0.1"
|
||||
lnurl = "==0.3.6"
|
||||
loguru = "==0.6.0"
|
||||
markupsafe = "==2.1.1"
|
||||
marshmallow = "==3.17.0"
|
||||
outcome = "==1.2.0"
|
||||
psycopg2-binary = "==2.9.3"
|
||||
pycparser = "==2.21"
|
||||
pycryptodomex = "==3.15.0"
|
||||
pydantic = "==1.9.1"
|
||||
pyngrok = "==5.1.0"
|
||||
pyparsing = "==3.0.9"
|
||||
pypng = "==0.20220715.0"
|
||||
pyqrcode = "==1.2.1"
|
||||
pyscss = "==1.4.0"
|
||||
python-dotenv = "==0.20.0"
|
||||
pyyaml = "==6.0"
|
||||
represent = "==1.6.0.post0"
|
||||
rfc3986 = "==1.5.0"
|
||||
secp256k1 = "==0.14.0"
|
||||
shortuuid = "==1.0.9"
|
||||
six = "==1.16.0"
|
||||
sniffio = "==1.2.0"
|
||||
sqlalchemy-aio = "==0.17.0"
|
||||
sqlalchemy = "==1.3.23"
|
||||
sse-starlette = "==0.10.3"
|
||||
starlette = "==0.19.1"
|
||||
typing-extensions = "==4.3.0"
|
||||
uvicorn = "==0.18.2"
|
||||
uvloop = "==0.16.0"
|
||||
watchfiles = "==0.16.0"
|
||||
websockets = "==10.3"
|
||||
|
||||
[dev-packages]
|
||||
|
||||
[requires]
|
||||
python_version = "3.9"
|
|
@ -2,5 +2,5 @@
|
|||
"name": "Gerty",
|
||||
"short_description": "Desktop bitcoin Assistant",
|
||||
"icon": "sentiment_satisfied",
|
||||
"contributors": ["arcbtc"]
|
||||
"contributors": ["arcbtc", "blackcoffeebtc"]
|
||||
}
|
||||
|
|
|
@ -10,8 +10,16 @@ async def create_gerty(wallet_id: str, data: Gerty) -> Gerty:
|
|||
gerty_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO gerty.gertys (id, name, wallet, lnbits_wallets, mempool_endpoint, sats_quote, exchange, onchain_stats, ln_stats)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO gerty.gertys (
|
||||
id,
|
||||
name,
|
||||
wallet,
|
||||
lnbits_wallets,
|
||||
mempool_endpoint,
|
||||
exchange,
|
||||
display_preferences
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
gerty_id,
|
||||
|
@ -19,10 +27,8 @@ async def create_gerty(wallet_id: str, data: Gerty) -> Gerty:
|
|||
data.wallet,
|
||||
data.lnbits_wallets,
|
||||
data.mempool_endpoint,
|
||||
data.sats_quote,
|
||||
data.exchange,
|
||||
data.onchain_stats,
|
||||
data.ln_stats,
|
||||
data.display_preferences
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial gertys table.
|
||||
Initial Gertys table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE gerty.gertys (
|
||||
id TEXT PRIMARY KEY,
|
||||
refresh_time INT,
|
||||
name TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
lnbits_wallets TEXT,
|
||||
mempool_endpoint TEXT,
|
||||
sats_quote BOOL,
|
||||
exchange TEXT,
|
||||
onchain_stats BOOL,
|
||||
ln_stats BOOL
|
||||
display_preferences TEXT
|
||||
);
|
||||
"""
|
||||
)
|
|
@ -4,18 +4,15 @@ from typing import Optional
|
|||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
|
||||
class Gerty(BaseModel):
|
||||
id: str = Query(None)
|
||||
name: str
|
||||
wallet: str
|
||||
refresh_time: int = Query(None)
|
||||
lnbits_wallets: str = Query(None) # Wallets to keep an eye on, {"wallet-id": "wallet-read-key, etc"}
|
||||
mempool_endpoint: str = Query(None) # Mempool endpoint to use
|
||||
sats_quote: bool = Query(False) # Fetch Satoshi quotes
|
||||
exchange: str = Query(None) # BTC <-> Fiat exchange rate to pull ie "USD", in 0.0001 and sats
|
||||
onchain_stats: bool = Query(False) # Onchain stats
|
||||
ln_stats: bool = Query(False) # ln Sats
|
||||
display_preferences: str = Query(None)
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Gerty":
|
||||
|
|
14
lnbits/extensions/gerty/static/pieter_wuille.json
Normal file
14
lnbits/extensions/gerty/static/pieter_wuille.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"facts": [
|
||||
"When a woman asked Pieter Wuille to talk dirty to her, he described the OpenSSL DER implementation.",
|
||||
"Pieter Wuille recently visited an event horizon and escaped with a cryptographic proof.",
|
||||
"Pieter Wuille's PhD thesis defence in full: \"Pieter Wuille, thank you\".",
|
||||
"Pieter Wuille is an acronym for Programmatic Intelligent Encrypted Telemetric Encapsulated Recursive Witness Upscaling Integrated Load-Balancing Logical Entity.",
|
||||
"Dan Bernstein only trusts one source of random numbers: Pieter Wuille.",
|
||||
"Putting Pieter Wuille in the title of an r/Bitcoin submission gets more upvotes than the same post from Pieter Wuille himself.",
|
||||
"Pieter Wuille won the underhanded crypto contest but his entry was so underhanded nobody even knows he entered.",
|
||||
"Greg Maxwell is a bot created by Pieter Wuille to argue on reddit while he can get code done.",
|
||||
"Pieter Wuille doesn't need the public key to calculate the corresponding private key.",
|
||||
"When the Wikipedia servers corrupted all data including backups, Pieter Wuille had to stay awake all night to retype it."
|
||||
]
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -1,15 +1,17 @@
|
|||
import math
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import httpx
|
||||
import random
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from lnurl import decode as decode_lnurl
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
|
||||
from lnbits.core.crud import get_wallet_for_key
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import create_invoice
|
||||
|
@ -27,7 +29,7 @@ from ...settings import LNBITS_PATH
|
|||
|
||||
@gerty_ext.get("/api/v1/gerty", status_code=HTTPStatus.OK)
|
||||
async def api_gertys(
|
||||
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
|
@ -39,9 +41,9 @@ async def api_gertys(
|
|||
@gerty_ext.post("/api/v1/gerty", status_code=HTTPStatus.CREATED)
|
||||
@gerty_ext.put("/api/v1/gerty/{gerty_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_create_or_update(
|
||||
data: Gerty,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
gerty_id: str = Query(None),
|
||||
data: Gerty,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
gerty_id: str = Query(None),
|
||||
):
|
||||
if gerty_id:
|
||||
gerty = await get_gerty(gerty_id)
|
||||
|
@ -63,9 +65,10 @@ async def api_link_create_or_update(
|
|||
|
||||
return {**gerty.dict()}
|
||||
|
||||
|
||||
@gerty_ext.delete("/api/v1/gerty/{gerty_id}")
|
||||
async def api_gerty_delete(
|
||||
gerty_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
gerty_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
|
||||
|
@ -83,83 +86,271 @@ async def api_gerty_delete(
|
|||
|
||||
#######################
|
||||
|
||||
with open(os.path.join(LNBITS_PATH, 'extensions/gerty/static/satoshi.json')) as fd:
|
||||
satoshiQuotes = json.load(fd)
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/satoshiquote", status_code=HTTPStatus.OK)
|
||||
async def api_gerty_satoshi():
|
||||
with open(os.path.join(LNBITS_PATH, 'extensions/gerty/static/satoshi.json')) as fd:
|
||||
satoshiQuotes = json.load(fd)
|
||||
return satoshiQuotes[random.randint(0, 100)]
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/{gerty_id}")
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/pieterwielliequote", status_code=HTTPStatus.OK)
|
||||
async def api_gerty_wuille():
|
||||
with open(os.path.join(LNBITS_PATH, 'extensions/gerty/static/pieter_wuille.json')) as fd:
|
||||
data = json.load(fd)
|
||||
return data['facts'][random.randint(0, (len(data['facts']) - 1))]
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/{gerty_id}/{p}")
|
||||
async def api_gerty_json(
|
||||
gerty_id: str
|
||||
gerty_id: str,
|
||||
p: int = None # page number
|
||||
):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
logger.debug(gerty.wallet)
|
||||
|
||||
if not gerty:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
|
||||
)
|
||||
gertyReturn = []
|
||||
|
||||
display_preferences = json.loads(gerty.display_preferences)
|
||||
|
||||
enabled_screen_count = 0
|
||||
|
||||
enabled_screens = []
|
||||
|
||||
for screen_slug in display_preferences:
|
||||
is_screen_enabled = display_preferences[screen_slug]
|
||||
if is_screen_enabled:
|
||||
enabled_screen_count += 1
|
||||
enabled_screens.append(screen_slug)
|
||||
|
||||
text = await get_screen_text(p, enabled_screens, gerty)
|
||||
|
||||
next_screen_number = 0 if ((p + 1) >= enabled_screen_count) else p + 1;
|
||||
|
||||
# ln = []
|
||||
# if gerty.ln_stats and isinstance(gerty.mempool_endpoint, str):
|
||||
# async with httpx.AsyncClient() as client:
|
||||
# r = await client.get(gerty.mempool_endpoint + "/api/v1/lightning/statistics/latest")
|
||||
# if r:
|
||||
# ln.append(r.json())
|
||||
|
||||
return {
|
||||
"settings": {
|
||||
"refreshTime": gerty.refresh_time,
|
||||
"requestTimestamp": round(time.time()),
|
||||
"nextScreenNumber": next_screen_number,
|
||||
"showTextBoundRect": True,
|
||||
"name": gerty.name
|
||||
},
|
||||
"screen": {
|
||||
"slug": get_screen_slug_by_index(p, enabled_screens),
|
||||
"group": get_screen_slug_by_index(p, enabled_screens),
|
||||
"text": text
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Get a screen slug by its position in the screens_list
|
||||
def get_screen_slug_by_index(index: int, screens_list):
|
||||
return list(screens_list)[index]
|
||||
|
||||
|
||||
# Get a list of text items for the screen number
|
||||
async def get_screen_text(screen_num: int, screens_list: dict, gerty):
|
||||
screen_slug = get_screen_slug_by_index(screen_num, screens_list)
|
||||
# first get the relevant slug from the display_preferences
|
||||
logger.debug('screen_slug')
|
||||
logger.debug(screen_slug)
|
||||
# text = []
|
||||
if screen_slug == "lnbits_wallets_balance":
|
||||
text = await get_lnbits_wallet_balances(gerty)
|
||||
elif screen_slug == "fun_satoshi_quotes":
|
||||
text = await get_satoshi_quotes()
|
||||
elif screen_slug == "fun_pieter_wuille_facts":
|
||||
text = await get_pieter_wuille_fact()
|
||||
elif screen_slug == "fun_exchange_market_rate":
|
||||
text = await get_exchange_rate(gerty)
|
||||
elif screen_slug == "onchain_difficulty_epoch_progress":
|
||||
text = await get_onchain_stat(screen_slug, gerty)
|
||||
elif screen_slug == "onchain_difficulty_retarget_date":
|
||||
text = await get_onchain_stat(screen_slug, gerty)
|
||||
elif screen_slug == "onchain_difficulty_blocks_remaining":
|
||||
text = await get_onchain_stat(screen_slug, gerty)
|
||||
elif screen_slug == "onchain_difficulty_epoch_time_remaining":
|
||||
text = await get_onchain_stat(screen_slug, gerty)
|
||||
elif screen_slug == "mempool_recommended_fees":
|
||||
text = await get_placeholder_text()
|
||||
elif screen_slug == "mempool_tx_count":
|
||||
text = await get_mempool_stat(screen_slug, gerty)
|
||||
elif screen_slug == "mining_current_hash_rate":
|
||||
text = await get_placeholder_text()
|
||||
elif screen_slug == "mining_current_difficulty":
|
||||
text = await get_placeholder_text()
|
||||
elif screen_slug == "lightning_channel_count":
|
||||
text = await get_placeholder_text()
|
||||
elif screen_slug == "lightning_node_count":
|
||||
text = await get_placeholder_text()
|
||||
elif screen_slug == "lightning_tor_node_count":
|
||||
text = await get_placeholder_text()
|
||||
elif screen_slug == "lightning_clearnet_nodes":
|
||||
text = await get_placeholder_text()
|
||||
elif screen_slug == "lightning_unannounced_nodes":
|
||||
text = await get_placeholder_text()
|
||||
elif screen_slug == "lightning_average_channel_capacity":
|
||||
text = await get_placeholder_text()
|
||||
return text
|
||||
|
||||
|
||||
async def get_lnbits_wallet_balances(gerty):
|
||||
# Get Wallet info
|
||||
wallets = []
|
||||
text = []
|
||||
if gerty.lnbits_wallets != "":
|
||||
for lnbits_wallet in json.loads(gerty.lnbits_wallets):
|
||||
|
||||
wallet = await get_wallet_for_key(key=lnbits_wallet)
|
||||
logger.debug(wallet)
|
||||
if wallet:
|
||||
wallets.append({
|
||||
"name": wallet.name,
|
||||
"balance": wallet.balance_msat,
|
||||
"inkey": wallet.inkey,
|
||||
})
|
||||
text.append(get_text_item_dict(wallet.name, 20))
|
||||
text.append(get_text_item_dict(wallet.balance, 40))
|
||||
return text
|
||||
|
||||
#Get Satoshi quotes
|
||||
satoshi = []
|
||||
if gerty.sats_quote:
|
||||
quote = await api_gerty_satoshi()
|
||||
if quote:
|
||||
satoshi.append(await api_gerty_satoshi())
|
||||
|
||||
#Get Exchange Value
|
||||
exchange = []
|
||||
async def get_placeholder_text():
|
||||
return [
|
||||
get_text_item_dict("Some placeholder text", 15, 10, 50),
|
||||
get_text_item_dict("Some placeholder text", 15, 10, 50)
|
||||
]
|
||||
|
||||
|
||||
async def get_satoshi_quotes():
|
||||
# Get Satoshi quotes
|
||||
text = []
|
||||
quote = await api_gerty_satoshi()
|
||||
if quote:
|
||||
if quote['text']:
|
||||
text.append(get_text_item_dict(quote['text'], 15))
|
||||
if quote['date']:
|
||||
text.append(get_text_item_dict(quote['date'], 15))
|
||||
return text
|
||||
|
||||
|
||||
async def get_pieter_wuille_fact():
|
||||
text = []
|
||||
quote = await api_gerty_wuille()
|
||||
if quote:
|
||||
text.append(get_text_item_dict(quote, 15))
|
||||
text.append(get_text_item_dict("Pieter Wuille facts", 15))
|
||||
return text
|
||||
|
||||
|
||||
# Get Exchange Value
|
||||
async def get_exchange_rate(gerty):
|
||||
text = []
|
||||
if gerty.exchange != "":
|
||||
try:
|
||||
amount = await satoshis_amount_as_fiat(100000000, gerty.exchange)
|
||||
if amount:
|
||||
exchange.append({
|
||||
"fiat": gerty.exchange,
|
||||
"amount": amount,
|
||||
})
|
||||
price = ('{0} {1}').format(format_number(amount), gerty.exchange)
|
||||
text.append(get_text_item_dict("Current BTC price", 15))
|
||||
text.append(get_text_item_dict(price, 80))
|
||||
except:
|
||||
pass
|
||||
return text
|
||||
|
||||
onchain = []
|
||||
if gerty.onchain_stats and isinstance(gerty.mempool_endpoint, str):
|
||||
|
||||
# A helper function get a nicely formated dict for the text
|
||||
def get_text_item_dict(text: str, font_size: int, x_pos: int = None, y_pos: int = None):
|
||||
text = {
|
||||
"value": text,
|
||||
"size": font_size
|
||||
}
|
||||
if x_pos is None and y_pos is None:
|
||||
text['position'] = 'center'
|
||||
else:
|
||||
text['x'] = x_pos
|
||||
text['y'] = y_pos
|
||||
return text
|
||||
|
||||
|
||||
async def get_onchain_stat(stat_slug: str, gerty):
|
||||
text = []
|
||||
if isinstance(gerty.mempool_endpoint, str):
|
||||
async with httpx.AsyncClient() as client:
|
||||
difficulty = []
|
||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/difficulty-adjustment")
|
||||
if r:
|
||||
difficulty.append(r.json())
|
||||
onchain.append({"difficulty":difficulty})
|
||||
mempool = []
|
||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/fees/mempool-blocks")
|
||||
if r:
|
||||
mempool.append(r.json())
|
||||
onchain.append({"mempool":mempool})
|
||||
threed = []
|
||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/mining/hashrate/3d")
|
||||
if r:
|
||||
threed.append(r.json())
|
||||
onchain.append({"threed":threed})
|
||||
if (
|
||||
stat_slug == "onchain_difficulty_epoch_progress" or
|
||||
stat_slug == "onchain_difficulty_retarget_date" or
|
||||
stat_slug == "onchain_difficulty_blocks_remaining" or
|
||||
stat_slug == "onchain_difficulty_epoch_time_remaining"
|
||||
):
|
||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/difficulty-adjustment")
|
||||
if stat_slug == "onchain_difficulty_epoch_progress":
|
||||
stat = round(r.json()['progressPercent'])
|
||||
text.append(get_text_item_dict("Progress through current difficulty epoch", 15))
|
||||
text.append(get_text_item_dict("{0}%".format(stat), 80))
|
||||
elif stat_slug == "onchain_difficulty_retarget_date":
|
||||
stat = r.json()['estimatedRetargetDate']
|
||||
dt = datetime.fromtimestamp(stat / 1000).strftime("%e %b %Y at %H:%M")
|
||||
text.append(get_text_item_dict("Estimated date of next difficulty adjustment", 15))
|
||||
text.append(get_text_item_dict(dt, 40))
|
||||
elif stat_slug == "onchain_difficulty_blocks_remaining":
|
||||
stat = r.json()['remainingBlocks']
|
||||
text.append(get_text_item_dict("Blocks remaining until next difficulty adjustment", 15))
|
||||
text.append(get_text_item_dict("{0}".format(format_number(stat)), 80))
|
||||
elif stat_slug == "onchain_difficulty_epoch_time_remaining":
|
||||
stat = r.json()['remainingTime']
|
||||
text.append(get_text_item_dict("Blocks remaining until next difficulty adjustment", 15))
|
||||
text.append(get_text_item_dict(get_time_remaining(stat / 1000, 4), 20))
|
||||
return text
|
||||
|
||||
ln = []
|
||||
if gerty.ln_stats and isinstance(gerty.mempool_endpoint, str):
|
||||
async def get_mempool_stat(stat_slug: str, gerty):
|
||||
text = []
|
||||
if isinstance(gerty.mempool_endpoint, str):
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/lightning/statistics/latest")
|
||||
if r:
|
||||
ln.append(r.json())
|
||||
if (
|
||||
stat_slug == "mempool_tx_count"
|
||||
):
|
||||
r = await client.get(gerty.mempool_endpoint + "/api/mempool")
|
||||
if stat_slug == "mempool_tx_count":
|
||||
stat = round(r.json()['count'])
|
||||
text.append(get_text_item_dict("Transactions in the mempool", 15))
|
||||
text.append(get_text_item_dict("{0}".format(format_number(stat)), 80))
|
||||
return text
|
||||
|
||||
return {"name":gerty.name, "wallets":wallets, "sats_quote":satoshi, "exchange":exchange, "onchain":onchain, "ln":ln}
|
||||
def get_date_suffix(dayNumber):
|
||||
if 4 <= dayNumber <= 20 or 24 <= dayNumber <= 30:
|
||||
return "th"
|
||||
else:
|
||||
return ["st", "nd", "rd"][dayNumber % 10 - 1]
|
||||
|
||||
# format a number for nice display output
|
||||
def format_number(number):
|
||||
return ("{:,}".format(round(number)))
|
||||
|
||||
|
||||
def get_time_remaining(seconds, granularity=2):
|
||||
|
||||
intervals = (
|
||||
('weeks', 604800), # 60 * 60 * 24 * 7
|
||||
('days', 86400), # 60 * 60 * 24
|
||||
('hours', 3600), # 60 * 60
|
||||
('minutes', 60),
|
||||
('seconds', 1),
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
for name, count in intervals:
|
||||
value = seconds // count
|
||||
if value:
|
||||
seconds -= value * count
|
||||
if value == 1:
|
||||
name = name.rstrip('s')
|
||||
result.append("{} {}".format(round(value), name))
|
||||
return ', '.join(result[:granularity])
|
Loading…
Add table
Reference in a new issue