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 = """
| CID | Title | Onchain | Preview link |
|---|---|---|---|
| {content.cid.serialize_v2()} | {metadata.get('name', "")} | {content.meta.get('item_address')} | """ + (f'Preview' if preview_link else "not ready") + """ |