uploader-bot/app/api/routes/content.py

580 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import datetime, timedelta
from sanic import response
from sqlalchemy import select, and_, func
from aiogram import Bot, types
from app.core.logger import make_log
from app.core.models._config import ServiceConfig
from app.core.models.node_storage import StoredContent
from app.core.models.keys import KnownKey
from app.core.models import StarsInvoice
from app.core.models.content.user_content import UserContent
from app.core._config import CLIENT_TELEGRAM_API_KEY, PROJECT_HOST
from app.core.models.content_v3 import EncryptedContent as ECv3, ContentDerivative as CDv3, UploadSession
from app.core.content.content_id import ContentId
import json
import uuid
async def s_api_v1_content_list(request):
offset = int(request.args.get('offset', 0))
limit = int(request.args.get('limit', 100))
assert 0 <= offset, "Invalid offset"
assert 0 < limit <= 1000, "Invalid limit"
store = request.args.get('store', 'local')
assert store in ('local', 'onchain'), "Invalid store"
stmt = (
select(StoredContent)
.where(
StoredContent.type.like(store + '%'),
StoredContent.disabled.is_(None)
)
.order_by(StoredContent.created.desc())
.offset(offset)
.limit(limit)
)
rows = (await request.ctx.db_session.execute(stmt)).scalars().all()
make_log("Content", f"Listed {len(rows)} contents", level='info')
result = {}
for content in rows:
content_json = content.json_format()
result[content_json["cid"]] = content_json
return response.json(result)
async def s_api_v1_content_view(request, content_address: str):
# content_address can be CID or TON address
license_exist = (await request.ctx.db_session.execute(
select(UserContent).where(UserContent.onchain_address == content_address)
)).scalars().first()
license_address = None
if license_exist:
license_address = license_exist.onchain_address
if license_exist.content_id:
linked_content = (await request.ctx.db_session.execute(
select(StoredContent).where(StoredContent.id == license_exist.content_id)
)).scalars().first()
if linked_content:
content_address = linked_content.cid.serialize_v2()
from app.core.content.content_id import ContentId
cid = ContentId.deserialize(content_address)
r_content = (await request.ctx.db_session.execute(
select(StoredContent).where(StoredContent.hash == cid.content_hash_b58)
)).scalars().first()
async def open_content_async(session, sc: StoredContent):
encrypted = sc if sc.encrypted else None
decrypted = sc if not sc.encrypted else None
if not sc.encrypted:
encrypted = (await session.execute(select(StoredContent).where(StoredContent.decrypted_content_id == sc.id))).scalars().first()
else:
decrypted = (await session.execute(select(StoredContent).where(StoredContent.id == sc.decrypted_content_id))).scalars().first()
if not encrypted:
raise AssertionError("Can't open content")
content_mime = None
if decrypted:
try:
content_mime = decrypted.json_format().get('content_type')
except Exception:
content_mime = None
if not content_mime:
meta = encrypted.meta or {}
content_mime = meta.get('content_type') or 'application/octet-stream'
try:
content_type = content_mime.split('/')[0]
except Exception:
content_type = 'application'
return {
'encrypted_content': encrypted,
'decrypted_content': decrypted,
'content_type': content_type,
'content_mime': content_mime,
}
content = await open_content_async(request.ctx.db_session, r_content)
encrypted_content = content['encrypted_content']
decrypted_content = content.get('decrypted_content')
content_mime = content.get('content_mime') or 'application/octet-stream'
is_audio = content_mime.startswith('audio/')
is_video = content_mime.startswith('video/')
content_kind = 'audio' if is_audio else ('video' if is_video else 'other')
master_address = encrypted_content.meta.get('item_address', '')
opts = {
'content_type': content_kind,
'content_kind': content_kind,
'content_mime': content_mime,
'content_address': license_address or master_address,
'license_address': license_address,
'master_address': master_address,
}
if encrypted_content.key_id:
known_key = (await request.ctx.db_session.execute(
select(KnownKey).where(KnownKey.id == encrypted_content.key_id)
)).scalars().first()
if known_key:
opts['key_hash'] = known_key.seed_hash # нахер не нужно на данный момент
# чисто болванки, заполнение дальше
opts['have_licenses'] = []
opts['invoice'] = None
have_access = False
if request.ctx.user:
user_wallet_address = await request.ctx.user.wallet_address_async(request.ctx.db_session)
have_access = (
(encrypted_content.owner_address == user_wallet_address)
or bool((await request.ctx.db_session.execute(select(UserContent).where(
and_(UserContent.owner_address == user_wallet_address, UserContent.status == 'active', UserContent.content_id == encrypted_content.id)
))).scalars().first()) \
or bool((await request.ctx.db_session.execute(select(StarsInvoice).where(
and_(
StarsInvoice.user_id == request.ctx.user.id,
StarsInvoice.content_hash == encrypted_content.hash,
StarsInvoice.paid == True
)
))).scalars().first())
)
if not have_access:
current_star_rate = (await ServiceConfig(request.ctx.db_session).get('live_tonPerStar', [0, 0]))[0]
if current_star_rate < 0:
current_star_rate = 0.00000001
stars_cost = int(int(encrypted_content.meta['license']['resale']['price']) / 1e9 / current_star_rate * 1.2)
if request.ctx.user.telegram_id in [5587262915, 6861699286]:
stars_cost = 2
invoice_id = f"access_{uuid.uuid4().hex}"
exist_invoice = (await request.ctx.db_session.execute(select(StarsInvoice).where(
and_(
StarsInvoice.user_id == request.ctx.user.id,
StarsInvoice.created > datetime.now() - timedelta(minutes=25),
StarsInvoice.amount == stars_cost,
StarsInvoice.content_hash == encrypted_content.hash,
)
))).scalars().first()
if exist_invoice:
invoice_url = exist_invoice.invoice_url
else:
invoice_url = None
try:
invoice_url = await Bot(token=CLIENT_TELEGRAM_API_KEY).create_invoice_link(
'Неограниченный доступ к контенту',
'Неограниченный доступ к контенту',
invoice_id, "XTR",
[
types.LabeledPrice(label='Lifetime access', amount=stars_cost),
], provider_token = ''
)
request.ctx.db_session.add(
StarsInvoice(
external_id=invoice_id,
type='access',
amount=stars_cost,
user_id=request.ctx.user.id,
content_hash=encrypted_content.hash,
invoice_url=invoice_url
)
)
await request.ctx.db_session.commit()
except BaseException as e:
make_log("Content", f"Can't create invoice link: {e}", level='warning')
if invoice_url:
opts['invoice'] = {
'url': invoice_url,
'amount': stars_cost,
}
display_options = {
'content_url': None,
'download_url': None,
'content_kind': content_kind,
'content_mime': content_mime,
}
if have_access:
opts['have_licenses'].append('listen')
enc_cid = encrypted_content.meta.get('content_cid') or encrypted_content.meta.get('encrypted_cid')
ec_v3 = None
derivative_rows = []
if enc_cid:
ec_v3 = (await request.ctx.db_session.execute(select(ECv3).where(ECv3.encrypted_cid == enc_cid))).scalars().first()
if ec_v3:
derivative_rows = (await request.ctx.db_session.execute(select(CDv3).where(CDv3.content_id == ec_v3.id))).scalars().all()
upload_row = None
if enc_cid:
upload_row = (await request.ctx.db_session.execute(select(UploadSession).where(UploadSession.encrypted_cid == enc_cid))).scalars().first()
converted_meta_map = dict(encrypted_content.meta.get('converted_content') or {})
derivative_latest = {}
if derivative_rows:
derivative_sorted = sorted(derivative_rows, key=lambda row: row.created_at or datetime.min)
for row in derivative_sorted:
derivative_latest[row.kind] = row
def _row_to_hash_and_url(row):
if not row or not row.local_path:
return None, None
file_hash = row.local_path.split('/')[-1]
return file_hash, f"{PROJECT_HOST}/api/v1.5/storage/{file_hash}"
preview_row = None
download_row = None
if have_access:
preview_priority = ['decrypted_low', 'decrypted_high'] if (is_audio or is_video) else []
download_priority = ['decrypted_high', 'decrypted_low']
if content_kind == 'other':
download_priority = ['decrypted_original']
for key in preview_priority:
if key in derivative_latest:
preview_row = derivative_latest[key]
break
for key in download_priority:
if key in derivative_latest:
download_row = derivative_latest[key]
break
else:
preview_priority = ['decrypted_preview', 'decrypted_low'] if (is_audio or is_video) else []
for key in preview_priority:
if key in derivative_latest:
preview_row = derivative_latest[key]
break
if preview_row:
file_hash, url = _row_to_hash_and_url(preview_row)
if url:
display_options['content_url'] = url
if preview_row.content_type and not opts.get('content_ext'):
opts['content_ext'] = (preview_row.content_type or '').split('/')[-1]
preview_map = {
'decrypted_low': 'low',
'decrypted_high': 'high',
'decrypted_preview': 'low_preview',
}
cache_key = preview_map.get(preview_row.kind)
if cache_key:
converted_meta_map.setdefault(cache_key, file_hash)
if download_row and have_access:
download_hash, download_url = _row_to_hash_and_url(download_row)
if download_url:
display_options['download_url'] = download_url
if download_row.content_type and not opts.get('content_ext'):
opts['content_ext'] = download_row.content_type.split('/')[-1]
download_map = {
'decrypted_high': 'high',
'decrypted_low': 'low',
'decrypted_original': 'original',
}
d_cache_key = download_map.get(download_row.kind)
if d_cache_key:
converted_meta_map.setdefault(d_cache_key, download_hash)
if not display_options['content_url'] and converted_meta_map:
preference = ['low', 'high', 'low_preview'] if have_access else ['low_preview', 'low', 'high']
for key in preference:
hash_value = converted_meta_map.get(key)
if not hash_value:
continue
stored = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == hash_value))).scalars().first()
if stored:
display_options['content_url'] = stored.web_url
if not opts.get('content_ext'):
opts['content_ext'] = stored.filename.split('.')[-1]
break
if have_access and not display_options['download_url'] and converted_meta_map:
download_keys = ['original', 'high', 'low']
for key in download_keys:
hash_value = converted_meta_map.get(key)
if not hash_value:
continue
stored = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == hash_value))).scalars().first()
if stored:
display_options['download_url'] = stored.web_url
if not opts.get('content_ext'):
opts['content_ext'] = stored.filename.split('.')[-1]
break
if not opts.get('content_ext'):
opts['content_ext'] = content_mime.split('/')[-1] if '/' in content_mime else None
# Metadata fallback
content_meta = encrypted_content.json_format()
content_metadata_json = None
_mcid = content_meta.get('metadata_cid') or None
if _mcid:
_cid = ContentId.deserialize(_mcid)
content_metadata = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == _cid.content_hash_b58))).scalars().first()
if content_metadata:
try:
with open(content_metadata.filepath, 'r') as f:
content_metadata_json = json.loads(f.read())
except Exception as exc:
make_log("Content", f"Can't read metadata file: {exc}", level='warning')
if not content_metadata_json:
fallback_name = (ec_v3.title if ec_v3 else None) or content_meta.get('title') or content_meta.get('cid')
fallback_description = (ec_v3.description if ec_v3 else '') or ''
fallback_artist = content_meta.get('artist') or None
content_metadata_json = {
'name': fallback_name or 'Без названия',
'title': fallback_name or 'Без названия',
'artist': fallback_artist,
'description': fallback_description,
'downloadable': False,
}
cover_cid = content_meta.get('cover_cid')
if cover_cid:
content_metadata_json.setdefault('image', f"{PROJECT_HOST}/api/v1.5/storage/{cover_cid}")
else:
if 'title' not in content_metadata_json or not content_metadata_json.get('title'):
content_metadata_json['title'] = content_metadata_json.get('name')
if 'artist' not in content_metadata_json:
inferred_artist = None
authors_list = content_metadata_json.get('authors')
if isinstance(authors_list, list) and authors_list:
inferred_artist = authors_list[0]
content_metadata_json['artist'] = inferred_artist
if content_metadata_json.get('artist') in ('', None):
content_metadata_json['artist'] = None
if not content_metadata_json.get('name'):
content_metadata_json['name'] = content_metadata_json.get('title') or 'Без названия'
if ec_v3 and not content_metadata_json.get('artist') and getattr(ec_v3, 'artist', None):
content_metadata_json['artist'] = ec_v3.artist
if ec_v3 and not content_metadata_json.get('title') and getattr(ec_v3, 'title', None):
content_metadata_json['title'] = ec_v3.title
display_title = content_metadata_json.get('title') or content_metadata_json.get('name') or 'Без названия'
display_artist = content_metadata_json.get('artist')
if display_artist:
content_metadata_json['display_name'] = f"{display_artist} {display_title}"
else:
content_metadata_json['display_name'] = display_title
content_metadata_json['mime_type'] = content_mime
if 'file_extension' not in content_metadata_json or not content_metadata_json.get('file_extension'):
try:
content_metadata_json['file_extension'] = content_mime.split('/')[1]
except Exception:
content_metadata_json['file_extension'] = None
content_metadata_json['content_kind'] = content_kind
display_options['metadata'] = content_metadata_json
display_options['is_preview_available'] = bool(display_options.get('content_url'))
display_options['is_download_available'] = bool(display_options.get('download_url'))
base_downloadable = content_metadata_json.get('downloadable', False)
if content_kind == 'other':
base_downloadable = True
opts['downloadable'] = bool(display_options.get('download_url')) and base_downloadable and have_access
# Conversion status summary
conversion_summary = {}
conversion_details = []
derivative_summary_map = {}
for row in derivative_latest.values():
conversion_summary[row.status] = conversion_summary.get(row.status, 0) + 1
derivative_summary_map[row.kind] = row
conversion_details.append({
'kind': row.kind,
'status': row.status,
'size_bytes': row.size_bytes,
'content_type': row.content_type,
'error': row.error,
'updated_at': (row.last_access_at or row.created_at).isoformat() + 'Z' if (row.last_access_at or row.created_at) else None,
})
effective_mime = (ec_v3.content_type if ec_v3 and ec_v3.content_type else content_mime) or ''
if effective_mime.startswith('audio/'):
required_kinds = {'decrypted_low', 'decrypted_high'}
elif effective_mime.startswith('video/'):
required_kinds = {'decrypted_low', 'decrypted_high', 'decrypted_preview'}
else:
required_kinds = {'decrypted_original'}
statuses_by_kind = {kind: row.status for kind, row in derivative_summary_map.items() if kind in required_kinds}
conversion_state = 'pending'
if required_kinds and all(statuses_by_kind.get(kind) == 'ready' for kind in required_kinds):
conversion_state = 'ready'
elif any(statuses_by_kind.get(kind) == 'failed' for kind in required_kinds):
conversion_state = 'failed'
elif any(statuses_by_kind.get(kind) in ('processing', 'pending') for kind in required_kinds):
conversion_state = 'processing'
elif statuses_by_kind:
conversion_state = 'partial'
if display_options['content_url'] or (content_kind == 'other' and display_options.get('download_url')):
conversion_state = 'ready'
upload_info = None
if upload_row:
upload_info = {
'id': upload_row.id,
'state': upload_row.state,
'error': upload_row.error,
'created_at': upload_row.created_at.isoformat() + 'Z' if upload_row.created_at else None,
'updated_at': upload_row.updated_at.isoformat() + 'Z' if upload_row.updated_at else None,
}
final_state = 'ready' if (display_options['content_url'] or (content_kind == 'other' and display_options.get('download_url'))) else None
if final_state != 'ready':
upload_state = upload_row.state if upload_row else None
if conversion_state == 'failed' or upload_state in ('failed', 'conversion_failed'):
final_state = 'failed'
elif conversion_state in ('processing', 'partial') or upload_state in ('processing', 'pinned'):
final_state = 'processing'
else:
final_state = 'uploaded'
conversion_info = {
'state': conversion_state,
'summary': conversion_summary,
'details': conversion_details,
'required_kinds': list(required_kinds),
}
opts['conversion'] = conversion_info
opts['upload'] = upload_info
opts['status'] = {
'state': final_state,
'conversion_state': conversion_state,
'upload_state': upload_info['state'] if upload_info else None,
}
encrypted_payload = encrypted_content.json_format()
if ec_v3:
encrypted_payload['artist'] = getattr(ec_v3, 'artist', None)
return response.json({
**opts,
'encrypted': encrypted_payload,
'display_options': display_options,
})
async def s_api_v1_content_friendly_list(request):
# return html table with content list. bootstrap is used
result = """
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</head>
<body>
<table class="table table-striped">
<thead>
<tr>
<th>CID</th>
<th>Title</th>
<th>Onchain</th>
<th>Preview link</th>
</tr>
</thead>
"""
contents = (await request.ctx.db_session.execute(select(StoredContent).where(
StoredContent.type == 'onchain/content'
))).scalars().all()
for content in contents:
if not content.meta.get('metadata_cid'):
make_log("Content", f"Content {content.cid.serialize_v2()} has no metadata", level='warning')
continue
from app.core.content.content_id import ContentId
_cid = ContentId.deserialize(content.meta.get('metadata_cid'))
metadata_content = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == _cid.content_hash_b58))).scalars().first()
with open(metadata_content.filepath, 'r') as f:
metadata = json.loads(f.read())
preview_link = None
if content.meta.get('converted_content'):
preview_link = f"{PROJECT_HOST}/api/v1.5/storage/{content.meta['converted_content']['low_preview']}"
result += f"""
<tr>
<td>{content.cid.serialize_v2()}</td>
<td>{metadata.get('name', "")}</td>
<td>{content.meta.get('item_address')}</td>
<td>""" + (f'<a href="{preview_link}">Preview</a>' if preview_link else "not ready") + """</td>
</tr>
"""
result += """
</table>
</body>
</html>
"""
return response.html(result)
async def s_api_v1_5_content_list(request):
# Validate offset and limit parameters
offset = int(request.args.get('offset', 0))
limit = int(request.args.get('limit', 100))
if offset < 0:
return response.json({'error': 'Invalid offset'}, status=400)
if limit <= 0 or limit > 1000:
return response.json({'error': 'Invalid limit'}, status=400)
# Query onchain contents which are not disabled
contents = (await request.ctx.db_session.execute(
select(StoredContent)
.where(StoredContent.type == 'onchain/content', StoredContent.disabled == False)
.order_by(StoredContent.created.desc())
.offset(offset).limit(limit)
)).scalars().all()
result = []
for content in contents:
# Retrieve metadata content using metadata_cid from content.meta
metadata_cid = content.meta.get('metadata_cid')
if not metadata_cid:
continue # Skip if no metadata_cid is found
from app.core.content.content_id import ContentId
_cid = ContentId.deserialize(metadata_cid)
metadata_content = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == _cid.content_hash_b58))).scalars().first()
try:
with open(metadata_content.filepath, 'r') as f:
metadata = json.load(f)
except Exception as e:
metadata = {}
media_type = 'audio'
# Get title from metadata (key 'name')
title = metadata.get('name', '')
# Build preview link if converted_content exists and contains 'low_preview'
preview_link = None
converted_content = content.meta.get('converted_content')
if converted_content:
converted_content = (await request.ctx.db_session.execute(select(StoredContent).where(
StoredContent.hash == converted_content['low_preview']
))).scalars().first()
preview_link = converted_content.web_url
if converted_content.filename.split('.')[-1] in ('mp4', 'mov'):
media_type = 'video'
else:
preview_link = None
# Get onchain address from content.meta
onchain_address = content.meta.get('item_address', '')
result.append({
'cid': content.cid.serialize_v2(),
'onchain_address': onchain_address,
'type': media_type,
'title': title,
'preview_link': preview_link,
'created_at': content.created.isoformat() # ISO 8601 format for datetime
})
return response.json(result)