diff --git a/.gitignore b/.gitignore index 6c6c783..3b1fb24 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ venv logs sqlStorage playground -alembic.ini .DS_Store messages.pot activeConfig diff --git a/alembic/env.py b/alembic/env.py index b50e221..38fea83 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,3 +1,4 @@ +import os from logging.config import fileConfig from sqlalchemy import engine_from_config @@ -7,6 +8,10 @@ from alembic import context config = context.config +database_url = os.environ.get("DATABASE_URL") +if database_url: + config.set_main_option("sqlalchemy.url", database_url) + if config.config_file_name is not None: fileConfig(config.config_file_name) diff --git a/app/api/routes/_blockchain.py b/app/api/routes/_blockchain.py index ce9e61d..ffcee89 100644 --- a/app/api/routes/_blockchain.py +++ b/app/api/routes/_blockchain.py @@ -56,6 +56,15 @@ async def s_api_v1_blockchain_send_new_content_message(request): assert field_key in request.json, f"No {field_key} provided" assert field_value(request.json[field_key]), f"Invalid {field_key} provided" + artist = request.json.get('artist') + if artist is not None: + assert isinstance(artist, str), "Invalid artist provided" + artist = artist.strip() + if artist == "": + artist = None + else: + artist = None + # Support legacy: 'content' as decrypted ContentId; and new: 'content' as encrypted IPFS CID source_content_cid, cid_err = resolve_content(request.json['content']) assert not cid_err, f"Invalid content CID provided: {cid_err}" @@ -85,11 +94,16 @@ async def s_api_v1_blockchain_send_new_content_message(request): image_content = None - content_title = f"{', '.join(request.json['authors'])} – {request.json['title']}" if request.json['authors'] else request.json['title'] + content_title = request.json['title'] + if artist: + content_title = f"{artist} – {content_title}" + elif request.json['authors']: + content_title = f"{', '.join(request.json['authors'])} – {request.json['title']}" metadata_content = await create_metadata_for_item( request.ctx.db_session, - title=content_title, + title=request.json['title'], + artist=artist, cover_url=f"{PROJECT_HOST}/api/v1.5/storage/{image_content_cid.serialize_v2()}" if image_content_cid else None, authors=request.json['authors'], hashtags=request.json['hashtags'], diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 6a6bc82..6b1b196 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -181,11 +181,14 @@ def _storage_download_url(file_hash: Optional[str]) -> Optional[str]: def _pick_primary_download(candidates: List[tuple[str, Optional[str], Optional[int]]]) -> Optional[str]: priority = ( 'decrypted_high', + 'decrypted_original', 'decrypted_low', 'decrypted_preview', 'high', 'low', 'preview', + 'original', + 'stored', ) for target in priority: for kind, url, _ in candidates: @@ -656,6 +659,7 @@ async def s_api_v1_admin_uploads(request): } search_parts: List[Any] = [ + content.artist, content.title, content.description, content.encrypted_cid, @@ -697,6 +701,7 @@ async def s_api_v1_admin_uploads(request): 'metadata_cid': metadata_cid, 'content_hash': content_hash, 'title': content.title, + 'artist': content.artist, 'description': content.description, 'content_type': content.content_type, 'size': { @@ -1342,10 +1347,21 @@ async def s_api_v1_admin_licenses(request): metadata_candidate = stored_meta.get('metadata') if isinstance(stored_meta.get('metadata'), dict) else {} title_candidates = [ stored_meta.get('title'), + metadata_candidate.get('title') if isinstance(metadata_candidate, dict) else None, metadata_candidate.get('name') if isinstance(metadata_candidate, dict) else None, stored_meta.get('license', {}).get('title') if isinstance(stored_meta.get('license'), dict) else None, ] title_value = next((value for value in title_candidates if isinstance(value, str) and value.strip()), None) + artist_candidates = [] + if isinstance(metadata_candidate, dict): + artist_candidates.extend([ + metadata_candidate.get('artist'), + (metadata_candidate.get('authors') or [None])[0] if isinstance(metadata_candidate.get('authors'), list) else None, + ]) + artist_candidates.extend([ + stored_meta.get('artist'), + ]) + artist_value = next((value for value in artist_candidates if isinstance(value, str) and value.strip()), None) try: cid_value = stored_content.cid.serialize_v2() except Exception: @@ -1354,7 +1370,8 @@ async def s_api_v1_admin_licenses(request): 'id': stored_content.id, 'hash': stored_content.hash, 'cid': cid_value, - 'title': title_value or stored_content.hash, + 'title': (title_value or stored_content.hash), + 'artist': artist_value, 'type': stored_content.type, 'owner_address': stored_content.owner_address, 'onchain_index': stored_content.onchain_index, @@ -1773,11 +1790,17 @@ async def s_api_v1_admin_status(request): ec = (await session.execute(select(EncryptedContent))).scalars().all() backlog = 0 for e in ec: - if not e.preview_enabled: + ctype = (e.content_type or '').lower() + if ctype.startswith('audio/'): + req = {'decrypted_low', 'decrypted_high'} + elif ctype.startswith('video/'): + req = {'decrypted_low', 'decrypted_high', 'decrypted_preview'} + else: + req = {'decrypted_original'} + if not req: continue - kinds = [d.kind for d in deriv if d.content_id == e.id and d.status == 'ready'] - req = {'decrypted_low', 'decrypted_high', 'decrypted_preview'} - if not req.issubset(set(kinds)): + kinds = {d.kind for d in deriv if d.content_id == e.id and d.status == 'ready'} + if not req.issubset(kinds): backlog += 1 try: bs = await bitswap_stat() diff --git a/app/api/routes/content.py b/app/api/routes/content.py index eeedc12..4b7a0cc 100644 --- a/app/api/routes/content.py +++ b/app/api/routes/content.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta from sanic import response from sqlalchemy import select, and_, func from aiogram import Bot, types -from sqlalchemy import and_ from app.core.logger import make_log from app.core.models._config import ServiceConfig from app.core.models.node_storage import StoredContent @@ -67,31 +66,53 @@ async def s_api_v1_content_view(request, content_address: str): 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: - decrypted = sc encrypted = (await session.execute(select(StoredContent).where(StoredContent.decrypted_content_id == sc.id))).scalars().first() else: - encrypted = sc decrypted = (await session.execute(select(StoredContent).where(StoredContent.id == sc.decrypted_content_id))).scalars().first() - assert decrypted and encrypted, "Can't open content" - ctype = decrypted.json_format().get('content_type', 'application/x-binary') + 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 = ctype.split('/')[0] + content_type = content_mime.split('/')[0] except Exception: content_type = 'application' - return {'encrypted_content': encrypted, 'decrypted_content': decrypted, 'content_type': content_type} + 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 = content['encrypted_content'].meta.get('item_address', '') + master_address = encrypted_content.meta.get('item_address', '') opts = { - 'content_type': content['content_type'], # возможно с ошибками, нужно переделать на ffprobe + '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 content['encrypted_content'].key_id: + if encrypted_content.key_id: known_key = (await request.ctx.db_session.execute( - select(KnownKey).where(KnownKey.id == content['encrypted_content'].key_id) + select(KnownKey).where(KnownKey.id == encrypted_content.key_id) )).scalars().first() if known_key: opts['key_hash'] = known_key.seed_hash # нахер не нужно на данный момент @@ -104,14 +125,14 @@ async def s_api_v1_content_view(request, content_address: str): if request.ctx.user: user_wallet_address = await request.ctx.user.wallet_address_async(request.ctx.db_session) have_access = ( - (content['encrypted_content'].owner_address == user_wallet_address) + (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 == content['encrypted_content'].id) + 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 == content['encrypted_content'].hash, + StarsInvoice.content_hash == encrypted_content.hash, StarsInvoice.paid == True ) ))).scalars().first()) @@ -122,7 +143,7 @@ async def s_api_v1_content_view(request, content_address: str): if current_star_rate < 0: current_star_rate = 0.00000001 - stars_cost = int(int(content['encrypted_content'].meta['license']['resale']['price']) / 1e9 / current_star_rate * 1.2) + 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 @@ -132,7 +153,7 @@ async def s_api_v1_content_view(request, content_address: str): StarsInvoice.user_id == request.ctx.user.id, StarsInvoice.created > datetime.now() - timedelta(minutes=25), StarsInvoice.amount == stars_cost, - StarsInvoice.content_hash == content['encrypted_content'].hash, + StarsInvoice.content_hash == encrypted_content.hash, ) ))).scalars().first() if exist_invoice: @@ -154,7 +175,7 @@ async def s_api_v1_content_view(request, content_address: str): type='access', amount=stars_cost, user_id=request.ctx.user.id, - content_hash=content['encrypted_content'].hash, + content_hash=encrypted_content.hash, invoice_url=invoice_url ) ) @@ -168,12 +189,17 @@ async def s_api_v1_content_view(request, content_address: str): 'amount': stars_cost, } - display_options = {'content_url': None} + 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 = content['encrypted_content'].meta.get('content_cid') or content['encrypted_content'].meta.get('encrypted_cid') + enc_cid = encrypted_content.meta.get('content_cid') or encrypted_content.meta.get('encrypted_cid') ec_v3 = None derivative_rows = [] if enc_cid: @@ -185,7 +211,7 @@ async def s_api_v1_content_view(request, content_address: str): 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(content['encrypted_content'].meta.get('converted_content') or {}) + converted_meta_map = dict(encrypted_content.meta.get('converted_content') or {}) derivative_latest = {} if derivative_rows: @@ -199,24 +225,57 @@ async def s_api_v1_content_view(request, content_address: str): file_hash = row.local_path.split('/')[-1] return file_hash, f"{PROJECT_HOST}/api/v1.5/storage/{file_hash}" - chosen_row = None + preview_row = None + download_row = None if have_access: - for key in ('decrypted_low', 'decrypted_high'): + 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: - chosen_row = derivative_latest[key] + preview_row = derivative_latest[key] + break + for key in download_priority: + if key in derivative_latest: + download_row = derivative_latest[key] break else: - for key in ('decrypted_preview', 'decrypted_low'): + preview_priority = ['decrypted_preview', 'decrypted_low'] if (is_audio or is_video) else [] + for key in preview_priority: if key in derivative_latest: - chosen_row = derivative_latest[key] + preview_row = derivative_latest[key] break - if chosen_row: - file_hash, url = _row_to_hash_and_url(chosen_row) + if preview_row: + file_hash, url = _row_to_hash_and_url(preview_row) if url: display_options['content_url'] = url - opts['content_ext'] = (chosen_row.content_type or '').split('/')[-1] if chosen_row.content_type else None - converted_meta_map.setdefault('low' if have_access else 'low_preview', file_hash) + 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'] @@ -227,11 +286,28 @@ async def s_api_v1_content_view(request, content_address: str): 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 - opts['content_ext'] = stored.filename.split('.')[-1] + 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 = content['encrypted_content'].json_format() + content_meta = encrypted_content.json_format() content_metadata_json = None _mcid = content_meta.get('metadata_cid') or None if _mcid: @@ -247,20 +323,58 @@ async def s_api_v1_content_view(request, content_address: str): 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')) - opts['downloadable'] = content_metadata_json.get('downloadable', False) - if opts['downloadable'] and 'listen' not in opts['have_licenses']: - opts['downloadable'] = False + 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 = {} @@ -278,9 +392,13 @@ async def s_api_v1_content_view(request, content_address: str): 'updated_at': (row.last_access_at or row.created_at).isoformat() + 'Z' if (row.last_access_at or row.created_at) else None, }) - required_kinds = {'decrypted_low', 'decrypted_high'} - if ec_v3 and ec_v3.content_type.startswith('video/'): - required_kinds.add('decrypted_preview') + 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' @@ -293,7 +411,7 @@ async def s_api_v1_content_view(request, content_address: str): elif statuses_by_kind: conversion_state = 'partial' - if display_options['content_url']: + if display_options['content_url'] or (content_kind == 'other' and display_options.get('download_url')): conversion_state = 'ready' upload_info = None @@ -306,7 +424,7 @@ async def s_api_v1_content_view(request, content_address: str): 'updated_at': upload_row.updated_at.isoformat() + 'Z' if upload_row.updated_at else None, } - final_state = 'ready' if display_options['content_url'] 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'): @@ -331,9 +449,12 @@ async def s_api_v1_content_view(request, content_address: str): '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': content['encrypted_content'].json_format(), + 'encrypted': encrypted_payload, 'display_options': display_options, }) diff --git a/app/api/routes/upload_status.py b/app/api/routes/upload_status.py index e8cc0a1..a1f50e9 100644 --- a/app/api/routes/upload_status.py +++ b/app/api/routes/upload_status.py @@ -27,9 +27,12 @@ async def s_api_v1_upload_status(request, upload_id: str): {"kind": kind, "status": status} for kind, status in derivative_rows ] - required = {"decrypted_high", "decrypted_low"} - if ec.preview_enabled and ec.content_type.startswith("video/"): - required.add("decrypted_preview") + if ec.content_type and ec.content_type.startswith("audio/"): + required = {"decrypted_high", "decrypted_low"} + elif ec.content_type and ec.content_type.startswith("video/"): + required = {"decrypted_high", "decrypted_low", "decrypted_preview"} + else: + required = {"decrypted_original"} statuses = {kind: status for kind, status in derivative_rows} if required and all(statuses.get(k) == "ready" for k in required): conv_state = "ready" diff --git a/app/api/routes/upload_tus.py b/app/api/routes/upload_tus.py index 27a4e77..e947b25 100644 --- a/app/api/routes/upload_tus.py +++ b/app/api/routes/upload_tus.py @@ -69,6 +69,7 @@ async def s_api_v1_upload_tus_hook(request): meta = upload.get("MetaData") or {} # Common metadata keys title = meta.get("title") or meta.get("Title") or meta.get("name") or "Untitled" + artist = (meta.get("artist") or meta.get("Artist") or "").strip() description = meta.get("description") or meta.get("Description") or "" content_type = meta.get("content_type") or meta.get("Content-Type") or "application/octet-stream" preview_enabled = content_type.startswith("audio/") or content_type.startswith("video/") @@ -155,6 +156,7 @@ async def s_api_v1_upload_tus_hook(request): ec = EncryptedContent( encrypted_cid=encrypted_cid, title=title, + artist=artist or None, description=description, content_type=content_type, enc_size_bytes=enc_size, @@ -196,7 +198,9 @@ async def s_api_v1_upload_tus_hook(request): 'storage': 'ipfs', 'encrypted_cid': encrypted_cid, 'upload_id': upload_id, - 'source': 'tusd' + 'source': 'tusd', + 'title': title, + 'artist': artist or None, } encrypted_stored_content = StoredContent( type="local/encrypted_ipfs", @@ -218,12 +222,14 @@ async def s_api_v1_upload_tus_hook(request): "encrypted_cid": encrypted_cid, "title": title, "description": description, + "artist": artist, "content_type": content_type, "size_bytes": enc_size, "preview_enabled": preview_enabled, "preview_conf": ec.preview_conf, "issuer_node_id": key_fpr, "salt_b64": _b64(salt), + "artist": artist or None, } try: from app.core._crypto.signer import Signer diff --git a/app/bot/routers/content.py b/app/bot/routers/content.py index bc69cc6..c1a416c 100644 --- a/app/bot/routers/content.py +++ b/app/bot/routers/content.py @@ -58,9 +58,12 @@ async def _compute_content_status(db_session, encrypted_cid: Optional[str], fall 'updated_at': (row.last_access_at or row.created_at).isoformat() + 'Z' if (row.last_access_at or row.created_at) else None, }) - required = {'decrypted_low', 'decrypted_high'} - if content_type.startswith('video/'): - required.add('decrypted_preview') + if content_type.startswith('audio/'): + required = {'decrypted_low', 'decrypted_high'} + elif content_type.startswith('video/'): + required = {'decrypted_low', 'decrypted_high', 'decrypted_preview'} + else: + required = {'decrypted_original'} statuses_by_kind = {kind: derivative_latest[kind].status for kind in required if kind in derivative_latest} conversion_state = 'pending' diff --git a/app/core/background/convert_v3_service.py b/app/core/background/convert_v3_service.py index 42f28a1..77c9323 100644 --- a/app/core/background/convert_v3_service.py +++ b/app/core/background/convert_v3_service.py @@ -8,7 +8,7 @@ from datetime import datetime from pathlib import Path from typing import List, Optional, Tuple -from sqlalchemy import select +from sqlalchemy import select, and_, or_ from app.core.logger import make_log from app.core.storage import db_session @@ -196,17 +196,45 @@ async def _convert_content(ec: EncryptedContent, staging: PlainStaging): plain_filename = f"{ec.encrypted_cid}.{input_ext}" if input_ext else ec.encrypted_cid async with db_session() as session: existing = (await session.execute(select(StoredContent).where(StoredContent.hash == file_hash))).scalars().first() - if not existing: + if existing: + sc = existing + sc.type = sc.type or "local/content_bin" + sc.filename = plain_filename + sc.meta = { + **(sc.meta or {}), + 'encrypted_cid': ec.encrypted_cid, + 'kind': 'original', + 'content_type': ec.content_type, + } + sc.updated = datetime.utcnow() + else: sc = StoredContent( type="local/content_bin", hash=file_hash, user_id=None, filename=plain_filename, - meta={'encrypted_cid': ec.encrypted_cid, 'kind': 'original'}, + meta={ + 'encrypted_cid': ec.encrypted_cid, + 'kind': 'original', + 'content_type': ec.content_type, + }, created=datetime.utcnow(), ) session.add(sc) await session.flush() + + encrypted_records = (await session.execute(select(StoredContent).where(StoredContent.hash == encrypted_hash_b58))).scalars().all() + for encrypted_sc in encrypted_records: + meta = dict(encrypted_sc.meta or {}) + converted = dict(meta.get('converted_content') or {}) + converted['original'] = file_hash + meta['converted_content'] = converted + if 'content_type' not in meta: + meta['content_type'] = ec.content_type + encrypted_sc.meta = meta + encrypted_sc.decrypted_content_id = sc.id + encrypted_sc.updated = datetime.utcnow() + derivative = ContentDerivative( content_id=ec.id, kind='decrypted_original', @@ -341,10 +369,17 @@ async def _convert_content(ec: EncryptedContent, staging: PlainStaging): async def _pick_pending(limit: int) -> List[Tuple[EncryptedContent, PlainStaging]]: async with db_session() as session: - # Find A/V contents with preview_enabled and no ready low/low_preview derivatives yet - ecs = (await session.execute(select(EncryptedContent).where( - EncryptedContent.preview_enabled == True - ).order_by(EncryptedContent.created_at.desc()))).scalars().all() + # Include preview-enabled media and non-media content that need decrypted originals + non_media_filter = and_( + EncryptedContent.content_type.isnot(None), + ~EncryptedContent.content_type.like('audio/%'), + ~EncryptedContent.content_type.like('video/%'), + ) + ecs = (await session.execute( + select(EncryptedContent) + .where(or_(EncryptedContent.preview_enabled == True, non_media_filter)) + .order_by(EncryptedContent.created_at.desc()) + )).scalars().all() picked: List[Tuple[EncryptedContent, PlainStaging]] = [] for ec in ecs: @@ -365,7 +400,12 @@ async def _pick_pending(limit: int) -> List[Tuple[EncryptedContent, PlainStaging # Check if derivatives already ready rows = (await session.execute(select(ContentDerivative).where(ContentDerivative.content_id == ec.id))).scalars().all() kinds_ready = {r.kind for r in rows if r.status == 'ready'} - required = {'decrypted_low', 'decrypted_high'} if ec.content_type.startswith('audio/') else {'decrypted_low', 'decrypted_high', 'decrypted_preview'} + if ec.content_type.startswith('audio/'): + required = {'decrypted_low', 'decrypted_high'} + elif ec.content_type.startswith('video/'): + required = {'decrypted_low', 'decrypted_high', 'decrypted_preview'} + else: + required = {'decrypted_original'} if required.issubset(kinds_ready): continue # Always decrypt from IPFS using local or remote key diff --git a/app/core/background/indexer_service.py b/app/core/background/indexer_service.py index 53b6acd..bbfcac0 100644 --- a/app/core/background/indexer_service.py +++ b/app/core/background/indexer_service.py @@ -86,11 +86,14 @@ async def indexer_loop(memory, platform_found: bool, seqno: int) -> [bool, int]: wallet_owner_user = await session.get(User, wallet_owner_connection.user_id) if wallet_owner_connection else None if wallet_owner_user.telegram_id: wallet_owner_bot = Wrapped_CBotChat(memory._telegram_bot, chat_id=wallet_owner_user.telegram_id, user=wallet_owner_user, db_session=session) + meta_title = content_metadata.get('title') or content_metadata.get('name') or 'Unknown' + meta_artist = content_metadata.get('artist') + formatted_title = f"{meta_artist} – {meta_title}" if meta_artist else meta_title await wallet_owner_bot.send_message( user.translated('p_licenseWasBought').format( username=user.front_format(), nft_address=f'"https://tonviewer.com/{new_license.onchain_address}"', - content_title=content_metadata.get('name', 'Unknown'), + content_title=formatted_title, ), message_type='notification', ) diff --git a/app/core/content/utils.py b/app/core/content/utils.py index 0f597fa..c67d29c 100644 --- a/app/core/content/utils.py +++ b/app/core/content/utils.py @@ -115,6 +115,7 @@ def _clean_text_content(text: str, is_hashtag: bool = False) -> str: async def create_metadata_for_item( db_session, title: str = None, + artist: str = None, cover_url: str = None, authors: list = None, hashtags: list = [], @@ -128,6 +129,15 @@ async def create_metadata_for_item( cleaned_title = cleaned_title[:100].strip() # Truncate and strip after cleaning assert len(cleaned_title) > 3, f"Cleaned title '{cleaned_title}' (from original '{title}') is too short or became empty after cleaning." + cleaned_artist = None + if artist: + cleaned_artist = _clean_text_content(artist, is_hashtag=False) + cleaned_artist = cleaned_artist[:100].strip() + if not cleaned_artist: + cleaned_artist = None + + display_name = f"{cleaned_artist} – {cleaned_title}" if cleaned_artist else cleaned_title + # Process and clean hashtags processed_hashtags = [] if hashtags and isinstance(hashtags, list): @@ -142,17 +152,21 @@ async def create_metadata_for_item( processed_hashtags = list(dict.fromkeys(processed_hashtags))[:10] item_metadata = { - 'name': cleaned_title, - 'attributes': [ - # { - # 'trait_type': 'Artist', - # 'value': 'Unknown' - # }, - ], + 'name': display_name, + 'title': cleaned_title, + 'display_name': display_name, 'downloadable': downloadable, 'tags': processed_hashtags, # New field for storing the list of cleaned hashtags + 'attributes': [], } + if cleaned_artist: + item_metadata['artist'] = cleaned_artist + item_metadata['attributes'].append({ + 'trait_type': 'Artist', + 'value': cleaned_artist, + }) + # Generate description from the processed hashtags item_metadata['description'] = ' '.join([f"#{h}" for h in processed_hashtags if h]) diff --git a/app/core/models/_telegram/templates/player.py b/app/core/models/_telegram/templates/player.py index 1b73f07..1c7492b 100644 --- a/app/core/models/_telegram/templates/player.py +++ b/app/core/models/_telegram/templates/player.py @@ -1,4 +1,3 @@ -import html from sqlalchemy import and_, select from app.core.models.node_storage import StoredContent from app.core.models.content.user_content import UserContent, UserAction @@ -26,7 +25,6 @@ class PlayerTemplates: text = "" content_metadata_json = {} description_block = "" - status_hint = "" if content: assert content.type.startswith('onchain/content'), "Invalid nodeStorage content type" cd_log = f"Content (SHA256: {content.hash}), Encrypted: {content.encrypted}, TelegramCID: {content.telegram_cid}. " @@ -114,9 +112,6 @@ class PlayerTemplates: if encrypted_content_row: break - if not local_content: - status_hint = self.user.translated('p_playerContext_contentNotReady') - description = (content_metadata_json.get('description') or '').strip() encrypted_description = (encrypted_content_row.description or '').strip() if encrypted_content_row and encrypted_content_row.description else '' if not description and encrypted_description: @@ -124,18 +119,24 @@ class PlayerTemplates: if description: description_block = f"{description}\n" - title = ( - content_metadata_json.get('name') - or (encrypted_content_row.title if encrypted_content_row and encrypted_content_row.title else None) - or (local_content.filename if local_content else None) - or (content.filename if content else None) - or content.cid.serialize_v2() - ) - - status_block = f"{status_hint}\n" if status_hint else "" + metadata_title = content_metadata_json.get('title') or content_metadata_json.get('name') + if not metadata_title: + metadata_title = ( + (encrypted_content_row.title if encrypted_content_row and encrypted_content_row.title else None) + or (local_content.filename if local_content else None) + or (content.filename if content else None) + or content.cid.serialize_v2() + ) + metadata_artist = content_metadata_json.get('artist') + if metadata_artist in ('', None): + metadata_artist = None + if not metadata_artist: + encrypted_artist = getattr(encrypted_content_row, 'artist', None) + metadata_artist = encrypted_artist if encrypted_artist else metadata_artist + title = f"{metadata_artist} – {metadata_title}" if metadata_artist else metadata_title text = f"""{title} -{description_block}{status_block}Этот контент был загружен в MY +{description_block}Этот контент был загружен в MY \t/ p2p content market /
🔴 «открыть в MY»
""" @@ -152,16 +153,7 @@ class PlayerTemplates: ) )).scalars().all() - if not local_content: - if not processing_messages: - notice = f"Контент «{html.escape(title)}» обрабатывается. Как только всё будет готово, отправим полную публикацию." - await self.send_message( - notice, - message_type='content/processing', - message_meta={'content_id': content.id}, - content_id=content.id, - ) - else: + if local_content and processing_messages: for msg in processing_messages: await self.delete_message(msg.message_id) diff --git a/app/core/models/content_v3.py b/app/core/models/content_v3.py index 9d7146e..cdc148d 100644 --- a/app/core/models/content_v3.py +++ b/app/core/models/content_v3.py @@ -16,6 +16,7 @@ class EncryptedContent(AlchemyBase): # Public metadata title = Column(String(512), nullable=False) + artist = Column(String(512), nullable=True) description = Column(String(4096), nullable=True) content_type = Column(String(64), nullable=False) # e.g. audio/flac, video/mp4, application/octet-stream diff --git a/requirements.txt b/requirements.txt index e8477fa..1ddd468 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ pillow==10.2.0 ffmpeg-python==0.2.0 python-magic==0.4.27 cryptography==42.0.5 +alembic==1.13.1